Skip to content

A masked redirect keeps its target in Location on a 200 #3102

Description

@frenzzy

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:

plain HTTP               | 302 | location: /elsewhere | cache-control: public, max-age=60
scripted (data address)  | 200 | location: /elsewhere | cache-control: public, max-age=60

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.

Steps to reproduce

mkdir sf-mask && cd sf-mask && npm init -y
npm i @solidjs/web@next
node repro.mjs

repro.mjs:

import { AsyncLocalStorage } from "node:async_hooks";
globalThis[Symbol.for("solid.RequestContext")] = new AsyncLocalStorage();
const srv = await import("@solidjs/web/server-functions/server");
const { respond } = await import("@solidjs/web");

// a redirect carrying an explicit cache policy — ordinary HTTP, redirects are cacheable
const ref = srv.createServerReference(
  srv.registerServerReference("redir", async () =>
    respond(undefined, {
      status: 302,
      headers: { location: "/elsewhere", "cache-control": "public, max-age=60" }
    })));
srv.GET(ref);

const show = async (label, url, scripted) => {
  const r = await srv.handleServerFunctionRequest(new Request(url, {
    headers: {
      "Sec-Fetch-Site": "same-origin",
      ...(scripted ? { "X-Server-Function-Instance": "i" } : {})
    }
  }));
  console.log(label.padEnd(24), "|", r.status,
    "| location:", (r.headers.get("location") ?? "-").padEnd(10),
    "| cache-control:", r.headers.get("cache-control") ?? "(none)");
};

await show("plain HTTP", "http://localhost/_server/redir", false);
await show("scripted (data address)", "http://localhost/_server/data/redir", true);

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 Location value, 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

  1. 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.
  2. 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.
  3. 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.
  4. 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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions