Repository navigation
Stop a redirect replaying the request it was answering - #9
Conversation
Pinning the socket stopped a redirect choosing an unchecked destination. It did not stop a redirect choosing what got sent there. Credentials were stripped across an origin boundary, but the method and the body were not, so a 307 — or a 302 answering a PUT — re-issued the whole request against whatever host the redirect named. For publishPost that is an article body, delivered to a site chosen by whoever controls the redirect, on behalf of a customer who asked only to publish to their own WordPress. Redirects now follow RFC 9110 section 15.4 method semantics. A 303, and a 301 or 302 answering a POST, become GET with the body dropped and the headers that described it removed, because a stale content-length makes the next request wait for bytes nobody will write. What rewriting cannot fix is 307 and 308, whose definition is that method and body survive; across an origin boundary those are refused outright, as is a 301 or 302 that would preserve any other non-GET method. Same-origin redirects still preserve method and body, so ordinary same-site behaviour is unchanged. Proxy-Authorization joins Authorization, Cookie and the API key in the credentials stripped when a hop crosses origin. DNS resolution now runs inside the caller's remaining timeout. The budget used to be read only after resolution returned, so a name server that never answered ran past the timeout it was meant to obey; a 400ms resolver against a 100ms budget took 401ms. It now races the remaining time and fails closed without learning an address or opening a socket. The lookup itself cannot be cancelled and may still be in flight afterwards, which is recorded in the runbook rather than glossed. Socket pinning, byte limits, TLS and SNI behaviour and every public signature are unchanged. No capability, provider, roadmap or deployment change; Phase 2 stays in progress. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PQ2eShwv2iVM3CQYpjEkxK
Final pre-merge review — findingsRead-only review against seven criteria, with runtime probes for the ones that cannot be settled by reading. No blockers. One non-blocking gap recorded at the end. 1. Origin comparison — scheme, normalized hostname, effective port ✅
The explicit-default-port and uppercase cases matter in the permissive direction — treating them as cross-origin would strip credentials on harmless same-site redirects. Scheme is part of the origin, so an One asymmetry worth knowing: 2. Body and header removal on 301/302/303
|
Closes the three outbound-request gaps recorded in the pre-merge review of #8. Narrow by design: one source file, one test file, two documents.
The gap
#8 stopped a redirect choosing an unchecked destination. It did not stop a redirect choosing what got sent there.
Credentials were stripped across an origin boundary, but the method and body were not. A
307— or a302answering aPUT— re-issued the entire request against whatever host the redirect named. ForpublishPostthat is an article body, delivered to a site chosen by whoever controls the redirect, on behalf of a customer who asked only to publish to their own WordPress.Two smaller gaps alongside it:
Proxy-Authorizationwas not in the strip list, and the timeout budget was read only after DNS resolution returned, so a hanging name server ran past the timeout it was meant to obey (measured: a 400 ms resolver against a 100 ms budget took 401 ms).What changed
1. Credential stripping —
Proxy-AuthorizationjoinsAuthorization,CookieandX-SuperTool-Key, stripped on every cross-origin hop.2. Redirect method semantics (RFC 9110 §15.4):
303on a non-GET/HEADGET, body dropped301/302on aPOSTGET, body droppedcontent-type,content-length,content-encoding,content-language,content-location,transfer-encodingremoved307/308301/302preserving any other non-GET method307/308Rewriting fixes the common case, which is what browsers have done for decades. What rewriting cannot fix is
307/308, whose whole definition is that method and body survive — there the only safe answer across an origin boundary is to refuse. A stalecontent-lengthon a dropped body is worse than merely wrong: the next request waits for bytes nobody will write.3. Resolver deadline — resolution races the caller's remaining budget. On timeout the request fails closed having learned no address and opened no socket. The lookup itself cannot be cancelled and may still be in flight afterwards; that is recorded in the runbook rather than glossed over.
Tests — 12 new, all six required proofs
never lets article content reach a cross-origin redirect targetPOSTbody containingTHE-ARTICLE-BODYis absent from the cross-origin hop — asserted on the whole serialized request, not just the body fieldturns a 303 into a GET and drops the bodyGET, bodyundefinedrefuses a cross-origin 307 or 308callshas length 1)strips proxy-authorization along with the other credentialsX-Keepretainedfails closed when the resolver hangs, without opening a socketgives each redirect hop the remaining budget, never a fresh onePlus: body-describing headers removed; cross-origin
301/302on aPUTrefused; same-origin307still preserves method and body; cross-originGETredirect still allowed; a slow resolver's cost is charged to the connection budget; an exhausted budget refuses before resolving again.The last four exist to catch over-blocking — the failure mode of a change like this is refusing traffic that was always fine.
Preserved
Socket pinning, byte limits (encoded and decoded), TLS/SNI and
Hostbehaviour, and every public signature:safeFetch,checkResolvedHost,resolveAndPin,BlockedRequestError,SafeFetchOptions.resolveAndPin's existing third parameter gained an optionaltimeoutMsfield rather than changing shape. All 56 pre-existing tests acrossnet-fetch,net-pinned,crawler.integrationandwordpress.integrationpass untouched.Verification
Run locally against PostgreSQL 16, matching the CI job:
npm run typecheck— cleannpm run lint— cleannpm test— 675 passed / 675, 39 files (was 663)npm run build— compiled successfullynpm run db:rehearse— passedBehavioral change worth knowing
A
POSTreceiving a301/302becomes aGETand loses its body. Correct, and what browsers do — but it makes one real case fail differently: a WordPress site stored ashttp://that redirects tohttps://will now see the publish arrive as aGET. It previously arrived as aPOSTstripped of its credentials and failed as a401. Neither is a working publish; the fix in both cases is to store thehttps://URL. Documented in ADR-017 and the runbook.Scope
No capability, provider, roadmap or deployment change.
capabilities.tsandroadmap.tsuntouched, nothing deployed, Railway unmodified, Phase 2 stays in progress.Generated by Claude Code