VYPR
High severityNVD Advisory· Published Jun 2, 2026

CVE-2026-48594

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

Patches

1
340f75b5d191

Merge commit from fork

https://github.com/elixir-tesla/teslaYordis PrietoJun 2, 2026via body-scan
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

4

News mentions

0

No linked articles in our index yet.