Skip to content

A refused upload keeps its answer, no clear-text 502 in a tunnel, and OPTIONS * keeps its host - #702

Merged
dylan-conway merged 3 commits into
fix/never-rewrite-a-request-target-silentlyfrom
fix/a-refused-upload-keeps-its-answer
Oct 14, 2026
Merged

dylan-conway merged 3 commits into
fix/never-rewrite-a-request-target-silentlyfrom
fix/a-refused-upload-keeps-its-answer

Conversation

@ronleizrowice-ant

@ronleizrowice-ant ronleizrowice-ant commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #698 (base: its branch). Three small ends of the same work. Parts 1 and 3 correct code that is in no release.

1. A refused upload keeps its answer

#698 answers 400 to a request the HTTP parser refuses in a terminated tunnel, and destroyed the socket as soon as the answer was flushed. With a body still on its way, unread, the close became a reset, and the reset discarded the answer. It now ends the socket and destroys it a second later, as a denial by filterRequest does.

curl --data-binary @file 'https://host/x?q=café', direct and through two socat relays:

the 400 arrives #698 here, three runtimes
256 KiB, 1 MB 2 to 5 of 40 (curl: (56)) 480 of 480

The violation was recorded either way.

2. The mux's 502 is an answer, for a client that has had none

Its handler for a failed dial ran on any error of the backend connection, at any time. So a backend that went away under an upload had HTTP/1.1 502 Bad Gateway … mux backend dial failed (EPIPE) written, in clear text, into whatever the client was in the middle of. In a TLS tunnel that is what curl reports as wrong version number. As in 0.0.79.

A relay recorded what went back to curl past the CONNECT's own answer, over 30 refused uploads:

#698 here
tunnels with clear text in them 18 to 29 of 30, by runtime 0 of 30

The 502 is now written only where nothing has gone back to the client yet: a dial that fails, or a backend that fails before its first reply, on plain HTTP as in a CONNECT. There the client is waiting for an answer, and this is one. After that the error is logged, and the close that follows ends the client.

Any bytes count as something gone back, an interim 100 Continue included. A backend that fails right after one leaves a bare close. To tell that apart the mux would have to parse status lines, which it does not.

3. OPTIONS * is judged under its own host

The URL for filterRequest was the authority and the target put together, so the asterisk form gave https://host*. A callback that reads the host off the URL got host*. (With a port in the authority the URL did not parse, and the request got 403.) Since #663; 0.0.79 closed the tunnel and never asked.

RFC 9112 3.3: the target URI of the asterisk form has an empty path. So the callback gets https://host/, and requestTarget says *, which is what goes on the wire.

Testing

Each part has a test that fails without it, run on all four combinations of parts 1 and 2:

  • Part 1: curl as a child process, through SandboxManager. A client in the test's own event loop reads the answer the moment it is there and shows nothing; so does curl with no mux in front.
  • Part 2: a client that is still sending when its backend goes, which has answered or has not. The "has not" case runs on Linux alone (1,200 repetitions, half under CPU contention: all pass): on macOS and Windows that client is just closed, so there is no 502 to tell apart. And, pinned for the first time, the 502 for a dial that fails.
  • Part 3: three modes, end to end. Driven with an upstream on port 443 in a private network namespace, on three runtimes.

37 network test files on Bun 1.4.2 and 1.4.3: nothing fails that does not on #698.

…, and OPTIONS * keeps its host

A request the parser refuses in a tunnel had its socket destroyed as soon as
the 400 was flushed. A body still on its way, unread, turned the close into a
reset, which discarded the answer. The socket is ended, and destroyed a second
later, as after a denial.

The mux's handler for a failed dial ran on any error of the backend
connection, so a backend that went away under an upload had a clear-text 502
written into an established connection, a TLS tunnel included. Past the dial
the error is logged, and the close that follows ends the client.

The URL given to filterRequest for the asterisk form was the authority and the
"*" put together, which reads the "*" into the host. Its path is empty
(RFC 9112 3.3).
The condition was that the dial had failed. But the mux is in front of plain
HTTP too, and a backend that fails after the dial and before any reply left
the client a bare close where it used to get a readable 502. What makes the
clear text harmful is that something has already gone back.
@ronleizrowice-ant ronleizrowice-ant changed the title A refused upload keeps its answer, the mux's 502 is for a failed dial, and OPTIONS * keeps its host A refused upload keeps its answer, no clear-text 502 in a tunnel, and OPTIONS * keeps its host Oct 13, 2026
Whether a backend that goes away shows at the mux as an error is the
platform's. On macOS and Windows the client in this case is just closed, so
there is no 502 to tell apart.
@dylan-conway
dylan-conway merged commit 9ce832f into fix/never-rewrite-a-request-target-silently Oct 14, 2026
16 checks passed
@dylan-conway
dylan-conway deleted the fix/a-refused-upload-keeps-its-answer branch October 14, 2026 02:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants