Skip to content

fix(pages-router): pass revalidateReason "on-demand" to gsp/gssp (#1462)#1856

Merged
james-elicx merged 6 commits into
mainfrom
fix/issue-1462-revalidate-reason-ondemand
Jun 9, 2026
Merged

fix(pages-router): pass revalidateReason "on-demand" to gsp/gssp (#1462)#1856
james-elicx merged 6 commits into
mainfrom
fix/issue-1462-revalidate-reason-ondemand

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Finishes #1462. The build and stale revalidate reasons already work; this wires the on-demand path (res.revalidate() / revalidatePath/revalidateTag) to pass context.revalidateReason: "on-demand" into getStaticProps/getServerSideProps. Verified by the now-passing revalidate-reason > should support revalidateReason: "on-demand" case. Closes #1462

#1462)

Implements res.revalidate() for Pages Router API routes and wires the
on-demand revalidation trigger through to getStaticProps/getServerSideProps
context as revalidateReason: "on-demand".

res.revalidate(urlPath) issues an internal HEAD request to the target path
carrying the x-prerender-revalidate header (mirroring Next.js's api-resolver).
The Pages render path (dev-server and prod pages-page-data) detects that
header, bypasses the fresh/stale cache-hit short-circuits, and regenerates
the entry synchronously with reason "on-demand".

The build and stale reasons already worked; this finishes the on-demand case.

Closes #1462
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Jun 8, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@1856
npm i https://pkg.pr.new/vinext@1856

commit: cdc9027

Comment thread tests/nextjs-compat/revalidate-reason.test.ts Fixed
Comment thread tests/nextjs-compat/revalidate-reason.test.ts Fixed
Comment thread tests/nextjs-compat/revalidate-reason.test.ts Fixed
@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
workers-cache preview production
web preview production

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: on-demand revalidateReason wiring (#1462)

The core wiring is clean and the dev/prod (page-handler + page-data) paths are kept in sync, which is good. I have one critical security issue and a couple of smaller parity notes.

🔴 Critical: x-prerender-revalidate is treated as an unauthenticated presence check

In Next.js, x-prerender-revalidate is not a boolean flag — it carries the server's secret previewModeId, and the render path only treats a request as on-demand revalidation when the header value equals that secret:

// api-utils/index.ts — checkIsOnDemandRevalidate
const previewModeId = headers[PRERENDER_REVALIDATE_HEADER]
const isOnDemandRevalidate = previewModeId === previewProps.previewModeId
// api-resolver.ts — revalidate()
const headers = { [PRERENDER_REVALIDATE_HEADER]: context.previewModeId, ... }

This PR instead sends the literal "1" and gates purely on presence:

  • api-handler.ts:338 / pages-node-compat.ts:328headers: { [PRERENDER_REVALIDATE_HEADER]: "1" }
  • dev-server.ts:830(req.headers[PRERENDER_REVALIDATE_HEADER] ?? undefined) !== undefined
  • pages-page-handler.ts:464request.headers.has(PRERENDER_REVALIDATE_HEADER)

Because the value is never validated against a secret, any external client can send x-prerender-revalidate: 1 to any ISR page and force synchronous regeneration, bypassing the fresh/stale cache short-circuits. That is a cache-stampede / DoS vector: an attacker can repeatedly defeat ISR caching and pin every request to a full getStaticProps re-render. Next.js's secret-matching is precisely what prevents this.

This should match Next.js: derive/generate a previewModeId secret, have res.revalidate() send it, and have the render path compare the incoming header against it (equivalent to checkIsOnDemandRevalidate). A bare presence check is not safe to ship.

🔶 res.revalidate() success detection diverges from Next.js

api-handler.ts:341 / pages-node-compat.ts treat success as revalidateRes.ok || status === 404. Next.js's helper checks the cache header first, because a successful revalidate can return a non-200:

const cacheHeader = res.headers.get('x-vercel-cache') || res.headers.get('x-nextjs-cache')
if (cacheHeader?.toUpperCase() !== 'REVALIDATED' && res.status !== 200 && !(404 && onlyGenerated)) {
  throw new Error(`Invalid response ${res.status}`)
}

Two practical gaps:

  1. A notFound: true regeneration returns 404 — Next.js only treats that as success when unstable_onlyGenerated is set; this PR always swallows 404.
  2. unstable_onlyGenerated (the documented second arg to res.revalidate(urlPath, opts)) is not supported at all. The signature here is revalidate(urlPath) only.

🔶 Host/protocol fallback for the internal fetch

The internal HEAD request resolves the target via resolveRequestHost(req, "localhost") with no port. When trustProxy is off (the default), resolveRequestHost returns the raw Host header (which normally includes the port, so that's usually fine), but if Host is absent it falls back to bare "localhost" with no port — the loopback fetch would then hit the wrong port and fail. Worth confirming the Host header is always present in the prod-server / Workers entry paths, or threading the real listening address through instead of relying on the header.

Minor

  • dev-server.ts:830: (req.headers[X] ?? undefined) !== undefined is just req.headers[X] !== undefined — the ?? undefined is a no-op.
  • Test only covers the dev SSR HTML path. Consider also asserting the __NEXT_DATA__ / data-request path (isDataReq) and a no-op case (a normal request still returns "stale", not "on-demand") to lock in that presence-vs-secret behavior once the secret check lands.

Verification done

Confirmed Next.js behavior against current canary:

  • packages/next/src/server/api-utils/node/api-resolver.ts (revalidate() sends previewModeId)
  • packages/next/src/server/api-utils/index.ts (checkIsOnDemandRevalidate compares against previewModeId)

The "stale"/"build"/"on-demand" resolution in pages-page-data.ts:651 correctly mirrors render.tsx. The only blocking concern is the missing secret validation.

@ask-bonk

ask-bonk Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #1856. Summary of findings:

Critical

x-prerender-revalidate is treated as an unauthenticated presence check. In Next.js this header carries the secret previewModeId, and checkIsOnDemandRevalidate only treats a request as on-demand revalidation when the value equals that secret. This PR sends the literal "1" and gates on header presence (dev-server.ts:830, pages-page-handler.ts:464, api-handler.ts:338). Result: any external client can send x-prerender-revalidate: 1 to force synchronous regeneration of any ISR page, bypassing cache short-circuits — a cache-stampede/DoS vector. Verified against Next.js canary (api-resolver.ts + api-utils/index.ts).

Notable

  • Success detection diverges from Next.js — Next.js checks x-nextjs-cache: REVALIDATED and only accepts 404 when unstable_onlyGenerated is set; this PR always swallows 404 and doesn't support unstable_onlyGenerated at all.
  • Host fallback — internal HEAD fetch falls back to bare "localhost" (no port) when the Host header is absent; worth confirming Host is always present.

Minor

  • (req.headers[X] ?? undefined) !== undefined is a no-op; simplifies to !== undefined.
  • Tests only cover the dev SSR HTML path — suggest adding data-request and negative ("stale" stays "stale") cases.

The "stale"/"build"/"on-demand" resolution itself correctly mirrors render.tsx. The only blocking concern is the missing secret validation.

github run

…1462)

The on-demand `revalidateReason` detection treated `x-prerender-revalidate`
as an unauthenticated presence check and sent the literal value "1". Any
external client could send `x-prerender-revalidate: 1` to force synchronous
regeneration of any ISR page, bypassing the fresh/stale cache short-circuits
— a cache-stampede/DoS vector.

Mirror Next.js's `checkIsOnDemandRevalidate`: a request is only treated as
on-demand revalidation when the `x-prerender-revalidate` header value EQUALS a
secret (the vinext analog of `previewModeId`). Introduce a per-process
revalidate secret (`getRevalidateSecret`, 256-bit random hex via Web Crypto)
that `res.revalidate()` attaches to its internal loopback request, and a
constant-time `isOnDemandRevalidateRequest` equality check on the receiving
dev-server and Pages page handler. Header presence alone is never honored.

Also fix the test's HTML-reason extraction to avoid CodeQL's
incomplete-sanitization / bad-HTML-regex findings, and add a negative test
asserting forged `x-prerender-revalidate` values ("1", "", a guess) are
rejected while the real secret is accepted.
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Re-review: on-demand revalidateReason wiring (#1462)

The blocking security issue from the previous round has been addressed by commit 2649cc0. res.revalidate() now sends a process secret (getRevalidateSecret()), and the render paths authorize via isOnDemandRevalidateRequest() using a constant-time safeEqual against that secret rather than a bare presence check. The new negative test (rejects a forged x-prerender-revalidate header) runs first against a primed "stale" entry, so a regression to presence-based gating would be observably caught. Good. The dev/prod parity (dev-server + page-handler + page-data) is kept in sync, and vp check + the new test + isr-cache.test.ts all pass.

A few remaining items, none blocking the core wiring:

🔶 res.revalidate() success detection still diverges from Next.js

api-handler.ts:340 and pages-node-compat.ts:331 treat !revalidateRes.ok && status !== 404 as failure — i.e. 404 is always swallowed as success. Next.js only accepts 404 when unstable_onlyGenerated is set, and otherwise checks the cache header:

// api-resolver.ts revalidate()
const cacheHeader = res.headers.get('x-vercel-cache') || res.headers.get('x-nextjs-cache')
if (cacheHeader?.toUpperCase() !== 'REVALIDATED' && res.status !== 200 && !(res.status === 404 && onlyGenerated)) {
  throw new Error(`Invalid response ${res.status}`)
}

Two gaps remain from the prior review:

  1. A notFound: true regeneration returns 404, which this PR silently treats as a successful revalidate even without unstable_onlyGenerated.
  2. unstable_onlyGenerated (the documented second arg, res.revalidate(urlPath, opts)) is still unsupported — the signature is revalidate(urlPath) only.

These are parity gaps, not correctness bugs for the happy path, but worth a tracking note or a follow-up.

🔹 Per-isolate secret + in-memory cache on Workers

getRevalidateSecret() is a module-scoped random value, regenerated per module-load. The doc comment justifies this as "loops back into the same server process" — true for single-process Node, but on Cloudflare Workers the loopback fetch() is a real subrequest that can land on a different isolate with a different secret, so the auth check would fail there.

That said, this is consistent with vinext's existing model: the default cache handler is per-isolate MemoryCacheHandler (KV is opt-in), so on-demand revalidation against the default cache is already isolate-local. The secret design doesn't make this worse. Still, it's worth contrasting with the existing build-time prerender secret (index.ts:4401 randomBytes(32), persisted to vinext-server.json and shared across processes) — reusing that persisted secret would make res.revalidate() work across isolates/processes for the KV-backed case. Consider a follow-up to reconcile the two secret mechanisms, or at least document that res.revalidate() on Workers is best-effort per-isolate unless a shared cache + shared secret is configured.

🔹 Minor

  • dev-server.ts reads req.headers[PRERENDER_REVALIDATE_HEADER] (Node lowercases header keys, fine) — the previous ?? undefined no-op is gone, good.
  • The two revalidate() implementations in api-handler.ts and pages-node-compat.ts are near-identical (validate path, build URL via resolveRequestProtocol/resolveRequestHost, HEAD fetch with secret, check status). Consider extracting a single performOnDemandRevalidate(headersSource, urlPath) helper into isr-cache.ts (or a new server/* module) so the two paths can't drift — matches the AGENTS.md guidance on keeping dev/prod request logic in sync.
  • revalidate-reason.test.ts covers only the dev SSR HTML path. A data-request (__NEXT_DATA__ / isDataReq) assertion would lock in that path too, but the negative+positive coverage here is the important part and it's solid.

Verification done

  • vp check clean on all 6 modified source files.
  • vp test run tests/nextjs-compat/revalidate-reason.test.ts — 2/2 pass (forged-header rejection + secret-accepted on-demand).
  • vp test run tests/isr-cache.test.ts — 64/64 pass.

Net: the critical issue is resolved and the implementation is sound. Remaining items are parity/robustness follow-ups (404/unstable_onlyGenerated handling, cross-isolate secret reconciliation, helper de-duplication).

@ask-bonk

ask-bonk Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #1856.

Summary

The previously-blocking critical security issue (unauthenticated x-prerender-revalidate presence check) has been fixed in commit 2649cc0. res.revalidate() now sends a per-process secret, and the render paths authorize via constant-time comparison against it. The new negative test verifies forged headers are rejected. vp check and all relevant tests pass.

Remaining (non-blocking) findings:

  1. 404 success detection still diverges from Next.js — 404 is always swallowed as success; Next.js only accepts it with unstable_onlyGenerated, which isn't supported. Carryover parity gap.
  2. Per-isolate secret + in-memory cache on Workers — the module-scoped random secret won't match across Workers isolates, but this is consistent with vinext's existing per-isolate MemoryCacheHandler default. Suggested reconciling with the existing persisted build-time prerender secret for the KV-backed case.
  3. Code duplication — the two near-identical revalidate() implementations in api-handler.ts and pages-node-compat.ts should be extracted to a shared helper to prevent dev/prod drift.

No new bugs introduced; the core wiring is correct and well-tested.

github run

…_onlyGenerated (#1462)

Address non-blocking bonk review findings:
- Extract the two near-identical `res.revalidate()` implementations
  (`api-handler.ts`, `pages-node-compat.ts`) into a shared
  `performOnDemandRevalidate` helper so the secret wiring and success detection
  cannot drift between dev and prod.
- Align success detection with Next.js's api-resolver: accept
  `x-nextjs-cache: REVALIDATED`, status 200, or a 404 when the caller passed
  `unstable_onlyGenerated`. Add `unstable_onlyGenerated` support
  (`x-prerender-revalidate-if-generated` header).
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Re-review: on-demand revalidateReason wiring (#1462)

Reviewed at HEAD 0c50788. The prior round's two non-blocking follow-ups have both been resolved by the refactor commit, and no new issues were introduced.

Resolved since last review

  • 404 / unstable_onlyGenerated parityperformOnDemandRevalidate (pages-revalidate.ts:59-73) now mirrors Next.js's api-resolver success detection: it accepts x-nextjs-cache: REVALIDATED, status === 200, or a 404 only when unstable_onlyGenerated is set, and sends the companion x-prerender-revalidate-if-generated header. The documented second arg res.revalidate(urlPath, opts) is now supported and typed on both NextApiResponse (api-handler.ts:44) and the Node-compat response (pages-node-compat.ts:51).
  • Code duplication — both res.revalidate() impls now delegate to the single performOnDemandRevalidate helper (api-handler.ts:322-324, pages-node-compat.ts:311-313), so the secret wiring and success detection can't drift between dev and prod. Matches the AGENTS.md guidance on keeping dev/prod request logic in sync.

Security gate (carried from prior round — still correct)

  • On-demand revalidation is authorized by equality against the per-process secret via constant-time safeEqual (isr-cache.ts:99-119), never header presence. The negative test (rejects a forged x-prerender-revalidate header) runs first against a primed "stale" entry, so a regression to presence gating would flip the rendered reason to "on-demand" and fail. Dev (dev-server.ts:834) and prod (pages-page-handler.ts:470) both gate their cache short-circuits on !isOnDemandRevalidate, and the reason resolver (pages-page-data.ts:653) mirrors render.tsx's on-demand > build > stale precedence.

🔹 Minor / non-blocking observations

  • REVALIDATED cache-header branch is effectively dead in vinext. cache-headers.ts only ever emits HIT/MISS/STALE for x-nextjs-cache (toNextJsCacheState), never REVALIDATED. A successful on-demand regen surfaces as a 200, so the status === 200 branch carries it. The REVALIDATED check is harmless (kept for Next.js parity / future-proofing) but currently never matches — worth a one-line note so a future reader doesn't assume vinext emits it.
  • Per-isolate secret on Workers (unchanged, pre-existing model): getRevalidateSecret() is module-scoped per isolate, so a loopback fetch() that lands on a different isolate would fail auth. This is consistent with the default per-isolate MemoryCacheHandler (on-demand revalidation against the default cache is already isolate-local), so the secret design doesn't make it worse. A follow-up to reconcile with the persisted build-time prerender secret for the KV-backed case is still worth tracking.
  • Host fallback (unchanged): the loopback HEAD falls back to bare "localhost" (no port) only when the Host header is entirely absent; normal Host values include the port. Not a regression.

Verification done

  • vp check clean on all 7 modified source files.
  • vp test run tests/nextjs-compat/revalidate-reason.test.ts — 2/2 pass (forged-header rejection + secret-accepted on-demand).
  • vp test run tests/isr-cache.test.ts — 64/64 pass.

Net: the implementation is complete and sound. Both prior follow-ups are addressed; remaining items are minor documentation/robustness notes, none blocking.

@ask-bonk

ask-bonk Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #1856.

Summary

Reviewed at HEAD 0c50788. The implementation is complete and sound — both non-blocking follow-ups from the prior review round are resolved, and no new issues were introduced.

Resolved since last review:

  1. 404 / unstable_onlyGenerated parity — the new shared performOnDemandRevalidate helper now mirrors Next.js's success detection (REVALIDATED header / 200 / 404-only-when-onlyGenerated) and supports the documented res.revalidate(urlPath, opts) second arg.
  2. Code duplication — both res.revalidate() impls delegate to one helper, preventing dev/prod drift.

Security gate (carried, still correct) — on-demand revalidation is authorized by constant-time equality against a per-process secret, never header presence. The negative test verifies forged headers are rejected.

Minor non-blocking notes:

  • The x-nextjs-cache: REVALIDATED branch is effectively dead in vinext (it only ever emits HIT/MISS/STALE); the 200 branch carries the happy path. Harmless but worth a comment.
  • Per-isolate secret on Workers and the localhost host fallback are pre-existing, consistent with vinext's model — not regressions.

Verification: vp check clean on all 7 files; revalidate-reason.test.ts 2/2 pass; isr-cache.test.ts 64/64 pass.

github run

@james-elicx james-elicx marked this pull request as ready for review June 8, 2026 21:08
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk re-review

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review: on-demand revalidateReason wiring (#1462)

Reviewed at HEAD 9448a6d. The only change since the prior round (0c50788) is the documentation commit 9448a6d, which directly addresses the last remaining minor note. No new issues; the PR is complete and ready.

Resolved since last review

  • Dead REVALIDATED branch now documentedperformOnDemandRevalidate (pages-revalidate.ts:65-69) now carries a note explaining that vinext's Pages ISR path only emits HIT/MISS/STALE on x-nextjs-cache (never REVALIDATED), so the happy path is the 200 branch, and the REVALIDATED check is retained for Next.js parity / future-proofing. This was the exact one-line note I asked for — a future reader won't assume vinext emits that header.

Security gate (carried — still correct)

  • On-demand revalidation is authorized by equality against the per-process secret via constant-time safeEqual (isr-cache.ts:99-119), never header presence. isOnDemandRevalidateRequest also rejects arrays (duplicate headers) and empty/absent values outright before comparison (isr-cache.ts:117-119). The negative test (rejects a forged x-prerender-revalidate header) runs first against a primed "stale" entry, so a regression to presence gating would flip the rendered reason to "on-demand" and fail.
  • Both res.revalidate() impls (api-handler.ts, pages-node-compat.ts) delegate to the single performOnDemandRevalidate helper, so the secret wiring and success detection can't drift between dev and prod.

Remaining non-blocking follow-ups (unchanged, pre-existing)

  • Per-isolate secret on WorkersgetRevalidateSecret() is module-scoped per isolate, so a loopback fetch() landing on a different isolate would fail auth. Consistent with the default per-isolate MemoryCacheHandler (on-demand revalidation against the default cache is already isolate-local), so the secret design doesn't make it worse. A follow-up to reconcile with the persisted build-time prerender secret for the KV-backed case is still worth tracking — not blocking.
  • Host fallback — the loopback HEAD falls back to bare "localhost" (no port) only when the Host header is entirely absent. Normal Host values include the port. Not a regression.

Verification done

  • vp check clean on pages-revalidate.ts (the only changed file since last review).
  • vp test run tests/nextjs-compat/revalidate-reason.test.ts — 2/2 pass (forged-header rejection + secret-accepted on-demand).
  • vp test run tests/isr-cache.test.ts — 64/64 pass.

Net: all prior review items are addressed. The implementation is sound, well-tested, and the dev/prod paths are kept in sync. Approving from my side — remaining notes are robustness follow-ups, none blocking.

@ask-bonk

ask-bonk Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #1856.

Summary

Reviewed at HEAD 9448a6d. The only change since my prior review (0c50788) is the documentation commit 9448a6d, which addresses the single remaining minor note from last round. No new issues — the PR is complete and ready.

Resolved since last review:

  • The dead x-nextjs-cache: REVALIDATED branch in performOnDemandRevalidate now carries the exact explanatory comment I requested (pages-revalidate.ts:65-69) — noting vinext only emits HIT/MISS/STALE and the 200 branch carries the happy path, with REVALIDATED kept for Next.js parity.

Security gate (carried, still correct): On-demand revalidation is authorized by constant-time safeEqual equality against a per-process secret (never header presence), and rejects arrays/empty values. The negative forged-header test guards against regression.

Remaining non-blocking follow-ups (pre-existing, unchanged):

  • Per-isolate secret on Workers — consistent with vinext's per-isolate MemoryCacheHandler default; worth a future tracking note to reconcile with the persisted build-time prerender secret for the KV case.
  • localhost host fallback only triggers when Host is entirely absent.

Verification: vp check clean on the changed file; revalidate-reason.test.ts 2/2 pass; isr-cache.test.ts 64/64 pass.

All prior review items are addressed; remaining notes are robustness follow-ups, none blocking.

github run

@james-elicx james-elicx marked this pull request as draft June 8, 2026 21:18
…d across isolates)

The on-demand ISR revalidation secret was a per-process random value. On
Cloudflare Workers, res.revalidate()'s loopback fetch() can land on a different
isolate than the sender, so a per-process secret mismatches across isolates and
false-rejects legitimate revalidations.

Mirror Next.js's previewModeId: generate one 256-bit secret at build time (in
the vinext build CLI, once per build, shared across plugin instances like
__VINEXT_SHARED_BUILD_ID) and bake it SERVER-ONLY into every server bundle via a
Vite define (__VINEXT_REVALIDATE_SECRET). It is byte-for-byte identical in every
isolate and never reaches the client bundle. getRevalidateSecret() reads the
baked constant at runtime and falls back to a process-shared random secret in
dev (single-process, no regression). Also persist revalidateSecret in
vinext-server.json for diagnostics/tooling parity with prerenderSecret.

Constant-time safeEqual validation and reject-arrays/empty/absent behavior are
unchanged.

Refs #1462
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review: build-time revalidate secret (47dddbd)

The new commit since my last review (9448a6d) makes the on-demand revalidation secret build-time and shared across isolates instead of per-process — directly addressing the "per-isolate secret on Workers" follow-up I flagged earlier. The approach (resolve once in the vinext build CLI, share via __VINEXT_SHARED_REVALIDATE_SECRET, bake server-only via define into __VINEXT_REVALIDATE_SECRET, read at runtime in getRevalidateSecret()) mirrors the existing __VINEXT_SHARED_BUILD_ID / __VINEXT_SHARED_RSC_COMPATIBILITY_ID coordination and is the right design. The dev fallback (devRevalidateSecret, lazily generated, single-process) is correct and safeEqual/reject-array/reject-empty behavior is unchanged. vp check is clean and revalidate-reason.test.ts (2/2) + isr-cache.test.ts (64/64) still pass.

Two things I'd want addressed before this lands, plus a minor.

🔶 The security-critical "server-only" property is completely untested

The entire point of this commit's security posture is the claim repeated in every comment: the secret is "baked SERVER-ONLY... never reaches the client bundle." If a regression ever let this define leak into the client bundle, the secret would ship to every browser, and anyone could forge x-prerender-revalidate requests — re-opening the exact cache-stampede/DoS vector the secret was introduced to close (the original critical finding in this PR).

That property has zero test coverage, even though the infrastructure to assert it already exists and is trivial to reuse:

  • tests/compiler-define.test.ts:111 already drives serverDefinePlugin.configEnvironment("rsc"|"ssr"|"client", ...) and asserts clientResult is null for server-only defines. A case that sets process.env.__VINEXT_SHARED_REVALIDATE_SECRET, then asserts the secret appears in the rsc/ssr define maps but the client env returns null, would lock this in directly.
  • tests/app-router-production-build.test.ts:138 already does the full-build version for __VINEXT_SHARED_RSC_COMPATIBILITY_IDreadAllJs(server) contains it, readAllJs(client) contains it. The revalidate secret needs the inverse client assertion: expect(readAllJs(path.join(outDir, "client"))).not.toContain(sharedSecret).

Given this PR's whole history is a security fix, shipping the new server-only-secret mechanism without a test that the secret stays out of the client bundle is the one gap I'd consider blocking.

There's also a latent fragility: tests/compiler-define.test.ts:221 (no-ops the configEnvironment hook when defineServer is not configured) now only passes because __VINEXT_SHARED_REVALIDATE_SECRET happens to be unset in that test's environment. With the env var set, configEnvironment would now return a non-null define and that assertion (expect(rscResult).toBeNull()) would flip. Worth making that test explicitly clear the env var so it can't break under a polluted environment.

🔶 readRevalidateSecret + persisted revalidateSecret are dead code

The commit adds readRevalidateSecret() to build/server-manifest.ts:35 and persists revalidateSecret into vinext-server.json (index.ts:4447), with a doc comment justifying it: "persisted here so the Node/prod-server path (which loads the bundle from disk) ... can recover it without re-deriving it."

But nothing reads it — readRevalidateSecret has no callers (only the definition + the doc references). This is not symmetric with readPrerenderSecret, which is genuinely consumed by prod-server.ts:1067/1375 and run-prerender.ts:231 because the host process (separate from the bundle) must send authenticated prerender requests. The revalidate flow is fully self-contained inside the server bundle: res.revalidate() calls getRevalidateSecret() (baked define) and the receiver checks the same baked define. The host never sends a revalidate request, so it never needs the disk value.

So the doc comment's rationale doesn't hold — the prod-server runs the bundle that already carries the inlined constant; it never calls readRevalidateSecret. Either:

  • drop readRevalidateSecret and the persisted revalidateSecret field (less surface, no dead code), or
  • if it's intended purely for diagnostics/tooling, say exactly that in the comment and don't imply the prod-server path consumes it.

I'd lean toward dropping it until there's a real consumer.

🔹 Minor

  • index.ts:3911 — the hook already gates the secret define with name !== "client", and then a few lines later re-checks if (name === "client") return null; (index.ts:3921). The first guard makes the second redundant for the secret (it's still needed for the user defineServer entries, so leaving it is fine — just noting the secret can never reach the second check via the client path anyway).
  • The dev-fallback getRevalidateSecret() reads process.env.__VINEXT_REVALIDATE_SECRET at runtime in dev. In prod this is statically replaced by define, but in dev it's a real env lookup — if a user happens to have __VINEXT_REVALIDATE_SECRET set in their shell, dev would adopt it. Harmless and namespaced, but the comment says "absent in dev"; it's "absent unless someone sets it."

Verification done

  • vp check clean on all 6 changed source files.
  • vp test run tests/nextjs-compat/revalidate-reason.test.ts — 2/2 pass.
  • vp test run tests/isr-cache.test.ts — 64/64 pass.
  • Confirmed readRevalidateSecret has no callers (only readPrerenderSecret is consumed, by prod-server.ts + run-prerender.ts).
  • Confirmed the existing compiler-define.test.ts / app-router-production-build.test.ts patterns make the missing server-only test cheap to add.

Net: the build-time-secret design is correct and resolves the cross-isolate concern. The blocking ask is a test that proves the secret never lands in the client bundle (the security property this PR depends on), plus dropping or re-justifying the unused readRevalidateSecret/persisted field.

@ask-bonk

ask-bonk Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #1856.

Summary

Reviewed the new commit 47dddbd (build-time revalidate secret), the only change since my last review at 9448a6d. It makes the on-demand revalidation secret build-time and shared across isolates instead of per-process — correctly addressing the cross-isolate-secret follow-up I'd flagged. The design mirrors the existing __VINEXT_SHARED_BUILD_ID coordination and is sound; vp check is clean and all relevant tests pass.

Findings:

  1. The security-critical "server-only" property is untested. This PR's whole purpose is a security fix, and the new mechanism depends on the secret never reaching the client bundle. If a regression leaked the define into the client bundle, the secret would ship to every browser and re-open the original cache-stampede/DoS vector. There's zero coverage of this, despite trivially-reusable infrastructure in compiler-define.test.ts (asserts client env returns null) and app-router-production-build.test.ts (readAllJs(client) assertion). I flagged this as the one blocking item. Also noted a latent fragility: the existing "no-ops when defineServer not configured" test only passes because the env var happens to be unset.

  2. readRevalidateSecret + the persisted revalidateSecret field are dead code. Exported and persisted with a doc comment claiming the prod-server recovers it from disk — but nothing calls it, and the rationale doesn't hold (the prod-server runs the bundle, which already carries the inlined constant). Not symmetric with readPrerenderSecret, which has real host-process consumers. Recommended dropping it or re-justifying as diagnostics-only.

  3. Minor: a redundant name === "client" re-check, and the dev comment says the secret is "absent in dev" when it's really "absent unless a user sets the env var."

The core implementation is correct; the blocking ask is the missing client-exclusion test.

github run

…nifest reader

Address ask-bonk review of the build-time revalidate secret:

- Add tests pinning the security-critical invariant that the
  __VINEXT_REVALIDATE_SECRET define is baked into server environments (rsc/ssr)
  but NEVER into the client bundle (the client env returns null outright). A
  leak would ship the secret to every browser and re-open the cache-stampede
  vector the equality check prevents. Also harden the existing "no-ops when
  defineServer unset" test to explicitly clear the env var instead of relying on
  it happening to be unset.
- Drop the unused readRevalidateSecret export and the orphaned revalidateSecret
  manifest field — the runtime authenticates against the baked define (both
  Workers isolates and the Node prod-server run the bundle), so the on-disk copy
  had no consumer (knip-flagged dead code).
- Hoist the client early-return in vinext:compiler-define-server so the
  server-only guarantee is structural (removes the redundant name check), and
  reword the dev-fallback comments ("absent unless set during vinext build").

Refs #1462
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Re-review: server-only secret test coverage + dead-code cleanup (cdc9027)

Reviewed at HEAD cdc9027, the only change since my last review (47dddbd). It directly resolves both blocking items I raised, and the minor note, with no new issues introduced. From my side this is ready.

Resolved since last review

1. The security-critical "server-only" invariant is now tested. This was the one blocking ask. The new build-time revalidate secret define (security: server-only) suite in compiler-define.test.ts pins exactly the property the whole PR depends on:

  • bakes the secret into server environments (rsc, ssr) asserts the define is present in both server envs with the correct JSON.stringify value.
  • NEVER bakes the secret into the client environment asserts configEnvironment("client", …) returns null outright — so the secret can't reach the browser bundle and re-open the cache-stampede/DoS vector the equality check exists to close.
  • The existing no-ops … when defineServer is not configured test was also hardened to explicitly delete process.env.__VINEXT_SHARED_REVALIDATE_SECRET (and restore it in finally), removing the latent fragility I flagged where it only passed because the env var happened to be unset.

The guarantee is now structural, not just tested: the if (name === "client") return null; early-return is hoisted to the very top of configEnvironment (index.ts:3904), so the secret-define branch is literally unreachable for the client env. The redundant second name === "client" check is gone.

2. Dead readRevalidateSecret + persisted revalidateSecret are removed. readRevalidateSecret is deleted from server-manifest.ts and the revalidateSecret field is dropped from the vinext-server.json manifest (index.ts). I confirmed there are zero remaining references to either symbol anywhere in src/ or tests/, and that no runtime path consumed the on-disk value — res.revalidate() reads the baked define via getRevalidateSecret(), and both Workers isolates and the Node prod-server run the bundle that already carries the inlined constant. The asymmetry with readPrerenderSecret (which has real host-process consumers in prod-server.ts / run-prerender.ts) is now correctly reflected — only the prerender secret is persisted.

3. Minor: dev-fallback comments reworded. isr-cache.ts and global.d.ts now say the secret is "absent unless set during vinext build" rather than the misleading "absent in dev," matching actual behavior (the env var is set by the CLI, not gated on mode).

CodeQL findings (from the GitHub Advanced Security review)

The three CodeQL alerts (170/171/172: incomplete multi-character sanitization / bad HTML-filtering regexp on revalidate-reason.test.ts) are addressed by the current reasonFromHtml (revalidate-reason.test.ts:29), which extracts a constrained [a-z-]* token from a fixed marker instead of stripping arbitrary HTML, with a comment explaining why. No unsafe HTML filtering remains.

Security gate (carried — still correct)

On-demand revalidation is still authorized by constant-time safeEqual equality against the build-shared secret (isr-cache.ts), never header presence; arrays and empty values are rejected. The negative test (rejects a forged x-prerender-revalidate header) runs first against a primed "stale" entry, so a regression to presence gating would flip the rendered reason and fail.

Verification done

  • vp check clean on all 5 changed source files.
  • vp test run tests/compiler-define.test.ts — 8/8 pass (including the two new server-only-secret cases).
  • vp test run tests/nextjs-compat/revalidate-reason.test.ts — 2/2 pass.
  • Confirmed grep for readRevalidateSecret / revalidateSecret returns no matches in src/ or tests/.
  • Confirmed the secret flow is self-contained: CLI sets __VINEXT_SHARED_REVALIDATE_SECRET (cli.ts:486) → server-only define → runtime getRevalidateSecret(). No consumer of the removed manifest field.

Net: both blocking items resolved, minor note addressed, no regressions. The implementation is complete and the security property is now locked in by tests. LGTM.

@ask-bonk

ask-bonk Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #1856.

Summary

Reviewed at HEAD cdc9027 (the only change since my last review at 47dddbd). It resolves both blocking items I raised, plus the minor note — no new issues. The PR is complete from my side.

Resolved since last review:

  1. Server-only secret invariant now tested (was the one blocking ask) — a new security: server-only suite in compiler-define.test.ts asserts the __VINEXT_REVALIDATE_SECRET define is baked into rsc/ssr envs but the client env returns null outright, so the secret can't leak to the browser and re-open the cache-stampede/DoS vector. The guarantee is also now structural: the client early-return is hoisted to the top of configEnvironment, making the secret branch unreachable for the client env. The existing "no-op" test was hardened to explicitly clear the env var.

  2. Dead code removedreadRevalidateSecret and the persisted revalidateSecret manifest field are gone. Verified zero remaining references in src//tests/ and that no runtime path consumed the on-disk value (the secret flows entirely through the baked define).

  3. Minor — dev-fallback comments reworded to the accurate "absent unless set during vinext build."

CodeQL alerts (170/171/172) on the test file are addressed by the constrained [a-z-]* token extraction in reasonFromHtml instead of arbitrary HTML stripping.

Verification: vp check clean on all 5 files; compiler-define.test.ts 8/8 pass; revalidate-reason.test.ts 2/2 pass.

LGTM — both blocking items resolved, no regressions.

github run

@james-elicx james-elicx marked this pull request as ready for review June 9, 2026 09:06
@james-elicx james-elicx merged commit 9596b72 into main Jun 9, 2026
51 checks passed
@james-elicx james-elicx deleted the fix/issue-1462-revalidate-reason-ondemand branch June 9, 2026 09:07
@github-actions github-actions Bot mentioned this pull request Jun 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pages Router: context.revalidateReason not passed to getStaticProps/getServerSideProps

2 participants