Repository navigation
feat(http): treat 2xx CONNECT responses as tunnel mode - #7
Conversation
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars.
|
A successful CONNECT response (any 2xx, RFC 9110 section 9.3.6) now converts the H1 session to tunnel mode, mirroring the 101 upgrade path: the body reader becomes close-delimited (serving pipelined preread bytes first), the body writer is close-delimited, and keepalive is disabled before the Connection header value is chosen so the response is sent with `Connection: close`. This is the session primitive needed for forward-proxying (praxis-proxy/praxis#781). With only the `allow_connect_method_proxying` gate flipped, a CONNECT 200 left the session in ordinary framing, so client tunnel bytes were never read and the 200 risked chunked framing. Non-2xx responses to CONNECT keep ordinary request/response framing. The 101 upgrade path is unchanged. Signed-off-by: Ed Snible <snible@us.ibm.com>
9adef9b to
02a8aec
Compare
The 2xx-to-CONNECT tunnel conversion on the downstream session left
the upstream half in ordinary framing: after the 200 head was written,
was_upgraded() was true, so the H1 relay's framed body tasks from the
upstream were rejected by downstream_response_task_filter ("received
body task after downstream 101 upgrade") and never reached the
client - the session was half-tunnel. test_connect_proxying_allowed_h1
and test_connect_proxying_allowed_h1_without_host regressed.
Mirror the 101 upgrade on the remaining halves:
- v1/client.rs: a 2xx response to a CONNECT request marks the session
upgraded and reads the rest of the connection close-delimited, so
tunnel bytes are emitted as UpgradedBody tasks and the relay's
existing upgrade machinery (duplex continue, request writer
conversion) applies to CONNECT tunnels as well.
- pingora-proxy: downstream_response_task_filter marks a 2xx response
to a CONNECT request as upgraded at header-filter time, so header
and tunnel body tasks filtered in one batch stay consistent
(was_upgraded() only flips once the head is written).
Also:
- v1/server.rs: init_body_writer checks the CONNECT tunnel before the
bodyless-status rules, so a 204 to CONNECT writes tunnel mode
instead of CL(0) (RFC 9110 section 9.3.6: any 2xx switches to
tunnel mode).
- Doc: quote the actual RFC 9110 section 9.3.6 text; drop the
Content-Transfer-Encoding clause (that field appears nowhere in
RFC 9110).
Signed-off-by: Ed Snible <snible@us.ibm.com>
Three regressions in the proxied CONNECT tunnel path: - A proxied 2xx to CONNECT could go out with `Transfer-Encoding: chunked`. The usual upstream reply has no framing headers, so after the upstream half became close-delimited its header task arrived with end == false, and h1_response_filter added chunked while the downstream writer was close-delimited. RFC 9110 section 9.3.6: "A server MUST NOT send any Transfer-Encoding or Content-Length header fields in a 2xx (Successful) response to CONNECT." - A tunnel 2xx that carried Content-Length was never flushed. The close-delimited tunnel writer never reports finished(), and should_flush is false when Content-Length is present, so the head stayed in the write buffer. A TLS client waits for the 2xx before it sends ClientHello, so the tunnel stalled. - downstream_response_task_filter marked a CONNECT 2xx as upgraded for every downstream session type. An HTTP/2 tunnel stays framed in DATA frames and never upgrades, so its body tasks were rejected as "body task after downstream 101 upgrade". Fixes: - v1/server.rs: prepare_response_header removes Transfer-Encoding and Content-Length from a 2xx response to CONNECT. With Content-Length gone, the existing should_flush rule flushes the head. - http/server.rs: add ServerSession::is_connect_tunnel_resp, true only for an H1 session (the v1 method is now pub). - pingora-proxy: the task filter asks the downstream session, so seen_upgraded matches what was_upgraded() becomes. The upstream 101/upgrade block in prepare_response_header is left unchanged, to stay close to upstream pingora. Tests: unit tests for stripped framing headers and for the head flush on write_response_header and write_proxy_tasks; an H2 CONNECT write_response_tasks test; and two proxied CONNECT round-trip integration tests (default and proxy-task write paths), where the upstream sends a bare 200 and echoes, and the client sends only after it reads the 200. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Ed Snible <snible@us.ibm.com>
There was a problem hiding this comment.
Thanks for this PR! I found a few issues that need fixing before merging.
It would help to create tests for these issues alongside the passing tests for refusals, response and tunnel bytes in one read, and disabled connection reuse after CONNECT 200/204.
| // data. Marking it here (rather than relying on | ||
| // `was_upgraded()`, which flips only once the head is written) | ||
| // keeps header and body tasks in one batch consistent. | ||
| if self.downstream_session.req_header().method == Method::CONNECT |
There was a problem hiding this comment.
H2 DATA comes through as Body, so setting seen_upgraded here causes the next task to be rejected. This fails on the PR and passes on main. The subrequest case also panics on UpgradedBody. Can we check the protocols on both sides and return a normal error for combinations we don't support before sending the 2xx? H1->H2 needs handling too.
There was a problem hiding this comment.
Fixed in cd4b2cd. A CONNECT tunnel is now only allowed h1→h1 (both halves switch to raw bytes) or h2→h2 (both stay framed). reject_unsupported_connect_tunnel refuses any other pair, including subrequest and custom downstreams and both h1↔h2 directions, with a 502. It runs after the request filters that can change the method and before anything is sent upstream or downstream, so the subrequest UpgradedBody panic is unreachable. The h2-downstream seen_upgraded case was fixed in 36a3946 (ServerSession::is_connect_tunnel_resp is h1-only). Tests: session-type pairs, the 502 (also when a filter turns a GET into CONNECT), and a subrequest CONNECT refused before the upstream.
| // A 2xx response to CONNECT also switches the connection to | ||
| // tunnel mode, mirroring the upgrade handshake | ||
| // (RFC 9110 section 9.3.6). | ||
| self.upgraded = self.is_connect_tunnel_resp( |
There was a problem hiding this comment.
response_filter can turn the upstream 200 into a 403 after we've switched to tunnel mode here. We then send the 403 and reject the next UpgradedBody. I also tried 403->200: we send the 200, then reject Body. I'd keep the original upstream method and status and check the downstream decision after filtering, before writing the header.
There was a problem hiding this comment.
Fixed in cd4b2cd, as you suggested. The session records the upstream's own tunnel decision from its original response, before any filter runs (ConnectTunnelStatus). downstream_response_task_filter compares it with the final status before writing the header, and returns InvalidHTTPHeader on a mismatch, so 200→403 and 403→200 both fail with nothing written. Changes within the same decision (200→204, 403→407) are still allowed. Tests cover both directions with the header alone, and end to end through h1_response_filter with a real response_filter.
| } else { | ||
| // already initialized as a framed (empty) body: | ||
| // reinterpret the rest of the stream as a tunnel | ||
| self.body_reader.convert_to_close_delimited(); |
There was a problem hiding this comment.
Sending the CONNECT head and tunnel bytes in one read works. Sending the same bytes in two reads fails with Sent data after end of body while we're waiting for the upstream response (read_body_or_idle(true), default settings). Could we keep those bytes in overread until CONNECT succeeds? The result shouldn't depend on how the bytes are split across reads.
There was a problem hiding this comment.
Fixed in 24653ae. While a CONNECT isn't established yet, the idle branch of read_body_or_idle keeps the bytes in overread, where one-read bytes already are, and stays pending. A 2xx then serves them first through convert_to_close_delimited. It initializes the body reader first, otherwise a later init would replace the overread and drop the bytes. Keepalive is turned off, so a refused CONNECT closes the connection instead of parsing them as a request, and it runs before the pipelining branch. Unit tests in test_connect_early_data; test_connect_tunnel_bytes_in_a_later_read in test_basic reproduces your Sent data after end of body without the fix.
| if self.downstream_session.req_header().method == Method::CONNECT | ||
| && resp.status.is_success() | ||
| { | ||
| *seen_upgraded = true; |
There was a problem hiding this comment.
I enabled gzip and sent CONNECT with Accept-Encoding: gzip. With a 200 carrying Content-Type: text/plain, the tunnel data came out gzip encoded. UpgradedBody still goes through the downstream response_body_filter. Should compression and decompression stop when CONNECT succeeds, as they do for 101? Refused CONNECT requests would still get normal response compression.
There was a problem hiding this comment.
Fixed in 9494678. ResponseCompressionCtx now records whether the request is CONNECT. It does this even while disabled, so a ctx enabled later still knows. A 2xx to CONNECT is then treated like 101: no compression or decompression for the rest of the connection, and will_decompress agrees. Since the ctx is shared, this covers the downstream module and the proxy's upstream compression. Refused CONNECT responses are still compressed. Tests at the ctx, module, and integration level (test_connect_tunnel_not_compressed, your gzip + text/plain case).
| if self.is_connect_tunnel_resp(header) { | ||
| self.set_keepalive(None); | ||
| } |
There was a problem hiding this comment.
h1_response_filter adds Transfer-Encoding: chunked to a CONNECT 200 with no Content-Length. That header survives even though the writer sends raw tunnel bytes. RFC 9110 §9.3.6 says successful CONNECT responses must omit both fields. Removing them here would also cover headers added by the proxy filters. Those filters should skip adding chunked encoding for CONNECT success too.
This could replace the existing if block:
| if self.is_connect_tunnel_resp(header) { | |
| self.set_keepalive(None); | |
| } | |
| if self.is_connect_tunnel_resp(header) { | |
| header.remove_header(&header::CONTENT_LENGTH); | |
| header.remove_header(&header::TRANSFER_ENCODING); | |
| self.set_keepalive(None); | |
| } |
There was a problem hiding this comment.
The prepare_response_header strip landed in 36a3946, matching your suggestion: it removes CL and TE and calls set_keepalive(None). In 48f9b37 the h1, h2 and custom response filters also stop adding chunked to a CONNECT 2xx, through one shared response_has_no_body helper. Test: h1_response_filter_chunks_only_body_bearing_responses (CONNECT 200/204 get no TE; CONNECT 403 and GET 200 still do).
| // https://datatracker.ietf.org/doc/html/rfc9110#status.101 | ||
| // as an "informational" header, this cannot have a body | ||
| self.upgraded = self | ||
| // A 2xx response to CONNECT also switches the connection to |
There was a problem hiding this comment.
CONNECT 200 with Content-Length: ignored is rejected in validate_response() before we get here. RFC 9110 §9.3.6 requires us to ignore Content-Length and Transfer-Encoding on these responses. An early return there would let this through while keeping length validation for refusals and other responses.
In validate_response(), just after getting resp_header:
if self.is_connect_tunnel_resp(resp_header) {
return Ok(());
}There was a problem hiding this comment.
Fixed in 48f9b37 with your suggested early return in validate_response. That function only does the CL check, and header bytes are validated earlier, so nothing else is skipped. Tests: invalid, conflicting and TE framing on a CONNECT 200 are accepted and the tunnel stays close-delimited; a CONNECT 403 with an invalid CL is still rejected.
| self.upgraded | ||
| } | ||
|
|
||
| /// Whether this response establishes a CONNECT tunnel: the request was |
There was a problem hiding this comment.
The docs above still say Upgrade+101 is required for was_upgraded() to return true. CONNECT+2xx now qualifies too. Could we update the H1 client/server docs and the wrapper wording? For the H1 methods:
/// Whether this request switched protocols through an Upgrade handshake
/// or established a tunnel through a successful CONNECT response.
The wrapper should mention that H2 still returns false.
There was a problem hiding this comment.
Updated in 48f9b37: the H1 client and server was_upgraded() docs use your wording. The ServerSession wrapper doc now says it is always false for HTTP/2, whose CONNECT tunnels stay framed. I also updated the task-filter doc and its error messages to mention the CONNECT tunnel.
Review follow-ups for the CONNECT tunnel change: - v1/client.rs: validate_response skips Content-Length validation for a 2xx to CONNECT. RFC 9110 section 9.3.6: "A client MUST ignore any Content-Length or Transfer-Encoding header fields received in a successful response to CONNECT." A tunnel response with an invalid or conflicting Content-Length was rejected before the tunnel switch; refusals keep the usual validation. - pingora-proxy: the h1, h2 and custom response filters no longer add Transfer-Encoding: chunked to a 2xx to CONNECT. The no-body rule (HEAD, 204, 304, CONNECT 2xx) is now one helper, response_has_no_body, shared by the three paths. - Docs: was_upgraded() on the H1 client and server, and the ServerSession wrapper (always false for HTTP/2), cover a successful CONNECT as well as a 101 upgrade, as do the downstream task-filter doc and its error messages. Tests: invalid, conflicting, and Transfer-Encoding framing on a CONNECT 200 is accepted and the tunnel stays close-delimited; a CONNECT 403 with an invalid Content-Length is still rejected; connection reuse is off after CONNECT 200, 204, and 200 with keep-alive; h1_response_filter adds chunked to CONNECT 403 and GET 200 but not to CONNECT 200 or 204. Signed-off-by: Ed Snible <snible@us.ibm.com>
With gzip enabled and a client sending Accept-Encoding: gzip, a 2xx to CONNECT whose header looked compressible (Content-Type: text/plain) got Content-Encoding: gzip, and the downstream module gzipped the UpgradedBody tunnel bytes. Only 101 skipped transformation. ResponseCompressionCtx records whether the request is CONNECT in request_filter, before the enabled check so a ctx enabled later still knows, and response_header_filter treats a 2xx to CONNECT like 101: no compression or decompression for the rest of the connection. will_decompress returns false in the same case so it keeps agreeing with the filter. The ctx is shared by the downstream module and the proxy's upstream compression, so both stop at the tunnel. A refused CONNECT is an ordinary response and is still (de)compressed. Tests: a CONNECT 200 or 204 is neither compressed nor decompressed and its body passes through, also when the level is raised after the request; a CONNECT 403 is compressed; through the downstream module, a CONNECT 200 text/plain with UpgradedBody is written raw with no Content-Encoding, and a CONNECT 403 is gzipped. Signed-off-by: Ed Snible <snible@us.ibm.com>
A client sending tunnel bytes after the CONNECT head, before the response, worked only when they arrived in the same read as the head. In a later read, the proxy's read_body_or_idle(true), polled while waiting for the upstream response, took them as data after the end of the empty CONNECT body and failed with "Sent data after end of body" (pipelining off, the default). The result depended on how the bytes were split across reads. The idle branch now keeps a non-zero read on a not yet established CONNECT in the body reader's overread, where bytes from the head read already are, and stays pending, as the pipelining stash does. It initializes the reader first so the bytes land after preread ones instead of being replaced when the reader is initialized later. A 2xx then serves them first as tunnel data through convert_to_close_delimited. Keepalive is turned off, so a refused CONNECT closes the connection instead of parsing them as a request. The branch runs before the pipelining one, so the bytes never become a pipelined prefix. Further idle polls stay pending and read nothing more until the response, keeping the client's flow control. Tests: early bytes in a second read, and in the head read followed by more, are served in order after a 200; a refused CONNECT with early bytes disables reuse; with pipelining on, early bytes go to the tunnel, not the pipeline. Signed-off-by: Ed Snible <snible@us.ibm.com>
Two ways a CONNECT tunnel could end up half-established, usually after the 2xx had already gone to the client: - Mixed protocols. Only HTTP/1 to HTTP/1 (both halves switch to raw bytes) and HTTP/2 to HTTP/2 (both stay framed) work. An HTTP/1 client through an HTTP/2 upstream, an HTTP/2 client through an HTTP/1 upstream, and subrequest or custom downstreams got task kinds the other side could not write; a subrequest panicked on UpgradedBody. reject_unsupported_connect_tunnel now refuses any other pair with a 502, after the request filters that can change the method and before the request is sent upstream, on the h1, h2, and custom paths. - A response_filter changing the status across the tunnel decision. The upstream HTTP/1 connection switches to tunnel mode as soon as it reads a 2xx to CONNECT. A filter turning that 200 into a 403 sent tunnel bytes as a refusal body; turning a 403 into a 200 told the client a tunnel was open that the upstream never opened. The session now records the upstream's own decision from its response header before any filter runs (ConnectTunnelStatus), and downstream_response_task_filter compares it with the final status before the header is written, refusing a mismatch with InvalidHTTPHeader. A status change within the same decision (200 to 204, 403 to 407) is still allowed, and a response the proxy produced itself has no upstream decision to check. Tests: the session-type pairs; a 502 for unsupported pairs, also when a filter turns a GET into CONNECT; a subrequest CONNECT refused before the upstream; a filter rewriting 200 to 403 or 403 to 200 rejected with nothing written, both through write_response_tasks with the header alone and end to end through h1_response_filter with a response_filter; agreeing rewrites accepted; no check without an upstream. Signed-off-by: Ed Snible <snible@us.ibm.com>
Integration tests through the test proxy's CONNECT listener, against an upstream that answers the CONNECT and then echoes: - tunnel bytes sent in the same write as the CONNECT head reach the origin first after the 200; - the same bytes sent in a later write, while the proxy is still waiting for the upstream response, do too. Without the overread stash this failed with "Sent data after end of body"; - an upstream 200 and tunnel bytes in one write reach the client as tunnel data, with no Transfer-Encoding on the head; - with downstream gzip on, Accept-Encoding: gzip, and an upstream 200 with Content-Type: text/plain, the tunnel bytes pass through raw and the head carries no Content-Encoding. The upstream also asserts that no tunnel bytes reach it before the tunnel is open. Signed-off-by: Ed Snible <snible@us.ibm.com>
|
Thanks for the review. I pushed six commits on top of 2412a6e; each inline thread names the commit that addresses it. Tests you asked for:
Each fix was mutation-checked: with the fix reverted, its tests fail. Locally on macOS, 🤖 Generated with Claude Code |
test_connect_proxying_allowed_h1 and its _without_host variant read
once and expected the whole response in it: a 200 and the "ok" the
upstream sends after it. Now that the head of a CONNECT 2xx is flushed
on its own and what follows is tunnel data, the head and "ok" can
arrive in separate reads, and the tests failed about half the time on
macOS (passing alone, failing more under load). The response itself
was complete and correct every time.
Both tests now read until the response ends with "ok" or the peer
closes, through a read_until_ends_with helper, keeping their
assertions. At the earlier commits of this branch the same tests also
caught a proxy panic ("Unexpected UpgradedBody task received on
un-upgraded downstream session") for this non-standard 200 with a
body; that is fixed on the branch and the tests now pass reliably.
Signed-off-by: Ed Snible <snible@us.ibm.com>
An empty request (a CONNECT, or an Upgrade GET) has its end queued as Body(None, true) before the relay loop starts. proxy_handle_upstream races that against reading the response. If a fast 101 or CONNECT 2xx was read first, the upstream session was already upgraded when the end marker arrived: send_body_to1 reported the body done, and the loop took request_done && was_upgraded() as the end of the tunnel and closed it. The client saw the 2xx or 101 and then EOF. The outcome depended on select! picking the response first whenever both were ready. A framed Body task now only completes the HTTP request sent before the protocol switch. Once the relay has upgraded, it no longer sets request_done, so the tunnel stays open; only the tunnel's own end (UpgradedBody end, or a closed pipe) ends it. Finishing that body is still right: it completes the request's empty Content-Length body without touching the tunnel's read side. The 101 case is inherited from upstream pingora; the same change covers it. Tests: a relay test drives proxy_handle_upstream with the end marker delivered after and before the switching response, for CONNECT 200 and for WebSocket 101; each step waits on one ready event, so the order is fixed. The after cases failed every time without this change. End to end, the test proxy holds back the end of an empty request when it sees x-delay-request-end, so the response reaches the relay first; the CONNECT tunnel and the WebSocket connection both still carry bytes afterwards (20/20 runs each). Signed-off-by: Ed Snible <snible@us.ibm.com>
Post-merge review findingsThese are from a review of the head (f32800e), which is identical to the merge commit 1fac8f2. Line links point to 1fac8f2. The must-fix is addressed in #8. The suggestions and nits are not fixed yet. Must-fixFixed in #8. This comment says early tunnel bytes "must not depend on whether they shared a read with the head" and that "a refused CONNECT closes the connection". That only holds for bytes from a later read. When the early bytes arrive in the same read as the CONNECT head, Then on a 403/407 with pipelining enabled, Suggested fix: in SuggestionsA CONNECT that carries a request body is still forwarded with its framing.
The cheapest fix is to reject CONNECT with CL>0 or any TE (400) in No test covers 4. No test covers "Recorded even while disabled". I moved the 5. "Either is refused" is only true for HTTP/1. This test doesn't reach the proxy-task write path. Uncached upstream tasks always go through The round-trip tests check exact tunnel bytes, but nothing checks teardown. A regression that leaves a tunnel half-open, or keeps the downstream connection alive after a tunnel, would pass. Suggested additions: after the echo, close the upstream socket and assert Nits8. This early return also skips the check when The tests never run the 10. This comment belongs to This assert runs in a spawned task, so a failure doesn't fail the test. It shows up as a 5s "CONNECT response head" timeout instead. The assert also only sees bytes that share a read with the head. Returning the 12.
The comment at line 887 ("CONNECT 2xx responses are not allowed to have response bodies, so this is non-standard behavior") is now out of date. This PR strips the Content-Length, so "ok" is tunnel data rather than a body. 14. "the same steps as Assisted-By: Claude Code |
What
Teach the H1 server session that a 2xx response to a CONNECT request establishes a tunnel (RFC 9110 section 9.3.6), by mirroring the existing 101 upgrade path in
prepare_response_header:is_connect_tunnel_resp(&self, header)helper: request method isCONNECTand response status is 2xx.prepare_response_header, a connect-tunnel response:Connectionheader value is chosen, so the response is emitted withConnection: close;upgraded = trueand converts the body reader to close-delimited — viainit_body_reader()(seeding frompreread_body) when not yet initialized, orconvert_to_close_delimited()(adopting the overread buffer) when it was, so bytes the client pipelined behind the CONNECT head are served as the first tunnel data;is_upgradebranch becomeselse if, leaving the 101 path unchanged.init_body_writeruses a close-delimited writer for connect-tunnel responses.Non-2xx responses to CONNECT (e.g. a proxy policy denial like 403) keep ordinary request/response framing.
Why
This is the session primitive needed for forward-proxying (praxis-proxy/praxis#781). The
allow_connect_method_proxyinggate already lets CONNECT reach theProxyHttpapp, but a CONNECT 200 left the session in ordinary framing: the request was framedContent-Length: 0(RFC 9112 section 6.3), so client-to-origin tunnel bytes were never read, and the 200 risked chunked framing being injected downstream. Without a raw-stream escape hatch onHttpSession, the only workable mechanism is to make the 2xx itself engage the same tunnel machinery the 101 path already provides.Downstream (the praxis proxy app) can then pump tunnel bytes through stable public APIs:
read_request_body()returns raw tunnel bytes including pipelined preread,write_response_body(bytes, end)writes raw bytes.Testing
New unit tests in
tests_stream(modeled on the 101/upgrade tests):connect_tunnel_resp_converts_session_to_tunnel— 2xx setswas_upgraded(), body reader isUntilClose, pipelined bytes are served first,Connection: closeis emitted.connect_tunnel_resp_after_body_reader_initialized— the framed-CL(0)-then-tunnel path; overread bytes still become tunnel data.connect_resp_non_success_keeps_framed_session— 403 does not convert the session.Results on macOS (aarch64):
cargo test -p quixotic-plecostomus-core --lib— 563 passed, 0 failed (includes the existing 101/upgrade regression tests, unchanged).cargo test -p quixotic-plecostomus-proxy --lib— 58 passed.cargo clippy -p quixotic-plecostomus-core— clean;cargo fmt --check— clean.pingora-proxytest_basicintegration tests requireopenresty(not installed locally);cargo clippy --all-featuresfails inside the third-partydial9-tokio-telemetrydependency.