Skip to content

fix(security): add pageToken opt-out to keep SSR payload deterministic#787

Closed
harlan-zw wants to merge 1 commit into
mainfrom
fix/proxy-token-payload-opt-out
Closed

fix(security): add pageToken opt-out to keep SSR payload deterministic#787
harlan-zw wants to merge 1 commit into
mainfrom
fix/proxy-token-payload-opt-out

Conversation

@harlan-zw
Copy link
Copy Markdown
Collaborator

@harlan-zw harlan-zw commented May 21, 2026

🔗 Linked issue

Resolves #783

❓ Type of change

  • 📖 Documentation
  • 🐞 Bug fix
  • 👌 Enhancement
  • ✨ New feature
  • 🧹 Chore
  • ⚠️ Breaking change

📚 Description

The per-request proxy page token is embedded in the SSR payload whenever a signing-required proxy handler is enabled. Being timestamp-based, it makes the payload differ on every request, which breaks response etag hashing.

Adds security.pageToken (default true). When false, the proxy-token server plugin is not registered, so no token is generated and the payload stays deterministic. Client-driven proxy URLs must then be pre-signed.

Default behaviour is unchanged — this is a pure opt-out, so it carries no regression for SSG, embeds, or client-side navigation. The issue author noted a higher-level flag would be acceptable.

Verified against test/fixtures/issue-783: with pageToken: false, useScriptProxyToken resolves to null and the SSR payload is byte-identical across requests.

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 21, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
scripts-playground Error Error May 21, 2026 3:14am

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 21, 2026

Open in StackBlitz

npm i https://pkg.pr.new/@nuxt/scripts@787

commit: b0919e1

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 21, 2026

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 98d73373-fa92-4682-9045-e789febb1458

📥 Commits

Reviewing files that changed from the base of the PR and between 587a6d3 and b0919e1.

📒 Files selected for processing (8)
  • docs/content/docs/1.guides/2.first-party.md
  • packages/script/src/module.ts
  • test/e2e/issue-783-proxy-token-payload.test.ts
  • test/fixtures/issue-783/app.vue
  • test/fixtures/issue-783/nuxt.config.ts
  • test/fixtures/issue-783/package.json
  • test/fixtures/issue-783/pages/index.vue
  • test/fixtures/issue-783/pages/proxy.vue
✅ Files skipped from review due to trivial changes (2)
  • test/fixtures/issue-783/app.vue
  • docs/content/docs/1.guides/2.first-party.md

📝 Walkthrough

Walkthrough

This PR adds an optional pageToken configuration flag to the Nuxt Scripts module, allowing developers to disable per-request proxy token emission in SSR payloads. When set to false, the feature preserves deterministic SSR output suitable for etag hashing, while requiring client-driven proxy calls to use pre-signed URLs. The module option is documented with configuration guidance and troubleshooting advice, and validated with e2e tests confirming payload stability across concurrent requests using a complete test fixture.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly and concisely describes the main change: adding a pageToken opt-out feature to maintain deterministic SSR payloads, which is the central objective.
Description check ✅ Passed The PR description is related to the changeset and explains the problem, solution, and verification approach for the pageToken feature.
Linked Issues check ✅ Passed All code changes directly address issue #783: the security.pageToken option with default true is implemented, proxy-token plugin registration is conditional, test fixture verifies deterministic payloads with pageToken: false, and documentation explains the new feature and troubleshooting.
Out of Scope Changes check ✅ Passed All changes are within scope: documentation updates, module configuration option, conditional plugin registration, and targeted e2e test fixture—all directly supporting the pageToken opt-out functionality.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/proxy-token-payload-opt-out

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/script/src/runtime/plugins/proxy-token.server.ts`:
- Around line 27-28: The server reads pageTokenMaxAge into the local maxAge but
useScriptProxyToken() still uses the hardcoded PAGE_TOKEN_MAX_AGE, causing
mismatch between cookie lifetime and reuse checks; fix by passing the resolved
maxAge into useScriptProxyToken(maxAge) (or alternatively update the composable
to read runtime config/public pageTokenMaxAge), and ensure PAGE_TOKEN_MAX_AGE is
only the fallback; update the call site in proxy-token.server.ts to pass the
resolved maxAge and adjust the composable signature to accept and apply that
maxAge for cookie creation and reuse logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: aa7fd0f0-fca8-4f04-b6e2-90cd029384ee

📥 Commits

Reviewing files that changed from the base of the PR and between f46ccb6 and e96c206.

📒 Files selected for processing (2)
  • packages/script/src/runtime/composables/useScriptProxyToken.ts
  • packages/script/src/runtime/plugins/proxy-token.server.ts

Comment thread packages/script/src/runtime/plugins/proxy-token.server.ts Outdated
@harlan-zw harlan-zw force-pushed the fix/proxy-token-payload-opt-out branch from e96c206 to 8a8714a Compare May 21, 2026 02:31
@harlan-zw harlan-zw changed the title fix(security): deliver proxy token via cookie to keep payload deterministic fix(security): make proxy token delivery configurable to keep payload deterministic May 21, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/script/src/module.ts`:
- Around line 418-432: The public config type is missing the 'payload' literal
while the docs and runtime accept it; update the type for the pageToken property
(named pageToken in the exported config/interface in module.ts) to include
'payload' (e.g. change the union from boolean | 'cookie' to boolean | 'cookie' |
'payload') so user code can set security.pageToken = 'payload' without a type
error and the public API matches the JSDoc/runtime behavior.

In `@packages/script/src/runtime/plugins/proxy-token.server.ts`:
- Around line 37-41: The current early-return only checks cookie.value.ts;
change it to validate the existing token before reusing by computing expected =
generateProxyToken(secret, cookie.value.ts) and confirming cookie.value.token
=== expected (and typeof cookie.value.token === 'string') in addition to the
timestamp check; only return early from the branch if both the timestamp is
within maxAge/2 and the token matches the generated value, otherwise set
cookie.value = { token: generateProxyToken(secret, now), ts: now } to regenerate
a fresh, valid token.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1d139bb3-40dd-428d-b75b-7d0bfffba108

📥 Commits

Reviewing files that changed from the base of the PR and between e96c206 and 8a8714a.

📒 Files selected for processing (4)
  • docs/content/docs/1.guides/2.first-party.md
  • packages/script/src/module.ts
  • packages/script/src/runtime/composables/useScriptProxyToken.ts
  • packages/script/src/runtime/plugins/proxy-token.server.ts

Comment thread packages/script/src/module.ts Outdated
Comment on lines +418 to +432
* - `true` / `'payload'` (default): the token is added to the SSR payload
* only on pages that actually use a proxy helper. Pages without one keep
* a deterministic payload. No cookie is set. A page reached purely via
* client-side navigation cannot mint a token, so dynamic client-driven
* proxy calls there fall back to unsigned (use `'cookie'` if you need
* them).
* - `'cookie'`: the token is delivered in a cookie instead of the payload.
* Every page's payload stays deterministic and the token remains
* available across client-side navigation. Adds one functional cookie.
* - `false`: no page token is issued. Client-driven proxy calls then
* require each URL to be pre-signed.
*
* @default true
*/
pageToken?: boolean | 'cookie'
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Expose 'payload' in the public config type.

The JSDoc and runtime both treat 'payload' as a valid mode, but pageToken?: boolean | 'cookie' rejects security.pageToken = 'payload' in user config. That leaves the public API type out of sync with the documented behavior.

Suggested fix
-    pageToken?: boolean | 'cookie'
+    pageToken?: boolean | 'payload' | 'cookie'
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
* - `true` / `'payload'` (default): the token is added to the SSR payload
* only on pages that actually use a proxy helper. Pages without one keep
* a deterministic payload. No cookie is set. A page reached purely via
* client-side navigation cannot mint a token, so dynamic client-driven
* proxy calls there fall back to unsigned (use `'cookie'` if you need
* them).
* - `'cookie'`: the token is delivered in a cookie instead of the payload.
* Every page's payload stays deterministic and the token remains
* available across client-side navigation. Adds one functional cookie.
* - `false`: no page token is issued. Client-driven proxy calls then
* require each URL to be pre-signed.
*
* @default true
*/
pageToken?: boolean | 'cookie'
* - `true` / `'payload'` (default): the token is added to the SSR payload
* only on pages that actually use a proxy helper. Pages without one keep
* a deterministic payload. No cookie is set. A page reached purely via
* client-side navigation cannot mint a token, so dynamic client-driven
* proxy calls there fall back to unsigned (use `'cookie'` if you need
* them).
* - `'cookie'`: the token is delivered in a cookie instead of the payload.
* Every page's payload stays deterministic and the token remains
* available across client-side navigation. Adds one functional cookie.
* - `false`: no page token is issued. Client-driven proxy calls then
* require each URL to be pre-signed.
*
* `@default` true
*/
pageToken?: boolean | 'payload' | 'cookie'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/script/src/module.ts` around lines 418 - 432, The public config type
is missing the 'payload' literal while the docs and runtime accept it; update
the type for the pageToken property (named pageToken in the exported
config/interface in module.ts) to include 'payload' (e.g. change the union from
boolean | 'cookie' to boolean | 'cookie' | 'payload') so user code can set
security.pageToken = 'payload' without a type error and the public API matches
the JSDoc/runtime behavior.

Comment thread packages/script/src/runtime/plugins/proxy-token.server.ts Outdated
@harlan-zw harlan-zw force-pushed the fix/proxy-token-payload-opt-out branch from 8a8714a to 587a6d3 Compare May 21, 2026 03:00
@harlan-zw harlan-zw changed the title fix(security): make proxy token delivery configurable to keep payload deterministic fix(security): inject proxy token into payload only when used May 21, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
packages/script/src/module.ts (1)

411-423: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Expose 'cookie' as a valid security.pageToken mode in the public type.

pageToken is currently typed as boolean, so typed user config cannot set security.pageToken = 'cookie'.

Proposed fix
-    pageToken?: boolean
+    pageToken?: boolean | 'cookie'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/script/src/module.ts` around lines 411 - 423, Change the public type
for pageToken from a plain boolean to allow the string mode 'cookie' so users
can set security.pageToken = 'cookie'; locate the declaration of pageToken (and
any related interface where security.pageToken is defined) and change its type
to a union (e.g. boolean | 'cookie'), update the JSDoc comment if needed to
document the new mode and default, and ensure any type usages/exports (e.g.
exported config types or interfaces referencing pageToken) are updated
accordingly so the compiler accepts the 'cookie' string value.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/script/src/runtime/plugins/proxy-token.server.ts`:
- Around line 18-24: The setup currently always stores the generated token on
nuxtApp._scriptsProxyToken (using generateProxyToken and the proxySecret from
useRuntimeConfig()['nuxt-scripts']), which ignores a 'cookie' delivery mode;
update setup to read the delivery mode from the same config (e.g.,
config['nuxt-scripts'].proxyMode or deliveryMode) and branch: if mode !==
'cookie' keep the existing nuxtApp._scriptsProxyToken behavior, but if mode ===
'cookie' set or reuse an HTTP cookie for the token (registering/using the SSR
response cookie API or a dedicated server plugin) instead of stashing on
nuxtApp; ensure token generation still uses generateProxyToken(secret, ts) and
keep ts tracking for reuse.

In `@test/e2e/issue-783-proxy-token-payload.test.ts`:
- Around line 23-29: The test "keeps the payload identical across requests for
token-free pages" can false-pass when two concurrent $fetch calls happen in the
same second; fix by making the timestamp deterministic during the test: stub
Date.now (or the app's time source) to return a fixed millisecond value before
the two $fetch<string>('/') calls (variables a and b), run the concurrent
requests, assert equality, then restore the original timer; this ensures the
payload identity check is robust and not reliant on same-second timing races.

---

Duplicate comments:
In `@packages/script/src/module.ts`:
- Around line 411-423: Change the public type for pageToken from a plain boolean
to allow the string mode 'cookie' so users can set security.pageToken =
'cookie'; locate the declaration of pageToken (and any related interface where
security.pageToken is defined) and change its type to a union (e.g. boolean |
'cookie'), update the JSDoc comment if needed to document the new mode and
default, and ensure any type usages/exports (e.g. exported config types or
interfaces referencing pageToken) are updated accordingly so the compiler
accepts the 'cookie' string value.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7efb5cb4-02ef-4793-a3df-49be767087a4

📥 Commits

Reviewing files that changed from the base of the PR and between 8a8714a and 587a6d3.

📒 Files selected for processing (10)
  • docs/content/docs/1.guides/2.first-party.md
  • packages/script/src/module.ts
  • packages/script/src/runtime/composables/useScriptProxyToken.ts
  • packages/script/src/runtime/plugins/proxy-token.server.ts
  • test/e2e/issue-783-proxy-token-payload.test.ts
  • test/fixtures/issue-783/app.vue
  • test/fixtures/issue-783/nuxt.config.ts
  • test/fixtures/issue-783/package.json
  • test/fixtures/issue-783/pages/index.vue
  • test/fixtures/issue-783/pages/proxy.vue
✅ Files skipped from review due to trivial changes (6)
  • test/fixtures/issue-783/package.json
  • test/fixtures/issue-783/pages/proxy.vue
  • test/fixtures/issue-783/app.vue
  • test/fixtures/issue-783/pages/index.vue
  • test/fixtures/issue-783/nuxt.config.ts
  • docs/content/docs/1.guides/2.first-party.md

Comment on lines 18 to 24
setup(nuxtApp) {
const secret = (useRuntimeConfig()['nuxt-scripts'] as { proxySecret?: string } | undefined)?.proxySecret
if (!secret)
return
const ts = Math.floor(Date.now() / 1000)
useScriptProxyToken().value = {
token: generateProxyToken(secret, ts),
ts,
}
nuxtApp._scriptsProxyToken = { token: generateProxyToken(secret, ts), ts }
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

'cookie' delivery mode is not represented in this server plugin path.

This setup always stashes the token on nuxtApp, which keeps delivery payload-based. If 'cookie' is a supported mode, add a mode branch here (or register a dedicated plugin) to issue/reuse the token via cookie instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/script/src/runtime/plugins/proxy-token.server.ts` around lines 18 -
24, The setup currently always stores the generated token on
nuxtApp._scriptsProxyToken (using generateProxyToken and the proxySecret from
useRuntimeConfig()['nuxt-scripts']), which ignores a 'cookie' delivery mode;
update setup to read the delivery mode from the same config (e.g.,
config['nuxt-scripts'].proxyMode or deliveryMode) and branch: if mode !==
'cookie' keep the existing nuxtApp._scriptsProxyToken behavior, but if mode ===
'cookie' set or reuse an HTTP cookie for the token (registering/using the SSR
response cookie API or a dedicated server plugin) instead of stashing on
nuxtApp; ensure token generation still uses generateProxyToken(secret, ts) and
keep ts tracking for reuse.

Comment on lines +23 to +29
it('keeps the payload identical across requests for token-free pages', async () => {
const [a, b] = await Promise.all([
$fetch<string>('/'),
$fetch<string>('/'),
])
expect(a).toBe(b)
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

The determinism assertion can false-pass with same-second concurrent requests.

Because token timestamps are second-based, the two concurrent fetches may match even if per-request token injection regresses.

Proposed fix
-  it('keeps the payload identical across requests for token-free pages', async () => {
-    const [a, b] = await Promise.all([
-      $fetch<string>('/'),
-      $fetch<string>('/'),
-    ])
+  it('keeps the payload identical across requests for token-free pages', async () => {
+    const a = await $fetch<string>('/')
+    await new Promise(resolve => setTimeout(resolve, 1100))
+    const b = await $fetch<string>('/')
     expect(a).toBe(b)
   })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('keeps the payload identical across requests for token-free pages', async () => {
const [a, b] = await Promise.all([
$fetch<string>('/'),
$fetch<string>('/'),
])
expect(a).toBe(b)
})
it('keeps the payload identical across requests for token-free pages', async () => {
const a = await $fetch<string>('/')
await new Promise(resolve => setTimeout(resolve, 1100))
const b = await $fetch<string>('/')
expect(a).toBe(b)
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/e2e/issue-783-proxy-token-payload.test.ts` around lines 23 - 29, The
test "keeps the payload identical across requests for token-free pages" can
false-pass when two concurrent $fetch calls happen in the same second; fix by
making the timestamp deterministic during the test: stub Date.now (or the app's
time source) to return a fixed millisecond value before the two
$fetch<string>('/') calls (variables a and b), run the concurrent requests,
assert equality, then restore the original timer; this ensures the payload
identity check is robust and not reliant on same-second timing races.

The per-request proxy page token is embedded in the SSR payload whenever a
signing-required proxy handler is enabled. Being timestamp-based, it makes the
payload differ on every request, which breaks response etag hashing.

Add a `security.pageToken` option (default true). When false, the
proxy-token server plugin is not registered, so no token is generated and the
payload stays deterministic. Client-driven proxy URLs must then be pre-signed.

Default behaviour is unchanged.

Resolves #783
@harlan-zw harlan-zw force-pushed the fix/proxy-token-payload-opt-out branch from 587a6d3 to b0919e1 Compare May 21, 2026 03:14
@harlan-zw harlan-zw changed the title fix(security): inject proxy token into payload only when used fix(security): add pageToken opt-out to keep SSR payload deterministic May 21, 2026
@harlan-zw
Copy link
Copy Markdown
Collaborator Author

Superseded by #788, which removes the proxy URL signing subsystem entirely (the page token was only one symptom of a mechanism that also breaks under caching/SSG and could not protect the one endpoint that mattered).

@harlan-zw harlan-zw closed this May 21, 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.

Nuxt Scripts injects proxy token into payload even if not used

1 participant