diff --git a/docs/content/docs/1.guides/2.first-party.md b/docs/content/docs/1.guides/2.first-party.md index 901fbc2c..7130009f 100644 --- a/docs/content/docs/1.guides/2.first-party.md +++ b/docs/content/docs/1.guides/2.first-party.md @@ -200,6 +200,121 @@ 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. +:: + +#### 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. + +#### 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/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"} 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..d94c98de --- /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..bbd4d4c6 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,79 @@ 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 + +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 +301,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 logs a warning; proxy endpoints remain functional but unprotected. + * + * 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. @@ -635,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.', ) } @@ -697,6 +803,7 @@ export default defineNuxtModule({ // Register server handlers for enabled registry scripts const scriptsPrefix = config.prefix || '/_scripts' const enabledEndpoints: Record = {} + let anyHandlerRequiresSigning = false for (const script of scripts) { if (!script.serverHandlers?.length || !script.registryKey) continue @@ -711,11 +818,14 @@ 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 } // Script-specific runtimeConfig setup @@ -740,5 +850,45 @@ export default defineNuxtModule({ { endpoints: enabledEndpoints }, nuxt.options.runtimeConfig.public['nuxt-scripts'] as any, ) as any + + // 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, + 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'] as any).proxySecret = proxySecretResolved.secret + } + else if (!nuxt.options.dev) { + logger.warn( + `[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}=`, + ) + } + } }, }) 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/utils/sign.ts b/packages/script/src/runtime/server/utils/sign.ts new file mode 100644 index 00000000..314a531a --- /dev/null +++ b/packages/script/src/runtime/server/utils/sign.ts @@ -0,0 +1,226 @@ +/** + * 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}` +} + +// --------------------------------------------------------------------------- +// 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 + +/** + * Generate a page token that authorizes client-side proxy requests. + * + * 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. + * + * Returns `true` if the token matches the HMAC for the given timestamp AND + * the timestamp is within `maxAge` seconds of `now`. + */ +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) { + 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) + } + + return false +} + +/** + * 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..ce0d734b --- /dev/null +++ b/packages/script/src/runtime/server/utils/withSigning.ts @@ -0,0 +1,53 @@ +/** + * 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: 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 unauthorized requests + * never reach the upstream fetch and cannot consume API quota. + */ + +import type { EventHandler, EventHandlerRequest, EventHandlerResponse } from 'h3' +import { createError, defineEventHandler } from 'h3' +import { verifyProxyRequest } from './sign' + +export function withSigning( + handler: EventHandler, +): 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 + + // No secret configured: pass through without verification. This lets the + // handler wiring ship before components emit signed URLs. Users opt in to + // enforcement by setting NUXT_SCRIPTS_PROXY_SECRET. + if (!secret) + return handler(event) as Res + + if (!verifyProxyRequest(event, secret)) { + throw createError({ + statusCode: 403, + statusMessage: 'Invalid signature', + }) + } + + return handler(event) as Res + }) +} 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 -}) +})) diff --git a/packages/script/src/runtime/types.ts b/packages/script/src/runtime/types.ts index 08c30e77..5e755db9 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/resolve-proxy-secret.test.ts b/test/unit/resolve-proxy-secret.test.ts new file mode 100644 index 00000000..21a3315f --- /dev/null +++ b/test/unit/resolve-proxy-secret.test.ts @@ -0,0 +1,105 @@ +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' +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 new file mode 100644 index 00000000..e809fed3 --- /dev/null +++ b/test/unit/sign.test.ts @@ -0,0 +1,290 @@ +import type { H3Event } from 'h3' +import { describe, expect, it } from 'vitest' +import { + buildSignedProxyUrl, + canonicalizeQuery, + 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', () => { + 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) + }) +}) + +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) + }) +}) + +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) + }) +})