From e8434f1d4cfc0948730ca96f2375a53e9b5886d6 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Fri, 10 Apr 2026 15:12:05 +1000 Subject: [PATCH 01/13] feat(security): HMAC signing infrastructure for proxy endpoints Adds the foundation for locking down proxy endpoints (Google Maps, Gravatar, embed image proxies) against cost/quota abuse. Signed URLs are generated server-side during SSR/prerender and verified at the endpoint so only legitimate requests reach upstream APIs. - sign/verify primitives with canonical query form and constant-time compare - withSigning handler wrapper (opt-in per endpoint via requiresSigning flag) - Secret management: env var > config > dev auto-generate to .env - /_scripts/sign endpoint for reactive client-side URLs (origin + rate-limited) - nuxt-scripts CLI with generate-secret command for explicit secret setup No existing endpoints are wired to signing yet; this PR is infrastructure only. --- packages/script/bin/cli.mjs | 2 + packages/script/build.config.ts | 1 + packages/script/package.json | 4 + packages/script/src/cli.ts | 67 +++++++ packages/script/src/module.ts | 165 +++++++++++++++++- .../script/src/runtime/server/sign-proxy.ts | 136 +++++++++++++++ .../script/src/runtime/server/utils/sign.ts | 151 ++++++++++++++++ .../src/runtime/server/utils/withSigning.ts | 50 ++++++ packages/script/src/runtime/types.ts | 8 + test/unit/sign.test.ts | 147 ++++++++++++++++ 10 files changed, 729 insertions(+), 2 deletions(-) create mode 100755 packages/script/bin/cli.mjs create mode 100644 packages/script/src/cli.ts create mode 100644 packages/script/src/runtime/server/sign-proxy.ts create mode 100644 packages/script/src/runtime/server/utils/sign.ts create mode 100644 packages/script/src/runtime/server/utils/withSigning.ts create mode 100644 test/unit/sign.test.ts diff --git a/packages/script/bin/cli.mjs b/packages/script/bin/cli.mjs new file mode 100755 index 00000000..4a3bf36f --- /dev/null +++ b/packages/script/bin/cli.mjs @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import('../dist/cli.mjs') diff --git a/packages/script/build.config.ts b/packages/script/build.config.ts index f8801682..28fd8255 100644 --- a/packages/script/build.config.ts +++ b/packages/script/build.config.ts @@ -6,6 +6,7 @@ export default defineBuildConfig({ './src/registry', './src/stats', './src/types-source', + './src/cli', ], externals: [ 'nuxt', diff --git a/packages/script/package.json b/packages/script/package.json index 485f4cc0..a77d9932 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -45,7 +45,11 @@ ] } }, + "bin": { + "nuxt-scripts": "./bin/cli.mjs" + }, "files": [ + "bin", "dist" ], "scripts": { diff --git a/packages/script/src/cli.ts b/packages/script/src/cli.ts new file mode 100644 index 00000000..7cb83ac6 --- /dev/null +++ b/packages/script/src/cli.ts @@ -0,0 +1,67 @@ +/** + * @nuxt/scripts CLI. + * + * Currently hosts a single command — `generate-secret` — which produces a + * cryptographically random HMAC secret for `NUXT_SCRIPTS_PROXY_SECRET`. This + * is an alternative to letting the module auto-write a secret into `.env`, + * for users who want explicit control (e.g. teams that commit secrets to a + * vault rather than `.env`). + * + * Keep this file zero-dependency: it runs standalone via `npx @nuxt/scripts` + * and should boot instantly. + */ + +import { randomBytes } from 'node:crypto' +import process from 'node:process' + +function generateSecret(): void { + const secret = randomBytes(32).toString('hex') + process.stdout.write( + [ + '', + ' @nuxt/scripts — proxy signing secret', + '', + ` Secret: ${secret}`, + '', + ' Add this to your environment:', + ` NUXT_SCRIPTS_PROXY_SECRET=${secret}`, + '', + ' The secret is automatically picked up by the module via runtime config.', + ' It must be the same across all deployments and prerender builds so that', + ' signed URLs remain valid.', + '', + '', + ].join('\n'), + ) +} + +function showHelp(): void { + process.stdout.write( + [ + '', + ' @nuxt/scripts CLI', + '', + ' Usage: npx @nuxt/scripts ', + '', + ' Commands:', + ' generate-secret Generate a signing secret for proxy URL tamper protection', + ' help Show this help', + '', + '', + ].join('\n'), + ) +} + +const command = process.argv[2] + +if (!command || command === 'help' || command === '--help' || command === '-h') { + showHelp() +} +else if (command === 'generate-secret') { + generateSecret() +} +else { + process.stderr.write(`Unknown command: ${command}\n`) + showHelp() + process.exit(1) +} diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index 460b382a..2f7504af 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -13,7 +13,8 @@ import type { RegistryScripts, ResolvedProxyAutoInject, } from './runtime/types' -import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { randomBytes } from 'node:crypto' +import { appendFileSync, existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' import { addBuildPlugin, addComponentsDir, @@ -114,6 +115,80 @@ function fixSelfClosingScriptComponents(nuxt: any) { const UPPER_RE = /([A-Z])/g const toScreamingSnake = (s: string) => s.replace(UPPER_RE, '_$1').toUpperCase() +const PROXY_SECRET_ENV_KEY = 'NUXT_SCRIPTS_PROXY_SECRET' +const PROXY_SECRET_ENV_LINE_RE = /^NUXT_SCRIPTS_PROXY_SECRET=/m +const PROXY_SECRET_ENV_VALUE_RE = /^NUXT_SCRIPTS_PROXY_SECRET=(.+)$/m +const WILDCARD_SUFFIX_RE = /\/\*\*$/ + +export interface ResolvedProxySecret { + secret: string + /** True when the secret exists only in memory (dev-only fallback; won't survive restarts). */ + ephemeral: boolean + /** Where the secret came from, for logging. */ + source: 'config' | 'env' | 'dotenv-generated' | 'memory-generated' +} + +/** + * Resolve the HMAC signing secret used for proxy URL signing. + * + * Precedence: + * 1. `scripts.security.secret` in nuxt.config + * 2. `NUXT_SCRIPTS_PROXY_SECRET` env var + * 3. Dev-only auto-generation: write to `.env` (or keep in memory as last resort) + * 4. Empty string (prod without secret — caller decides whether this is fatal) + */ +export function resolveProxySecret( + rootDir: string, + isDev: boolean, + configSecret?: string, + autoGenerate: boolean = true, +): ResolvedProxySecret | undefined { + if (configSecret) + return { secret: configSecret, ephemeral: false, source: 'config' } + + const envSecret = process.env[PROXY_SECRET_ENV_KEY] + if (envSecret) + return { secret: envSecret, ephemeral: false, source: 'env' } + + if (!isDev || !autoGenerate) + return undefined + + // Dev fallback: generate a 32-byte hex secret and try to persist to .env. + // Persisting matters because the same dev machine restarts many times and + // we don't want signed URLs cached in the browser to stop working across HMR. + const secret = randomBytes(32).toString('hex') + const envPath = resolvePath_(rootDir, '.env') + const line = `${PROXY_SECRET_ENV_KEY}=${secret}\n` + + try { + if (existsSync(envPath)) { + const contents = readFileSync(envPath, 'utf-8') + // Safety: don't append if another process already wrote one between the read above + // and this branch. The regex check is cheap and idempotent. + if (PROXY_SECRET_ENV_LINE_RE.test(contents)) { + // Another instance already wrote it — re-read and return that value + const match = contents.match(PROXY_SECRET_ENV_VALUE_RE) + if (match?.[1]) + return { secret: match[1].trim(), ephemeral: false, source: 'dotenv-generated' } + } + appendFileSync(envPath, contents.endsWith('\n') ? line : `\n${line}`) + } + else { + writeFileSync(envPath, `# Generated by @nuxt/scripts\n${line}`) + } + // Also populate process.env so that anything reading it later in the same + // dev process (e.g. child workers) sees the value without a restart. + process.env[PROXY_SECRET_ENV_KEY] = secret + return { secret, ephemeral: false, source: 'dotenv-generated' } + } + catch { + // Writing .env failed (read-only FS, permission denied). Fall back to + // in-memory only — URLs signed this session won't verify after restart. + process.env[PROXY_SECRET_ENV_KEY] = secret + return { secret, ephemeral: true, source: 'memory-generated' } + } +} + export function isProxyDisabled( registryKey: string, registry?: NuxtConfigScriptRegistry, @@ -227,6 +302,38 @@ export interface ModuleOptions { */ integrity?: boolean | 'sha256' | 'sha384' | 'sha512' } + /** + * Proxy endpoint security. + * + * Several proxy endpoints (Google Static Maps, Geocode, Gravatar, embed image proxies) + * inject server-side API keys or forward requests to third-party services. Without + * signing, these are open to cost/quota abuse. Enable signing to require that only + * URLs generated server-side (during SSR/prerender, or via `/_scripts/sign`) are + * accepted. + * + * The secret must be deterministic across deployments so that prerendered URLs + * remain valid. Set it via `NUXT_SCRIPTS_PROXY_SECRET` or `security.secret`. + */ + security?: { + /** + * HMAC secret used to sign proxy URLs. + * + * Falls back to `process.env.NUXT_SCRIPTS_PROXY_SECRET` if unset. In dev, + * the module auto-generates a secret into your `.env` file when neither is + * provided (disable via `autoGenerateSecret: false`). In production, a + * missing secret is a fatal error when any signed endpoint is registered. + * + * Generate one with: `npx @nuxt/scripts generate-secret` + */ + secret?: string + /** + * Automatically generate and persist a signing secret to `.env` when running + * `nuxt dev` without one configured. + * + * @default true + */ + autoGenerateSecret?: boolean + } /** * Google Static Maps proxy configuration. * Proxies static map images through your server to fix CORS issues and enable caching. @@ -374,11 +481,28 @@ export default defineNuxtModule({ ) } + // Resolve the HMAC signing secret used to lock down proxy endpoints. + // Deterministic across deploys is mandatory: signed URLs embedded in prerendered + // HTML must still verify against the runtime server. The module auto-generates + // and persists one into `.env` in dev so users don't hit friction on first run. + const proxySecretResolved = resolveProxySecret( + nuxt.options.rootDir, + !!nuxt.options.dev, + config.security?.secret, + config.security?.autoGenerateSecret !== false, + ) + if (proxySecretResolved?.source === 'dotenv-generated') + logger.info(`[security] Generated ${PROXY_SECRET_ENV_KEY} in .env for signed proxy URLs.`) + else if (proxySecretResolved?.source === 'memory-generated') + logger.warn(`[security] Generated an in-memory ${PROXY_SECRET_ENV_KEY} (could not write .env). Signed URLs will break across restarts.`) + // Setup runtimeConfig for proxies and devtools. // Must run AFTER env var resolution above so the API key is populated. const googleMapsEnabled = config.googleStaticMapsProxy?.enabled || !!config.registry?.googleMaps nuxt.options.runtimeConfig['nuxt-scripts'] = { version: version!, + // HMAC secret for signed proxy URLs (server-only private config) + proxySecret: proxySecretResolved?.secret || '', // Private proxy config with API key (server-side only) googleStaticMapsProxy: googleMapsEnabled ? { apiKey: (nuxt.options.runtimeConfig.public.scripts as any)?.googleMaps?.apiKey } @@ -697,6 +821,9 @@ export default defineNuxtModule({ // Register server handlers for enabled registry scripts const scriptsPrefix = config.prefix || '/_scripts' const enabledEndpoints: Record = {} + /** Signable routes that the `/_scripts/sign` endpoint is allowed to sign. */ + const signableRoutes: string[] = [] + let anyHandlerRequiresSigning = false for (const script of scripts) { if (!script.serverHandlers?.length || !script.registryKey) continue @@ -711,11 +838,17 @@ export default defineNuxtModule({ enabledEndpoints[script.registryKey] = true for (const handler of script.serverHandlers) { + const resolvedRoute = handler.route.replace('/_scripts', scriptsPrefix) addServerHandler({ - route: handler.route.replace('/_scripts', scriptsPrefix), + route: resolvedRoute, handler: handler.handler, middleware: handler.middleware, }) + if (handler.requiresSigning) { + anyHandlerRequiresSigning = true + // Store the non-wildcard prefix so `/sign` can exact-match against it. + signableRoutes.push(resolvedRoute.replace(WILDCARD_SUFFIX_RE, '')) + } } // Script-specific runtimeConfig setup @@ -740,5 +873,33 @@ export default defineNuxtModule({ { endpoints: enabledEndpoints }, nuxt.options.runtimeConfig.public['nuxt-scripts'] as any, ) as any + + // Fail hard if a signed endpoint is enabled in production without a secret. + // Dev falls back to auto-generated secrets above, so this only trips real + // deployments that forgot to set the env var. + if (anyHandlerRequiresSigning && !proxySecretResolved?.secret && !nuxt.options.dev) { + throw new Error( + `[@nuxt/scripts] ${PROXY_SECRET_ENV_KEY} is required in production when signed proxy endpoints are enabled.\n` + + 'Generate one with: npx @nuxt/scripts generate-secret\n' + + `Then set the env var: ${PROXY_SECRET_ENV_KEY}=`, + ) + } + + // Publish the signable routes list to server runtime so `/sign` knows what + // paths it's allowed to sign on behalf of clients. + if (anyHandlerRequiresSigning) { + nuxt.options.runtimeConfig['nuxt-scripts'] = defu( + { signableRoutes }, + nuxt.options.runtimeConfig['nuxt-scripts'] as any, + ) as any + + // Register the `/_scripts/sign` endpoint so reactive client-side URL + // changes (e.g. Google Static Maps size recomputed on mount) can get a + // fresh signature without exposing the secret. + addServerHandler({ + route: `${scriptsPrefix}/sign`, + handler: await resolvePath('./runtime/server/sign-proxy'), + }) + } }, }) diff --git a/packages/script/src/runtime/server/sign-proxy.ts b/packages/script/src/runtime/server/sign-proxy.ts new file mode 100644 index 00000000..3094a77a --- /dev/null +++ b/packages/script/src/runtime/server/sign-proxy.ts @@ -0,0 +1,136 @@ +/** + * Sign-on-demand endpoint for reactive client-side proxy URLs. + * + * ## Why this exists + * + * Most proxy URLs are built server-side during SSR/prerender and embedded in + * HTML with a signature already attached — the client just uses them verbatim. + * But some components rebuild their URLs reactively on the client after mount + * (e.g. `ScriptGoogleMapsStaticMap` recomputes `size` from measured element + * dimensions). Those URLs need a fresh signature, and the client cannot sign + * them itself without leaking the secret. + * + * This endpoint takes a `{ path, query }` request, validates it, and returns + * a signed URL. It's the only way for client code to obtain a signature. + * + * ## Threat model + * + * The endpoint is itself the new attack surface: anything the client can get + * signed, an attacker can also get signed. Mitigations: + * + * 1. **Signable path allowlist** — only routes explicitly marked + * `requiresSigning: true` in the registry can be signed. Arbitrary paths + * are rejected with 403. + * 2. **Same-origin check** — the `Origin` header must match the request's + * `Host` header. This blocks naive cross-site abuse; it's defense-in-depth, + * not a complete CSRF solution. + * 3. **Per-IP rate limiting** — a fixed-window counter in nitro storage caps + * sign requests per IP per minute. This effectively caps the rate at which + * any single attacker can burn downstream API quota. + * + * The domain allowlists on individual proxy handlers provide a separate + * defense-in-depth against SSRF. + */ + +import { useRuntimeConfig } from '#imports' +import { + createError, + defineEventHandler, + getHeader, + getRequestIP, + readBody, +} from 'h3' +import { useStorage } from 'nitropack/runtime' +import { buildSignedProxyUrl } from './utils/sign' + +/** Per-IP sign requests permitted per minute. */ +const RATE_LIMIT_PER_MINUTE = 60 + +/** Storage key namespace for rate-limit counters. */ +const RATE_LIMIT_KEY_PREFIX = 'scripts:sign:ratelimit' + +interface SignBody { + path?: unknown + query?: unknown +} + +interface NuxtScriptsServerConfig { + proxySecret?: string + signableRoutes?: string[] +} + +export default defineEventHandler(async (event) => { + const runtimeConfig = useRuntimeConfig(event) + const scriptsConfig = runtimeConfig['nuxt-scripts'] as NuxtScriptsServerConfig | undefined + const secret = scriptsConfig?.proxySecret + const signableRoutes = scriptsConfig?.signableRoutes || [] + + if (!secret) { + throw createError({ + statusCode: 500, + statusMessage: 'Proxy secret not configured', + }) + } + + // Same-origin check. `Origin` is set by browsers on cross-origin requests + // and on POST. We require it to match the request's Host, which blocks + // naive cross-site CSRF-ish abuse. Not a complete CSRF defense (attackers + // can still craft matching Origin headers from non-browser clients), but + // raises the bar meaningfully for the common case. + const origin = getHeader(event, 'origin') + const host = getHeader(event, 'host') + if (!origin || !host) { + throw createError({ statusCode: 403, statusMessage: 'Origin header required' }) + } + let originHost: string + try { + originHost = new URL(origin).host + } + catch { + throw createError({ statusCode: 403, statusMessage: 'Invalid Origin header' }) + } + if (originHost !== host) { + throw createError({ statusCode: 403, statusMessage: 'Cross-origin requests forbidden' }) + } + + // Per-IP rate limit. Uses a fixed one-minute bucket keyed by `${ip}:${bucket}`. + // Simple and cheap; sliding windows are overkill for this endpoint. The TTL + // is 2 minutes so the bucket survives its own window plus a bit of clock skew. + const ip = getRequestIP(event, { xForwardedFor: true }) || 'unknown' + const bucket = Math.floor(Date.now() / 60_000) + const rateLimitKey = `${RATE_LIMIT_KEY_PREFIX}:${ip}:${bucket}` + const storage = useStorage() + const currentCount = Number((await storage.getItem(rateLimitKey)) ?? 0) + if (currentCount >= RATE_LIMIT_PER_MINUTE) { + throw createError({ + statusCode: 429, + statusMessage: 'Too many signing requests', + }) + } + await storage.setItem(rateLimitKey, currentCount + 1, { ttl: 120 } as any) + + // Parse and validate body. We accept any JSON-serializable query payload but + // require `path` to be a string so the path allowlist check is sound. + const body = (await readBody(event).catch(() => null)) as SignBody | null + if (!body || typeof body.path !== 'string') { + throw createError({ statusCode: 400, statusMessage: 'Invalid body: { path: string, query: object }' }) + } + const { path } = body + const query = (body.query && typeof body.query === 'object' && !Array.isArray(body.query)) + ? body.query as Record + : {} + + // Path allowlist check. A path is signable if it equals a registered + // signable route, or extends one (for `/**` handlers). Using string match + // with a trailing slash guard prevents prefix hijacking like + // `/_scripts/proxy/xx-evil` matching `/_scripts/proxy/x`. + const isSignable = signableRoutes.some(route => + path === route || path.startsWith(`${route}/`), + ) + if (!isSignable) { + throw createError({ statusCode: 403, statusMessage: 'Path not signable' }) + } + + const url = buildSignedProxyUrl(path, query, secret) + return { url } +}) diff --git a/packages/script/src/runtime/server/utils/sign.ts b/packages/script/src/runtime/server/utils/sign.ts new file mode 100644 index 00000000..80b6e7ea --- /dev/null +++ b/packages/script/src/runtime/server/utils/sign.ts @@ -0,0 +1,151 @@ +/** + * HMAC URL signing for proxy endpoints. + * + * ## Why + * + * Proxy endpoints like `/_scripts/proxy/google-static-maps` inject a server-side + * API key and forward requests to third-party services. Without signing, anyone + * can call these endpoints with arbitrary parameters and burn the site owner's + * API quota. Signing ensures only URLs generated server-side (during SSR/prerender + * or via the `/_scripts/sign` endpoint) are accepted. + * + * ## How + * + * 1. The module stores a deterministic secret in `runtimeConfig.nuxt-scripts.proxySecret` + * (env: `NUXT_SCRIPTS_PROXY_SECRET`). + * 2. URLs are canonicalized (sort query keys, strip `sig`) and signed with HMAC-SHA256. + * 3. The first 16 hex chars (64 bits) of the digest is appended as `?sig=`. + * 4. Endpoints wrapped with `withSigning()` verify the sig against the current request. + * + * A 64-bit signature is enough to defeat brute force for this threat model + * (a billion guesses gives a ~5% hit rate at 2^64). Longer signatures bloat + * prerendered HTML for no practical gain. + */ + +import type { H3Event } from 'h3' +import { createHmac } from 'node:crypto' +import { getQuery } from 'h3' + +/** Query param name for the signature. Chosen to be unlikely to collide with upstream APIs. */ +export const SIG_PARAM = 'sig' + +/** Length of the hex signature (16 chars = 64 bits). */ +export const SIG_LENGTH = 16 + +/** + * Canonicalize a query object into a deterministic string suitable for HMAC input. + * + * Rules: + * - The `sig` param is stripped (it can't sign itself). + * - `undefined` and `null` values are skipped (mirrors `ufo.withQuery`). + * - Keys are sorted alphabetically so order-independent reconstruction works. + * - Arrays expand to repeated keys (e.g. `markers=a&markers=b`), matching how + * `ufo.withQuery` serializes array-valued params. + * - Objects are JSON-stringified (rare, but consistent with `ufo.withQuery`). + * - Encoding uses `encodeURIComponent` for both keys and values so the canonical + * form matches what shows up on the wire. + * + * The resulting string is stable across server/client and different JS runtimes + * because it does not depend on `URLSearchParams` insertion order. + */ +export function canonicalizeQuery(query: Record): string { + const keys = Object.keys(query) + .filter(k => k !== SIG_PARAM && query[k] !== undefined && query[k] !== null) + .sort() + + const parts: string[] = [] + for (const key of keys) { + const value = query[key] + const encodedKey = encodeURIComponent(key) + if (Array.isArray(value)) { + // Preserve array order (order matters for e.g. map markers) but sort keys above. + for (const item of value) { + if (item === undefined || item === null) + continue + parts.push(`${encodedKey}=${encodeURIComponent(serializeValue(item))}`) + } + } + else { + parts.push(`${encodedKey}=${encodeURIComponent(serializeValue(value))}`) + } + } + return parts.join('&') +} + +function serializeValue(value: unknown): string { + if (typeof value === 'string') + return value + if (typeof value === 'object') + return JSON.stringify(value) + return String(value) +} + +/** + * Sign a path + query using HMAC-SHA256 and return the 16-char hex digest. + * + * The HMAC input is `${path}?${canonicalQuery}` so that the same query signed + * against a different endpoint yields a different signature (prevents cross- + * endpoint signature reuse). + * + * `path` should be the URL path without query string (e.g. `/_scripts/proxy/google-static-maps`). + * Callers should not include origin / host since the signing contract is path-relative. + */ +export function signProxyUrl(path: string, query: Record, secret: string): string { + const canonical = canonicalizeQuery(query) + const input = canonical ? `${path}?${canonical}` : path + return createHmac('sha256', secret).update(input).digest('hex').slice(0, SIG_LENGTH) +} + +/** + * Build a fully-formed signed URL (path + query + sig). + * + * This is the primary helper for code paths that need to emit a proxy URL + * (SSR components, server-side URL rewriters like instagram-embed). + */ +export function buildSignedProxyUrl(path: string, query: Record, secret: string): string { + const sig = signProxyUrl(path, query, secret) + const canonical = canonicalizeQuery(query) + const queryString = canonical ? `${canonical}&${SIG_PARAM}=${sig}` : `${SIG_PARAM}=${sig}` + return `${path}?${queryString}` +} + +/** + * Verify a request's signature against the current event's path and query. + * + * Reads the `sig` param from the query, reconstructs the canonical form from + * the remaining params, and compares against a freshly computed HMAC. Returns + * `false` if the sig is missing, malformed, or doesn't match. + * + * Uses constant-time comparison to prevent timing side-channels. + */ +export function verifyProxyRequest(event: H3Event, secret: string): boolean { + if (!secret) + return false + + const query = getQuery(event) as Record + const rawSig = query[SIG_PARAM] + const sig = Array.isArray(rawSig) ? rawSig[0] : rawSig + if (typeof sig !== 'string' || sig.length !== SIG_LENGTH) + return false + + // Use the event path without query string as the signing path + const path = (event.path || '').split('?')[0] || '' + const expected = signProxyUrl(path, query, secret) + return constantTimeEqual(expected, sig) +} + +/** + * Constant-time string comparison. + * + * Both inputs are expected to be equal-length hex strings. The loop runs over + * the longer length so an early-exit on length mismatch doesn't leak the + * expected length (though both are fixed at `SIG_LENGTH` in practice). + */ +export function constantTimeEqual(a: string, b: string): boolean { + if (a.length !== b.length) + return false + let diff = 0 + for (let i = 0; i < a.length; i++) + diff |= a.charCodeAt(i) ^ b.charCodeAt(i) + return diff === 0 +} diff --git a/packages/script/src/runtime/server/utils/withSigning.ts b/packages/script/src/runtime/server/utils/withSigning.ts new file mode 100644 index 00000000..ed1eee63 --- /dev/null +++ b/packages/script/src/runtime/server/utils/withSigning.ts @@ -0,0 +1,50 @@ +/** + * Middleware wrapper that enforces HMAC signature verification on a proxy handler. + * + * Usage: + * ```ts + * export default withSigning(defineEventHandler(async (event) => { + * // ... handler logic + * })) + * ``` + * + * Behavior: + * - Reads `runtimeConfig.nuxt-scripts.proxySecret` (server-only). + * - If no secret is configured: 500 — the module is misconfigured. + * - If the request's `sig` param is missing, malformed, or doesn't match: 403. + * - Otherwise, delegates to the wrapped handler. + * + * The outer wrapper runs before any handler logic, so misconfigured / unauthorized + * requests never reach the upstream fetch and cannot consume API quota. + */ + +import type { EventHandler, EventHandlerRequest, EventHandlerResponse } from 'h3' +import { useRuntimeConfig } from '#imports' +import { createError, defineEventHandler } from 'h3' +import { verifyProxyRequest } from './sign' + +export function withSigning( + handler: EventHandler, +): EventHandler { + return defineEventHandler(async (event) => { + const runtimeConfig = useRuntimeConfig(event) + const secret = (runtimeConfig['nuxt-scripts'] as { proxySecret?: string } | undefined)?.proxySecret + + if (!secret) { + throw createError({ + statusCode: 500, + statusMessage: 'Proxy secret not configured', + message: 'NUXT_SCRIPTS_PROXY_SECRET is not set. Run `npx @nuxt/scripts generate-secret` and set the env var.', + }) + } + + if (!verifyProxyRequest(event, secret)) { + throw createError({ + statusCode: 403, + statusMessage: 'Invalid signature', + }) + } + + return handler(event) as Res + }) +} diff --git a/packages/script/src/runtime/types.ts b/packages/script/src/runtime/types.ts index 9cd282bf..cb9c6cb4 100644 --- a/packages/script/src/runtime/types.ts +++ b/packages/script/src/runtime/types.ts @@ -304,6 +304,14 @@ export interface RegistryScriptServerHandler { route: string handler: string middleware?: boolean + /** + * Whether this handler verifies HMAC signatures via `withSigning()`. + * + * When any enabled script registers a handler with `requiresSigning: true`, + * the module enforces that `NUXT_SCRIPTS_PROXY_SECRET` is set in production, + * and the `/_scripts/sign` endpoint will accept this route as a signable path. + */ + requiresSigning?: boolean } /** diff --git a/test/unit/sign.test.ts b/test/unit/sign.test.ts new file mode 100644 index 00000000..9c56580d --- /dev/null +++ b/test/unit/sign.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest' +import { + buildSignedProxyUrl, + canonicalizeQuery, + constantTimeEqual, + SIG_LENGTH, + SIG_PARAM, + signProxyUrl, +} from '../../packages/script/src/runtime/server/utils/sign' + +const SECRET = 'test-secret-9f2c8b4e7a1d6f3c5b9e8a2d4f7c1b6e' + +describe('canonicalizeQuery', () => { + it('sorts keys alphabetically for order-independence', () => { + expect(canonicalizeQuery({ b: '2', a: '1', c: '3' })) + .toBe('a=1&b=2&c=3') + }) + + it('strips the sig param so it can never sign itself', () => { + expect(canonicalizeQuery({ a: '1', sig: 'abc123' })) + .toBe('a=1') + }) + + it('skips undefined and null values (matches ufo.withQuery)', () => { + expect(canonicalizeQuery({ a: '1', b: undefined, c: null, d: '' })) + .toBe('a=1&d=') + }) + + it('expands arrays to repeated keys in order', () => { + expect(canonicalizeQuery({ markers: ['Sydney', 'Melbourne', 'Perth'] })) + .toBe('markers=Sydney&markers=Melbourne&markers=Perth') + }) + + it('skips undefined and null items inside arrays', () => { + expect(canonicalizeQuery({ a: ['x', undefined, 'y', null, 'z'] })) + .toBe('a=x&a=y&a=z') + }) + + it('uRL-encodes keys and values', () => { + expect(canonicalizeQuery({ 'q': 'hello world', 'a+b': 'c&d' })) + .toBe('a%2Bb=c%26d&q=hello%20world') + }) + + it('jSON-stringifies object values for stable comparison', () => { + expect(canonicalizeQuery({ style: { color: 'red' } })) + .toBe('style=%7B%22color%22%3A%22red%22%7D') + }) + + it('coerces numbers and booleans via String()', () => { + expect(canonicalizeQuery({ zoom: 15, enabled: true, ratio: 1.5 })) + .toBe('enabled=true&ratio=1.5&zoom=15') + }) + + it('produces the same output regardless of insertion order', () => { + const a = canonicalizeQuery({ zoom: 15, center: 'Sydney', size: '640x400' }) + const b = canonicalizeQuery({ size: '640x400', zoom: 15, center: 'Sydney' }) + expect(a).toBe(b) + }) +}) + +describe('signProxyUrl', () => { + it('returns a 16-char hex signature', () => { + const sig = signProxyUrl('/_scripts/proxy/google-static-maps', { center: 'Sydney' }, SECRET) + expect(sig).toHaveLength(SIG_LENGTH) + expect(sig).toMatch(/^[0-9a-f]+$/) + }) + + it('is deterministic for the same input', () => { + const a = signProxyUrl('/_scripts/proxy/x', { a: '1' }, SECRET) + const b = signProxyUrl('/_scripts/proxy/x', { a: '1' }, SECRET) + expect(a).toBe(b) + }) + + it('changes when the path changes (prevents cross-endpoint replay)', () => { + const a = signProxyUrl('/_scripts/proxy/google-static-maps', { center: 'Sydney' }, SECRET) + const b = signProxyUrl('/_scripts/proxy/google-maps-geocode', { center: 'Sydney' }, SECRET) + expect(a).not.toBe(b) + }) + + it('changes when any query param changes', () => { + const a = signProxyUrl('/p', { center: 'Sydney' }, SECRET) + const b = signProxyUrl('/p', { center: 'Melbourne' }, SECRET) + expect(a).not.toBe(b) + }) + + it('changes when the secret changes', () => { + const a = signProxyUrl('/p', { center: 'Sydney' }, 'secret-a') + const b = signProxyUrl('/p', { center: 'Sydney' }, 'secret-b') + expect(a).not.toBe(b) + }) + + it('is insensitive to query key insertion order', () => { + const a = signProxyUrl('/p', { a: '1', b: '2', c: '3' }, SECRET) + const b = signProxyUrl('/p', { c: '3', a: '1', b: '2' }, SECRET) + expect(a).toBe(b) + }) + + it('ignores a provided sig param in the query (signing is self-consistent)', () => { + const a = signProxyUrl('/p', { a: '1' }, SECRET) + const b = signProxyUrl('/p', { a: '1', sig: 'pre-existing-garbage' }, SECRET) + expect(a).toBe(b) + }) +}) + +describe('buildSignedProxyUrl', () => { + it('appends sig as the last query param', () => { + const url = buildSignedProxyUrl('/_scripts/proxy/x', { a: '1' }, SECRET) + expect(url).toMatch(new RegExp(`^/_scripts/proxy/x\\?a=1&${SIG_PARAM}=[0-9a-f]{${SIG_LENGTH}}$`)) + }) + + it('works with empty query', () => { + const url = buildSignedProxyUrl('/p', {}, SECRET) + expect(url).toMatch(new RegExp(`^/p\\?${SIG_PARAM}=[0-9a-f]{${SIG_LENGTH}}$`)) + }) + + it('round-trips through verify (via manually constructed event)', () => { + const url = buildSignedProxyUrl('/_scripts/proxy/x', { center: 'Sydney', zoom: 15 }, SECRET) + // Reuse signProxyUrl logic from a parsed URL to verify the embedded sig matches + const [path, queryString] = url.split('?') + const query: Record = {} + for (const pair of queryString!.split('&')) { + const [k, v] = pair.split('=') + query[decodeURIComponent(k!)] = decodeURIComponent(v!) + } + const embeddedSig = query[SIG_PARAM] + const expectedSig = signProxyUrl(path!, query, SECRET) + expect(embeddedSig).toBe(expectedSig) + }) +}) + +describe('constantTimeEqual', () => { + it('returns true for equal strings', () => { + expect(constantTimeEqual('abc123', 'abc123')).toBe(true) + }) + + it('returns false for different strings of the same length', () => { + expect(constantTimeEqual('abc123', 'abc124')).toBe(false) + }) + + it('returns false for strings of different length', () => { + expect(constantTimeEqual('abc', 'abcd')).toBe(false) + }) + + it('returns true for empty strings', () => { + expect(constantTimeEqual('', '')).toBe(true) + }) +}) From 67249591ae51588280ad757dfe11d73731999b6c Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Fri, 10 Apr 2026 15:26:15 +1000 Subject: [PATCH 02/13] chore: remove em dashes from comments and strings --- packages/script/src/cli.ts | 4 ++-- packages/script/src/module.ts | 6 +++--- packages/script/src/runtime/server/sign-proxy.ts | 8 ++++---- packages/script/src/runtime/server/utils/withSigning.ts | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/script/src/cli.ts b/packages/script/src/cli.ts index 7cb83ac6..d94c98de 100644 --- a/packages/script/src/cli.ts +++ b/packages/script/src/cli.ts @@ -1,7 +1,7 @@ /** * @nuxt/scripts CLI. * - * Currently hosts a single command — `generate-secret` — which produces a + * Currently hosts a single command, `generate-secret`, which produces a * cryptographically random HMAC secret for `NUXT_SCRIPTS_PROXY_SECRET`. This * is an alternative to letting the module auto-write a secret into `.env`, * for users who want explicit control (e.g. teams that commit secrets to a @@ -19,7 +19,7 @@ function generateSecret(): void { process.stdout.write( [ '', - ' @nuxt/scripts — proxy signing secret', + ' @nuxt/scripts: proxy signing secret', '', ` Secret: ${secret}`, '', diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index 2f7504af..c00a48b0 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -135,7 +135,7 @@ export interface ResolvedProxySecret { * 1. `scripts.security.secret` in nuxt.config * 2. `NUXT_SCRIPTS_PROXY_SECRET` env var * 3. Dev-only auto-generation: write to `.env` (or keep in memory as last resort) - * 4. Empty string (prod without secret — caller decides whether this is fatal) + * 4. Empty string (prod without secret; caller decides whether this is fatal) */ export function resolveProxySecret( rootDir: string, @@ -166,7 +166,7 @@ export function resolveProxySecret( // Safety: don't append if another process already wrote one between the read above // and this branch. The regex check is cheap and idempotent. if (PROXY_SECRET_ENV_LINE_RE.test(contents)) { - // Another instance already wrote it — re-read and return that value + // Another instance already wrote it. Re-read and return that value. const match = contents.match(PROXY_SECRET_ENV_VALUE_RE) if (match?.[1]) return { secret: match[1].trim(), ephemeral: false, source: 'dotenv-generated' } @@ -183,7 +183,7 @@ export function resolveProxySecret( } catch { // Writing .env failed (read-only FS, permission denied). Fall back to - // in-memory only — URLs signed this session won't verify after restart. + // in-memory only; URLs signed this session won't verify after restart. process.env[PROXY_SECRET_ENV_KEY] = secret return { secret, ephemeral: true, source: 'memory-generated' } } diff --git a/packages/script/src/runtime/server/sign-proxy.ts b/packages/script/src/runtime/server/sign-proxy.ts index 3094a77a..54aa3ad6 100644 --- a/packages/script/src/runtime/server/sign-proxy.ts +++ b/packages/script/src/runtime/server/sign-proxy.ts @@ -4,7 +4,7 @@ * ## Why this exists * * Most proxy URLs are built server-side during SSR/prerender and embedded in - * HTML with a signature already attached — the client just uses them verbatim. + * HTML with a signature already attached; the client just uses them verbatim. * But some components rebuild their URLs reactively on the client after mount * (e.g. `ScriptGoogleMapsStaticMap` recomputes `size` from measured element * dimensions). Those URLs need a fresh signature, and the client cannot sign @@ -18,13 +18,13 @@ * The endpoint is itself the new attack surface: anything the client can get * signed, an attacker can also get signed. Mitigations: * - * 1. **Signable path allowlist** — only routes explicitly marked + * 1. **Signable path allowlist**: only routes explicitly marked * `requiresSigning: true` in the registry can be signed. Arbitrary paths * are rejected with 403. - * 2. **Same-origin check** — the `Origin` header must match the request's + * 2. **Same-origin check**: the `Origin` header must match the request's * `Host` header. This blocks naive cross-site abuse; it's defense-in-depth, * not a complete CSRF solution. - * 3. **Per-IP rate limiting** — a fixed-window counter in nitro storage caps + * 3. **Per-IP rate limiting**: a fixed-window counter in nitro storage caps * sign requests per IP per minute. This effectively caps the rate at which * any single attacker can burn downstream API quota. * diff --git a/packages/script/src/runtime/server/utils/withSigning.ts b/packages/script/src/runtime/server/utils/withSigning.ts index ed1eee63..86acf545 100644 --- a/packages/script/src/runtime/server/utils/withSigning.ts +++ b/packages/script/src/runtime/server/utils/withSigning.ts @@ -10,7 +10,7 @@ * * Behavior: * - Reads `runtimeConfig.nuxt-scripts.proxySecret` (server-only). - * - If no secret is configured: 500 — the module is misconfigured. + * - If no secret is configured: 500 (the module is misconfigured). * - If the request's `sig` param is missing, malformed, or doesn't match: 403. * - Otherwise, delegates to the wrapped handler. * From d13999ab6ca660ccd21f416b9adbe6819d3f67bd Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Fri, 10 Apr 2026 15:50:29 +1000 Subject: [PATCH 03/13] refactor(security): replace /sign endpoint with stateless page tokens Remove the /_scripts/sign endpoint (rate limiter, origin check, server-side storage) and replace it with stateless page tokens that the server embeds in the SSR payload. Page tokens are HMAC-signed timestamps valid for 1 hour. The client attaches _pt + _ts params to proxy requests instead of needing a signing round-trip. verifyProxyRequest now accepts either a URL signature (exact params, from SSR) or a page token (any params, time-limited, from client). This removes the rate limiter from the module. Rate limiting is the site owner's concern, not the module's. --- packages/script/src/module.ts | 25 +--- .../script/src/runtime/server/sign-proxy.ts | 136 ------------------ .../script/src/runtime/server/utils/sign.ts | 99 +++++++++++-- test/unit/sign.test.ts | 70 +++++++++ 4 files changed, 158 insertions(+), 172 deletions(-) delete mode 100644 packages/script/src/runtime/server/sign-proxy.ts diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index c00a48b0..bea0ad23 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -118,7 +118,6 @@ const toScreamingSnake = (s: string) => s.replace(UPPER_RE, '_$1').toUpperCase() const PROXY_SECRET_ENV_KEY = 'NUXT_SCRIPTS_PROXY_SECRET' const PROXY_SECRET_ENV_LINE_RE = /^NUXT_SCRIPTS_PROXY_SECRET=/m const PROXY_SECRET_ENV_VALUE_RE = /^NUXT_SCRIPTS_PROXY_SECRET=(.+)$/m -const WILDCARD_SUFFIX_RE = /\/\*\*$/ export interface ResolvedProxySecret { secret: string @@ -821,8 +820,6 @@ export default defineNuxtModule({ // Register server handlers for enabled registry scripts const scriptsPrefix = config.prefix || '/_scripts' const enabledEndpoints: Record = {} - /** Signable routes that the `/_scripts/sign` endpoint is allowed to sign. */ - const signableRoutes: string[] = [] let anyHandlerRequiresSigning = false for (const script of scripts) { if (!script.serverHandlers?.length || !script.registryKey) @@ -844,11 +841,8 @@ export default defineNuxtModule({ handler: handler.handler, middleware: handler.middleware, }) - if (handler.requiresSigning) { + if (handler.requiresSigning) anyHandlerRequiresSigning = true - // Store the non-wildcard prefix so `/sign` can exact-match against it. - signableRoutes.push(resolvedRoute.replace(WILDCARD_SUFFIX_RE, '')) - } } // Script-specific runtimeConfig setup @@ -884,22 +878,5 @@ export default defineNuxtModule({ + `Then set the env var: ${PROXY_SECRET_ENV_KEY}=`, ) } - - // Publish the signable routes list to server runtime so `/sign` knows what - // paths it's allowed to sign on behalf of clients. - if (anyHandlerRequiresSigning) { - nuxt.options.runtimeConfig['nuxt-scripts'] = defu( - { signableRoutes }, - nuxt.options.runtimeConfig['nuxt-scripts'] as any, - ) as any - - // Register the `/_scripts/sign` endpoint so reactive client-side URL - // changes (e.g. Google Static Maps size recomputed on mount) can get a - // fresh signature without exposing the secret. - addServerHandler({ - route: `${scriptsPrefix}/sign`, - handler: await resolvePath('./runtime/server/sign-proxy'), - }) - } }, }) diff --git a/packages/script/src/runtime/server/sign-proxy.ts b/packages/script/src/runtime/server/sign-proxy.ts deleted file mode 100644 index 54aa3ad6..00000000 --- a/packages/script/src/runtime/server/sign-proxy.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * Sign-on-demand endpoint for reactive client-side proxy URLs. - * - * ## Why this exists - * - * Most proxy URLs are built server-side during SSR/prerender and embedded in - * HTML with a signature already attached; the client just uses them verbatim. - * But some components rebuild their URLs reactively on the client after mount - * (e.g. `ScriptGoogleMapsStaticMap` recomputes `size` from measured element - * dimensions). Those URLs need a fresh signature, and the client cannot sign - * them itself without leaking the secret. - * - * This endpoint takes a `{ path, query }` request, validates it, and returns - * a signed URL. It's the only way for client code to obtain a signature. - * - * ## Threat model - * - * The endpoint is itself the new attack surface: anything the client can get - * signed, an attacker can also get signed. Mitigations: - * - * 1. **Signable path allowlist**: only routes explicitly marked - * `requiresSigning: true` in the registry can be signed. Arbitrary paths - * are rejected with 403. - * 2. **Same-origin check**: the `Origin` header must match the request's - * `Host` header. This blocks naive cross-site abuse; it's defense-in-depth, - * not a complete CSRF solution. - * 3. **Per-IP rate limiting**: a fixed-window counter in nitro storage caps - * sign requests per IP per minute. This effectively caps the rate at which - * any single attacker can burn downstream API quota. - * - * The domain allowlists on individual proxy handlers provide a separate - * defense-in-depth against SSRF. - */ - -import { useRuntimeConfig } from '#imports' -import { - createError, - defineEventHandler, - getHeader, - getRequestIP, - readBody, -} from 'h3' -import { useStorage } from 'nitropack/runtime' -import { buildSignedProxyUrl } from './utils/sign' - -/** Per-IP sign requests permitted per minute. */ -const RATE_LIMIT_PER_MINUTE = 60 - -/** Storage key namespace for rate-limit counters. */ -const RATE_LIMIT_KEY_PREFIX = 'scripts:sign:ratelimit' - -interface SignBody { - path?: unknown - query?: unknown -} - -interface NuxtScriptsServerConfig { - proxySecret?: string - signableRoutes?: string[] -} - -export default defineEventHandler(async (event) => { - const runtimeConfig = useRuntimeConfig(event) - const scriptsConfig = runtimeConfig['nuxt-scripts'] as NuxtScriptsServerConfig | undefined - const secret = scriptsConfig?.proxySecret - const signableRoutes = scriptsConfig?.signableRoutes || [] - - if (!secret) { - throw createError({ - statusCode: 500, - statusMessage: 'Proxy secret not configured', - }) - } - - // Same-origin check. `Origin` is set by browsers on cross-origin requests - // and on POST. We require it to match the request's Host, which blocks - // naive cross-site CSRF-ish abuse. Not a complete CSRF defense (attackers - // can still craft matching Origin headers from non-browser clients), but - // raises the bar meaningfully for the common case. - const origin = getHeader(event, 'origin') - const host = getHeader(event, 'host') - if (!origin || !host) { - throw createError({ statusCode: 403, statusMessage: 'Origin header required' }) - } - let originHost: string - try { - originHost = new URL(origin).host - } - catch { - throw createError({ statusCode: 403, statusMessage: 'Invalid Origin header' }) - } - if (originHost !== host) { - throw createError({ statusCode: 403, statusMessage: 'Cross-origin requests forbidden' }) - } - - // Per-IP rate limit. Uses a fixed one-minute bucket keyed by `${ip}:${bucket}`. - // Simple and cheap; sliding windows are overkill for this endpoint. The TTL - // is 2 minutes so the bucket survives its own window plus a bit of clock skew. - const ip = getRequestIP(event, { xForwardedFor: true }) || 'unknown' - const bucket = Math.floor(Date.now() / 60_000) - const rateLimitKey = `${RATE_LIMIT_KEY_PREFIX}:${ip}:${bucket}` - const storage = useStorage() - const currentCount = Number((await storage.getItem(rateLimitKey)) ?? 0) - if (currentCount >= RATE_LIMIT_PER_MINUTE) { - throw createError({ - statusCode: 429, - statusMessage: 'Too many signing requests', - }) - } - await storage.setItem(rateLimitKey, currentCount + 1, { ttl: 120 } as any) - - // Parse and validate body. We accept any JSON-serializable query payload but - // require `path` to be a string so the path allowlist check is sound. - const body = (await readBody(event).catch(() => null)) as SignBody | null - if (!body || typeof body.path !== 'string') { - throw createError({ statusCode: 400, statusMessage: 'Invalid body: { path: string, query: object }' }) - } - const { path } = body - const query = (body.query && typeof body.query === 'object' && !Array.isArray(body.query)) - ? body.query as Record - : {} - - // Path allowlist check. A path is signable if it equals a registered - // signable route, or extends one (for `/**` handlers). Using string match - // with a trailing slash guard prevents prefix hijacking like - // `/_scripts/proxy/xx-evil` matching `/_scripts/proxy/x`. - const isSignable = signableRoutes.some(route => - path === route || path.startsWith(`${route}/`), - ) - if (!isSignable) { - throw createError({ statusCode: 403, statusMessage: 'Path not signable' }) - } - - const url = buildSignedProxyUrl(path, query, secret) - return { url } -}) diff --git a/packages/script/src/runtime/server/utils/sign.ts b/packages/script/src/runtime/server/utils/sign.ts index 80b6e7ea..314a531a 100644 --- a/packages/script/src/runtime/server/utils/sign.ts +++ b/packages/script/src/runtime/server/utils/sign.ts @@ -109,29 +109,104 @@ export function buildSignedProxyUrl(path: string, query: Record return `${path}?${queryString}` } +// --------------------------------------------------------------------------- +// Page tokens: stateless, short-lived access tokens for client-side proxy use +// --------------------------------------------------------------------------- + +/** Query param name for the page token. */ +export const PAGE_TOKEN_PARAM = '_pt' + +/** Query param name for the page token timestamp. */ +export const PAGE_TOKEN_TS_PARAM = '_ts' + +/** Default max age for page tokens in seconds (1 hour). */ +export const PAGE_TOKEN_MAX_AGE = 3600 + /** - * Verify a request's signature against the current event's path and query. + * Generate a page token that authorizes client-side proxy requests. * - * Reads the `sig` param from the query, reconstructs the canonical form from - * the remaining params, and compares against a freshly computed HMAC. Returns - * `false` if the sig is missing, malformed, or doesn't match. + * Embedded in the SSR payload so the browser can attach it to reactive proxy + * URL updates without needing a `/sign` round-trip. The token is scoped to + * a timestamp and expires after `PAGE_TOKEN_MAX_AGE` seconds. + * + * Construction: first 16 hex chars of `HMAC(secret, "proxy-access:")`. + */ +export function generateProxyToken(secret: string, timestamp: number): string { + return createHmac('sha256', secret) + .update(`proxy-access:${timestamp}`) + .digest('hex') + .slice(0, SIG_LENGTH) +} + +/** + * Verify a page token against the current time. * - * Uses constant-time comparison to prevent timing side-channels. + * Returns `true` if the token matches the HMAC for the given timestamp AND + * the timestamp is within `maxAge` seconds of `now`. */ -export function verifyProxyRequest(event: H3Event, secret: string): boolean { +export function verifyProxyToken( + token: string, + timestamp: number, + secret: string, + maxAge: number = PAGE_TOKEN_MAX_AGE, + now: number = Math.floor(Date.now() / 1000), +): boolean { + if (!token || !secret || typeof timestamp !== 'number') + return false + if (token.length !== SIG_LENGTH) + return false + + // Reject expired or future tokens (future tolerance: 60s for clock skew) + const age = now - timestamp + if (age > maxAge || age < -60) + return false + + const expected = generateProxyToken(secret, timestamp) + return constantTimeEqual(expected, token) +} + +/** + * Verify a request against either a URL signature or a page token. + * + * Two verification modes, checked in order: + * + * 1. **URL signature** (`sig` param): the exact URL was signed server-side + * during SSR/prerender. Locked to the specific path + query params. + * + * 2. **Page token** (`_pt` + `_ts` params): the client received a short-lived + * token during SSR and is making a reactive proxy request with new params. + * Valid for any params on the target path, but expires after `maxAge`. + * + * Returns `false` if neither mode validates. + */ +export function verifyProxyRequest(event: H3Event, secret: string, maxAge?: number): boolean { if (!secret) return false const query = getQuery(event) as Record + + // Mode 1: exact URL signature const rawSig = query[SIG_PARAM] const sig = Array.isArray(rawSig) ? rawSig[0] : rawSig - if (typeof sig !== 'string' || sig.length !== SIG_LENGTH) - return false + if (typeof sig === 'string' && sig.length === SIG_LENGTH) { + const path = (event.path || '').split('?')[0] || '' + const expected = signProxyUrl(path, query, secret) + if (constantTimeEqual(expected, sig)) + return true + } + + // Mode 2: page token + const rawToken = query[PAGE_TOKEN_PARAM] + const rawTs = query[PAGE_TOKEN_TS_PARAM] + const token = Array.isArray(rawToken) ? rawToken[0] : rawToken + const ts = Array.isArray(rawTs) ? rawTs[0] : rawTs + if (typeof token === 'string' && ts !== undefined) { + const timestamp = Number(ts) + if (!Number.isNaN(timestamp)) + return verifyProxyToken(token, timestamp, secret, maxAge) + } - // Use the event path without query string as the signing path - const path = (event.path || '').split('?')[0] || '' - const expected = signProxyUrl(path, query, secret) - return constantTimeEqual(expected, sig) + return false } /** diff --git a/test/unit/sign.test.ts b/test/unit/sign.test.ts index 9c56580d..73405e12 100644 --- a/test/unit/sign.test.ts +++ b/test/unit/sign.test.ts @@ -3,9 +3,12 @@ import { buildSignedProxyUrl, canonicalizeQuery, constantTimeEqual, + generateProxyToken, + PAGE_TOKEN_MAX_AGE, SIG_LENGTH, SIG_PARAM, signProxyUrl, + verifyProxyToken, } from '../../packages/script/src/runtime/server/utils/sign' const SECRET = 'test-secret-9f2c8b4e7a1d6f3c5b9e8a2d4f7c1b6e' @@ -145,3 +148,70 @@ describe('constantTimeEqual', () => { expect(constantTimeEqual('', '')).toBe(true) }) }) + +describe('generateProxyToken', () => { + it('returns a 16-char hex token', () => { + const token = generateProxyToken(SECRET, 1712764800) + expect(token).toHaveLength(SIG_LENGTH) + expect(token).toMatch(/^[0-9a-f]+$/) + }) + + it('is deterministic for the same secret and timestamp', () => { + const a = generateProxyToken(SECRET, 1712764800) + const b = generateProxyToken(SECRET, 1712764800) + expect(a).toBe(b) + }) + + it('changes when timestamp changes', () => { + const a = generateProxyToken(SECRET, 1712764800) + const b = generateProxyToken(SECRET, 1712764801) + expect(a).not.toBe(b) + }) + + it('changes when secret changes', () => { + const a = generateProxyToken('secret-a', 1712764800) + const b = generateProxyToken('secret-b', 1712764800) + expect(a).not.toBe(b) + }) +}) + +describe('verifyProxyToken', () => { + const ts = 1712764800 + const token = generateProxyToken(SECRET, ts) + + it('verifies a valid token within the time window', () => { + expect(verifyProxyToken(token, ts, SECRET, PAGE_TOKEN_MAX_AGE, ts + 100)).toBe(true) + }) + + it('verifies a token at the exact boundary', () => { + expect(verifyProxyToken(token, ts, SECRET, PAGE_TOKEN_MAX_AGE, ts + PAGE_TOKEN_MAX_AGE)).toBe(true) + }) + + it('rejects an expired token', () => { + expect(verifyProxyToken(token, ts, SECRET, PAGE_TOKEN_MAX_AGE, ts + PAGE_TOKEN_MAX_AGE + 1)).toBe(false) + }) + + it('rejects a token from the far future (clock skew > 60s)', () => { + expect(verifyProxyToken(token, ts, SECRET, PAGE_TOKEN_MAX_AGE, ts - 61)).toBe(false) + }) + + it('allows minor clock skew (up to 60s into the future)', () => { + expect(verifyProxyToken(token, ts, SECRET, PAGE_TOKEN_MAX_AGE, ts - 30)).toBe(true) + }) + + it('rejects a tampered token', () => { + expect(verifyProxyToken('0000000000000000', ts, SECRET)).toBe(false) + }) + + it('rejects a wrong-length token', () => { + expect(verifyProxyToken('abc', ts, SECRET)).toBe(false) + }) + + it('rejects empty secret', () => { + expect(verifyProxyToken(token, ts, '')).toBe(false) + }) + + it('rejects a token verified with the wrong secret', () => { + expect(verifyProxyToken(token, ts, 'wrong-secret')).toBe(false) + }) +}) From c3a69973fcb40c6b8d933b705bd3a380435769a2 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Fri, 10 Apr 2026 16:02:40 +1000 Subject: [PATCH 04/13] feat(security): wire withSigning on all proxy/embed handlers All 10 server handlers now enforce HMAC signature verification: - google-static-maps-proxy, google-maps-geocode-proxy, gravatar-proxy - x-embed, bluesky-embed, instagram-embed - createImageProxyHandler (covers x-embed-image, bluesky-embed-image, instagram-embed-image, instagram-embed-asset) Registry entries updated with requiresSigning: true so the module enforces NUXT_SCRIPTS_PROXY_SECRET in production when any of these scripts are enabled. --- packages/script/src/registry.ts | 20 +++++++++---------- .../src/runtime/server/bluesky-embed.ts | 5 +++-- .../server/google-maps-geocode-proxy.ts | 5 +++-- .../server/google-static-maps-proxy.ts | 5 +++-- .../src/runtime/server/gravatar-proxy.ts | 5 +++-- .../src/runtime/server/instagram-embed.ts | 5 +++-- .../src/runtime/server/utils/image-proxy.ts | 5 +++-- packages/script/src/runtime/server/x-embed.ts | 5 +++-- 8 files changed, 31 insertions(+), 24 deletions(-) diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index 9db818af..0f935281 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -621,8 +621,8 @@ export async function registry(resolve?: (path: string) => Promise): Pro envDefaults: { apiKey: '' }, category: 'content', serverHandlers: [ - { route: '/_scripts/proxy/google-static-maps', handler: './runtime/server/google-static-maps-proxy' }, - { route: '/_scripts/proxy/google-maps-geocode', handler: './runtime/server/google-maps-geocode-proxy' }, + { route: '/_scripts/proxy/google-static-maps', handler: './runtime/server/google-static-maps-proxy', requiresSigning: true }, + { route: '/_scripts/proxy/google-maps-geocode', handler: './runtime/server/google-maps-geocode-proxy', requiresSigning: true }, ], }), def('blueskyEmbed', { @@ -631,8 +631,8 @@ export async function registry(resolve?: (path: string) => Promise): Pro label: 'Bluesky Embed', category: 'content', serverHandlers: [ - { route: '/_scripts/embed/bluesky', handler: './runtime/server/bluesky-embed' }, - { route: '/_scripts/embed/bluesky-image', handler: './runtime/server/bluesky-embed-image' }, + { route: '/_scripts/embed/bluesky', handler: './runtime/server/bluesky-embed', requiresSigning: true }, + { route: '/_scripts/embed/bluesky-image', handler: './runtime/server/bluesky-embed-image', requiresSigning: true }, ], }), def('instagramEmbed', { @@ -641,9 +641,9 @@ export async function registry(resolve?: (path: string) => Promise): Pro label: 'Instagram Embed', category: 'content', serverHandlers: [ - { route: '/_scripts/embed/instagram', handler: './runtime/server/instagram-embed' }, - { route: '/_scripts/embed/instagram-image', handler: './runtime/server/instagram-embed-image' }, - { route: '/_scripts/embed/instagram-asset', handler: './runtime/server/instagram-embed-asset' }, + { route: '/_scripts/embed/instagram', handler: './runtime/server/instagram-embed', requiresSigning: true }, + { route: '/_scripts/embed/instagram-image', handler: './runtime/server/instagram-embed-image', requiresSigning: true }, + { route: '/_scripts/embed/instagram-asset', handler: './runtime/server/instagram-embed-asset', requiresSigning: true }, ], }), def('xEmbed', { @@ -652,8 +652,8 @@ export async function registry(resolve?: (path: string) => Promise): Pro label: 'X Embed', category: 'content', serverHandlers: [ - { route: '/_scripts/embed/x', handler: './runtime/server/x-embed' }, - { route: '/_scripts/embed/x-image', handler: './runtime/server/x-embed-image' }, + { route: '/_scripts/embed/x', handler: './runtime/server/x-embed', requiresSigning: true }, + { route: '/_scripts/embed/x-image', handler: './runtime/server/x-embed-image', requiresSigning: true }, ], }), // support @@ -757,7 +757,7 @@ export async function registry(resolve?: (path: string) => Promise): Pro privacy: PRIVACY_IP_ONLY, }, serverHandlers: [ - { route: '/_scripts/proxy/gravatar', handler: './runtime/server/gravatar-proxy' }, + { route: '/_scripts/proxy/gravatar', handler: './runtime/server/gravatar-proxy', requiresSigning: true }, ], }), ]) diff --git a/packages/script/src/runtime/server/bluesky-embed.ts b/packages/script/src/runtime/server/bluesky-embed.ts index 131b7b8c..81b66c2b 100644 --- a/packages/script/src/runtime/server/bluesky-embed.ts +++ b/packages/script/src/runtime/server/bluesky-embed.ts @@ -1,5 +1,6 @@ import { createError, defineEventHandler, getQuery, setHeader } from 'h3' import { $fetch } from 'ofetch' +import { withSigning } from './utils/withSigning' interface PostThreadResponse { thread: { @@ -22,7 +23,7 @@ interface PostThreadResponse { const BSKY_POST_URL_RE = /^https:\/\/bsky\.app\/profile\/([^/]+)\/post\/([^/?]+)$/ -export default defineEventHandler(async (event) => { +export default withSigning(defineEventHandler(async (event) => { const query = getQuery(event) const postUrl = query.url as string @@ -92,4 +93,4 @@ export default defineEventHandler(async (event) => { setHeader(event, 'Cache-Control', 'public, max-age=600, s-maxage=600') return post -}) +})) diff --git a/packages/script/src/runtime/server/google-maps-geocode-proxy.ts b/packages/script/src/runtime/server/google-maps-geocode-proxy.ts index 6dd30a6c..ee162462 100644 --- a/packages/script/src/runtime/server/google-maps-geocode-proxy.ts +++ b/packages/script/src/runtime/server/google-maps-geocode-proxy.ts @@ -2,8 +2,9 @@ import { useRuntimeConfig } from '#imports' import { createError, defineEventHandler, getQuery, setHeader } from 'h3' import { $fetch } from 'ofetch' import { withQuery } from 'ufo' +import { withSigning } from './utils/withSigning' -export default defineEventHandler(async (event) => { +export default withSigning(defineEventHandler(async (event) => { const runtimeConfig = useRuntimeConfig() const privateConfig = (runtimeConfig['nuxt-scripts'] as any)?.googleMapsGeocodeProxy @@ -38,4 +39,4 @@ export default defineEventHandler(async (event) => { setHeader(event, 'Cache-Control', 'public, max-age=86400, s-maxage=86400') return data -}) +})) diff --git a/packages/script/src/runtime/server/google-static-maps-proxy.ts b/packages/script/src/runtime/server/google-static-maps-proxy.ts index b0205269..96899050 100644 --- a/packages/script/src/runtime/server/google-static-maps-proxy.ts +++ b/packages/script/src/runtime/server/google-static-maps-proxy.ts @@ -2,8 +2,9 @@ import { useRuntimeConfig } from '#imports' import { createError, defineEventHandler, getQuery, setHeader } from 'h3' import { $fetch } from 'ofetch' import { withQuery } from 'ufo' +import { withSigning } from './utils/withSigning' -export default defineEventHandler(async (event) => { +export default withSigning(defineEventHandler(async (event) => { const runtimeConfig = useRuntimeConfig() const publicConfig = (runtimeConfig.public['nuxt-scripts'] as any)?.googleStaticMapsProxy const privateConfig = (runtimeConfig['nuxt-scripts'] as any)?.googleStaticMapsProxy @@ -51,4 +52,4 @@ export default defineEventHandler(async (event) => { setHeader(event, 'Vary', 'Accept-Encoding') return response._data -}) +})) diff --git a/packages/script/src/runtime/server/gravatar-proxy.ts b/packages/script/src/runtime/server/gravatar-proxy.ts index 3c075a45..128b07a8 100644 --- a/packages/script/src/runtime/server/gravatar-proxy.ts +++ b/packages/script/src/runtime/server/gravatar-proxy.ts @@ -2,8 +2,9 @@ import { useRuntimeConfig } from '#imports' import { createError, defineEventHandler, getQuery, setHeader } from 'h3' import { $fetch } from 'ofetch' import { withQuery } from 'ufo' +import { withSigning } from './utils/withSigning' -export default defineEventHandler(async (event) => { +export default withSigning(defineEventHandler(async (event) => { const runtimeConfig = useRuntimeConfig() const proxyConfig = (runtimeConfig.public['nuxt-scripts'] as any)?.gravatarProxy @@ -55,4 +56,4 @@ export default defineEventHandler(async (event) => { setHeader(event, 'Vary', 'Accept-Encoding') return response._data -}) +})) diff --git a/packages/script/src/runtime/server/instagram-embed.ts b/packages/script/src/runtime/server/instagram-embed.ts index e078074b..71f948f8 100644 --- a/packages/script/src/runtime/server/instagram-embed.ts +++ b/packages/script/src/runtime/server/instagram-embed.ts @@ -1,6 +1,7 @@ import { createError, defineEventHandler, getQuery, setHeader } from 'h3' import { $fetch } from 'ofetch' import { ELEMENT_NODE, parse, renderSync, TEXT_NODE, walkSync } from 'ultrahtml' +import { withSigning } from './utils/withSigning' export const RSRC_RE = /url\(\/rsrc\.php([^)]+)\)/g export const AMP_RE = /&/g @@ -186,7 +187,7 @@ function extractBlock(css: string, openBrace: number): { content: string, end: n return null } -export default defineEventHandler(async (event) => { +export default withSigning(defineEventHandler(async (event) => { // Derive the scripts prefix from the handler's own route path. // The route is registered as `/embed/instagram`, so strip `/embed/instagram`. const handlerPath = event.path?.split('?')[0] || '' @@ -331,4 +332,4 @@ export default defineEventHandler(async (event) => { setHeader(event, 'Cache-Control', 'public, max-age=600, s-maxage=600') return result -}) +})) diff --git a/packages/script/src/runtime/server/utils/image-proxy.ts b/packages/script/src/runtime/server/utils/image-proxy.ts index da99d0b1..201e644e 100644 --- a/packages/script/src/runtime/server/utils/image-proxy.ts +++ b/packages/script/src/runtime/server/utils/image-proxy.ts @@ -1,5 +1,6 @@ import { createError, defineEventHandler, getQuery, setHeader } from 'h3' import { $fetch } from 'ofetch' +import { withSigning } from './withSigning' const AMP_RE = /&/g @@ -25,7 +26,7 @@ export function createImageProxyHandler(config: ImageProxyConfig) { decodeAmpersands = false, } = config - return defineEventHandler(async (event) => { + return withSigning(defineEventHandler(async (event) => { const query = getQuery(event) let url = query.url as string @@ -95,5 +96,5 @@ export function createImageProxyHandler(config: ImageProxyConfig) { setHeader(event, 'Cache-Control', `public, max-age=${cacheMaxAge}, s-maxage=${cacheMaxAge}`) return response._data - }) + })) } diff --git a/packages/script/src/runtime/server/x-embed.ts b/packages/script/src/runtime/server/x-embed.ts index abd90ae7..8d05460f 100644 --- a/packages/script/src/runtime/server/x-embed.ts +++ b/packages/script/src/runtime/server/x-embed.ts @@ -1,5 +1,6 @@ import { createError, defineEventHandler, getQuery, setHeader } from 'h3' import { $fetch } from 'ofetch' +import { withSigning } from './utils/withSigning' interface TweetData { id_str: string @@ -45,7 +46,7 @@ interface TweetData { const TWEET_ID_RE = /^\d+$/ -export default defineEventHandler(async (event) => { +export default withSigning(defineEventHandler(async (event) => { const query = getQuery(event) const tweetId = query.id as string @@ -81,4 +82,4 @@ export default defineEventHandler(async (event) => { setHeader(event, 'Cache-Control', 'public, max-age=600, s-maxage=600') return tweetData -}) +})) From deacf59ce9a16ff9296562391877a1a98c5cef6c Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Fri, 10 Apr 2026 16:08:23 +1000 Subject: [PATCH 05/13] fix(security): pass through when no proxy secret is configured withSigning now delegates to the inner handler without verification when NUXT_SCRIPTS_PROXY_SECRET is not set. This avoids breaking existing users who upgrade before components emit signed URLs. Signing enforcement activates automatically once a secret is set. --- .../src/runtime/server/utils/withSigning.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/script/src/runtime/server/utils/withSigning.ts b/packages/script/src/runtime/server/utils/withSigning.ts index 86acf545..bf4a5197 100644 --- a/packages/script/src/runtime/server/utils/withSigning.ts +++ b/packages/script/src/runtime/server/utils/withSigning.ts @@ -10,12 +10,14 @@ * * Behavior: * - Reads `runtimeConfig.nuxt-scripts.proxySecret` (server-only). - * - If no secret is configured: 500 (the module is misconfigured). - * - If the request's `sig` param is missing, malformed, or doesn't match: 403. + * - If no secret is configured: passes through (signing not yet enabled). + * This allows shipping handler wiring before components emit signed URLs. + * Once `NUXT_SCRIPTS_PROXY_SECRET` is set, verification is enforced. + * - If a secret IS configured and the request's signature is invalid: 403. * - Otherwise, delegates to the wrapped handler. * - * The outer wrapper runs before any handler logic, so misconfigured / unauthorized - * requests never reach the upstream fetch and cannot consume API quota. + * The outer wrapper runs before any handler logic, so unauthorized requests + * never reach the upstream fetch and cannot consume API quota. */ import type { EventHandler, EventHandlerRequest, EventHandlerResponse } from 'h3' @@ -30,13 +32,11 @@ export function withSigning Date: Fri, 10 Apr 2026 16:23:44 +1000 Subject: [PATCH 06/13] fix(security): defer secret resolution and downgrade prod error to warning Two fixes from code review: 1. resolveProxySecret now runs AFTER the handler registration loop, gated on anyHandlerRequiresSigning. Users with only client-side scripts no longer get a surprise NUXT_SCRIPTS_PROXY_SECRET in their .env. 2. Missing secret in production is now a warning, not a fatal throw. This avoids breaking nuxt prepare / nuxt build / CI where the env var isn't set. withSigning already passes through gracefully when no secret is configured, so endpoints remain functional but unsigned. Users opt in to enforcement by setting the env var. --- packages/script/src/module.ts | 56 +++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index bea0ad23..d9cf87f9 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -480,28 +480,11 @@ export default defineNuxtModule({ ) } - // Resolve the HMAC signing secret used to lock down proxy endpoints. - // Deterministic across deploys is mandatory: signed URLs embedded in prerendered - // HTML must still verify against the runtime server. The module auto-generates - // and persists one into `.env` in dev so users don't hit friction on first run. - const proxySecretResolved = resolveProxySecret( - nuxt.options.rootDir, - !!nuxt.options.dev, - config.security?.secret, - config.security?.autoGenerateSecret !== false, - ) - if (proxySecretResolved?.source === 'dotenv-generated') - logger.info(`[security] Generated ${PROXY_SECRET_ENV_KEY} in .env for signed proxy URLs.`) - else if (proxySecretResolved?.source === 'memory-generated') - logger.warn(`[security] Generated an in-memory ${PROXY_SECRET_ENV_KEY} (could not write .env). Signed URLs will break across restarts.`) - // Setup runtimeConfig for proxies and devtools. // Must run AFTER env var resolution above so the API key is populated. const googleMapsEnabled = config.googleStaticMapsProxy?.enabled || !!config.registry?.googleMaps nuxt.options.runtimeConfig['nuxt-scripts'] = { version: version!, - // HMAC secret for signed proxy URLs (server-only private config) - proxySecret: proxySecretResolved?.secret || '', // Private proxy config with API key (server-side only) googleStaticMapsProxy: googleMapsEnabled ? { apiKey: (nuxt.options.runtimeConfig.public.scripts as any)?.googleMaps?.apiKey } @@ -868,15 +851,38 @@ export default defineNuxtModule({ nuxt.options.runtimeConfig.public['nuxt-scripts'] as any, ) as any - // Fail hard if a signed endpoint is enabled in production without a secret. - // Dev falls back to auto-generated secrets above, so this only trips real - // deployments that forgot to set the env var. - if (anyHandlerRequiresSigning && !proxySecretResolved?.secret && !nuxt.options.dev) { - throw new Error( - `[@nuxt/scripts] ${PROXY_SECRET_ENV_KEY} is required in production when signed proxy endpoints are enabled.\n` - + 'Generate one with: npx @nuxt/scripts generate-secret\n' - + `Then set the env var: ${PROXY_SECRET_ENV_KEY}=`, + // Resolve the HMAC signing secret only when at least one handler needs it. + // This avoids writing NUXT_SCRIPTS_PROXY_SECRET to .env for users who only + // use client-side scripts (analytics, tracking) with no proxy endpoints. + if (anyHandlerRequiresSigning) { + const proxySecretResolved = resolveProxySecret( + nuxt.options.rootDir, + !!nuxt.options.dev, + config.security?.secret, + config.security?.autoGenerateSecret !== false, ) + if (proxySecretResolved?.source === 'dotenv-generated') + logger.info(`[security] Generated ${PROXY_SECRET_ENV_KEY} in .env for signed proxy URLs.`) + else if (proxySecretResolved?.source === 'memory-generated') + logger.warn(`[security] Generated an in-memory ${PROXY_SECRET_ENV_KEY} (could not write .env). Signed URLs will break across restarts.`) + + if (proxySecretResolved?.secret) { + nuxt.options.runtimeConfig['nuxt-scripts'] = defu( + { proxySecret: proxySecretResolved.secret }, + nuxt.options.runtimeConfig['nuxt-scripts'] as any, + ) as any + } + else if (!nuxt.options.dev) { + // Warn (not throw) so that nuxt prepare, nuxt build, and CI work without + // the secret. withSigning passes through when no secret is configured, so + // endpoints remain functional but unsigned. Users opt in to enforcement by + // setting the env var. + logger.warn( + `[security] ${PROXY_SECRET_ENV_KEY} is not set. Proxy endpoints are unprotected.\n` + + ' Generate one with: npx @nuxt/scripts generate-secret\n' + + ` Then set the env var: ${PROXY_SECRET_ENV_KEY}=`, + ) + } } }, }) From eee5ffcacb503c78f3a22f853c20711fa537e41d Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Fri, 10 Apr 2026 16:38:11 +1000 Subject: [PATCH 07/13] fix: lazy-resolve useRuntimeConfig in withSigning Avoids pulling nitro virtual modules (#imports) into unit tests that import handler files for their exported utilities (e.g. instagram-embed exports rewriteUrl/scopeCss alongside its default handler). The dynamic import only runs at request time inside the nitro runtime where #imports is available. --- packages/script/src/runtime/server/utils/withSigning.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/script/src/runtime/server/utils/withSigning.ts b/packages/script/src/runtime/server/utils/withSigning.ts index bf4a5197..ce0d734b 100644 --- a/packages/script/src/runtime/server/utils/withSigning.ts +++ b/packages/script/src/runtime/server/utils/withSigning.ts @@ -21,7 +21,6 @@ */ import type { EventHandler, EventHandlerRequest, EventHandlerResponse } from 'h3' -import { useRuntimeConfig } from '#imports' import { createError, defineEventHandler } from 'h3' import { verifyProxyRequest } from './sign' @@ -29,6 +28,10 @@ export function withSigning, ): EventHandler { return defineEventHandler(async (event) => { + // Lazy-resolve useRuntimeConfig to avoid pulling nitro's virtual modules + // into unit tests that import handler files for their exported utilities + // (e.g. instagram-embed.ts exports rewriteUrl / scopeCss alongside the handler). + const { useRuntimeConfig } = await import('#imports') const runtimeConfig = useRuntimeConfig(event) const secret = (runtimeConfig['nuxt-scripts'] as { proxySecret?: string } | undefined)?.proxySecret From 48c0865a4bdde8e9cddc5e03a7d6d06563557be7 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Fri, 10 Apr 2026 16:47:11 +1000 Subject: [PATCH 08/13] test: add resolveProxySecret and verifyProxyRequest tests - resolveProxySecret: config precedence, env var fallback, .env auto-gen, existing key detection, newline handling, read-only FS fallback, process.env population (10 tests) - verifyProxyRequest: URL signature mode, page token mode, expired tokens, tampered sigs, empty secret, any-params page token, dual-mode precedence (8 tests) Total: 54 tests across sign.test.ts and resolve-proxy-secret.test.ts --- test/unit/resolve-proxy-secret.test.ts | 105 +++++++++++++++++++++++++ test/unit/sign.test.ts | 73 +++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 test/unit/resolve-proxy-secret.test.ts diff --git a/test/unit/resolve-proxy-secret.test.ts b/test/unit/resolve-proxy-secret.test.ts new file mode 100644 index 00000000..71549d11 --- /dev/null +++ b/test/unit/resolve-proxy-secret.test.ts @@ -0,0 +1,105 @@ +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { resolveProxySecret } from '../../packages/script/src/module' + +const ENV_KEY = 'NUXT_SCRIPTS_PROXY_SECRET' + +describe('resolveProxySecret', () => { + let testDir: string + let savedEnv: string | undefined + + beforeEach(() => { + testDir = join(tmpdir(), `nuxt-scripts-test-${Date.now()}`) + mkdirSync(testDir, { recursive: true }) + savedEnv = process.env[ENV_KEY] + delete process.env[ENV_KEY] + }) + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }) + if (savedEnv !== undefined) + process.env[ENV_KEY] = savedEnv + else + delete process.env[ENV_KEY] + }) + + it('returns config secret with highest priority', () => { + process.env[ENV_KEY] = 'env-secret' + const result = resolveProxySecret(testDir, true, 'config-secret') + expect(result).toEqual({ secret: 'config-secret', ephemeral: false, source: 'config' }) + }) + + it('falls back to env var when no config secret', () => { + process.env[ENV_KEY] = 'env-secret' + const result = resolveProxySecret(testDir, true) + expect(result).toEqual({ secret: 'env-secret', ephemeral: false, source: 'env' }) + }) + + it('returns undefined in prod when no secret is available', () => { + const result = resolveProxySecret(testDir, false) + expect(result).toBeUndefined() + }) + + it('returns undefined when autoGenerate is false even in dev', () => { + const result = resolveProxySecret(testDir, true, undefined, false) + expect(result).toBeUndefined() + }) + + it('auto-generates and writes to .env in dev when file does not exist', () => { + const result = resolveProxySecret(testDir, true) + expect(result).toBeDefined() + expect(result!.source).toBe('dotenv-generated') + expect(result!.ephemeral).toBe(false) + expect(result!.secret).toHaveLength(64) // 32 bytes hex + + const envContent = readFileSync(join(testDir, '.env'), 'utf-8') + expect(envContent).toContain(`${ENV_KEY}=${result!.secret}`) + expect(envContent).toContain('# Generated by @nuxt/scripts') + }) + + it('appends to existing .env in dev', () => { + writeFileSync(join(testDir, '.env'), 'OTHER_VAR=value\n') + const result = resolveProxySecret(testDir, true) + expect(result!.source).toBe('dotenv-generated') + + const envContent = readFileSync(join(testDir, '.env'), 'utf-8') + expect(envContent).toContain('OTHER_VAR=value') + expect(envContent).toContain(`${ENV_KEY}=`) + }) + + it('returns existing secret from .env without generating a new one', () => { + writeFileSync(join(testDir, '.env'), `${ENV_KEY}=existing-secret-value\n`) + const result = resolveProxySecret(testDir, true) + expect(result).toEqual({ secret: 'existing-secret-value', ephemeral: false, source: 'dotenv-generated' }) + + // Should not have written a second line + const envContent = readFileSync(join(testDir, '.env'), 'utf-8') + const matches = envContent.match(new RegExp(ENV_KEY, 'g')) + expect(matches).toHaveLength(1) + }) + + it('populates process.env after generating a new secret', () => { + resolveProxySecret(testDir, true) + expect(process.env[ENV_KEY]).toBeDefined() + expect(process.env[ENV_KEY]).toHaveLength(64) + }) + + it('falls back to in-memory when .env dir is read-only', () => { + // Use a non-existent deeply nested path that can't be written + const result = resolveProxySecret('/proc/nonexistent/path', true) + expect(result).toBeDefined() + expect(result!.source).toBe('memory-generated') + expect(result!.ephemeral).toBe(true) + expect(result!.secret).toHaveLength(64) + }) + + it('adds newline before appending when .env does not end with newline', () => { + writeFileSync(join(testDir, '.env'), 'OTHER_VAR=value') + resolveProxySecret(testDir, true) + const envContent = readFileSync(join(testDir, '.env'), 'utf-8') + // Should have a newline between existing content and new key + expect(envContent).toMatch(/value\n.*NUXT_SCRIPTS_PROXY_SECRET=/) + }) +}) diff --git a/test/unit/sign.test.ts b/test/unit/sign.test.ts index 73405e12..e809fed3 100644 --- a/test/unit/sign.test.ts +++ b/test/unit/sign.test.ts @@ -1,3 +1,4 @@ +import type { H3Event } from 'h3' import { describe, expect, it } from 'vitest' import { buildSignedProxyUrl, @@ -5,12 +6,27 @@ import { constantTimeEqual, generateProxyToken, PAGE_TOKEN_MAX_AGE, + PAGE_TOKEN_PARAM, + PAGE_TOKEN_TS_PARAM, SIG_LENGTH, SIG_PARAM, signProxyUrl, + verifyProxyRequest, verifyProxyToken, } from '../../packages/script/src/runtime/server/utils/sign' +/** Create a minimal mock H3Event with a path and query params. */ +function mockEvent(url: string): H3Event { + const parsed = new URL(url, 'http://localhost') + const query: Record = {} + for (const [k, v] of parsed.searchParams.entries()) + query[k] = v + return { + path: parsed.pathname + parsed.search, + _query: query, + } as unknown as H3Event +} + const SECRET = 'test-secret-9f2c8b4e7a1d6f3c5b9e8a2d4f7c1b6e' describe('canonicalizeQuery', () => { @@ -215,3 +231,60 @@ describe('verifyProxyToken', () => { expect(verifyProxyToken(token, ts, 'wrong-secret')).toBe(false) }) }) + +describe('verifyProxyRequest', () => { + it('verifies a valid URL signature (mode 1)', () => { + const url = buildSignedProxyUrl('/_scripts/proxy/x', { center: 'Sydney' }, SECRET) + const event = mockEvent(url) + expect(verifyProxyRequest(event, SECRET)).toBe(true) + }) + + it('rejects a tampered URL signature', () => { + const url = buildSignedProxyUrl('/_scripts/proxy/x', { center: 'Sydney' }, SECRET) + const tampered = url.replace(/sig=[0-9a-f]+/, 'sig=0000000000000000') + const event = mockEvent(tampered) + expect(verifyProxyRequest(event, SECRET)).toBe(false) + }) + + it('rejects a request with no sig and no page token', () => { + const event = mockEvent('/_scripts/proxy/x?center=Sydney') + expect(verifyProxyRequest(event, SECRET)).toBe(false) + }) + + it('returns false when secret is empty', () => { + const url = buildSignedProxyUrl('/_scripts/proxy/x', { center: 'Sydney' }, SECRET) + const event = mockEvent(url) + expect(verifyProxyRequest(event, '')).toBe(false) + }) + + it('verifies a valid page token (mode 2)', () => { + const ts = Math.floor(Date.now() / 1000) + const token = generateProxyToken(SECRET, ts) + const event = mockEvent(`/_scripts/proxy/x?center=Sydney&${PAGE_TOKEN_PARAM}=${token}&${PAGE_TOKEN_TS_PARAM}=${ts}`) + expect(verifyProxyRequest(event, SECRET)).toBe(true) + }) + + it('rejects an expired page token', () => { + const ts = Math.floor(Date.now() / 1000) - PAGE_TOKEN_MAX_AGE - 100 + const token = generateProxyToken(SECRET, ts) + const event = mockEvent(`/_scripts/proxy/x?center=Sydney&${PAGE_TOKEN_PARAM}=${token}&${PAGE_TOKEN_TS_PARAM}=${ts}`) + expect(verifyProxyRequest(event, SECRET)).toBe(false) + }) + + it('allows page token with different query params than original (any-params mode)', () => { + const ts = Math.floor(Date.now() / 1000) + const token = generateProxyToken(SECRET, ts) + // Token was generated without any query context, so it works with any params + const event = mockEvent(`/_scripts/proxy/x?center=Melbourne&zoom=10&${PAGE_TOKEN_PARAM}=${token}&${PAGE_TOKEN_TS_PARAM}=${ts}`) + expect(verifyProxyRequest(event, SECRET)).toBe(true) + }) + + it('prefers URL signature over page token when both are present', () => { + const ts = Math.floor(Date.now() / 1000) + const pageToken = generateProxyToken(SECRET, ts) + // Build a signed URL and also add a page token + const signedUrl = buildSignedProxyUrl('/_scripts/proxy/x', { center: 'Sydney' }, SECRET) + const event = mockEvent(`${signedUrl}&${PAGE_TOKEN_PARAM}=${pageToken}&${PAGE_TOKEN_TS_PARAM}=${ts}`) + expect(verifyProxyRequest(event, SECRET)).toBe(true) + }) +}) From 4e234d1ebe2860dcc8f0a217cfaeba13c9c6208a Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Fri, 10 Apr 2026 16:53:04 +1000 Subject: [PATCH 09/13] docs: add proxy endpoint security section to first-party guide Documents HMAC URL signing, page tokens, the generate-secret CLI, dev auto-generation, and production setup requirements. --- docs/content/docs/1.guides/2.first-party.md | 54 +++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/docs/content/docs/1.guides/2.first-party.md b/docs/content/docs/1.guides/2.first-party.md index 901fbc2c..bb750e67 100644 --- a/docs/content/docs/1.guides/2.first-party.md +++ b/docs/content/docs/1.guides/2.first-party.md @@ -200,6 +200,60 @@ The same pattern applies to [Netlify](https://netlify.com) (`[[redirects]]` with Platform-level rewrites bypass the privacy anonymisation layer. The proxy handler only runs in a Nitro server runtime. :: +## Proxy Endpoint Security + +Several proxy endpoints (Google Static Maps, Geocode, Gravatar, embed image proxies) inject server-side API keys or forward requests to third-party services. Without protection, anyone who discovers these endpoints could call them directly and consume your API quota. + +### HMAC URL Signing + +The module provides optional HMAC signing to lock down proxy endpoints. When enabled, only URLs generated server-side (during SSR or prerender) or accompanied by a valid page token are accepted. Unsigned requests receive a `403`. + +#### Setup + +Generate a signing secret: + +```bash +npx @nuxt/scripts generate-secret +``` + +Then set it as an environment variable: + +```bash +NUXT_SCRIPTS_PROXY_SECRET= +``` + +Or configure it directly: + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + scripts: { + security: { + secret: process.env.NUXT_SCRIPTS_PROXY_SECRET, + } + } +}) +``` + +#### How It Works + +The module uses two verification modes: + +1. **URL signatures** for server-rendered content. During SSR/prerender, proxy URLs include a `sig` parameter: an HMAC of the path and query params. The proxy endpoint verifies the signature before forwarding. + +2. **Page tokens** for client-side reactive updates. Some components recompute their proxy URL after mount (e.g. measuring element dimensions). The server embeds a short-lived token (`_pt` + `_ts` params) in the SSR payload. The token is valid for any params on any proxy path and expires after 1 hour. + +#### Development + +In development, the module auto-generates a secret and writes it to your `.env` file on first run. You don't need to configure anything for local dev. + +#### Production + +Set `NUXT_SCRIPTS_PROXY_SECRET` in your deployment environment. The secret must be the same across all replicas and across build/runtime so that URLs signed at prerender time remain valid. + +::callout{type="warning"} +Without a secret, proxy endpoints remain functional but unprotected. The module logs a warning at startup when it detects signed endpoints without a secret. +:: + ## Supported Scripts ### Full First-Party (Bundled + Proxied) From 695a48bc93d3ef7b81e3d89a6d3c747c10713367 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Fri, 10 Apr 2026 17:00:19 +1000 Subject: [PATCH 10/13] fix: remove unused existsSync import in test --- test/unit/resolve-proxy-secret.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/resolve-proxy-secret.test.ts b/test/unit/resolve-proxy-secret.test.ts index 71549d11..21a3315f 100644 --- a/test/unit/resolve-proxy-secret.test.ts +++ b/test/unit/resolve-proxy-secret.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' From be026433611f41c56d2fc7433abaf4192ee8b75e Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Sat, 11 Apr 2026 11:25:23 +1000 Subject: [PATCH 11/13] docs(security): add signed endpoints table, config reference, and troubleshooting Lists which endpoints use signing, documents autoGenerateSecret option, and covers common issues: 403 after deploy, cross-replica mismatches, unexpected .env entries, and page token expiry. --- docs/content/docs/1.guides/2.first-party.md | 49 +++++++++++++++++++++ packages/script/src/module.ts | 2 +- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/docs/content/docs/1.guides/2.first-party.md b/docs/content/docs/1.guides/2.first-party.md index bb750e67..0fcce34b 100644 --- a/docs/content/docs/1.guides/2.first-party.md +++ b/docs/content/docs/1.guides/2.first-party.md @@ -254,6 +254,55 @@ Set `NUXT_SCRIPTS_PROXY_SECRET` in your deployment environment. The secret must Without a secret, proxy endpoints remain functional but unprotected. The module logs a warning at startup when it detects signed endpoints without a secret. :: +#### Signed Endpoints + +The following proxy endpoints require signing when you configure a secret: + +| Script | Endpoints | +|--------|-----------| +| **Google Maps** | `/_scripts/proxy/google-static-maps`, `/_scripts/proxy/google-maps-geocode` | +| **Gravatar** | `/_scripts/proxy/gravatar` | +| **Bluesky** | `/_scripts/embed/bluesky`, `/_scripts/embed/bluesky-image` | +| **Instagram** | `/_scripts/embed/instagram`, `/_scripts/embed/instagram-image`, `/_scripts/embed/instagram-asset` | +| **X (Twitter)** | `/_scripts/embed/x`, `/_scripts/embed/x-image` | + +Analytics proxy endpoints (Google Analytics, Plausible, etc.) do not use signing because they only forward collection payloads and never expose API keys. + +#### Configuration Reference + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + scripts: { + security: { + // HMAC secret for signing proxy URLs. + // Falls back to process.env.NUXT_SCRIPTS_PROXY_SECRET. + secret: undefined, + // Auto-generate and persist a secret to .env in dev mode. + // Set to false to disable. + autoGenerateSecret: true, + } + } +}) +``` + +#### Troubleshooting + +**Signed URLs return 403 after deploy** + +The secret must be identical at build time (when URLs are signed during prerender) and at runtime (when the server verifies them). If you prerender pages, ensure `NUXT_SCRIPTS_PROXY_SECRET` is available in both your build environment and your deployment environment. + +**403 errors across multiple replicas** + +All server instances must share the same secret. If each replica generates its own secret, a URL signed by one instance will fail verification on another. Set `NUXT_SCRIPTS_PROXY_SECRET` as a shared environment variable across all replicas. + +**Unexpected `NUXT_SCRIPTS_PROXY_SECRET` in `.env`** + +The module only writes this when running `nuxt dev` with a signed endpoint enabled and no secret configured. If you only use client-side scripts (analytics, tracking), the module does not generate a secret. To prevent auto-generation entirely, set `autoGenerateSecret: false`. + +**Page tokens expire** + +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. + ## Supported Scripts ### Full First-Party (Bundled + Proxied) diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index d9cf87f9..ce55b6c8 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -320,7 +320,7 @@ export interface ModuleOptions { * Falls back to `process.env.NUXT_SCRIPTS_PROXY_SECRET` if unset. In dev, * the module auto-generates a secret into your `.env` file when neither is * provided (disable via `autoGenerateSecret: false`). In production, a - * missing secret is a fatal error when any signed endpoint is registered. + * missing secret logs a warning; proxy endpoints remain functional but unprotected. * * Generate one with: `npx @nuxt/scripts generate-secret` */ From de1ecb4b0607bc6bfc5264d5161e61d52deb2c53 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Sat, 11 Apr 2026 11:30:59 +1000 Subject: [PATCH 12/13] docs(security): add signing callout to each registry script with proxy endpoints Adds a brief callout linking to the security guide on Google Maps, Gravatar, Bluesky, Instagram, and X embed doc pages. --- docs/content/scripts/bluesky-embed.md | 4 ++++ docs/content/scripts/google-maps/2.api/1b.static-map.md | 4 ++++ docs/content/scripts/google-maps/index.md | 4 ++++ docs/content/scripts/gravatar.md | 4 ++++ docs/content/scripts/instagram-embed.md | 4 ++++ docs/content/scripts/x-embed.md | 4 ++++ 6 files changed, 24 insertions(+) diff --git a/docs/content/scripts/bluesky-embed.md b/docs/content/scripts/bluesky-embed.md index 070a0549..b736e1e7 100644 --- a/docs/content/scripts/bluesky-embed.md +++ b/docs/content/scripts/bluesky-embed.md @@ -18,6 +18,10 @@ Nuxt Scripts provides a [``{lang="html"}](/scripts/bluesky-e ::script-docs{embed} :: +::callout{type="info"} +This script's proxy endpoints use [HMAC URL signing](/docs/guides/first-party#proxy-endpoint-security) when you configure a `NUXT_SCRIPTS_PROXY_SECRET`. See the [security guide](/docs/guides/first-party#proxy-endpoint-security) for setup instructions. +:: + This registers the required server API routes (`/_scripts/embed/bluesky` and `/_scripts/embed/bluesky-image`) that handle fetching post data and proxying images. ## [``{lang="html"}](/scripts/bluesky-embed){lang="html"} diff --git a/docs/content/scripts/google-maps/2.api/1b.static-map.md b/docs/content/scripts/google-maps/2.api/1b.static-map.md index 9f84a93d..0392bc22 100644 --- a/docs/content/scripts/google-maps/2.api/1b.static-map.md +++ b/docs/content/scripts/google-maps/2.api/1b.static-map.md @@ -4,6 +4,10 @@ title: Renders a [Google Maps Static API](https://developers.google.com/maps/documentation/maps-static) image. Use standalone for static map previews, or drop into the `#placeholder` slot of [``{lang="html"}](/scripts/google-maps/api/script-google-maps) for a loading placeholder. +::callout{type="info"} +This script's proxy endpoints use [HMAC URL signing](/docs/guides/first-party#proxy-endpoint-security) when you configure a `NUXT_SCRIPTS_PROXY_SECRET`. See the [security guide](/docs/guides/first-party#proxy-endpoint-security) for setup instructions. +:: + ::script-types{script-key="google-maps" filter="ScriptGoogleMapsStaticMap"} :: diff --git a/docs/content/scripts/google-maps/index.md b/docs/content/scripts/google-maps/index.md index 8fc18bae..8a5d72e5 100644 --- a/docs/content/scripts/google-maps/index.md +++ b/docs/content/scripts/google-maps/index.md @@ -63,6 +63,10 @@ You must add this. It registers server proxy routes that keep your API key serve You can pass `api-key` directly on the ``{lang="html"} component, but this approach is not recommended, as it exposes your key in client-side requests. :: +::callout{type="info"} +This script's proxy endpoints use [HMAC URL signing](/docs/guides/first-party#proxy-endpoint-security) when you configure a `NUXT_SCRIPTS_PROXY_SECRET`. See the [security guide](/docs/guides/first-party#proxy-endpoint-security) for setup instructions. +:: + See [Billing & Permissions](/scripts/google-maps/guides/billing) for API costs and required permissions. ## Quick Start diff --git a/docs/content/scripts/gravatar.md b/docs/content/scripts/gravatar.md index c0f3ea97..00555105 100644 --- a/docs/content/scripts/gravatar.md +++ b/docs/content/scripts/gravatar.md @@ -20,6 +20,10 @@ links: ::script-docs :: +::callout{type="info"} +This script's proxy endpoints use [HMAC URL signing](/docs/guides/first-party#proxy-endpoint-security) when you configure a `NUXT_SCRIPTS_PROXY_SECRET`. See the [security guide](/docs/guides/first-party#proxy-endpoint-security) for setup instructions. +:: + ## [``{lang="html"}](/scripts/gravatar){lang="html"} The [``{lang="html"}](/scripts/gravatar){lang="html"} component renders a Gravatar avatar for a given email address. All requests are proxied through your server - Gravatar never sees your user's IP address or headers. diff --git a/docs/content/scripts/instagram-embed.md b/docs/content/scripts/instagram-embed.md index 64711ed9..4d7ad880 100644 --- a/docs/content/scripts/instagram-embed.md +++ b/docs/content/scripts/instagram-embed.md @@ -18,6 +18,10 @@ Nuxt Scripts provides a [``{lang="html"}](/scripts/instagr ::script-docs{embed} :: +::callout{type="info"} +This script's proxy endpoints use [HMAC URL signing](/docs/guides/first-party#proxy-endpoint-security) when you configure a `NUXT_SCRIPTS_PROXY_SECRET`. See the [security guide](/docs/guides/first-party#proxy-endpoint-security) for setup instructions. +:: + This registers the required server API routes (`/_scripts/embed/instagram`, `/_scripts/embed/instagram-image`, and `/_scripts/embed/instagram-asset`) that handle fetching embed HTML and proxying images/assets. ## [``{lang="html"}](/scripts/instagram-embed){lang="html"} diff --git a/docs/content/scripts/x-embed.md b/docs/content/scripts/x-embed.md index f99900c7..7bb97253 100644 --- a/docs/content/scripts/x-embed.md +++ b/docs/content/scripts/x-embed.md @@ -18,6 +18,10 @@ Nuxt Scripts provides a [``{lang="html"}](/scripts/x-embed){lang=" ::script-docs{embed} :: +::callout{type="info"} +This script's proxy endpoints use [HMAC URL signing](/docs/guides/first-party#proxy-endpoint-security) when you configure a `NUXT_SCRIPTS_PROXY_SECRET`. See the [security guide](/docs/guides/first-party#proxy-endpoint-security) for setup instructions. +:: + This registers the required server API routes (`/_scripts/embed/x` and `/_scripts/embed/x-image`) that handle fetching tweet data and proxying images. ## [``{lang="html"}](/scripts/x-embed){lang="html"} From 51d3c14f98af2aaf38841f18786ab94c4183ebb5 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Sat, 11 Apr 2026 13:01:31 +1000 Subject: [PATCH 13/13] fix(security): skip signing setup for SSG and SPA modes Signing requires a server runtime to verify HMACs. The module now detects ssr: false and static presets (github-pages, etc.) and skips secret resolution entirely, logging a clear warning instead. Also documents the SSG/SPA limitations in the security guide. --- docs/content/docs/1.guides/2.first-party.md | 12 ++++++ packages/script/src/module.ts | 42 ++++++++++++--------- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/docs/content/docs/1.guides/2.first-party.md b/docs/content/docs/1.guides/2.first-party.md index 0fcce34b..7130009f 100644 --- a/docs/content/docs/1.guides/2.first-party.md +++ b/docs/content/docs/1.guides/2.first-party.md @@ -303,6 +303,18 @@ 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. +#### Static Generation and SPA Mode + +URL signing requires a server runtime to verify HMAC signatures. Two deployment modes cannot support signing: + +**`nuxt generate` (SSG) with static hosting**: Prerendered pages contain proxy URLs, but no Nitro server exists at runtime to verify signatures or forward requests. Proxy endpoints will not work on static hosts (GitHub Pages, Cloudflare Pages static, etc.). If you need proxy endpoints with prerendering, deploy to a server target that supports both prerendering and runtime request handling (e.g. Node, Cloudflare Workers, [Vercel](https://vercel.com)). + +**`ssr: false` (SPA mode)**: No server-side rendering means no opportunity to sign URLs or embed page tokens. The signing secret lives in server-only runtime config and cannot be accessed from the client. Proxy endpoints still function if deployed with a server, but requests will be unsigned. + +::callout{type="info"} +In both cases, the module automatically detects the limitation and skips signing setup. Proxy endpoints remain functional but unprotected. The module logs a warning at build time. +:: + ## Supported Scripts ### Full First-Party (Bundled + Proxied) diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index ce55b6c8..bbd4d4c6 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -741,12 +741,12 @@ export default defineNuxtModule({ } // Warn for static presets - const staticPresets = ['static', 'github-pages', 'cloudflare-pages-static', 'netlify-static', 'azure-static', 'firebase-static'] - const preset = process.env.NITRO_PRESET || '' - if (staticPresets.includes(preset)) { + const proxyStaticPresets = ['static', 'github-pages', 'cloudflare-pages-static', 'netlify-static', 'azure-static', 'firebase-static'] + const proxyPreset = process.env.NITRO_PRESET || '' + if (proxyStaticPresets.includes(proxyPreset)) { logger.warn( - `Proxy collection endpoints require a server runtime (detected: ${preset || 'static'}).\n` - + 'Scripts will be bundled, but collection requests will not be proxied.\n' + `Proxy collection endpoints require a server runtime (detected: ${proxyPreset || 'static'}).\n` + + 'Scripts will be bundled, but collection requests will not be proxied and URL signing will be unavailable.\n' + 'Options: configure platform rewrites, switch to server-rendered mode, or disable with proxy: false.', ) } @@ -851,10 +851,23 @@ export default defineNuxtModule({ nuxt.options.runtimeConfig.public['nuxt-scripts'] as any, ) as any - // Resolve the HMAC signing secret only when at least one handler needs it. - // This avoids writing NUXT_SCRIPTS_PROXY_SECRET to .env for users who only - // use client-side scripts (analytics, tracking) with no proxy endpoints. - if (anyHandlerRequiresSigning) { + // Signing requires a server runtime to verify HMACs. Skip setup entirely + // for SPA mode or static presets where no Nitro server exists at runtime. + const staticPresets = ['static', 'github-pages', 'cloudflare-pages-static', 'netlify-static', 'azure-static', 'firebase-static'] + const nitroPreset = process.env.NITRO_PRESET || '' + const isStaticTarget = staticPresets.includes(nitroPreset) + const isSpa = nuxt.options.ssr === false + + if (anyHandlerRequiresSigning && (isSpa || isStaticTarget)) { + logger.warn( + `[security] URL signing requires a server runtime${isStaticTarget ? ` (detected preset: ${nitroPreset})` : ' (ssr: false)'}.\n` + + ' Proxy endpoints will work without signature verification.\n' + + ' To enable signing, deploy with a server-rendered target or configure platform-level rewrites.', + ) + } + // Resolve the HMAC signing secret only when at least one handler needs it + // and a server runtime can actually verify signatures. + else if (anyHandlerRequiresSigning) { const proxySecretResolved = resolveProxySecret( nuxt.options.rootDir, !!nuxt.options.dev, @@ -867,18 +880,11 @@ export default defineNuxtModule({ logger.warn(`[security] Generated an in-memory ${PROXY_SECRET_ENV_KEY} (could not write .env). Signed URLs will break across restarts.`) if (proxySecretResolved?.secret) { - nuxt.options.runtimeConfig['nuxt-scripts'] = defu( - { proxySecret: proxySecretResolved.secret }, - nuxt.options.runtimeConfig['nuxt-scripts'] as any, - ) as any + ;(nuxt.options.runtimeConfig['nuxt-scripts'] as any).proxySecret = proxySecretResolved.secret } else if (!nuxt.options.dev) { - // Warn (not throw) so that nuxt prepare, nuxt build, and CI work without - // the secret. withSigning passes through when no secret is configured, so - // endpoints remain functional but unsigned. Users opt in to enforcement by - // setting the env var. logger.warn( - `[security] ${PROXY_SECRET_ENV_KEY} is not set. Proxy endpoints are unprotected.\n` + `[security] ${PROXY_SECRET_ENV_KEY} is not set. Proxy endpoints will pass requests through without signature verification.\n` + ' Generate one with: npx @nuxt/scripts generate-secret\n' + ` Then set the env var: ${PROXY_SECRET_ENV_KEY}=`, )