Skip to content

PPR: capture-time taint guard — refuse to store shells that read per-request data into shell content #653

Closed

Description

@ivogt

Problem

Opting a route into PPR asserts "this document's shell contains nothing visitor-specific," but nothing verifies it. A handler is free to read cookies()/headers() (PPR is not a cache() scope — the existing INSIDE_CACHE_EXEC guard does not apply) and bake the value into the shared prelude. Middleware guards access, not content. Today this is a silent cross-visitor leak waiting on a code-review miss.

Direction (defense in depth — explicit opt-in remains the model; router-level default-on is rejected, see #648 notes)

During a capture render, flag any per-request read (cookies()/headers()/other request-identity surfaces) that executes OUTSIDE a hole's masked scope, reusing the taint machinery pattern from the cache scopes. If tainted: REFUSE to store (route silently stays axis 1) with a once-per-key diagnostic naming the read and the fix ("move it into a hole or set ppr: false"). Loaders are already exempt by construction (masked at capture). Decide during design whether a per-route escape hatch (ppr: { allowRequestReads: true }) is wanted or whether refusal is absolute.

Test plan

Unit: cookies() in handler → capture refuses + diagnostic; same read inside a hole (behind loading()) → capture proceeds; repeated requests warn once. E2e (dev + production): a tainted fixture stays x-rango-shell MISS with the diagnostic, an equivalent hole-scoped fixture reaches HIT.

Activity

  1. ivogt commented on Aug 30, 2026

    @ivogt
    CollaboratorAuthor

    Capture-time identity guard is in.

    cookies() / headers() trip under _shellCaptureRun (src/server/cookie-store.ts). The capture then refuses on _shellCaptureGuardTripped (src/rsc/shell-capture.ts) — no store write, once-per-key diagnostic, route stays axis 1. Loaders are already masked at capture, so a hole-scoped read still bakes. Matches the issue's direction (defense in depth on an explicit ppr opt-in; no router-level default-on).

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