You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Updated after 9522945. The original text argued this was mainly a shared-cache problem. That half is gone: scripted and plain-HTTP callers no longer share an address, so the two shapes can no longer collide in a cache. What is left is the carrier itself, below.
Describe the bug
When a redirect is masked for a scripted caller, the target keeps riding in Location on a 200. Masking is right — fetch follows redirects before the transport can read them, and #3096 settled that. This is about the carrier, which was option (3) there and was not taken.
Location has no defined meaning on a 200. RFC 9110 §10.2.2: "The type of relationship is defined by the combination of request method and status code semantics", and the only combinations it defines are "For 201 (Created) responses, the Location value refers to the primary resource created by the request. For 3xx (Redirection) responses, the Location value refers to the preferred target resource". For every other 2xx the specification is silent, so this response asserts a relationship nothing defines to any reader that is not this client.
The same answer therefore reaches a cache, a log or a proxy as a plain success:
Nothing is poisoned — those are two different urls now — but the bottom row is stored, counted and logged as a successful response that happens to mention another url. Status choice matters to caches beyond this example, too: RFC 9110 §15.1 lists the heuristically cacheable statuses — "200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, and 501" — and 302 is absent, so masking moves an answer caches must not store on heuristics into one they may, whenever the author has not set an explicit policy.
#3107 reports a redirect that silently fails to navigate, and the mechanism runs through this carrier: the client integration branches on the Locationvalue, sending anything starting with http to window.location.href and everything else through soft navigation, where a cancelable microtask can drop the commit. So the shape of the carrier decides which path a redirect takes. Whatever replaces Location here has to keep same-origin resolution intact, or that hard-navigation fallback disappears along with it.
Prior art
Every peer masks the redirect, and none leaves the target in Location:
Remix deletes it outright and carries the target in its own headers: a 204 with X-Remix-Redirect / X-Remix-Status, headers.delete("Location").
React Router v7 encodes it as data at status 202, and says why not 200: "We use a 202 to avoid any automatic caching we might get from a 200 since a 'temporary' redirect should not be cached." Their choice is exactly right by the spec — 202 is not in §15.1's heuristically cacheable list.
Next.js answers 200 with x-action-redirect.
SvelteKit answers 200 with a JSON envelope { type: 'redirect', status, location }.
Expected behavior
A masked redirect should not be indistinguishable, to a cache or a proxy, from an ordinary successful answer that happens to mention another url.
Options
Move the target to a dedicated header and drop Location from the masked response. What Remix, Next.js and SvelteKit all do. Nothing downstream sees a claim it can misread, and the client integration reads a field that exists for exactly this. Costs a wire change and a name — and, per navigate() from flight-data consumer can silently drop a relative-Location redirect (reliable with absolute URLs) #3107, the integration must still resolve same-origin targets the way it does today.
Keep Location, change the masked status to one outside the heuristically cacheable set — React Router's 202. Smaller than (1) and it fixes how caches file the answer, but a Location on a 202 is still undefined by the spec.
Force no-store on a masked redirect, overriding the author's policy. Now that the addresses are split this only affects the scripted address, so it is narrow — and it treats the symptom rather than the claim.
Leave it and document that a masked redirect is a 200 by design.
I lean to (1). (2) is a reasonable middle if you would rather not name a new header. Given #3107, whichever lands is worth landing together with the client integration change.
Related
Follows #3096, which fixed which statuses mask and left how they mask open. #3107 is the client-side half of the same carrier. The cache-collision argument this issue originally made was resolved by 9522945 (#3094).
Describe the bug
When a redirect is masked for a scripted caller, the target keeps riding in
Locationon a200. Masking is right —fetchfollows redirects before the transport can read them, and #3096 settled that. This is about the carrier, which was option (3) there and was not taken.Locationhas no defined meaning on a200. RFC 9110 §10.2.2: "The type of relationship is defined by the combination of request method and status code semantics", and the only combinations it defines are "For 201 (Created) responses, the Location value refers to the primary resource created by the request. For 3xx (Redirection) responses, the Location value refers to the preferred target resource". For every other 2xx the specification is silent, so this response asserts a relationship nothing defines to any reader that is not this client.The same answer therefore reaches a cache, a log or a proxy as a plain success:
Nothing is poisoned — those are two different urls now — but the bottom row is stored, counted and logged as a successful response that happens to mention another url. Status choice matters to caches beyond this example, too: RFC 9110 §15.1 lists the heuristically cacheable statuses — "200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, and 501" — and
302is absent, so masking moves an answer caches must not store on heuristics into one they may, whenever the author has not set an explicit policy.Steps to reproduce
repro.mjs:Output on
next(cc2fb53e) — the table above.It is also load-bearing on the client
#3107 reports a redirect that silently fails to navigate, and the mechanism runs through this carrier: the client integration branches on the
Locationvalue, sending anything starting withhttptowindow.location.hrefand everything else through soft navigation, where a cancelable microtask can drop the commit. So the shape of the carrier decides which path a redirect takes. Whatever replacesLocationhere has to keep same-origin resolution intact, or that hard-navigation fallback disappears along with it.Prior art
Every peer masks the redirect, and none leaves the target in
Location:204withX-Remix-Redirect/X-Remix-Status,headers.delete("Location").202, and says why not200: "We use a 202 to avoid any automatic caching we might get from a 200 since a 'temporary' redirect should not be cached." Their choice is exactly right by the spec —202is not in §15.1's heuristically cacheable list.200withx-action-redirect.200with a JSON envelope{ type: 'redirect', status, location }.Expected behavior
A masked redirect should not be indistinguishable, to a cache or a proxy, from an ordinary successful answer that happens to mention another url.
Options
Locationfrom the masked response. What Remix, Next.js and SvelteKit all do. Nothing downstream sees a claim it can misread, and the client integration reads a field that exists for exactly this. Costs a wire change and a name — and, per navigate() from flight-data consumer can silently drop a relative-Location redirect (reliable with absolute URLs) #3107, the integration must still resolve same-origin targets the way it does today.Location, change the masked status to one outside the heuristically cacheable set — React Router's202. Smaller than (1) and it fixes how caches file the answer, but aLocationon a202is still undefined by the spec.no-storeon a masked redirect, overriding the author's policy. Now that the addresses are split this only affects the scripted address, so it is narrow — and it treats the symptom rather than the claim.200by design.I lean to (1). (2) is a reasonable middle if you would rather not name a new header. Given #3107, whichever lands is worth landing together with the client integration change.
Related
Follows #3096, which fixed which statuses mask and left how they mask open. #3107 is the client-side half of the same carrier. The cache-collision argument this issue originally made was resolved by 9522945 (#3094).