VYPR
High severityNVD Advisory· Published Jun 8, 2026· Updated Jun 8, 2026

CVE-2026-49755

CVE-2026-49755

Description

Improper Handling of Highly Compressed Data (Data Amplification) vulnerability in wojtekmach Req allows attacker-controlled HTTP servers to exhaust memory in a Req client via decompression-bomb response bodies.

Req's default response pipeline includes Req.Steps.decode_body/1 and Req.Steps.decompress_body/1 in lib/req/steps.ex. decode_body/1 dispatches on the server-supplied content-type (or URL extension) and calls :zip.extract(body, [:memory]) for application/zip, :erl_tar.extract({:binary, body}, [:memory]) for application/x-tar, and :erl_tar.extract({:binary, body}, [:memory, :compressed]) for application/gzip / .tgz. Each returns the full decompressed archive contents as a [{name, bytes}] list in memory, with no per-entry or total size cap. decompress_body/1 walks the content-encoding header and chains :zlib/:brotli/:ezstd decoders, so a response advertising content-encoding: gzip, gzip, gzip inflates through multiple layers without bound.

Both steps are enabled by default, no caller opt-in is required, and the attacker controls the content-type and content-encoding headers on their own server (or on any host reached via Req's automatic redirect following). A sub-megabyte response can expand to multiple gigabytes on the victim, crashing the BEAM process.

This issue affects req: from 0.1.0 before 0.6.1.

Affected products

2
  • Wojtekmach/Reqreferences2 versions
    (expand)+ 1 more
    • (no CPE)
    • (no CPE)range: 0.1.0 <= 0.6.1

Patches

1
84977e5b1a83

`compressed`, `decompress_body`: Disable automatic decompression

https://github.com/wojtekmach/reqWojtek MachJun 8, 2026via body-scan
5 files changed · +91 44
  • lib/req.ex+5 4 modified
    @@ -301,11 +301,12 @@ defmodule Req do
     
       Response body options:
     
    -    * `:compressed` - if set to `true`, asks the server to return compressed response.
    -      (via [`compressed`](`Req.Steps.compressed/1`) step.) Defaults to `true`.
    +    * `:compressed` - if set to `true`, asks the server to return a compressed response and
    +      decompresses it (via the [`compressed`](`Req.Steps.compressed/1`) and
    +      [`decompress_body`](`Req.Steps.decompress_body/1`) steps.) Defaults to `false`.
     
    -    * `:raw` - if set to `true`, disables automatic body decompression
    -      ([`decompress_body`](`Req.Steps.decompress_body/1`) step) and decoding
    +    * `:raw` - if set to `true`, disables body decompression
    +      ([`decompress_body`](`Req.Steps.decompress_body/1`) step) and automatic decoding
           ([`decode_body`](`Req.Steps.decode_body/1`) step.) Defaults to `false`.
     
         * `:decode_body` - if set to `false`, disables automatic response body decoding.
    
  • lib/req/steps.ex+38 18 modified
    @@ -326,6 +326,9 @@ defmodule Req.Steps do
       @doc """
       Asks the server to return compressed response.
     
    +  This step also enables the [`decompress_body`](`Req.Steps.decompress_body/1`) step, which
    +  decompresses the response body. Both steps are off by default; set `compressed: true` to opt in.
    +
       Supported formats:
     
         * `gzip`
    @@ -334,33 +337,40 @@ defmodule Req.Steps do
     
         * `zstd` (requires Erlang/OTP 28+)
     
    +  > #### Only enable compression for trusted servers {: .info}
    +  >
    +  > The `decompress_body/1` step decompresses the whole response body into memory with no size
    +  > limit, so a small response can expand into many gigabytes. A malicious or compromised server
    +  > can exploit this to exhaust memory and crash the client (a decompression bomb / denial of
    +  > service). For this reason compression is off by default; only set `compressed: true` for
    +  > endpoints you trust.
    +
       ## Request Options
     
         * `:compressed` - if set to `true`, sets the `accept-encoding` header with compression
    -      algorithms that Req supports. Defaults to `true`.
    +      algorithms that Req supports and decompresses the response body. Defaults to `false`.
     
    -      When streaming response body (`into: fun | collectable`), `compressed` defaults to `false`.
    +      This option has no effect when streaming the response body (`into: fun | collectable`).
     
       ## Examples
     
    -  Req automatically decompresses response body (`decompress_body/1` step) so let's disable that by
    -  passing `raw: true`.
    +  By default, Req does not ask for a compressed response. Pass `compressed: true` to request one
    +  and have Req decompress the body, so we get back the decompressed content:
     
    -  By default, we ask the server to send compressed response. Let's look at the headers and the raw
    -  body. Notice the body starts with `<<31, 139>>` (`<<0x1F, 0x8B>>`), the "magic bytes" for gzip:
    +      iex> response = Req.get!("https://elixir-lang.org", compressed: true)
    +      iex> response.body |> binary_part(0, 15)
    +      "<!DOCTYPE html>"
    +
    +  To inspect the raw compressed bytes the server sent, additionally pass `raw: true`, which
    +  disables decompression. Notice the body now starts with `<<31, 139>>`, the "magic bytes"
    +  for gzip:
     
    -      iex> response = Req.get!("https://elixir-lang.org", raw: true)
    +      iex> response = Req.get!("https://elixir-lang.org", compressed: true, raw: true)
           iex> Req.Response.get_header(response, "content-encoding")
           ["gzip"]
           iex> response.body |> binary_part(0, 2)
           <<31, 139>>
     
    -  Now, let's pass `compressed: false` and notice the raw body was not compressed:
    -
    -      iex> response = Req.get!("https://elixir-lang.org", raw: true, compressed: false)
    -      iex> response.body |> binary_part(0, 15)
    -      "<!DOCTYPE html>"
    -
       Zstandard is supported out of the box on Erlang/OTP 28+ (via the built-in `:zstd` module).
       Brotli is supported if the optional [brotli] package is installed:
     
    @@ -369,15 +379,15 @@ defmodule Req.Steps do
             {:brotli, "~> 0.3.0"}
           ])
     
    -      response = Req.get!("https://httpbin.org/anything")
    +      response = Req.get!("https://httpbin.org/anything", compressed: true)
           response.body["headers"]["Accept-Encoding"]
           #=> "zstd, br, gzip"
     
       [brotli]: https://hex.pm/packages/brotli
       """
       @doc step: :request
       def compressed(%Req.Request{into: nil} = request) do
    -    case Req.Request.get_option(request, :compressed, true) do
    +    case Req.Request.get_option(request, :compressed, false) do
           true ->
             Req.Request.put_new_header(request, "accept-encoding", supported_accept_encoding())
     
    @@ -1593,6 +1603,10 @@ defmodule Req.Steps do
       @doc """
       Decompresses the response body based on the `content-encoding` header.
     
    +  This step only runs when the `:compressed` option is set to `true` (see the `compressed/1`
    +  step); otherwise the body is left as is. This guards against decompression bombs, where a
    +  small compressed response expands into a much larger body in memory.
    +
       This step is disabled on response body streaming. If response body is not a binary, in other
       words it has been transformed by another step, it is left as is.
     
    @@ -1612,13 +1626,16 @@ defmodule Req.Steps do
     
       ## Options
     
    +    * `:compressed` - if set to `true`, decompresses the response body. Defaults to `false`.
    +      See also the `compressed/1` step.
    +
         * `:raw` - if set to `true`, disables response body decompression. Defaults to `false`.
     
           Note: setting `raw: true` also disables response body decoding in the `decode_body/1` step.
     
       ## Examples
     
    -      iex> response = Req.get!("https://httpbin.org/gzip")
    +      iex> response = Req.get!("https://httpbin.org/gzip", compressed: true)
           iex> response.body["gzipped"]
           true
     
    @@ -1629,7 +1646,7 @@ defmodule Req.Steps do
             {:brotli, "~> 0.3.0"}
           ])
     
    -      response = Req.get!("https://httpbin.org/brotli")
    +      response = Req.get!("https://httpbin.org/brotli", compressed: true)
           Req.Response.get_header(response, "content-encoding")
           #=> ["br"]
           response.body["brotli"]
    @@ -1648,7 +1665,10 @@ defmodule Req.Steps do
       end
     
       def decompress_body({request, response}) do
    -    if request.options[:raw] do
    +    compressed? = Req.Request.get_option(request, :compressed, false) == true
    +    raw? = request.options[:raw] == true
    +
    +    if not compressed? or raw? do
           {request, response}
         else
           encoding_headers = Req.Response.get_header(response, "content-encoding")
    
  • README.md+3 3 modified
    @@ -18,7 +18,7 @@ Req.get!("https://api.github.com/repos/wojtekmach/req").body["description"]
     #=> "Req is a batteries-included HTTP client for Elixir."
     ```
     
    -we get automatic response body decompression & decoding, following redirects, retrying on errors,
    +we get automatic response body decoding, following redirects, retrying on errors,
     and much more. Virtually all of the features are broken down into individual functions called
     _steps_. You can easily re-use and re-arrange built-in steps (see [`Req.Steps`] module) and
     write new ones.
    @@ -31,15 +31,15 @@ write new ones.
     
       * Request body compression (via [`compress_body`] step)
     
    -  * Automatic response body decompression (via [`compressed`] and [`decompress_body`] steps). Supports gzip, brotli, and zstd.
    +  * Opt-in response body decompression (via [`compressed`] and [`decompress_body`] steps). Supports gzip, brotli, and zstd.
     
       * Request body encoding. Supports urlencoded and multipart forms, and JSON. See [`encode_body`].
     
       * Automatic response body decoding (via [`decode_body`] step.)
     
       * Encode params as query string (via [`put_params`] step.)
     
    -  * Setting base URL (via [`put_base_url`] step.)
    +  * Setting base URL (via [`put_base_url`] step.
     
       * Templated request paths (via [`put_path_params`] step.)
     
    
  • test/req/request_test.exs+7 1 modified
    @@ -254,7 +254,13 @@ defmodule Req.RequestTest do
       @tag skip: System.otp_release() < "28"
       test "prepare/1" do
         request =
    -      Req.new(method: :get, base_url: "http://foo", url: "/bar", auth: {:basic, "foo:bar"})
    +      Req.new(
    +        method: :get,
    +        base_url: "http://foo",
    +        url: "/bar",
    +        auth: {:basic, "foo:bar"},
    +        compressed: true
    +      )
           |> Req.Request.prepare()
     
         assert request.url == URI.parse("http://foo/bar")
    
  • test/req/steps_test.exs+38 18 modified
    @@ -9,13 +9,18 @@ defmodule Req.StepsTest do
       ## Request steps
     
       describe "compressed" do
    -    test "sets accept-encoding" do
    -      req = Req.new() |> Req.Request.prepare()
    +    test "sets accept-encoding when compressed: true" do
    +      req = Req.new(compressed: true) |> Req.Request.prepare()
           assert req.headers["accept-encoding"] == ["zstd, br, gzip"]
         end
     
    +    test "does not set accept-encoding by default" do
    +      req = Req.new() |> Req.Request.prepare()
    +      refute req.headers["accept-encoding"]
    +    end
    +
         test "does not set accept-encoding when streaming response body" do
    -      req = Req.new(into: []) |> Req.Request.prepare()
    +      req = Req.new(compressed: true, into: []) |> Req.Request.prepare()
           refute req.headers["accept-encoding"]
         end
       end
    @@ -491,7 +496,7 @@ defmodule Req.StepsTest do
               |> Plug.Conn.send_resp(200, :zlib.gzip("foo"))
             end)
     
    -      req = Req.new(url: url)
    +      req = Req.new(url: url, compressed: true)
     
           resp = Req.get!(req, checksum: @foo_md5)
           assert resp.body == "foo"
    @@ -942,6 +947,7 @@ defmodule Req.StepsTest do
           req =
             Req.new(
               url: "https://s3.amazonaws.com",
    +          compressed: true,
               aws_sigv4: [access_key_id: "foo", secret_access_key: "bar"],
               headers: [
                 "x-amzn-trace-id": "trace-123",
    @@ -996,14 +1002,26 @@ defmodule Req.StepsTest do
       ## Response steps
     
       describe "decompress_body" do
    +    test "is disabled by default" do
    +      plug = fn conn ->
    +        conn
    +        |> Plug.Conn.put_resp_header("content-encoding", "gzip")
    +        |> Plug.Conn.send_resp(200, :zlib.gzip("foo"))
    +      end
    +
    +      resp = Req.get!(plug: plug)
    +      assert Req.Response.get_header(resp, "content-encoding") == ["gzip"]
    +      assert resp.body == :zlib.gzip("foo")
    +    end
    +
         test "gzip success" do
           plug = fn conn ->
             conn
             |> Plug.Conn.put_resp_header("content-encoding", "x-gzip")
             |> Plug.Conn.send_resp(200, :zlib.gzip("foo"))
           end
     
    -      resp = Req.get!(plug: plug)
    +      resp = Req.get!(plug: plug, compressed: true)
           assert Req.Response.get_header(resp, "content-encoding") == []
           assert resp.body == "foo"
         end
    @@ -1016,7 +1034,7 @@ defmodule Req.StepsTest do
           end
     
           assert_raise Req.DecompressError, "gzip decompression failed", fn ->
    -        Req.get!(plug: plug)
    +        Req.get!(plug: plug, compressed: true)
           end
         end
     
    @@ -1027,7 +1045,7 @@ defmodule Req.StepsTest do
             |> Plug.Conn.send_resp(200, "foo")
           end
     
    -      resp = Req.get!(plug: plug)
    +      resp = Req.get!(plug: plug, compressed: true)
           assert Req.Response.get_header(resp, "content-encoding") == []
           assert resp.body == "foo"
         end
    @@ -1041,7 +1059,7 @@ defmodule Req.StepsTest do
             |> Plug.Conn.send_resp(200, body)
           end
     
    -      resp = Req.get!(plug: plug)
    +      resp = Req.get!(plug: plug, compressed: true)
           assert resp.body == "foo"
         end
     
    @@ -1053,7 +1071,7 @@ defmodule Req.StepsTest do
           end
     
           assert_raise Req.DecompressError, "br decompression failed", fn ->
    -        Req.get!(plug: plug)
    +        Req.get!(plug: plug, compressed: true)
           end
         end
     
    @@ -1064,7 +1082,7 @@ defmodule Req.StepsTest do
             |> Plug.Conn.send_resp(200, :zstd.compress("foo"))
           end
     
    -      resp = Req.get!(plug: plug)
    +      resp = Req.get!(plug: plug, compressed: true)
           assert resp.body == "foo"
         end
     
    @@ -1078,7 +1096,7 @@ defmodule Req.StepsTest do
           assert_raise Req.DecompressError,
                        ~S[zstd decompression failed, reason: "Unknown frame descriptor"],
                        fn ->
    -                     Req.get!(plug: plug)
    +                     Req.get!(plug: plug, compressed: true)
                        end
         end
     
    @@ -1089,7 +1107,7 @@ defmodule Req.StepsTest do
             |> Plug.Conn.send_resp(200, "foo" |> :zlib.gzip() |> :zstd.compress())
           end
     
    -      resp = Req.get!(plug: plug)
    +      resp = Req.get!(plug: plug, compressed: true)
           assert Req.Response.get_header(resp, "content-encoding") == []
           assert resp.body == "foo"
         end
    @@ -1113,7 +1131,7 @@ defmodule Req.StepsTest do
               :ok = :gen_tcp.send(socket, data)
             end)
     
    -      resp = Req.get!(url)
    +      resp = Req.get!(url, compressed: true)
           assert Req.Response.get_header(resp, "content-encoding") == []
           assert Req.Response.get_header(resp, "content-length") == []
           assert resp.body == "foo"
    @@ -1127,7 +1145,7 @@ defmodule Req.StepsTest do
             |> Plug.Conn.send_resp(200, <<1, 2, 3>>)
           end
     
    -      resp = Req.get!(plug: plug)
    +      resp = Req.get!(plug: plug, compressed: true)
           assert Req.Response.get_header(resp, "content-encoding") == ["unknown1, unknown2"]
           assert resp.body == <<1, 2, 3>>
         end
    @@ -1139,7 +1157,7 @@ defmodule Req.StepsTest do
             |> Plug.Conn.send_resp(200, "")
           end
     
    -      assert Req.head!(plug: plug).body == ""
    +      assert Req.head!(plug: plug, compressed: true).body == ""
         end
       end
     
    @@ -1486,7 +1504,7 @@ defmodule Req.StepsTest do
           |> Plug.Conn.send_resp(200, body)
         end
     
    -    assert Req.get!("", plug: plug).body == %{"a" => 1}
    +    assert Req.get!("", plug: plug, compressed: true).body == %{"a" => 1}
       end
     
       test "decompress and decode in raw mode" do
    @@ -1502,7 +1520,9 @@ defmodule Req.StepsTest do
           |> Plug.Conn.send_resp(200, body)
         end
     
    -    assert Req.get!("", plug: plug, raw: true).body |> :zlib.gunzip() |> Jason.decode!() == %{
    +    assert Req.get!("", plug: plug, compressed: true, raw: true).body
    +           |> :zlib.gunzip()
    +           |> Jason.decode!() == %{
                  "a" => 1
                }
       end
    @@ -1522,7 +1542,7 @@ defmodule Req.StepsTest do
     
         {resp, log} =
           ExUnit.CaptureLog.with_log(fn ->
    -        Req.get!(plug: plug)
    +        Req.get!(plug: plug, compressed: true)
           end)
     
         assert resp.body |> :zlib.uncompress() |> Jason.decode!() == %{"a" => 1}
    

Vulnerability mechanics

Root cause

"The default response pipeline automatically decompresses and decodes archive and compressed bodies without enforcing size limits."

Attack vector

An attacker controls an HTTP server that responds to a Req client with a specially crafted response body. This response can be a small compressed archive or data that, when decompressed by Req's default pipeline, expands to consume a large amount of memory. This can be achieved by setting appropriate `Content-Type` and `Content-Encoding` headers on the attacker-controlled server [ref_id=2]. The vulnerability is triggered by a simple `Req.get!/1` call without special options, as decompression is enabled by default [ref_id=2].

Affected code

The vulnerability resides in `Req.Steps.decode_body/1` and `Req.Steps.decompress_body/1` within `lib/req/steps.ex`. `decode_body/1` handles archive decompression (e.g., zip, tar, tgz) using Erlang's archive libraries with the `:memory` option, while `decompress_body/1` handles content encoding decompression (e.g., gzip, brotli, zstd) by chaining decoders. Both are enabled by default in the response pipeline [ref_id=2].

What the fix does

The patch disables automatic decompression by default in both the `Req.Steps.compressed/1` and `Req.Steps.decompress_body/1` steps [patch_id=5238294]. The `compressed` option now defaults to `false`, requiring explicit opt-in via `compressed: true` to enable compression and decompression. This change prevents the client from automatically decompressing potentially malicious, oversized responses, thereby mitigating the memory exhaustion vulnerability [patch_id=5238294].

Preconditions

  • networkThe Req client must be able to reach an attacker-controlled HTTP server.
  • inputThe attacker must control an HTTP server capable of sending a response with specific `Content-Type` and `Content-Encoding` headers and a payload that results in significant data amplification upon decompression.

Generated on Jun 8, 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.