From d53e49777ba5ee076eb86ff30559c97f73698575 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 4 Nov 2026 12:39:11 +1300 Subject: [PATCH 1/6] Strip `te`, `trailer` and `proxy-connection` hop-by-hop headers in `Falcon::Middleware::Proxy`. --- lib/falcon/middleware/proxy.rb | 7 +++++-- releases.md | 4 ++++ test/falcon/middleware/proxy.rb | 28 ++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/lib/falcon/middleware/proxy.rb b/lib/falcon/middleware/proxy.rb index aafd1a70..5f4825c1 100644 --- a/lib/falcon/middleware/proxy.rb +++ b/lib/falcon/middleware/proxy.rb @@ -37,13 +37,16 @@ class Proxy < Protocol::HTTP::Middleware VIA = "via" CONNECTION = "connection" - # HTTP hop headers which *should* not be passed through the proxy. + # HTTP hop-by-hop headers which *should* not be passed through the proxy, per RFC 9110 §7.6.1 and RFC 9112 §6.1/§7.1. `proxy-connection` is non-standard, but widely emitted by clients and treated as hop-by-hop by other proxies. HOP_HEADERS = [ "connection", "keep-alive", - "public", "proxy-authenticate", "proxy-authorization", + "proxy-connection", + "public", + "te", + "trailer", "transfer-encoding", "upgrade", ] diff --git a/releases.md b/releases.md index f1710013..62e64b5d 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,9 @@ # Releases +## Unreleased + + - Strip `te`, `trailer` and `proxy-connection` hop-by-hop headers in `Falcon::Middleware::Proxy`. + ## v0.57.0 - Update the Envoy cluster example to use dedicated CDS and EDS services from `async-service-supervisor-envoy` v0.5. diff --git a/test/falcon/middleware/proxy.rb b/test/falcon/middleware/proxy.rb index ab948373..dbb14b27 100644 --- a/test/falcon/middleware/proxy.rb +++ b/test/falcon/middleware/proxy.rb @@ -39,6 +39,34 @@ def proxy_for(**options) expect(headers["proxy-authorization"]).to be_nil end + it "removes hop-by-hop headers" do + headers = Protocol::HTTP::Headers[ + "accept" => "*/*", + "te" => "gzip, trailers", + "trailer" => "etag", + "proxy-connection" => "keep-alive", + "upgrade" => "h2c", + ] + proxy.prepare_headers(headers) + expect(headers["accept"]).to be == ["*/*"] + expect(headers["te"]).to be_nil + expect(headers["trailer"]).to be_nil + expect(headers["proxy-connection"]).to be_nil + expect(headers["upgrade"]).to be_nil + end + + it "removes headers named by the connection header" do + headers = Protocol::HTTP::Headers[ + "accept" => "*/*", + "connection" => "x-custom-hop", + "x-custom-hop" => "value", + ] + proxy.prepare_headers(headers) + expect(headers["accept"]).to be == ["*/*"] + expect(headers["connection"]).to be_nil + expect(headers["x-custom-hop"]).to be_nil + end + it "can select client based on authority" do request = Protocol::HTTP::Request.new("https", "www.google.com", "GET", "/", nil, headers, nil) From 1dfe0150a7e4f5d4e957ec7412439c91a23847e0 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 4 Nov 2026 13:13:39 +1300 Subject: [PATCH 2/6] Correct the RFC attribution for `Falcon::Middleware::Proxy::HOP_HEADERS`. --- lib/falcon/middleware/proxy.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/falcon/middleware/proxy.rb b/lib/falcon/middleware/proxy.rb index 5f4825c1..fa19e4c5 100644 --- a/lib/falcon/middleware/proxy.rb +++ b/lib/falcon/middleware/proxy.rb @@ -37,7 +37,7 @@ class Proxy < Protocol::HTTP::Middleware VIA = "via" CONNECTION = "connection" - # HTTP hop-by-hop headers which *should* not be passed through the proxy, per RFC 9110 §7.6.1 and RFC 9112 §6.1/§7.1. `proxy-connection` is non-standard, but widely emitted by clients and treated as hop-by-hop by other proxies. + # Connection-specific fields which intermediaries must remove before forwarding (RFC 9110 §7.6.1; RFC 9112 §7.1.3 for `trailer`, which is meaningless once the body is de-chunked), plus the legacy RFC 2616 §13.5.1 proxy-authentication fields. `public` is a response-only relic of RFC 2068 §14.35. HOP_HEADERS = [ "connection", "keep-alive", From d0f4643563d710892461c33f0f32efd7c76aa998 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 4 Nov 2026 13:15:59 +1300 Subject: [PATCH 3/6] Preserve `te: trailers` when stripping hop-by-hop headers in `Falcon::Middleware::Proxy`. --- lib/falcon/middleware/proxy.rb | 14 +++++++++++++- releases.md | 2 +- test/falcon/middleware/proxy.rb | 28 +++++++++++++++++++++++++++- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/lib/falcon/middleware/proxy.rb b/lib/falcon/middleware/proxy.rb index fa19e4c5..d4575194 100644 --- a/lib/falcon/middleware/proxy.rb +++ b/lib/falcon/middleware/proxy.rb @@ -37,6 +37,9 @@ class Proxy < Protocol::HTTP::Middleware VIA = "via" CONNECTION = "connection" + TE = "te" + TRAILERS = "trailers" + # Connection-specific fields which intermediaries must remove before forwarding (RFC 9110 §7.6.1; RFC 9112 §7.1.3 for `trailer`, which is meaningless once the body is de-chunked), plus the legacy RFC 2616 §13.5.1 proxy-authentication fields. `public` is a response-only relic of RFC 2068 §14.35. HOP_HEADERS = [ "connection", @@ -93,13 +96,22 @@ def lookup(request) end # Prepare the headers to be sent to an upstream host. - # In particular, we delete all connection and hop headers. + # In particular, we delete all connection and hop headers, but preserve `te: trailers` if the client sent it. + # @parameter headers [Protocol::HTTP::Headers] The request headers, modified in place. def prepare_headers(headers) + # Capture `te` before extraction, as a compliant HTTP/1.1 client also lists it in `connection`: + te = headers[TE] + if connection = headers[CONNECTION] headers.extract(connection) end headers.extract(HOP_HEADERS) + + # RFC 9113 §8.2.2 permits `te: trailers` over HTTP/2 and gRPC requires it to detect incompatible proxies. We forward request trailers end-to-end, so advertising them is accurate. Over HTTP/1.1, RFC 9110 §10.1.4 also requires `te` to be listed in `connection`, but the upstream protocol is not known here and the client writes its own `connection` header; this is the same trade-off made by Envoy and nginx. + if te&.trailers? + headers.add(TE, TRAILERS) + end end # Prepare the request to be proxied to the specified host. diff --git a/releases.md b/releases.md index 62e64b5d..057f882a 100644 --- a/releases.md +++ b/releases.md @@ -2,7 +2,7 @@ ## Unreleased - - Strip `te`, `trailer` and `proxy-connection` hop-by-hop headers in `Falcon::Middleware::Proxy`. + - Strip `te`, `trailer` and `proxy-connection` hop-by-hop headers in `Falcon::Middleware::Proxy`, preserving `te: trailers` which is permitted over HTTP/2 and required by gRPC. ## v0.57.0 diff --git a/test/falcon/middleware/proxy.rb b/test/falcon/middleware/proxy.rb index dbb14b27..e140e37c 100644 --- a/test/falcon/middleware/proxy.rb +++ b/test/falcon/middleware/proxy.rb @@ -49,12 +49,38 @@ def proxy_for(**options) ] proxy.prepare_headers(headers) expect(headers["accept"]).to be == ["*/*"] - expect(headers["te"]).to be_nil + expect(headers["te"]).to be == ["trailers"] expect(headers["trailer"]).to be_nil expect(headers["proxy-connection"]).to be_nil expect(headers["upgrade"]).to be_nil end + it "removes te if it does not include trailers" do + headers = Protocol::HTTP::Headers[ + "te" => "gzip", + ] + proxy.prepare_headers(headers) + expect(headers["te"]).to be_nil + end + + it "preserves te: trailers" do + headers = Protocol::HTTP::Headers[ + "te" => "trailers", + ] + proxy.prepare_headers(headers) + expect(headers["te"]).to be == ["trailers"] + end + + it "preserves te: trailers when te is listed in the connection header" do + headers = Protocol::HTTP::Headers[ + "connection" => "te", + "te" => "trailers", + ] + proxy.prepare_headers(headers) + expect(headers["connection"]).to be_nil + expect(headers["te"]).to be == ["trailers"] + end + it "removes headers named by the connection header" do headers = Protocol::HTTP::Headers[ "accept" => "*/*", From e743dc7dba792a71dde2c14c98d6bf4953525ab8 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 4 Nov 2026 13:16:20 +1300 Subject: [PATCH 4/6] Remove the stale `host` header when `Falcon::Middleware::Proxy` rewrites the request authority. --- lib/falcon/middleware/proxy.rb | 4 ++++ releases.md | 1 + test/falcon/middleware/proxy.rb | 13 +++++++++++++ 3 files changed, 18 insertions(+) diff --git a/lib/falcon/middleware/proxy.rb b/lib/falcon/middleware/proxy.rb index d4575194..947f5b26 100644 --- a/lib/falcon/middleware/proxy.rb +++ b/lib/falcon/middleware/proxy.rb @@ -36,6 +36,7 @@ class Proxy < Protocol::HTTP::Middleware VIA = "via" CONNECTION = "connection" + HOST = "host" TE = "te" TRAILERS = "trailers" @@ -129,6 +130,9 @@ def prepare_request(request, host) # The authority of the request must match the authority of the endpoint we are proxying to, otherwise SNI and other things won't work correctly. request.authority = host.authority + # HTTP/2 clients may send `host` alongside `:authority`; it now refers to the original authority, and the upstream connection derives its own from `request.authority` (RFC 9113 §8.3.1). + request.headers.delete(HOST) + if address = request.remote_address request.headers.add(X_FORWARDED_FOR, address.ip_address) forwarded << "for=#{address.ip_address}" diff --git a/releases.md b/releases.md index 057f882a..b1cfd2f7 100644 --- a/releases.md +++ b/releases.md @@ -3,6 +3,7 @@ ## Unreleased - Strip `te`, `trailer` and `proxy-connection` hop-by-hop headers in `Falcon::Middleware::Proxy`, preserving `te: trailers` which is permitted over HTTP/2 and required by gRPC. + - Remove the `host` header in `Falcon::Middleware::Proxy` when rewriting the request authority, so HTTP/2 upstreams no longer receive a `host` that disagrees with `:authority`. ## v0.57.0 diff --git a/test/falcon/middleware/proxy.rb b/test/falcon/middleware/proxy.rb index e140e37c..32920a10 100644 --- a/test/falcon/middleware/proxy.rb +++ b/test/falcon/middleware/proxy.rb @@ -133,6 +133,19 @@ def proxy_for(**options) expect(response.read).to be == "RuntimeError" end + it "removes the host header when rewriting the authority" do + headers = Protocol::HTTP::Headers["host" => "www.google.com"] + request = Protocol::HTTP::Request.new("https", "www.google.com", "GET", "/", "HTTP/2", headers, nil) + host = proxy_for(authority: "upstream.example.com", endpoint: Async::HTTP::Endpoint.parse("https://upstream.example.com")) + + expect(request).to receive(:remote_address).and_return(nil) + + proxy.prepare_request(request, host) + + expect(request.authority).to be == "upstream.example.com" + expect(request.headers["host"]).to be_nil + end + it "logs proxy request details when preparing requests" do request = Protocol::HTTP::Request.new("https", "www.google.com", "GET", "/", "HTTP/1.1", headers, nil) host = proxy_for(authority: "www.google.com", endpoint: Async::HTTP::Endpoint.parse("https://www.google.com")) From 4b6c8b84f086ee9b9cd7bdf70acbdc68d51ad6d8 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 4 Nov 2026 13:16:30 +1300 Subject: [PATCH 5/6] Add a test pinning case-insensitive extraction of headers named by `connection`. --- test/falcon/middleware/proxy.rb | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/falcon/middleware/proxy.rb b/test/falcon/middleware/proxy.rb index 32920a10..85691adc 100644 --- a/test/falcon/middleware/proxy.rb +++ b/test/falcon/middleware/proxy.rb @@ -93,6 +93,20 @@ def proxy_for(**options) expect(headers["x-custom-hop"]).to be_nil end + it "removes headers named by the connection header regardless of case" do + headers = Protocol::HTTP::Headers[ + "Accept" => "*/*", + "Connection" => "TE, Keep-Alive", + "TE" => "gzip", + "Keep-Alive" => "timeout=5", + ] + proxy.prepare_headers(headers) + expect(headers["accept"]).to be == ["*/*"] + expect(headers["connection"]).to be_nil + expect(headers["te"]).to be_nil + expect(headers["keep-alive"]).to be_nil + end + it "can select client based on authority" do request = Protocol::HTTP::Request.new("https", "www.google.com", "GET", "/", nil, headers, nil) From 2b2c114b539dad5e619f3eb7004f62f939c64b38 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 5 Nov 2026 23:02:03 +1300 Subject: [PATCH 6/6] Shorten the documentation. --- lib/falcon/middleware/proxy.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/falcon/middleware/proxy.rb b/lib/falcon/middleware/proxy.rb index 947f5b26..31dec39d 100644 --- a/lib/falcon/middleware/proxy.rb +++ b/lib/falcon/middleware/proxy.rb @@ -41,7 +41,7 @@ class Proxy < Protocol::HTTP::Middleware TE = "te" TRAILERS = "trailers" - # Connection-specific fields which intermediaries must remove before forwarding (RFC 9110 §7.6.1; RFC 9112 §7.1.3 for `trailer`, which is meaningless once the body is de-chunked), plus the legacy RFC 2616 §13.5.1 proxy-authentication fields. `public` is a response-only relic of RFC 2068 §14.35. + # Connection-specific fields which intermediaries must remove before forwarding (RFC 9110 Section 7.6.1), plus the legacy proxy-authentication fields. HOP_HEADERS = [ "connection", "keep-alive", @@ -109,7 +109,7 @@ def prepare_headers(headers) headers.extract(HOP_HEADERS) - # RFC 9113 §8.2.2 permits `te: trailers` over HTTP/2 and gRPC requires it to detect incompatible proxies. We forward request trailers end-to-end, so advertising them is accurate. Over HTTP/1.1, RFC 9110 §10.1.4 also requires `te` to be listed in `connection`, but the upstream protocol is not known here and the client writes its own `connection` header; this is the same trade-off made by Envoy and nginx. + # `te: trailers` is preserved since request trailers are forwarded end-to-end, and gRPC requires it: if te&.trailers? headers.add(TE, TRAILERS) end @@ -130,7 +130,7 @@ def prepare_request(request, host) # The authority of the request must match the authority of the endpoint we are proxying to, otherwise SNI and other things won't work correctly. request.authority = host.authority - # HTTP/2 clients may send `host` alongside `:authority`; it now refers to the original authority, and the upstream connection derives its own from `request.authority` (RFC 9113 §8.3.1). + # The upstream connection derives its own `host` from `request.authority`: request.headers.delete(HOST) if address = request.remote_address