Repository navigation
Never rewrite a request target silently - #698
Merged
ronleizrowice-ant merged 9 commits intoOct 16, 2026
Merged
Conversation
While a filterRequest is set the request target was forwarded normalized. A request signed over its path then no longer verifies, and with a masked AWS credential it is re-signed, validly, for another path. network.ambiguousRequestTarget says what becomes of a target that names another path once normalized: "refuse" (default) answers 400 before the callback is asked, "asSpelled" forwards the spelling, "normalize" forwards the normalized form. Every other target goes out byte for byte. The callback gets both forms, and re-signing covers the bytes sent.
The rule against direct realpath calls and the one call that is made on purpose met on main.
Merged
…y refusal is recorded A plain-HTTP target with raw UTF-8 bytes, which only Bun's listener lets through, was answered 400. It is forwarded percent-encoded, as in 0.0.79. Bytes that are not UTF-8 are refused: the runtime has replaced them, so what the client named is lost. A raw "#" is refused in every mode: a request-target has no fragment, no client sends one, and upstreams differ in what they make of it. Every refusal of a target is recorded as a violation, and the line shows the URL as spelled, its query or fragment cut.
…s answered and recorded A raw "#" in a request-target was answered 400. Python's urllib, Perl's LWP and Java's HttpClient leave the fragment in the absolute form they send a proxy, and 0.0.79 dropped it there. It is cut before anything is read, judged, signed, reported or sent, in every mode and on both legs. A fragment right after the authority leaves the root. In a terminated tunnel, a request the runtime's HTTP parser refuses (raw bytes outside ASCII in the target, as curl sends a query) closed the connection with no answer and no record. It is answered 400 with what to do, and recorded. Nothing is written while an earlier response is under way.
This was referenced Oct 13, 2026
Merged
… OPTIONS * keeps its host (#702) * A refused upload keeps its answer, the mux's 502 is for a failed dial, 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 mux's 502 is an answer, for a client that has had none 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. * test: the mux's 502 for a backend that fails unanswered, on Linux alone 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
approved these changes
Oct 14, 2026
dylan-conway
enabled auto-merge (squash)
October 14, 2026 02:12
dylan-conway
approved these changes
Oct 14, 2026
dylan-conway
disabled auto-merge
October 14, 2026 16:27
dylan-conway
enabled auto-merge (squash)
October 14, 2026 16:27
ronleizrowice-ant
disabled auto-merge
October 16, 2026 01:24
ronleizrowice-ant
enabled auto-merge
October 16, 2026 01:25
ronleizrowice-ant
deleted the
fix/never-rewrite-a-request-target-silently
branch
October 16, 2026 01:37
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Waiting for a second reviewer. It is approved and every check passes, but the branch rules want the most recent reviewable push approved by someone other than whoever pushed it. Here that push is the merge of #702 into this branch, so an approval from whoever made that merge does not count. An approval from anyone else with write access is enough: auto-merge is on and will take it. Nothing has changed since but a merge of main.
This branch now holds #702 too, which was stacked on it and has been merged into it (
9ce832f: five files, +150/-16). So merging this PR lands both. See "Three small ends (#702)" at the foot, and #702 for its own description and figures.While a
filterRequestis set,mainforwards the request target normalised (#663; in no release). So:/fail.PUT /a/../b.txtoverwritesb.txt, and/bucket/../up.txtleaves the bucket. Each reports success: 22 of 44.0.0.79 forwards the spelling, and shows the callback only a parsed URL:
/denied/../allowed/xis judged as/allowed/x, while a literal upstream acts underdenied/. That is what #663 set out to close.What changes
New
network.ambiguousRequestTarget, for a target that names another path once normalised: a dot segment (%2eincluded), leading//, or\in the path."refuse"(default)X-Proxy-Error: ambiguous-request-targetbefore the callback is asked. The body names the option. Recorded as a violation. The connection ends, as after a denial."asSpelled"/a\b/../admin, normalised/a/admin, is/adminto one that keeps the\."normalize"main's behaviour.#and what follows are cut off first, in every mode. Python'surllib, Perl's LWP and Java'sHttpClientleave the fragment in the absolute form they send a proxy, and 0.0.79 dropped it there. The callback, a signature, the upstream and a violation line get the target without it.%23is data.%EF%BF%BD, another resource).filterRequest: 400 with what to do (curl 'https://host/x?q=café': percent-encode the bytes) andConnection: close. It used to be a closed connection with no answer.requestTarget(as spelled, without a fragment),normalizedRequestTargetandrequestTargetIsAmbiguous.request.urlmeans what it did in 0.0.79.filterRequest, and insrt proxy, nothing changes on the wire to the upstream.The trade-off, for whoever decides
Neither form is safe to forward silently. The spelling lets a literal upstream act on what the policy did not judge. The normalised form changes which resource the request names. So the default forwards neither, and says so.
Its cost: a caller who upgrades from 0.0.79 with a
filterRequestgets 400s for requests that worked, on every host. Of ten common clients, eight send a leading//as typed (curl,wget,git, Node'sfetchamong them). Onlycurl --path-as-isandurllibsend a raw... None sends#to an origin."asSpelled"keeps all they send...%2f,..;and an inner//are the same in both forms, and stay the policy's own matter.Testing
main:mainrewrites 34. Here nothing is rewritten outside"normalize", and the default refuses 23."asSpelled"66 of 66 and 44 of 44, as on 0.0.79.urllibwith a fragment, with and without a path, gets 200 and 0.0.79's wire.curlwithcaféin the query over TLS gets the 400 and its advice.request-target-forwarding, of which about 475 fail onmain's source. 173 mutations: 171 fail a test, and two change no answer.user:secret@in the authority, a token in the query and one in the fragment, on both legs and in an absolute-form target inside a tunnel: none reaches a violation line. A request line with an injected header line is recorded as the authority alone.request-rewritingnow run under"normalize".Not covered
filterRequestforwards the parsed path, as in 0.0.79.http://host#frag) stay a bare 400, unrecorded, as in 0.0.79: nothing has authenticated the sender at that point.srt proxy's strict spelling still answers#with 400 on its plain leg.Host,requireAsciiHost, the SigV4 refusals, 407.Merge order
Meets #700 in one list of options (
sandbox-manager.ts). Whichever merges second needsmainmerged in.Three small ends (#702)
curl: (56); the 400 arrived in 2 to 5 of 40). The socket is now ended, and destroyed a second later, as after a denial: 480 of 480.502 … dial failedwas written into established connections, TLS tunnels included (18 to 29 of 30; as in 0.0.79). It is now written only where nothing has gone back to the client yet.OPTIONS *is judged under its own host. The URL forfilterRequestwas the authority and the target put together:https://host*. The target URI of the asterisk form has an empty path (RFC 9112 3.3):https://host/, withrequestTarget*.