Skip to content

A raw Response bypasses the Location and X-Revalidate bounds that redirect() and the revalidate helper enforce #3158

Description

@frenzzy

Summary

redirect() refuses a target past RESPONSE_HEADER_VALUE_LIMIT, but a raw Response returned from a server function reaches the transport with no such check — so a ~1 MB Location becomes a ~1 MB X-Server-Function-Redirect on the wire.

Tested against next @ fa137614, built from source, Node 24.19.

Reproduction

registerServerFunction("bigloc", async () =>
  new Response(null, { status: 302, headers: { Location: "/" + "x".repeat(1e6) } })
);
raw Response      -> status=200  X-Server-Function-Redirect length = 1000021
redirect() helper -> refused with a TypeError for the same value

(Probe headers: Sec-Fetch-Site: same-origin, content-type: application/json, x-server-function-format: 8, plus a createEvent. Without those a probe here answers 400 or a bodiless 500 and looks like a clean negative.)

The same asymmetry applies to X-Revalidate: initWithRevalidate() bounds the key list, a hand-built Response does not.

Why it matters

The bound exists because an over-long response header is not delivered — it is dropped or rejected by the proxy, after the handler has already run. redirect()'s own refusal is the right behaviour and its reasoning applies identically here: a trimmed target is a different address, so refusing is safer than truncating.

The gap is that the check lives at the helper rather than at the edge where the header is written. An author who builds a Response by hand — a perfectly ordinary thing to do — gets a committed mutation followed by a network-level failure the client cannot interpret.

Reachability: ordinary application code, but it needs unbounded input echoed into a hand-built redirect target. Not attacker-reachable on its own.

Options

  1. Enforce the bound where the header is written — in the transport path that sets REDIRECT_HEADER / X-Revalidate, mirroring what the helpers already do. One check, applies to every producer, and the helpers' own refusal becomes a fast path rather than the only guard.
  2. Refuse at the merge seam — validate any Location / revalidate header on a returned Response when it is folded, leaving the helpers untouched.
  3. Truncate instead of refusing. I'd argue against it for Location for the reason redirect() already gives: a trimmed target is a different address. It may be defensible for the revalidate key list, where a dropped key is a missed invalidation rather than a wrong destination — but those are different enough that one policy for both looks wrong.
  4. Document it as an author responsibility. Weakest option: the failure surfaces at the proxy, long after the mutation committed, with nothing naming the cause.

(1) seems right — it makes the invariant a property of the transport rather than of one calling convention.

Happy to send a PR with the check plus a regression test covering a raw Response on both the scripted and unscripted paths, with the helper's existing refusal as the control.

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