Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/content/docs/1.guides/2.first-party.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
})
Expand All @@ -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:
Expand Down
26 changes: 22 additions & 4 deletions packages/script/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -1058,10 +1072,14 @@ export default defineNuxtModule<ModuleOptions>({
// 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(
Expand Down
39 changes: 39 additions & 0 deletions test/e2e/issue-783-proxy-token-payload.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>('/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<string>('/proxy'),
$fetch<string>('/proxy'),
])
expect(a).toBe(b)
})

it('keeps token-free pages identical across requests', async () => {
const [a, b] = await Promise.all([
$fetch<string>('/'),
$fetch<string>('/'),
])
expect(a).toBe(b)
})
})
3 changes: 3 additions & 0 deletions test/fixtures/issue-783/app.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<NuxtPage />
</template>
22 changes: 22 additions & 0 deletions test/fixtures/issue-783/nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -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',
})
3 changes: 3 additions & 0 deletions test/fixtures/issue-783/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"private": true
}
5 changes: 5 additions & 0 deletions test/fixtures/issue-783/pages/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<template>
<div id="no-proxy">
page without any proxy helper
</div>
</template>
11 changes: 11 additions & 0 deletions test/fixtures/issue-783/pages/proxy.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<script setup lang="ts">
// Reading the token is what would normally lift it into the SSR payload.
// With `security.pageToken: false` it must stay null.
const proxyToken = useScriptProxyToken()
</script>

<template>
<div id="with-proxy">
token: {{ proxyToken ?? 'none' }}
</div>
</template>
Loading