From b0919e1f9220ba839d5356b8f5e5adbb105995a8 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 21 May 2026 13:13:02 +1000 Subject: [PATCH] fix(security): add pageToken opt-out to keep SSR payload deterministic 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 --- docs/content/docs/1.guides/2.first-party.md | 8 ++++ packages/script/src/module.ts | 26 +++++++++++-- .../e2e/issue-783-proxy-token-payload.test.ts | 39 +++++++++++++++++++ test/fixtures/issue-783/app.vue | 3 ++ test/fixtures/issue-783/nuxt.config.ts | 22 +++++++++++ test/fixtures/issue-783/package.json | 3 ++ test/fixtures/issue-783/pages/index.vue | 5 +++ test/fixtures/issue-783/pages/proxy.vue | 11 ++++++ 8 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 test/e2e/issue-783-proxy-token-payload.test.ts create mode 100644 test/fixtures/issue-783/app.vue create mode 100644 test/fixtures/issue-783/nuxt.config.ts create mode 100644 test/fixtures/issue-783/package.json create mode 100644 test/fixtures/issue-783/pages/index.vue create mode 100644 test/fixtures/issue-783/pages/proxy.vue diff --git a/docs/content/docs/1.guides/2.first-party.md b/docs/content/docs/1.guides/2.first-party.md index ba6a2f98c..37da68723 100644 --- a/docs/content/docs/1.guides/2.first-party.md +++ b/docs/content/docs/1.guides/2.first-party.md @@ -280,6 +280,10 @@ export default defineNuxtConfig({ // Auto-generate and persist a secret to .env in dev mode. // Set to false to disable. autoGenerateSecret: true, + // Emit a per-request page token into the SSR payload. + // Set false for a deterministic payload (e.g. response etag hashing); + // client-driven proxy URLs must then be pre-signed. + pageToken: true, } } }) @@ -303,6 +307,10 @@ The module only writes this when running `nuxt dev` with a signed endpoint enabl Page tokens are valid for 1 hour. If a user leaves a tab open longer than that, client-side proxy requests will start returning 403. The page will recover on next navigation or refresh. +**Non-deterministic SSR payload** + +The page token is unique per request, so it changes the Nuxt payload on every render. If you hash the response (for example, computing an `etag` in a Nitro plugin), each request produces a different hash. Set `security.pageToken: false` to skip the token and keep the payload deterministic. Client-driven proxy calls must then use pre-signed URLs. + #### Static Generation and SPA Mode URL signing requires a server runtime to verify HMAC signatures. Two deployment modes cannot support signing: diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index f2f073fa0..f7087d0f9 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -408,6 +408,20 @@ export interface ModuleOptions { * @default 3600 */ pageTokenMaxAge?: number + /** + * Issue a per-request page token during SSR. + * + * The token authorizes client-driven proxy calls (reactive fetches, + * dynamic image helpers) and is embedded in the SSR payload, which makes + * the payload differ on every request. + * + * Set to `false` if you only use pre-signed proxy URLs and need a + * deterministic payload (e.g. for response `etag` hashing). Client-driven + * proxy calls then require each URL to be HMAC-signed up front. + * + * @default true + */ + pageToken?: boolean } /** * Google Static Maps proxy configuration. @@ -1058,10 +1072,14 @@ export default defineNuxtModule({ // Emit a per-request page token during SSR so client-driven proxy // calls (reactive fetches, dynamic image helpers) authenticate via // `_pt` + `_ts` without needing each URL to be HMAC-signed up front. - addPlugin({ - src: await resolvePath('./runtime/plugins/proxy-token.server'), - mode: 'server', - }) + // Opt out via `security.pageToken: false` to keep the SSR payload + // deterministic (e.g. for response `etag` hashing). + if (config.security?.pageToken !== false) { + addPlugin({ + src: await resolvePath('./runtime/plugins/proxy-token.server'), + mode: 'server', + }) + } } else if (!nuxt.options.dev) { logger.warn( diff --git a/test/e2e/issue-783-proxy-token-payload.test.ts b/test/e2e/issue-783-proxy-token-payload.test.ts new file mode 100644 index 000000000..8cef4b55d --- /dev/null +++ b/test/e2e/issue-783-proxy-token-payload.test.ts @@ -0,0 +1,39 @@ +import { createResolver } from '@nuxt/kit' +import { $fetch, setup } from '@nuxt/test-utils/e2e' +import { describe, expect, it } from 'vitest' + +const { resolve } = createResolver(import.meta.url) + +// https://github.com/nuxt/scripts/issues/783 +// With `security.pageToken: false`, the per-request proxy token must not be +// generated, so the SSR payload (and any response `etag` derived from it) +// stays identical across requests. +await setup({ + rootDir: resolve('../fixtures/issue-783'), + dev: true, + browser: false, +}) + +describe('issue-783 proxy token payload', () => { + it('does not issue a page token when security.pageToken is false', async () => { + const html = await $fetch('/proxy') + // useScriptProxyToken resolves to null, so the page renders the fallback. + expect(html).toContain('token: none') + }) + + it('keeps the SSR payload identical across requests', async () => { + const [a, b] = await Promise.all([ + $fetch('/proxy'), + $fetch('/proxy'), + ]) + expect(a).toBe(b) + }) + + it('keeps token-free pages identical across requests', async () => { + const [a, b] = await Promise.all([ + $fetch('/'), + $fetch('/'), + ]) + expect(a).toBe(b) + }) +}) diff --git a/test/fixtures/issue-783/app.vue b/test/fixtures/issue-783/app.vue new file mode 100644 index 000000000..8f62b8bf9 --- /dev/null +++ b/test/fixtures/issue-783/app.vue @@ -0,0 +1,3 @@ + diff --git a/test/fixtures/issue-783/nuxt.config.ts b/test/fixtures/issue-783/nuxt.config.ts new file mode 100644 index 000000000..c27b14ea8 --- /dev/null +++ b/test/fixtures/issue-783/nuxt.config.ts @@ -0,0 +1,22 @@ +import { defineNuxtConfig } from 'nuxt/config' + +// https://github.com/nuxt/scripts/issues/783 +// gravatar registers a signing-required proxy handler, so a proxy secret is +// resolved. `security.pageToken: false` must then keep the per-request token +// out of the SSR payload so the payload (and any response etag) is stable. +export default defineNuxtConfig({ + modules: ['@nuxt/scripts'], + // devtools injects a per-request `timeSsrStart` into the payload; disable it + // so this fixture isolates the proxy token as the only payload variable. + devtools: { enabled: false }, + scripts: { + registry: { + gravatar: true, + }, + security: { + secret: 'issue-783-test-secret', + pageToken: false, + }, + }, + compatibilityDate: '2024-07-05', +}) diff --git a/test/fixtures/issue-783/package.json b/test/fixtures/issue-783/package.json new file mode 100644 index 000000000..352055cdf --- /dev/null +++ b/test/fixtures/issue-783/package.json @@ -0,0 +1,3 @@ +{ + "private": true +} diff --git a/test/fixtures/issue-783/pages/index.vue b/test/fixtures/issue-783/pages/index.vue new file mode 100644 index 000000000..13a8cce9d --- /dev/null +++ b/test/fixtures/issue-783/pages/index.vue @@ -0,0 +1,5 @@ + diff --git a/test/fixtures/issue-783/pages/proxy.vue b/test/fixtures/issue-783/pages/proxy.vue new file mode 100644 index 000000000..4f6d74ffd --- /dev/null +++ b/test/fixtures/issue-783/pages/proxy.vue @@ -0,0 +1,11 @@ + + +