From 0252f714c2b7fbc5b863d0fc1db7a24395127c79 Mon Sep 17 00:00:00 2001 From: Regan Karlewicz Date: Wed, 20 May 2026 13:26:20 -0700 Subject: [PATCH 1/2] Exclude accept-encoding and hop-by-hop headers from aws_sigv4 signature --- lib/req/steps.ex | 27 ++++++++++++++++- test/req/steps_test.exs | 64 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/lib/req/steps.ex b/lib/req/steps.ex index 1ae66da6..3d205235 100644 --- a/lib/req/steps.ex +++ b/lib/req/steps.ex @@ -1357,6 +1357,27 @@ defmodule Req.Steps do defp hash_init(:sha1), do: hash_init(:sha) defp hash_init(type), do: :crypto.hash_init(type) + @aws_sigv4_excluded_headers [ + # Services like R2 can rewrite this header when + # encodings it doesn't support are included, i.e. zstd + "accept-encoding", + # Trace ID can be rewritten by AWS infrastructure + "x-amzn-trace-id", + # Authorization is set by SigV4 itself / not part of canonical request + "authorization", + # RFC 2616 Section 13.5.1 "hop-by-hop" headers + # (list is historical; RFC 7230/9110 use Connection header as the + # authoritative mechanism, but this enumeration remains the practical baseline) + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade" + ] + @doc """ Signs request with AWS Signature Version 4. @@ -1457,7 +1478,11 @@ defmodule Req.Steps do request = Req.Request.put_new_header(request, "host", request.url.host) - headers = for {name, values} <- request.headers, value <- values, do: {name, value} + headers = + for {name, values} <- request.headers, + String.downcase(name) not in @aws_sigv4_excluded_headers, + value <- values, + do: {name, value} headers = Req.Utils.aws_sigv4_headers( diff --git a/test/req/steps_test.exs b/test/req/steps_test.exs index 309da58c..3a9f4dc4 100644 --- a/test/req/steps_test.exs +++ b/test/req/steps_test.exs @@ -899,6 +899,70 @@ defmodule Req.StepsTest do assert Req.put!(req).body == "ok" end + test "excludes accept-encoding, hop-by-hop, and trace-id headers from signature" do + plug = fn conn -> + [authorization] = Plug.Conn.get_req_header(conn, "authorization") + + signed_headers = + authorization + |> String.split(",") + |> Enum.find_value(fn part -> + case String.split(part, "=", parts: 2) do + ["SignedHeaders", value] -> String.split(value, ";") + _ -> nil + end + end) + + for excluded <- [ + "accept-encoding", + "x-amzn-trace-id", + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade" + ] do + refute excluded in signed_headers, + "expected #{excluded} not in SignedHeaders, got: #{inspect(signed_headers)}" + end + + # Headers excluded from the signature are still sent on the wire. + assert ["zstd, br, gzip"] = Plug.Conn.get_req_header(conn, "accept-encoding") + assert ["trace-123"] = Plug.Conn.get_req_header(conn, "x-amzn-trace-id") + assert ["keep-alive"] = Plug.Conn.get_req_header(conn, "connection") + + # Non-excluded custom headers are still signed. + assert "x-custom" in signed_headers + + Plug.Conn.send_resp(conn, 200, "ok") + end + + req = + Req.new( + url: "https://s3.amazonaws.com", + aws_sigv4: [access_key_id: "foo", secret_access_key: "bar"], + headers: [ + "x-amzn-trace-id": "trace-123", + connection: "keep-alive", + "keep-alive": "timeout=5", + "proxy-authenticate": "Basic", + "proxy-authorization": "Basic foo", + te: "trailers", + trailer: "Expires", + "transfer-encoding": "chunked", + upgrade: "websocket", + "x-custom": "signed" + ], + body: "hello", + plug: plug + ) + + assert Req.put!(req).body == "ok" + end + test "missing :access_key_id" do req = Req.new(aws_sigv4: []) From 953d474eb29f444c390b4ff1fcffd29d53e9020f Mon Sep 17 00:00:00 2001 From: Wojtek Mach Date: Fri, 22 May 2026 17:05:58 +0200 Subject: [PATCH 2/2] Add internal Req.Fields.drop/2 --- lib/req/fields.ex | 23 +++++++++++++++++++++++ lib/req/steps.ex | 7 +------ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/lib/req/fields.ex b/lib/req/fields.ex index e71a2301..0e95ed45 100644 --- a/lib/req/fields.ex +++ b/lib/req/fields.ex @@ -213,6 +213,29 @@ defmodule Req.Fields do end end + @doc """ + Drops the given `names`. + """ + if @legacy? do + def drop(fields, names) when is_binary(name) do + names_to_drop = Enum.map(names, &ensure_name_downcase/1) + + for {name, value} <- fields, + name not in names_to_drop do + {name, value} + end + end + else + def drop(fields, names) do + names_to_drop = Enum.map(names, &ensure_name_downcase/1) + + for {name, values} <- fields, + name not in names_to_drop do + {name, values} + end + end + end + @doc """ Returns fields as list. """ diff --git a/lib/req/steps.ex b/lib/req/steps.ex index 3d205235..b7c2e017 100644 --- a/lib/req/steps.ex +++ b/lib/req/steps.ex @@ -1477,12 +1477,7 @@ defmodule Req.Steps do end request = Req.Request.put_new_header(request, "host", request.url.host) - - headers = - for {name, values} <- request.headers, - String.downcase(name) not in @aws_sigv4_excluded_headers, - value <- values, - do: {name, value} + headers = Req.Fields.drop(request.headers, @aws_sigv4_excluded_headers) headers = Req.Utils.aws_sigv4_headers(