CVE-2026-48594
Description
Improper Handling of Highly Compressed Data (Data Amplification) vulnerability in elixir-tesla tesla allows a denial of service via decompression bomb in HTTP response bodies.
When Tesla.Middleware.DecompressResponse or Tesla.Middleware.Compression is included in a Tesla middleware pipeline, HTTP response bodies are decompressed eagerly with no size limit. The decompress_body/2 function in lib/tesla/middleware/compression.ex passes the entire response body to :zlib.gunzip/1 or :zlib.unzip/1 without any cap on the output size. Additionally, compression_algorithms/1 splits the content-encoding header on commas and decompress_body/2 recurses once per token, applying a decompression pass on each iteration. A server advertising content-encoding: gzip, gzip, gzip, gzip causes four recursive decompression passes, yielding exponential amplification: each gzip layer can expand its input roughly 1000x, so a payload of a few hundred bytes on the wire inflates to gigabytes of BEAM heap, exhausting memory and crashing or freezing the calling process.
This issue affects tesla: from 0.6.0 before 1.18.3.
Affected products
1- Range: >=0.6.0 <1.18.3
Patches
1340f75b5d191Merge commit from fork
2 files changed · +263 −31
lib/tesla/middleware/compression.ex+161 −19 modified@@ -10,26 +10,76 @@ defmodule Tesla.Middleware.Compression do defmodule MyClient do def client do Tesla.client([ - {Tesla.Middleware.Compression, format: "gzip"} + {Tesla.Middleware.Compression, format: "gzip", max_body_size: 32 * 1024 * 1024} ]) end end ``` ## Options - - `:format` - request compression format, `"gzip"` (default) or `"deflate"` + - `:format` - request compression format, `"gzip"` (default) or `"deflate"`. + - `:max_body_size` - **required.** Maximum size, in bytes, of any decompressed + response body. Pass a positive integer (e.g. `32 * 1024 * 1024`) or `:infinity` + to disable the cap explicitly. Responses that decompress to more bytes raise + `Tesla.Middleware.Compression.Error` with reason `:max_body_size_exceeded`. + + ## Security + + Decompressing untrusted response bodies without a size cap is a denial-of-service + vector ("zip bomb"): a small response can inflate into gigabytes of memory and + exhaust the BEAM heap. To make that impossible by construction this middleware: + + - requires `:max_body_size` to be set so the cap is always a conscious choice; + - streams inflation through `:zlib.safeInflate/2` and aborts as soon as the + cap is exceeded, before the full body is materialised in memory; and + - rejects responses that advertise more than one supported compression codec + in `content-encoding`, since stacked codecs are almost exclusively a bomb + pattern and cannot be safely bounded by a single per-layer cap. """ @behaviour Tesla.Middleware + defmodule Error do + @moduledoc """ + Raised when a response cannot be safely decompressed. + + The `:reason` field is one of: + + - `:max_body_size_exceeded` - the decompressed body grew past the configured + `:max_body_size`. + - `:multiple_codecs` - the response advertised more than one supported + `content-encoding` codec (e.g. `gzip, gzip`). + - `{:zlib, term}` - the underlying `:zlib` stream returned an error. + """ + + defexception [:reason, :message] + + @impl true + def exception(opts) do + reason = Keyword.fetch!(opts, :reason) + %__MODULE__{reason: reason, message: message_for(reason)} + end + + defp message_for(:max_body_size_exceeded), + do: "decompressed response body exceeds the configured :max_body_size" + + defp message_for(:multiple_codecs), + do: + "refusing to decompress response advertising more than one supported " <> + "content-encoding codec (stacked codecs are a zip-bomb amplification vector)" + + defp message_for({:zlib, reason}), + do: "zlib error during decompression: #{inspect(reason)}" + end + @impl Tesla.Middleware def call(env, next, opts) do env |> compress(opts) |> add_accept_encoding() |> Tesla.run(next) - |> decompress() + |> decompress(opts) end @doc false @@ -64,23 +114,55 @@ defmodule Tesla.Middleware.Compression do It is used by `Tesla.Middleware.DecompressResponse`. """ - def decompress({:ok, env}), do: {:ok, decompress(env)} - def decompress({:error, reason}), do: {:error, reason} + def decompress({:ok, env}, opts), do: {:ok, decompress(env, opts)} + def decompress({:error, reason}, _opts), do: {:error, reason} # HEAD requests may be used to obtain information on the transfer size and properties # and their empty bodies are not actually valid for the possibly indicated encodings # thus we want to preserve them unchanged. - def decompress(%Tesla.Env{method: :head} = env), do: env + def decompress(%Tesla.Env{method: :head} = env, _opts), do: env - def decompress(env) do + def decompress(env, opts) do + max_body_size = fetch_max_body_size!(opts) codecs = compression_algorithms(Tesla.get_header(env, "content-encoding")) - {decompressed_body, unknown_codecs} = decompress_body(codecs, env.body) + + if count_known_codecs(codecs) > 1 do + raise Error, reason: :multiple_codecs + end + + {decompressed_body, unknown_codecs} = decompress_body(codecs, env.body, max_body_size) env |> put_decompressed_body(decompressed_body) |> put_or_delete_content_encoding(unknown_codecs) end + defp fetch_max_body_size!(opts) do + case Keyword.fetch(opts || [], :max_body_size) do + {:ok, :infinity} -> + :infinity + + {:ok, size} when is_integer(size) and size > 0 -> + size + + {:ok, other} -> + raise ArgumentError, + "Tesla.Middleware.Compression :max_body_size must be a positive integer " <> + "or :infinity, got: #{inspect(other)}" + + :error -> + raise ArgumentError, + "Tesla.Middleware.Compression requires the :max_body_size option to be set. " <> + "Pass a positive integer cap (e.g. max_body_size: 32 * 1024 * 1024) " <> + "or :infinity to opt out of the cap explicitly. " <> + "See the moduledoc for the security rationale." + end + end + + defp count_known_codecs(codecs) do + Enum.count(codecs, &(&1 in ["gzip", "x-gzip", "deflate"])) + end + defp put_or_delete_content_encoding(env, []) do Tesla.delete_header(env, "content-encoding") end @@ -89,26 +171,85 @@ defmodule Tesla.Middleware.Compression do Tesla.put_header(env, "content-encoding", Enum.join(unknown_codecs, ", ")) end - defp decompress_body([gzip | rest], body) when gzip in ["gzip", "x-gzip"] do - decompress_body(rest, :zlib.gunzip(body)) + defp decompress_body([gzip | rest], body, max_body_size) when gzip in ["gzip", "x-gzip"] do + decompress_body(rest, inflate(body, 31, max_body_size), max_body_size) end - defp decompress_body(["deflate" | rest], body) do - decompress_body(rest, :zlib.unzip(body)) + # `:zlib.unzip/1` (the original Tesla call) uses raw deflate with negative + # window bits; `:zlib.zip/1` round-trips through that, so keep the same + # framing here for backwards-compatible "deflate" responses. + defp decompress_body(["deflate" | rest], body, max_body_size) do + decompress_body(rest, inflate(body, -15, max_body_size), max_body_size) end - defp decompress_body(["identity" | rest], body) do - decompress_body(rest, body) + defp decompress_body(["identity" | rest], body, max_body_size) do + decompress_body(rest, body, max_body_size) end - defp decompress_body([codec | rest], body) do + defp decompress_body([codec | rest], body, _max_body_size) do {body, Enum.reverse([codec | rest])} end - defp decompress_body([], body) do + defp decompress_body([], body, _max_body_size) do {body, []} end + defp inflate(body, window_bits, max_body_size) do + z = :zlib.open() + + try do + :zlib.inflateInit(z, window_bits) + result = start_inflate(z, body, max_body_size) + # Verifies the stream was complete; raises :data_error for empty, + # truncated, or otherwise invalid input the same way :zlib.gunzip/1 did. + :zlib.inflateEnd(z) + result + catch + :error, :data_error -> + reraise Error, [reason: {:zlib, :data_error}], __STACKTRACE__ + + :error, {:data_error, _} = reason -> + reraise Error, [reason: {:zlib, reason}], __STACKTRACE__ + after + :zlib.close(z) + end + end + + defp start_inflate(z, body, max_body_size) do + case :zlib.safeInflate(z, body) do + {:finished, output} -> + size = IO.iodata_length(output) + ensure_within_limit!(size, max_body_size) + IO.iodata_to_binary(output) + + {:continue, output} -> + size = IO.iodata_length(output) + ensure_within_limit!(size, max_body_size) + drain_inflate(z, max_body_size, size, [output]) + end + end + + defp drain_inflate(z, max_body_size, total_size, acc) do + case :zlib.safeInflate(z, []) do + {:continue, output} -> + new_total = total_size + IO.iodata_length(output) + ensure_within_limit!(new_total, max_body_size) + drain_inflate(z, max_body_size, new_total, [acc, output]) + + {:finished, output} -> + new_total = total_size + IO.iodata_length(output) + ensure_within_limit!(new_total, max_body_size) + IO.iodata_to_binary([acc, output]) + end + end + + defp ensure_within_limit!(_size, :infinity), do: :ok + defp ensure_within_limit!(size, max) when size <= max, do: :ok + + defp ensure_within_limit!(_size, _max) do + raise Error, reason: :max_body_size_exceeded + end + defp compression_algorithms(nil) do [] end @@ -169,16 +310,17 @@ defmodule Tesla.Middleware.DecompressResponse do @moduledoc """ Only decompress response. - See `Tesla.Middleware.Compression` for options. + See `Tesla.Middleware.Compression` for options. The `:max_body_size` option is + **required**; see that module's "Security" section for the rationale. """ @behaviour Tesla.Middleware @impl Tesla.Middleware - def call(env, next, _opts) do + def call(env, next, opts) do env |> Tesla.Middleware.Compression.add_accept_encoding() |> Tesla.run(next) - |> Tesla.Middleware.Compression.decompress() + |> Tesla.Middleware.Compression.decompress(opts) end end
test/tesla/middleware/compression_test.exs+102 −12 modified@@ -4,7 +4,7 @@ defmodule Tesla.Middleware.CompressionTest do defmodule CompressionGzipRequestClient do use Tesla - plug Tesla.Middleware.Compression + plug Tesla.Middleware.Compression, max_body_size: 32 * 1024 * 1024 adapter fn env -> {status, headers, body} = @@ -25,7 +25,7 @@ defmodule Tesla.Middleware.CompressionTest do defmodule CompressionDeflateRequestClient do use Tesla - plug Tesla.Middleware.Compression, format: "deflate" + plug Tesla.Middleware.Compression, format: "deflate", max_body_size: 32 * 1024 * 1024 adapter fn env -> {status, headers, body} = @@ -46,7 +46,7 @@ defmodule Tesla.Middleware.CompressionTest do defmodule CompressionResponseClient do use Tesla - plug Tesla.Middleware.Compression + plug Tesla.Middleware.Compression, max_body_size: 32 * 1024 * 1024 adapter fn env -> {status, headers, body} = @@ -59,8 +59,8 @@ defmodule Tesla.Middleware.CompressionTest do {200, [{"content-type", "text/plain"}, {"content-encoding", "deflate"}], :zlib.zip("decompressed deflate")} - "/multiple-encodings" -> - {200, [{"content-type", "text/plain"}, {"content-encoding", "gzip, zstd, gzip"}], + "/single-known-with-unknown" -> + {200, [{"content-type", "text/plain"}, {"content-encoding", "zstd, gzip"}], :zlib.gzip("decompressed gzip")} "/response-identity" -> @@ -86,6 +86,14 @@ defmodule Tesla.Middleware.CompressionTest do {"content-encoding", "gzip"}, {"content-length", "4194304"} ], ""} + + "/stacked-gzip" -> + {200, [{"content-type", "text/plain"}, {"content-encoding", "gzip, gzip"}], + :zlib.gzip(:zlib.gzip("inner"))} + + "/stacked-mixed" -> + {200, [{"content-type", "text/plain"}, {"content-encoding", "gzip, deflate"}], + "irrelevant - never reached"} end {:ok, %{env | status: status, headers: headers, body: body}} @@ -104,9 +112,9 @@ defmodule Tesla.Middleware.CompressionTest do end test "stops decompressing on first unsupported content-encoding" do - assert {:ok, env} = CompressionResponseClient.get("/multiple-encodings") + assert {:ok, env} = CompressionResponseClient.get("/single-known-with-unknown") assert env.body == "decompressed gzip" - assert env.headers == [{"content-type", "text/plain"}, {"content-encoding", "gzip, zstd"}] + assert env.headers == [{"content-type", "text/plain"}, {"content-encoding", "zstd"}] end test "return unchanged response for unsupported content-encoding" do @@ -116,9 +124,9 @@ defmodule Tesla.Middleware.CompressionTest do end test "raises on invalid empty-body response (gzip)" do - assert_raise(ErlangError, "Erlang error: :data_error", fn -> + assert_raise Tesla.Middleware.Compression.Error, fn -> CompressionResponseClient.get("/response-empty") - end) + end end test "updates existing content-length header" do @@ -143,11 +151,93 @@ defmodule Tesla.Middleware.CompressionTest do ] end + test "rejects responses advertising stacked gzip codecs (zip-bomb pattern)" do + assert_raise Tesla.Middleware.Compression.Error, ~r/more than one supported/, fn -> + CompressionResponseClient.get("/stacked-gzip") + end + end + + test "rejects responses advertising mixed stacked codecs" do + assert_raise Tesla.Middleware.Compression.Error, ~r/more than one supported/, fn -> + CompressionResponseClient.get("/stacked-mixed") + end + end + + defmodule BombClient do + use Tesla + + plug Tesla.Middleware.Compression, max_body_size: 1024 + + adapter fn env -> + case env.url do + "/bomb" -> + body = :zlib.gzip(:binary.copy(<<0>>, 4 * 1024 * 1024)) + + {:ok, + %{ + env + | status: 200, + headers: [{"content-type", "application/octet-stream"}, {"content-encoding", "gzip"}], + body: body + }} + end + end + end + + test "aborts decompression when output exceeds :max_body_size" do + assert_raise Tesla.Middleware.Compression.Error, ~r/max_body_size/, fn -> + BombClient.get("/bomb") + end + end + + defmodule MissingLimitClient do + use Tesla + + plug Tesla.Middleware.Compression + + adapter fn env -> + {:ok, + %{ + env + | status: 200, + headers: [{"content-type", "text/plain"}, {"content-encoding", "gzip"}], + body: :zlib.gzip("hello") + }} + end + end + + test "requires :max_body_size to be set" do + assert_raise ArgumentError, ~r/:max_body_size/, fn -> + MissingLimitClient.get("/") + end + end + + defmodule InfinityLimitClient do + use Tesla + + plug Tesla.Middleware.Compression, max_body_size: :infinity + + adapter fn env -> + {:ok, + %{ + env + | status: 200, + headers: [{"content-type", "text/plain"}, {"content-encoding", "gzip"}], + body: :zlib.gzip("hello") + }} + end + end + + test "accepts :infinity as an explicit opt-out of the cap" do + assert {:ok, env} = InfinityLimitClient.get("/") + assert env.body == "hello" + end + defmodule CompressRequestDecompressResponseClient do use Tesla plug Tesla.Middleware.CompressRequest - plug Tesla.Middleware.DecompressResponse + plug Tesla.Middleware.DecompressResponse, max_body_size: 32 * 1024 * 1024 adapter fn env -> {status, headers, body} = @@ -169,7 +259,7 @@ defmodule Tesla.Middleware.CompressionTest do defmodule CompressionHeadersClient do use Tesla - plug Tesla.Middleware.Compression + plug Tesla.Middleware.Compression, max_body_size: 32 * 1024 * 1024 adapter fn env -> {status, headers, body} = @@ -191,7 +281,7 @@ defmodule Tesla.Middleware.CompressionTest do defmodule DecompressResponseHeadersClient do use Tesla - plug Tesla.Middleware.DecompressResponse + plug Tesla.Middleware.DecompressResponse, max_body_size: 32 * 1024 * 1024 adapter fn env -> {status, headers, body} =
Vulnerability mechanics
Root cause
"The decompression middleware decompresses HTTP response bodies without a size limit, allowing for exponential data amplification."
Attack vector
An attacker controls a server that an application using the `elixir-tesla` library contacts. This server can return a specially crafted HTTP response with a `Content-Encoding` header indicating multiple compression layers, such as `gzip, gzip, gzip, gzip`. The `elixir-tesla` client eagerly decompresses this response, leading to a denial-of-service condition by exhausting memory.
Affected code
The vulnerability resides in the `decompress_body/2` function within `lib/tesla/middleware/compression.ex`. This function recursively calls decompression functions like `:zlib.gunzip/1` or `:zlib.unzip/1` without any checks on the output size. The `call/3` function in both `Tesla.Middleware.Compression` and `Tesla.Middleware.DecompressResponse` is where the decompression is initiated.
What the fix does
The patch introduces a mandatory `:max_body_size` option to the `Tesla.Middleware.Compression` and `Tesla.Middleware.DecompressResponse` middleware. This option enforces a limit on the decompressed response body size, preventing excessive memory consumption. Additionally, the middleware now rejects responses advertising more than one supported compression codec, as stacked codecs are a common pattern for decompression bombs [patch_id=4524237].
Preconditions
- configThe application must include `Tesla.Middleware.DecompressResponse` or `Tesla.Middleware.Compression` in its Tesla middleware pipeline.
- networkThe attacker must control a server that the vulnerable client application contacts, potentially through redirects.
Generated on Jun 2, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.