diff --git a/docs/content/docs/1.guides/2.first-party.md b/docs/content/docs/1.guides/2.first-party.md index ba6a2f98c..5cc38bb51 100644 --- a/docs/content/docs/1.guides/2.first-party.md +++ b/docs/content/docs/1.guides/2.first-party.md @@ -202,118 +202,11 @@ Platform-level rewrites bypass the privacy anonymisation layer. The proxy handle ## 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. +Proxy and embed endpoints (Google Static Maps, Geocode, Gravatar, embed image proxies) forward requests to third-party services. Each endpoint is restricted to an allowlist of upstream domains, so it cannot be used as an open proxy for arbitrary URLs. -### HMAC URL Signing +The Google Maps endpoints inject your server-side API key. They are cache-shielded (static maps for 7 days, geocode for 30 days), so repeated requests for the same map do not bill again. The endpoints are still reachable by anyone who discovers them; if quota abuse is a concern, add rate limiting at your platform edge or via Nitro `routeRules` for the `/_scripts/proxy/**` paths. -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. -:: +Analytics proxy endpoints (Google Analytics, Plausible, etc.) only forward collection payloads and never expose API keys. ## Supported Scripts diff --git a/docs/content/scripts/bluesky-embed.md b/docs/content/scripts/bluesky-embed.md index b736e1e7c..070a0549a 100644 --- a/docs/content/scripts/bluesky-embed.md +++ b/docs/content/scripts/bluesky-embed.md @@ -18,10 +18,6 @@ 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 259f63398..f1a77b1dd 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,10 +4,6 @@ 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 7683f9fcb..4751da868 100644 --- a/docs/content/scripts/google-maps/index.md +++ b/docs/content/scripts/google-maps/index.md @@ -63,10 +63,6 @@ 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 005551057..c0f3ea97c 100644 --- a/docs/content/scripts/gravatar.md +++ b/docs/content/scripts/gravatar.md @@ -20,10 +20,6 @@ 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 4d7ad8806..64711ed99 100644 --- a/docs/content/scripts/instagram-embed.md +++ b/docs/content/scripts/instagram-embed.md @@ -18,10 +18,6 @@ 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 7bb972533..f99900c78 100644 --- a/docs/content/scripts/x-embed.md +++ b/docs/content/scripts/x-embed.md @@ -18,10 +18,6 @@ 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/src/cli.ts b/packages/script/src/cli.ts index d94c98de4..6cbd0ae63 100644 --- a/packages/script/src/cli.ts +++ b/packages/script/src/cli.ts @@ -1,40 +1,12 @@ /** * @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( [ @@ -44,8 +16,7 @@ function showHelp(): void { ' Usage: npx @nuxt/scripts ', '', ' Commands:', - ' generate-secret Generate a signing secret for proxy URL tamper protection', - ' help Show this help', + ' help Show this help', '', '', ].join('\n'), @@ -57,9 +28,6 @@ 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() diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index f2f073fa0..275b40ce5 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -13,13 +13,11 @@ import type { RegistryScripts, ResolvedProxyAutoInject, } from './runtime/types' -import { randomBytes } from 'node:crypto' -import { appendFileSync, existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, readdirSync, readFileSync } from 'node:fs' import { addBuildPlugin, addComponentsDir, addImports, - addPlugin, addPluginTemplate, addServerHandler, addTemplate, @@ -117,79 +115,6 @@ 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, @@ -362,53 +287,6 @@ 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 - /** - * How long (in seconds) a page token issued during SSR remains valid on the - * client. Client-driven proxy requests (dynamic fetches, runtime image - * helpers) attach this token so `withSigning` accepts them without each URL - * being HMAC-signed up front. - * - * The default of 1 hour is safe for SSR; for SSG or prerendered routes, - * deployed HTML carries the build-time token, so bump this (e.g. `2592000` - * for 30 days) to keep client-side proxy calls working after the build. - * Longer TTLs widen the replay window if a token is scraped, so prefer the - * shortest value that covers your cache horizon. - * - * @default 3600 - */ - pageTokenMaxAge?: number - } /** * Google Static Maps proxy configuration. * Proxies static map images through your server to fix CORS issues and enable caching. @@ -618,7 +496,6 @@ export default defineNuxtModule({ const composables = [ 'useScript', 'useScriptEventPage', - 'useScriptProxyToken', 'useScriptProxyUrl', 'useScriptTriggerConsent', 'useScriptTriggerElement', @@ -917,7 +794,7 @@ export default defineNuxtModule({ if (proxyStaticPresets.includes(proxyPreset)) { logger.warn( `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' + + 'Scripts will be bundled, but collection requests will not be proxied.\n' + 'Options: configure platform rewrites, switch to server-rendered mode, or disable with proxy: false.', ) } @@ -974,7 +851,6 @@ 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 @@ -995,8 +871,6 @@ export default defineNuxtModule({ handler: handler.handler, middleware: handler.middleware, }) - if (handler.requiresSigning) - anyHandlerRequiresSigning = true } // Script-specific runtimeConfig setup @@ -1021,55 +895,5 @@ 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) { - const scriptsRuntime = nuxt.options.runtimeConfig['nuxt-scripts'] as Record - scriptsRuntime.proxySecret = proxySecretResolved.secret - if (config.security?.pageTokenMaxAge !== undefined) - scriptsRuntime.pageTokenMaxAge = config.security.pageTokenMaxAge - // Emit a per-request page token during SSR so client-driven proxy - // calls (reactive fetches, dynamic image helpers) authenticate via - // `_pt` + `_ts` without needing each URL to be HMAC-signed up front. - addPlugin({ - src: await resolvePath('./runtime/plugins/proxy-token.server'), - mode: 'server', - }) - } - 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 8bb07b448..1e77a7fc0 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -683,8 +683,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', requiresSigning: true }, - { route: '/_scripts/proxy/google-maps-geocode', handler: './runtime/server/google-maps-geocode-proxy', requiresSigning: true }, + { 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' }, ], }), def('blueskyEmbed', { @@ -693,8 +693,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', requiresSigning: true }, - { route: '/_scripts/embed/bluesky-image', handler: './runtime/server/bluesky-embed-image', requiresSigning: true }, + { route: '/_scripts/embed/bluesky', handler: './runtime/server/bluesky-embed' }, + { route: '/_scripts/embed/bluesky-image', handler: './runtime/server/bluesky-embed-image' }, ], }), def('instagramEmbed', { @@ -703,9 +703,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', 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 }, + { 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' }, ], }), def('xEmbed', { @@ -714,8 +714,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', requiresSigning: true }, - { route: '/_scripts/embed/x-image', handler: './runtime/server/x-embed-image', requiresSigning: true }, + { route: '/_scripts/embed/x', handler: './runtime/server/x-embed' }, + { route: '/_scripts/embed/x-image', handler: './runtime/server/x-embed-image' }, ], }), // support @@ -848,7 +848,7 @@ export async function registry(resolve?: (path: string) => Promise): Pro privacy: PRIVACY_IP_ONLY, }, serverHandlers: [ - { route: '/_scripts/proxy/gravatar', handler: './runtime/server/gravatar-proxy', requiresSigning: true }, + { route: '/_scripts/proxy/gravatar', handler: './runtime/server/gravatar-proxy' }, ], }), ]) diff --git a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsStaticMap.vue b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsStaticMap.vue index 63a1b5983..7a07d20eb 100644 --- a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsStaticMap.vue +++ b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsStaticMap.vue @@ -222,8 +222,7 @@ const src = computed(() => { } if (useProxy) { - // Route through the module's signed proxy. Client-generated URLs attach a - // page token from the SSR payload so `withSigning` lets them through. + // Route through the module's proxy so the API key stays server-side. return proxyUrl('/_scripts/proxy/google-static-maps', query) } return withQuery('https://maps.googleapis.com/maps/api/staticmap', query as QueryObject) diff --git a/packages/script/src/runtime/composables/useScriptProxyToken.ts b/packages/script/src/runtime/composables/useScriptProxyToken.ts deleted file mode 100644 index f159cb247..000000000 --- a/packages/script/src/runtime/composables/useScriptProxyToken.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { useState } from 'nuxt/app' - -export interface ScriptProxyToken { - token: string - ts: number -} - -/** - * Shared `useState` holding the proxy page token emitted during SSR. - * - * Populated by the `nuxt-scripts:proxy-token` server plugin when - * `runtimeConfig['nuxt-scripts'].proxySecret` is set, and hydrated to the - * client via the Nuxt payload. Stays null when signing is disabled. - */ -export function useScriptProxyToken() { - return useState('nuxt-scripts:proxy-token', () => null) -} diff --git a/packages/script/src/runtime/composables/useScriptProxyUrl.ts b/packages/script/src/runtime/composables/useScriptProxyUrl.ts index 93c0fcc47..c0d207b27 100644 --- a/packages/script/src/runtime/composables/useScriptProxyUrl.ts +++ b/packages/script/src/runtime/composables/useScriptProxyUrl.ts @@ -1,19 +1,11 @@ -import { PAGE_TOKEN_PARAM, PAGE_TOKEN_TS_PARAM } from '../server/utils/sign-constants' -import { useScriptProxyToken } from './useScriptProxyToken' - /** - * Build proxy URLs that the server's `withSigning` middleware accepts. + * Build a proxy URL (path + query string) for the module's proxy endpoints. * - * Attaches the page token emitted during SSR (`_pt` + `_ts`) when one is - * available, so client-driven proxy calls (e.g. reactive fetches, dynamic - * image helpers exposed in slot props) authenticate without needing a - * server round-trip to sign each URL. - * - * When no token is present (signing disabled or no secret), emits plain - * `?url=...` URLs, matching the pre-signing behavior. + * Used by registry helpers (e.g. gravatar) and embed components to point + * requests at `/_scripts/proxy/*` and `/_scripts/embed/*`. These endpoints are + * restricted to an allowlist of upstream domains. */ export function useScriptProxyUrl() { - const token = useScriptProxyToken() return (path: string, query: Record = {}): string => { const parts: string[] = [] for (const [key, value] of Object.entries(query)) { @@ -31,10 +23,6 @@ export function useScriptProxyUrl() { parts.push(`${encodedKey}=${encodeURIComponent(String(value))}`) } } - if (token.value) { - parts.push(`${PAGE_TOKEN_PARAM}=${encodeURIComponent(token.value.token)}`) - parts.push(`${PAGE_TOKEN_TS_PARAM}=${token.value.ts}`) - } return parts.length ? `${path}?${parts.join('&')}` : path } } diff --git a/packages/script/src/runtime/plugins/proxy-token.server.ts b/packages/script/src/runtime/plugins/proxy-token.server.ts deleted file mode 100644 index 0cbed4888..000000000 --- a/packages/script/src/runtime/plugins/proxy-token.server.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { defineNuxtPlugin, useRuntimeConfig } from 'nuxt/app' -import { useScriptProxyToken } from '../composables/useScriptProxyToken' -import { generateProxyToken } from '../server/utils/sign' - -/** - * Emit a per-request proxy page token into the SSR payload. - * - * The token authorizes client-side proxy calls (`/embed/x-image?url=...`, - * `/embed/bluesky?url=...`, etc.) without needing each URL to be signed - * ahead of time. It stays null when no proxy secret is configured, in - * which case `withSigning` passes requests through unchecked. - */ -export default defineNuxtPlugin({ - name: 'nuxt-scripts:proxy-token', - enforce: 'pre', - setup() { - const secret = (useRuntimeConfig()['nuxt-scripts'] as { proxySecret?: string } | undefined)?.proxySecret - if (!secret) - return - const ts = Math.floor(Date.now() / 1000) - useScriptProxyToken().value = { - token: generateProxyToken(secret, ts), - ts, - } - }, -}) diff --git a/packages/script/src/runtime/server/bluesky-embed.ts b/packages/script/src/runtime/server/bluesky-embed.ts index 45090faa1..a3eea259e 100644 --- a/packages/script/src/runtime/server/bluesky-embed.ts +++ b/packages/script/src/runtime/server/bluesky-embed.ts @@ -1,8 +1,6 @@ import { createError, defineEventHandler, getQuery, setHeader } from 'h3' -import { useRuntimeConfig } from 'nitropack/runtime' import { createCachedJsonFetch } from './utils/cached-upstream' import { rewriteBlueskyPostImages } from './utils/embed-rewriters' -import { withSigning } from './utils/withSigning' interface PostThreadResponse { thread: { @@ -43,7 +41,7 @@ const cachedPostFetch = createCachedJsonFetch( url => url, ) -export default withSigning(defineEventHandler(async (event) => { +export default defineEventHandler(async (event) => { const query = getQuery(event) const postUrl = query.url as string @@ -110,17 +108,16 @@ export default withSigning(defineEventHandler(async (event) => { }) } - // Rewrite CDN image URLs to proxied (HMAC-signed when a secret is set) URLs - // so the client can render them without tripping the `withSigning` 403. + // Rewrite CDN image URLs to proxied URLs so the client loads them through + // the site origin. const handlerPath = event.path?.split('?')[0] || '' const prefix = handlerPath.replace(EMBED_BSKY_SUFFIX_RE, '') || '/_scripts' const imagePath = `${prefix}/embed/bluesky-image` - const secret = (useRuntimeConfig(event)['nuxt-scripts'] as { proxySecret?: string } | undefined)?.proxySecret - rewriteBlueskyPostImages(post, imagePath, secret) + rewriteBlueskyPostImages(post, imagePath) // Cache for 10 minutes setHeader(event, 'Content-Type', 'application/json') 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 79df25499..224d7de66 100644 --- a/packages/script/src/runtime/server/google-maps-geocode-proxy.ts +++ b/packages/script/src/runtime/server/google-maps-geocode-proxy.ts @@ -2,7 +2,6 @@ import { createError, defineEventHandler, getQuery, setHeader } from 'h3' import { useRuntimeConfig } from 'nitropack/runtime' import { withQuery } from 'ufo' import { createCachedJsonFetch } from './utils/cached-upstream' -import { withSigning } from './utils/withSigning' // Addresses rarely change; a 30-day cache avoids billable geocode lookups for // the same address on every page render. Keyed on the upstream URL (the API @@ -13,7 +12,7 @@ const cachedGeocodeFetch = createCachedJsonFetch( url => url, ) -export default withSigning(defineEventHandler(async (event) => { +export default defineEventHandler(async (event) => { const runtimeConfig = useRuntimeConfig() const privateConfig = (runtimeConfig['nuxt-scripts'] as any)?.googleMapsGeocodeProxy @@ -46,4 +45,4 @@ export default withSigning(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 e79486f99..64ffc0adf 100644 --- a/packages/script/src/runtime/server/google-static-maps-proxy.ts +++ b/packages/script/src/runtime/server/google-static-maps-proxy.ts @@ -2,19 +2,17 @@ import { createError, defineEventHandler, getQuery, setHeader } from 'h3' import { useRuntimeConfig } from 'nitropack/runtime' import { withQuery } from 'ufo' import { createCachedBinaryFetch } from './utils/cached-upstream' -import { PAGE_TOKEN_PARAM, PAGE_TOKEN_TS_PARAM, SIG_PARAM } from './utils/sign-constants' -import { withSigning } from './utils/withSigning' // Static maps by (center, zoom, size, style, markers, ...) are essentially // immutable; a 7-day cache drastically reduces billable map loads for the // common "same map on every page visit" case. const cachedMapFetch = createCachedBinaryFetch('nuxt-scripts-static-map', 604800) -// Strip query params that vary per-request (auth artefacts + client-provided -// API key) so the cache key is pinned to the actual map being requested. -const STRIP_PARAMS = new Set([SIG_PARAM, PAGE_TOKEN_PARAM, PAGE_TOKEN_TS_PARAM, 'key']) +// Strip the client-provided API key so the cache key is pinned to the actual +// map being requested (the real key is server-injected below). +const STRIP_PARAMS = new Set(['key']) -export default withSigning(defineEventHandler(async (event) => { +export default defineEventHandler(async (event) => { const runtimeConfig = useRuntimeConfig() const publicConfig = (runtimeConfig.public['nuxt-scripts'] as any)?.googleStaticMapsProxy const privateConfig = (runtimeConfig['nuxt-scripts'] as any)?.googleStaticMapsProxy @@ -62,4 +60,4 @@ export default withSigning(defineEventHandler(async (event) => { setHeader(event, 'Vary', 'Accept-Encoding') return result.body -})) +}) diff --git a/packages/script/src/runtime/server/gravatar-proxy.ts b/packages/script/src/runtime/server/gravatar-proxy.ts index 693ae3a59..a3a458bd3 100644 --- a/packages/script/src/runtime/server/gravatar-proxy.ts +++ b/packages/script/src/runtime/server/gravatar-proxy.ts @@ -2,14 +2,13 @@ import { createError, defineEventHandler, getQuery, setHeader } from 'h3' import { useRuntimeConfig } from 'nitropack/runtime' import { withQuery } from 'ufo' import { createCachedBinaryFetch } from './utils/cached-upstream' -import { withSigning } from './utils/withSigning' // Gravatar avatars keyed on `hash + sizing/default/rating` are essentially // immutable for the hour timescale; a 1-hour cache balances freshness (users // rotating avatars) against origin-shielding upstream traffic. const cachedGravatarFetch = createCachedBinaryFetch('nuxt-scripts-gravatar', 3600) -export default withSigning(defineEventHandler(async (event) => { +export default defineEventHandler(async (event) => { const runtimeConfig = useRuntimeConfig() const proxyConfig = (runtimeConfig.public['nuxt-scripts'] as any)?.gravatarProxy @@ -59,4 +58,4 @@ export default withSigning(defineEventHandler(async (event) => { setHeader(event, 'Vary', 'Accept-Encoding') return result.body -})) +}) diff --git a/packages/script/src/runtime/server/instagram-embed.ts b/packages/script/src/runtime/server/instagram-embed.ts index 4d3173bed..950bc090e 100644 --- a/packages/script/src/runtime/server/instagram-embed.ts +++ b/packages/script/src/runtime/server/instagram-embed.ts @@ -1,9 +1,7 @@ import { createError, defineEventHandler, getQuery, setHeader } from 'h3' -import { useRuntimeConfig } from 'nitropack/runtime' import { ELEMENT_NODE, parse, renderSync, TEXT_NODE, walkSync } from 'ultrahtml' import { createCachedJsonFetch } from './utils/cached-upstream' import { proxyAssetUrl, rewriteUrl, rewriteUrlsInText, RSRC_RE, scopeCss } from './utils/instagram-embed' -import { withSigning } from './utils/withSigning' export { proxyAssetUrl, proxyImageUrl, rewriteUrl, rewriteUrlsInText, scopeCss } from './utils/instagram-embed' @@ -38,12 +36,11 @@ function removeNode(node: any): void { node.children = [] } -export default withSigning(defineEventHandler(async (event) => { +export default 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] || '' const prefix = handlerPath.replace(EMBED_INSTAGRAM_SUFFIX_RE, '') || '/_scripts' - const secret = (useRuntimeConfig(event)['nuxt-scripts'] as { proxySecret?: string } | undefined)?.proxySecret const query = getQuery(event) const postUrl = query.url as string @@ -112,7 +109,7 @@ export default withSigning(defineEventHandler(async (event) => { for (const attr of ['src', 'poster']) { if (node.attributes[attr]) - node.attributes[attr] = rewriteUrl(node.attributes[attr], prefix, secret) + node.attributes[attr] = rewriteUrl(node.attributes[attr], prefix) } if (node.attributes.srcset) { @@ -122,18 +119,18 @@ export default withSigning(defineEventHandler(async (event) => { const parts = entry.trim().split(SRCSET_SPLIT_RE) const url = parts[0] const descriptor = parts.slice(1).join(' ') - return url ? `${rewriteUrl(url, prefix, secret)}${descriptor ? ` ${descriptor}` : ''}` : entry + return url ? `${rewriteUrl(url, prefix)}${descriptor ? ` ${descriptor}` : ''}` : entry }) .join(', ') } if (node.attributes.style) - node.attributes.style = rewriteUrlsInText(node.attributes.style, prefix, secret) + node.attributes.style = rewriteUrlsInText(node.attributes.style, prefix) }) walkSync(ast, (node) => { if (node.type === TEXT_NODE && node.value) - node.value = rewriteUrlsInText(node.value, prefix, secret) + node.value = rewriteUrlsInText(node.value, prefix) }) let bodyNode: any = null @@ -157,9 +154,9 @@ export default withSigning(defineEventHandler(async (event) => { let combinedCss = cssContents.join('\n') combinedCss = combinedCss.replace( RSRC_RE, - (_m, path) => `url(${proxyAssetUrl(`https://static.cdninstagram.com/rsrc.php${path}`, prefix, secret)})`, + (_m, path) => `url(${proxyAssetUrl(`https://static.cdninstagram.com/rsrc.php${path}`, prefix)})`, ) - combinedCss = rewriteUrlsInText(combinedCss, prefix, secret) + combinedCss = rewriteUrlsInText(combinedCss, prefix) combinedCss = scopeCss(combinedCss, '.instagram-embed-root') const baseStyles = ` @@ -175,4 +172,4 @@ export default withSigning(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/cached-upstream.ts b/packages/script/src/runtime/server/utils/cached-upstream.ts index 7d2fdd4c8..654effd36 100644 --- a/packages/script/src/runtime/server/utils/cached-upstream.ts +++ b/packages/script/src/runtime/server/utils/cached-upstream.ts @@ -7,17 +7,10 @@ import { $fetch } from 'ofetch' * * ## Why * - * Proxy URLs arriving from the client carry per-request auth artefacts (`sig`, - * `_pt`, `_ts`) that change across renders. CDNs key on full URL so each - * rotation produces a unique edge cache entry and upstream origins take the hit - * on every render. Caching the *upstream response* here — keyed on the inner - * resource URL (or normalized param set) — dedupes those fetches across every - * request that resolves to the same upstream, regardless of how the caller - * authenticated. - * - * Safe because `withSigning` runs before any cache path: unsigned requests 403 - * before they can do a cache lookup. Cache stores hold only responses produced - * from legitimately-authenticated requests. + * The same upstream resource is often requested under slightly different proxy + * URLs across renders. Caching the *upstream response* here — keyed on the + * inner resource URL (or normalized param set) — dedupes those fetches across + * every request that resolves to the same upstream. * * ## Binary payloads * diff --git a/packages/script/src/runtime/server/utils/embed-rewriters.ts b/packages/script/src/runtime/server/utils/embed-rewriters.ts index f3c86731c..4ff62513e 100644 --- a/packages/script/src/runtime/server/utils/embed-rewriters.ts +++ b/packages/script/src/runtime/server/utils/embed-rewriters.ts @@ -2,9 +2,7 @@ import { buildProxyUrl } from './proxy-url' /** * Mutate a tweet (and any quoted tweet) in place so every raw CDN image URL - * is rewritten to route through the site's `/embed/x-image` proxy. When a - * `secret` is provided, URLs are HMAC-signed and pass `withSigning` without a - * page token. + * is rewritten to route through the site's `/embed/x-image` proxy. * * Clone the input first if it came from a shared cache — this function does * not copy. @@ -12,33 +10,32 @@ import { buildProxyUrl } from './proxy-url' export function rewriteTweetImages( tweet: any, imagePath: string, - secret?: string, ): void { if (!tweet) return if (tweet.user?.profile_image_url_https) - tweet.user.profile_image_url_https = buildProxyUrl(imagePath, { url: tweet.user.profile_image_url_https }, secret) + tweet.user.profile_image_url_https = buildProxyUrl(imagePath, { url: tweet.user.profile_image_url_https }) if (tweet.photos) { for (const photo of tweet.photos) { if (photo.url) - photo.url = buildProxyUrl(imagePath, { url: photo.url }, secret) + photo.url = buildProxyUrl(imagePath, { url: photo.url }) } } if (tweet.entities?.media) { for (const media of tweet.entities.media) { if (media.media_url_https) - media.media_url_https = buildProxyUrl(imagePath, { url: media.media_url_https }, secret) + media.media_url_https = buildProxyUrl(imagePath, { url: media.media_url_https }) } } if (tweet.video?.poster) - tweet.video.poster = buildProxyUrl(imagePath, { url: tweet.video.poster }, secret) + tweet.video.poster = buildProxyUrl(imagePath, { url: tweet.video.poster }) if (tweet.quoted_tweet) - rewriteTweetImages(tweet.quoted_tweet, imagePath, secret) + rewriteTweetImages(tweet.quoted_tweet, imagePath) } /** @@ -52,13 +49,12 @@ export function rewriteTweetImages( export function rewriteBlueskyPostImages( post: any, imagePath: string, - secret?: string, ): void { if (!post) return const proxy = (url: string | undefined): string | undefined => - url ? buildProxyUrl(imagePath, { url }, secret) : url + url ? buildProxyUrl(imagePath, { url }) : url if (post.author?.avatar) post.author.avatar = proxy(post.author.avatar) diff --git a/packages/script/src/runtime/server/utils/image-proxy.ts b/packages/script/src/runtime/server/utils/image-proxy.ts index bb90844b8..faef8de66 100644 --- a/packages/script/src/runtime/server/utils/image-proxy.ts +++ b/packages/script/src/runtime/server/utils/image-proxy.ts @@ -1,6 +1,5 @@ import { createError, defineEventHandler, getQuery, setHeader } from 'h3' import { createCachedBinaryFetch } from './cached-upstream' -import { withSigning } from './withSigning' const AMP_RE = /&/g @@ -33,7 +32,7 @@ export function createImageProxyHandler(config: ImageProxyConfig) { const cachedFetch = createCachedBinaryFetch(cacheName, cacheMaxAge) - return withSigning(defineEventHandler(async (event) => { + return defineEventHandler(async (event) => { const query = getQuery(event) let url = query.url as string @@ -103,5 +102,5 @@ export function createImageProxyHandler(config: ImageProxyConfig) { setHeader(event, 'Cache-Control', `public, max-age=${cacheMaxAge}, s-maxage=${cacheMaxAge}`) return result.body - })) + }) } diff --git a/packages/script/src/runtime/server/utils/instagram-embed.ts b/packages/script/src/runtime/server/utils/instagram-embed.ts index 352b8199a..654b49909 100644 --- a/packages/script/src/runtime/server/utils/instagram-embed.ts +++ b/packages/script/src/runtime/server/utils/instagram-embed.ts @@ -14,21 +14,21 @@ const WHITESPACE_RE = /\s/ const AT_RULE_NAME_RE = /@([\w-]+)/ const MULTI_SPACE_RE = /\s+/g -export function proxyImageUrl(url: string, prefix = '/_scripts', secret?: string): string { - return buildProxyUrl(`${prefix}/embed/instagram-image`, { url: url.replace(AMP_RE, '&') }, secret) +export function proxyImageUrl(url: string, prefix = '/_scripts'): string { + return buildProxyUrl(`${prefix}/embed/instagram-image`, { url: url.replace(AMP_RE, '&') }) } -export function proxyAssetUrl(url: string, prefix = '/_scripts', secret?: string): string { - return buildProxyUrl(`${prefix}/embed/instagram-asset`, { url: url.replace(AMP_RE, '&') }, secret) +export function proxyAssetUrl(url: string, prefix = '/_scripts'): string { + return buildProxyUrl(`${prefix}/embed/instagram-asset`, { url: url.replace(AMP_RE, '&') }) } -export function rewriteUrl(url: string, prefix = '/_scripts', secret?: string): string { +export function rewriteUrl(url: string, prefix = '/_scripts'): string { try { const parsed = new URL(url) if (parsed.hostname === INSTAGRAM_ASSET_HOST) - return proxyAssetUrl(url, prefix, secret) + return proxyAssetUrl(url, prefix) if (INSTAGRAM_IMAGE_HOSTS.some(h => parsed.hostname === h || parsed.hostname.endsWith(`.cdninstagram.com`))) - return proxyImageUrl(url, prefix, secret) + return proxyImageUrl(url, prefix) } catch { // Non-URL values are left unchanged by design. @@ -36,11 +36,11 @@ export function rewriteUrl(url: string, prefix = '/_scripts', secret?: string): return url } -export function rewriteUrlsInText(text: string, prefix = '/_scripts', secret?: string): string { +export function rewriteUrlsInText(text: string, prefix = '/_scripts'): string { return text - .replace(SCONTENT_RE, m => proxyImageUrl(m, prefix, secret)) - .replace(STATIC_CDN_RE, m => proxyAssetUrl(m, prefix, secret)) - .replace(LOOKASIDE_RE, m => proxyImageUrl(m, prefix, secret)) + .replace(SCONTENT_RE, m => proxyImageUrl(m, prefix)) + .replace(STATIC_CDN_RE, m => proxyAssetUrl(m, prefix)) + .replace(LOOKASIDE_RE, m => proxyImageUrl(m, prefix)) } /** diff --git a/packages/script/src/runtime/server/utils/proxy-url.ts b/packages/script/src/runtime/server/utils/proxy-url.ts index 62e373b37..7499778d6 100644 --- a/packages/script/src/runtime/server/utils/proxy-url.ts +++ b/packages/script/src/runtime/server/utils/proxy-url.ts @@ -1,17 +1,11 @@ -import { buildSignedProxyUrl } from './sign' - /** - * Build a proxy URL with query params, signing it when a secret is available. + * Build a proxy URL with query params. * - * Used by embed handlers that inject proxy URLs into HTML/JSON responses. - * When `secret` is set, URLs are HMAC-signed so clients can fetch them without - * needing a page token. When it's undefined, URLs fall back to unsigned form - * (which is only safe when the `withSigning` middleware has no secret either). + * Used by embed handlers that inject proxy URLs into HTML/JSON responses so + * the client loads upstream assets through the site origin. The proxy + * endpoints are restricted to an allowlist of upstream domains. */ -export function buildProxyUrl(path: string, query: Record, secret?: string): string { - if (secret) - return buildSignedProxyUrl(path, query, secret) - +export function buildProxyUrl(path: string, query: Record): string { const parts: string[] = [] for (const [key, value] of Object.entries(query)) { if (value === undefined || value === null) diff --git a/packages/script/src/runtime/server/utils/sign-constants.ts b/packages/script/src/runtime/server/utils/sign-constants.ts deleted file mode 100644 index f6c908272..000000000 --- a/packages/script/src/runtime/server/utils/sign-constants.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Signing constants shared between server (HMAC) and client (page-token) code. - * - * Kept in a crypto-free module so client bundles can import the param names - * without pulling in `node:crypto`. - */ - -/** Query param name for the HMAC signature. */ -export const SIG_PARAM = 'sig' - -/** Length of the hex signature (16 chars = 64 bits). */ -export const SIG_LENGTH = 16 - -/** 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 diff --git a/packages/script/src/runtime/server/utils/sign.ts b/packages/script/src/runtime/server/utils/sign.ts deleted file mode 100644 index ab40d0c9a..000000000 --- a/packages/script/src/runtime/server/utils/sign.ts +++ /dev/null @@ -1,220 +0,0 @@ -/** - * 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' -import { - PAGE_TOKEN_MAX_AGE, - PAGE_TOKEN_PARAM, - PAGE_TOKEN_TS_PARAM, - SIG_LENGTH, - SIG_PARAM, -} from './sign-constants' - -export { PAGE_TOKEN_MAX_AGE, PAGE_TOKEN_PARAM, PAGE_TOKEN_TS_PARAM, SIG_LENGTH, SIG_PARAM } - -/** - * 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 -// --------------------------------------------------------------------------- - -/** - * 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 deleted file mode 100644 index 495362226..000000000 --- a/packages/script/src/runtime/server/utils/withSigning.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * 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 { useRuntimeConfig } from 'nitropack/runtime' -import { verifyProxyRequest } from './sign' - -export function withSigning( - handler: EventHandler, -) { - return defineEventHandler(async (event) => { - const runtimeConfig = useRuntimeConfig(event) - const scriptsConfig = runtimeConfig['nuxt-scripts'] as { proxySecret?: string, pageTokenMaxAge?: number } | undefined - const secret = scriptsConfig?.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, scriptsConfig?.pageTokenMaxAge)) { - 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 bca7c8bd4..15480364b 100644 --- a/packages/script/src/runtime/server/x-embed.ts +++ b/packages/script/src/runtime/server/x-embed.ts @@ -1,8 +1,6 @@ import { createError, defineEventHandler, getQuery, setHeader } from 'h3' -import { useRuntimeConfig } from 'nitropack/runtime' import { createCachedJsonFetch } from './utils/cached-upstream' import { rewriteTweetImages } from './utils/embed-rewriters' -import { withSigning } from './utils/withSigning' interface TweetData { id_str: string @@ -64,7 +62,7 @@ const cachedTweetFetch = createCachedJsonFetch( }, ) -export default withSigning(defineEventHandler(async (event) => { +export default defineEventHandler(async (event) => { const query = getQuery(event) const tweetId = query.id as string @@ -95,21 +93,19 @@ export default withSigning(defineEventHandler(async (event) => { }) }) - // Rewrite raw CDN image URLs to proxied (and, when signing is enabled, - // HMAC-signed) URLs so the client can load them through the site origin - // without triggering the `withSigning` 403. Clone first — the cached tweet - // is a shared reference under the memory driver and mutation would corrupt - // subsequent cache hits. + // Rewrite raw CDN image URLs to proxied URLs so the client loads them + // through the site origin. Clone first — the cached tweet is a shared + // reference under the memory driver and mutation would corrupt subsequent + // cache hits. const tweetData = structuredClone(tweetRaw) as TweetData const handlerPath = event.path?.split('?')[0] || '' const prefix = handlerPath.replace(EMBED_X_SUFFIX_RE, '') || '/_scripts' const imagePath = `${prefix}/embed/x-image` - const secret = (useRuntimeConfig(event)['nuxt-scripts'] as { proxySecret?: string } | undefined)?.proxySecret - rewriteTweetImages(tweetData, imagePath, secret) + rewriteTweetImages(tweetData, imagePath) // Cache for 10 minutes setHeader(event, 'Content-Type', 'application/json') 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 8419fc27b..b0799de88 100644 --- a/packages/script/src/runtime/types.ts +++ b/packages/script/src/runtime/types.ts @@ -361,14 +361,6 @@ 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/e2e/issue-783-proxy-token-payload.test.ts b/test/e2e/issue-783-proxy-token-payload.test.ts new file mode 100644 index 000000000..712020eed --- /dev/null +++ b/test/e2e/issue-783-proxy-token-payload.test.ts @@ -0,0 +1,33 @@ +import { createResolver } from '@nuxt/kit' +import { $fetch, setup } from '@nuxt/test-utils/e2e' +import { describe, expect, it } from 'vitest' + +const { resolve } = createResolver(import.meta.url) + +// https://github.com/nuxt/scripts/issues/783 +// Proxy URL signing and the per-request page token were removed. Proxy URLs +// carry no per-request artefacts, so the SSR payload is identical across +// requests (which a response `etag` can rely on). +await setup({ + rootDir: resolve('../fixtures/issue-783'), + dev: true, + browser: false, +}) + +describe('issue-783 proxy token payload', () => { + it('renders a plain proxy URL with no signature or page token', async () => { + const html = await $fetch('/') + expect(html).toContain('/_scripts/proxy/google-static-maps') + expect(html).not.toContain('_pt=') + expect(html).not.toContain('_ts=') + expect(html).not.toMatch(/[?&]sig=/) + }) + + it('keeps the SSR payload identical across requests', async () => { + const [a, b] = await Promise.all([ + $fetch('/'), + $fetch('/'), + ]) + expect(a).toBe(b) + }) +}) diff --git a/test/fixtures/issue-783/app.vue b/test/fixtures/issue-783/app.vue new file mode 100644 index 000000000..8f62b8bf9 --- /dev/null +++ b/test/fixtures/issue-783/app.vue @@ -0,0 +1,3 @@ + diff --git a/test/fixtures/issue-783/nuxt.config.ts b/test/fixtures/issue-783/nuxt.config.ts new file mode 100644 index 000000000..731f1d0f0 --- /dev/null +++ b/test/fixtures/issue-783/nuxt.config.ts @@ -0,0 +1,16 @@ +import { defineNuxtConfig } from 'nuxt/config' + +// https://github.com/nuxt/scripts/issues/783 +// Proxy URL signing (and the per-request page token) was removed. Proxy URLs +// are now plain, so the SSR payload is deterministic across requests. +export default defineNuxtConfig({ + modules: ['@nuxt/scripts'], + devtools: { enabled: false }, + scripts: { + registry: { + googleMaps: { apiKey: 'test-key' }, + }, + googleStaticMapsProxy: { enabled: true }, + }, + compatibilityDate: '2024-07-05', +}) diff --git a/test/fixtures/issue-783/package.json b/test/fixtures/issue-783/package.json new file mode 100644 index 000000000..352055cdf --- /dev/null +++ b/test/fixtures/issue-783/package.json @@ -0,0 +1,3 @@ +{ + "private": true +} diff --git a/test/fixtures/issue-783/pages/index.vue b/test/fixtures/issue-783/pages/index.vue new file mode 100644 index 000000000..e8e39a8f9 --- /dev/null +++ b/test/fixtures/issue-783/pages/index.vue @@ -0,0 +1,5 @@ + diff --git a/test/unit/embed-rewriters.test.ts b/test/unit/embed-rewriters.test.ts index 59d3d4b12..30f797452 100644 --- a/test/unit/embed-rewriters.test.ts +++ b/test/unit/embed-rewriters.test.ts @@ -3,9 +3,7 @@ import { rewriteBlueskyPostImages, rewriteTweetImages, } from '../../packages/script/src/runtime/server/utils/embed-rewriters' -import { SIG_PARAM } from '../../packages/script/src/runtime/server/utils/sign-constants' -const SECRET = 'test-secret-deterministic' const X_PATH = '/_scripts/embed/x-image' const BSKY_PATH = '/_scripts/embed/bluesky-image' @@ -25,7 +23,7 @@ function makeTweet(overrides: Record = {}) { } } -describe('rewriteTweetImages (unsigned)', () => { +describe('rewriteTweetImages', () => { it('rewrites the user avatar URL', () => { const tweet = makeTweet() rewriteTweetImages(tweet, X_PATH) @@ -91,47 +89,6 @@ describe('rewriteTweetImages (unsigned)', () => { }) }) -describe('rewriteTweetImages (signed)', () => { - it('appends sig= on every rewritten URL when a secret is provided', () => { - const tweet = makeTweet({ - photos: [{ url: 'https://pbs.twimg.com/media/a.jpg', width: 1, height: 1 }], - video: { poster: 'https://pbs.twimg.com/media/p.jpg', variants: [] }, - quoted_tweet: makeTweet({ - user: { - name: 'Bob', - screen_name: 'bob', - profile_image_url_https: 'https://pbs.twimg.com/profile_images/2/q.jpg', - }, - }), - }) - rewriteTweetImages(tweet, X_PATH, SECRET) - for (const url of [ - tweet.user.profile_image_url_https, - tweet.photos[0].url, - tweet.video.poster, - tweet.quoted_tweet.user.profile_image_url_https, - ]) { - expect(url).toMatch(new RegExp(`${SIG_PARAM}=[a-f0-9]{16}`)) - } - }) - - it('produces deterministic URLs for the same input + secret', () => { - const a = makeTweet() - const b = makeTweet() - rewriteTweetImages(a, X_PATH, SECRET) - rewriteTweetImages(b, X_PATH, SECRET) - expect(a.user.profile_image_url_https).toBe(b.user.profile_image_url_https) - }) - - it('produces different signatures for different secrets', () => { - const a = makeTweet() - const b = makeTweet() - rewriteTweetImages(a, X_PATH, SECRET) - rewriteTweetImages(b, X_PATH, `${SECRET}-other`) - expect(a.user.profile_image_url_https).not.toBe(b.user.profile_image_url_https) - }) -}) - describe('rewriteBlueskyPostImages', () => { it('rewrites the author avatar', () => { const post = { @@ -179,19 +136,6 @@ describe('rewriteBlueskyPostImages', () => { expect(post.embed.external.thumb).toContain(encodeURIComponent('https://cdn.bsky.app/img/ext-thumb.jpg')) }) - it('signs URLs when a secret is provided', () => { - const post = { - author: { avatar: 'https://cdn.bsky.app/img/avatar.jpg' }, - embed: { - images: [{ thumb: 'https://cdn.bsky.app/img/t.jpg', fullsize: 'https://cdn.bsky.app/img/f.jpg' }], - }, - } - rewriteBlueskyPostImages(post, BSKY_PATH, SECRET) - expect(post.author.avatar).toMatch(new RegExp(`${SIG_PARAM}=[a-f0-9]{16}`)) - expect(post.embed.images[0].thumb).toMatch(new RegExp(`${SIG_PARAM}=[a-f0-9]{16}`)) - expect(post.embed.images[0].fullsize).toMatch(new RegExp(`${SIG_PARAM}=[a-f0-9]{16}`)) - }) - it('is a no-op on null/undefined', () => { expect(() => rewriteBlueskyPostImages(null, BSKY_PATH)).not.toThrow() expect(() => rewriteBlueskyPostImages(undefined, BSKY_PATH)).not.toThrow() diff --git a/test/unit/proxy-url.test.ts b/test/unit/proxy-url.test.ts index a0a0bf003..3a8427154 100644 --- a/test/unit/proxy-url.test.ts +++ b/test/unit/proxy-url.test.ts @@ -1,11 +1,8 @@ import { describe, expect, it } from 'vitest' import { buildProxyUrl } from '../../packages/script/src/runtime/server/utils/proxy-url' -import { buildSignedProxyUrl, SIG_PARAM } from '../../packages/script/src/runtime/server/utils/sign' -const SECRET = 'test-secret-9f2c8b4e7a1d6f3c5b9e8a2d4f7c1b6e' - -describe('buildProxyUrl: unsigned (no secret)', () => { - it('returns unsigned URL with standard URL-encoding', () => { +describe('buildProxyUrl', () => { + it('returns a URL with standard URL-encoding', () => { const result = buildProxyUrl('/api/proxy', { k: 'v', foo: 'bar' }) expect(result).toBe('/api/proxy?k=v&foo=bar') }) @@ -51,43 +48,9 @@ describe('buildProxyUrl: unsigned (no secret)', () => { }) expect(result).toBe('/api/proxy?tags=one&tags=two') }) -}) - -describe('buildProxyUrl: signed (with secret)', () => { - it('appends a 16-char hex sig as the last query param', () => { - const result = buildProxyUrl('/api/proxy', { k: 'v' }, SECRET) - expect(result).toMatch(new RegExp(`&${SIG_PARAM}=[a-f0-9]{16}$`)) - }) - - it('emits ?sig=... when there is no other query', () => { - const result = buildProxyUrl('/api/proxy', {}, SECRET) - expect(result).toMatch(new RegExp(`^/api/proxy\\?${SIG_PARAM}=[a-f0-9]{16}$`)) - }) - - it('delegates to buildSignedProxyUrl (produces identical output)', () => { - const query = { url: 'https://example.com/img.jpg', w: 640 } - expect(buildProxyUrl('/api/proxy', query, SECRET)).toBe( - buildSignedProxyUrl('/api/proxy', query, SECRET), - ) - }) it('is deterministic: same inputs produce identical URLs', () => { const query = { a: '1', b: ['x', 'y'] } - const a = buildProxyUrl('/api/proxy', query, SECRET) - const b = buildProxyUrl('/api/proxy', query, SECRET) - expect(a).toBe(b) - }) - - it('different secrets produce different signatures', () => { - const query = { k: 'v' } - const a = buildProxyUrl('/api/proxy', query, SECRET) - const b = buildProxyUrl('/api/proxy', query, 'different-secret-value') - expect(a).not.toBe(b) - - const sigA = a.match(/sig=([a-f0-9]+)$/)?.[1] - const sigB = b.match(/sig=([a-f0-9]+)$/)?.[1] - expect(sigA).toBeTruthy() - expect(sigB).toBeTruthy() - expect(sigA).not.toBe(sigB) + expect(buildProxyUrl('/api/proxy', query)).toBe(buildProxyUrl('/api/proxy', query)) }) }) diff --git a/test/unit/resolve-proxy-secret.test.ts b/test/unit/resolve-proxy-secret.test.ts deleted file mode 100644 index 21a3315ff..000000000 --- a/test/unit/resolve-proxy-secret.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -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 deleted file mode 100644 index 9bdfc4943..000000000 --- a/test/unit/sign.test.ts +++ /dev/null @@ -1,342 +0,0 @@ -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) - }) - - it('rejects an empty token', () => { - expect(verifyProxyToken('', ts, SECRET)).toBe(false) - }) - - it('rejects a non-numeric timestamp (NaN)', () => { - expect(verifyProxyToken(token, Number.NaN, SECRET)).toBe(false) - }) - - it('rejects a timestamp that is not a number type', () => { - // Guards against string ts leaking through at the boundary - expect(verifyProxyToken(token, 'not-a-number' as unknown as number, 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) - }) - - it('rejects a URL signature built with the wrong secret', () => { - // Valid structure/length but signed under a different secret - const badUrl = buildSignedProxyUrl('/_scripts/proxy/x', { center: 'Sydney' }, 'other-secret') - const event = mockEvent(badUrl) - expect(verifyProxyRequest(event, SECRET)).toBe(false) - }) - - it('rejects a signature valid for a different path (cross-endpoint replay defense)', () => { - // Sign for path A, then present the same sig on path B with identical query - const query = { center: 'Sydney' } - const sigForA = signProxyUrl('/_scripts/proxy/a', query, SECRET) - const event = mockEvent(`/_scripts/proxy/b?center=Sydney&${SIG_PARAM}=${sigForA}`) - expect(verifyProxyRequest(event, SECRET)).toBe(false) - }) - - it('rejects a page token that does not match the given timestamp', () => { - const ts = Math.floor(Date.now() / 1000) - const tokenForOtherTs = generateProxyToken(SECRET, ts - 500) - const event = mockEvent(`/_scripts/proxy/x?${PAGE_TOKEN_PARAM}=${tokenForOtherTs}&${PAGE_TOKEN_TS_PARAM}=${ts}`) - expect(verifyProxyRequest(event, SECRET)).toBe(false) - }) - - it('rejects a page token with a non-numeric timestamp', () => { - const ts = Math.floor(Date.now() / 1000) - const token = generateProxyToken(SECRET, ts) - const event = mockEvent(`/_scripts/proxy/x?${PAGE_TOKEN_PARAM}=${token}&${PAGE_TOKEN_TS_PARAM}=not-a-number`) - expect(verifyProxyRequest(event, SECRET)).toBe(false) - }) - - it('respects a custom maxAge override (tighter than the default)', () => { - // Token is 10 seconds old; default PAGE_TOKEN_MAX_AGE (3600) would accept it, - // but a 5-second maxAge must reject it. - const ts = Math.floor(Date.now() / 1000) - 10 - const token = generateProxyToken(SECRET, ts) - const event = mockEvent(`/_scripts/proxy/x?${PAGE_TOKEN_PARAM}=${token}&${PAGE_TOKEN_TS_PARAM}=${ts}`) - expect(verifyProxyRequest(event, SECRET, 3600)).toBe(true) - expect(verifyProxyRequest(event, SECRET, 5)).toBe(false) - }) -}) diff --git a/test/unit/use-script-proxy-url.test.ts b/test/unit/use-script-proxy-url.test.ts index c864e5e8a..a58c779c2 100644 --- a/test/unit/use-script-proxy-url.test.ts +++ b/test/unit/use-script-proxy-url.test.ts @@ -1,32 +1,10 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ref } from 'vue' -import { - PAGE_TOKEN_PARAM, - PAGE_TOKEN_TS_PARAM, -} from '../../packages/script/src/runtime/server/utils/sign-constants' +import { describe, expect, it } from 'vitest' +import { useScriptProxyUrl } from '../../packages/script/src/runtime/composables/useScriptProxyUrl' -const tokenState = ref<{ token: string, ts: number } | null>(null) - -vi.mock('../../packages/script/src/runtime/composables/useScriptProxyToken', () => ({ - useScriptProxyToken: () => tokenState, -})) - -// Import after mock so the composable picks up the mocked token state. -const { useScriptProxyUrl } = await import( - '../../packages/script/src/runtime/composables/useScriptProxyUrl', -) - -beforeEach(() => { - tokenState.value = null -}) - -describe('useScriptProxyUrl: no token', () => { - it('returns path?k=v with no token params when state is null', () => { +describe('useScriptProxyUrl: basic', () => { + it('returns path?k=v', () => { const build = useScriptProxyUrl() - const result = build('/api/proxy', { k: 'v' }) - expect(result).toBe('/api/proxy?k=v') - expect(result).not.toContain(PAGE_TOKEN_PARAM) - expect(result).not.toContain(PAGE_TOKEN_TS_PARAM) + expect(build('/api/proxy', { k: 'v' })).toBe('/api/proxy?k=v') }) it('returns bare path when query is empty', () => { @@ -36,45 +14,6 @@ describe('useScriptProxyUrl: no token', () => { }) }) -describe('useScriptProxyUrl: with token', () => { - it('appends _pt=&_ts= after existing query params', () => { - tokenState.value = { token: 'abc123def4567890', ts: 1700000000 } - const build = useScriptProxyUrl() - const result = build('/api/proxy', { k: 'v' }) - expect(result).toBe( - `/api/proxy?k=v&${PAGE_TOKEN_PARAM}=abc123def4567890&${PAGE_TOKEN_TS_PARAM}=1700000000`, - ) - }) - - it('attaches token params even when caller query is empty', () => { - tokenState.value = { token: 'tok', ts: 42 } - const build = useScriptProxyUrl() - const result = build('/api/proxy', {}) - expect(result).toBe(`/api/proxy?${PAGE_TOKEN_PARAM}=tok&${PAGE_TOKEN_TS_PARAM}=42`) - }) - - it('places token params AFTER the caller-supplied query params', () => { - tokenState.value = { token: 'tok', ts: 1 } - const build = useScriptProxyUrl() - const result = build('/api/proxy', { a: '1', b: '2' }) - const aIdx = result.indexOf('a=1') - const bIdx = result.indexOf('b=2') - const ptIdx = result.indexOf(`${PAGE_TOKEN_PARAM}=`) - const tsIdx = result.indexOf(`${PAGE_TOKEN_TS_PARAM}=`) - expect(aIdx).toBeGreaterThan(-1) - expect(bIdx).toBeGreaterThan(aIdx) - expect(ptIdx).toBeGreaterThan(bIdx) - expect(tsIdx).toBeGreaterThan(ptIdx) - }) - - it('uRL-encodes the token value', () => { - tokenState.value = { token: 'a/b=c&d', ts: 99 } - const build = useScriptProxyUrl() - const result = build('/api/proxy', {}) - expect(result).toContain(`${PAGE_TOKEN_PARAM}=${encodeURIComponent('a/b=c&d')}`) - }) -}) - describe('useScriptProxyUrl: query serialization', () => { it('uRL-encodes keys and values with special chars and unicode', () => { const build = useScriptProxyUrl() @@ -113,28 +52,3 @@ describe('useScriptProxyUrl: query serialization', () => { expect(result).toBe('/api/proxy?tags=one&tags=two') }) }) - -describe('useScriptProxyUrl: reactive token reads', () => { - it('reflects token state changes between calls', () => { - const build = useScriptProxyUrl() - - // Start with no token - expect(build('/api/proxy', { k: 'v' })).toBe('/api/proxy?k=v') - - // Set a token; next call should include it - tokenState.value = { token: 'first', ts: 100 } - expect(build('/api/proxy', { k: 'v' })).toBe( - `/api/proxy?k=v&${PAGE_TOKEN_PARAM}=first&${PAGE_TOKEN_TS_PARAM}=100`, - ) - - // Swap token; next call should reflect the new one - tokenState.value = { token: 'second', ts: 200 } - expect(build('/api/proxy', { k: 'v' })).toBe( - `/api/proxy?k=v&${PAGE_TOKEN_PARAM}=second&${PAGE_TOKEN_TS_PARAM}=200`, - ) - - // Clear token; next call should drop the params - tokenState.value = null - expect(build('/api/proxy', { k: 'v' })).toBe('/api/proxy?k=v') - }) -}) diff --git a/test/unit/with-signing.test.ts b/test/unit/with-signing.test.ts deleted file mode 100644 index 151144d4b..000000000 --- a/test/unit/with-signing.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import type { H3Event } from 'h3' -import { defineEventHandler } from 'h3' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - buildSignedProxyUrl, - generateProxyToken, - PAGE_TOKEN_MAX_AGE, - PAGE_TOKEN_PARAM, - PAGE_TOKEN_TS_PARAM, -} from '../../packages/script/src/runtime/server/utils/sign' - -// Hoisted runtime config mock — swapped between tests via `runtimeConfigMock`. -const { runtimeConfigMock } = vi.hoisted(() => ({ - runtimeConfigMock: { - current: {} as Record, - }, -})) - -vi.mock('nitropack/runtime', () => ({ - useRuntimeConfig: () => runtimeConfigMock.current, -})) - -// Import AFTER vi.mock so withSigning resolves against the mocked module. -const { withSigning } = await import('../../packages/script/src/runtime/server/utils/withSigning') - -const SECRET = 'with-signing-test-secret' -const PATH = '/_scripts/proxy/google-static-maps' - -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 SENTINEL = { hello: 'world' } - -const wrappedHandler = withSigning(defineEventHandler(() => SENTINEL)) - -beforeEach(() => { - runtimeConfigMock.current = {} -}) - -describe('withSigning: no secret configured', () => { - it('passes through without any verification', async () => { - runtimeConfigMock.current = { 'nuxt-scripts': {} } - const event = mockEvent(`${PATH}?center=Sydney`) - const result = await wrappedHandler(event) - expect(result).toBe(SENTINEL) - }) - - it('passes through even when the caller sends junk sig/token', async () => { - runtimeConfigMock.current = { 'nuxt-scripts': undefined } - const event = mockEvent(`${PATH}?center=Sydney&sig=deadbeef&${PAGE_TOKEN_PARAM}=xxx`) - const result = await wrappedHandler(event) - expect(result).toBe(SENTINEL) - }) -}) - -describe('withSigning: secret configured — URL signature mode', () => { - beforeEach(() => { - runtimeConfigMock.current = { 'nuxt-scripts': { proxySecret: SECRET } } - }) - - it('accepts a valid HMAC signature', async () => { - const signed = buildSignedProxyUrl(PATH, { center: 'Sydney' }, SECRET) - const event = mockEvent(signed) - const result = await wrappedHandler(event) - expect(result).toBe(SENTINEL) - }) - - it('rejects a missing signature with 403', async () => { - const event = mockEvent(`${PATH}?center=Sydney`) - await expect(wrappedHandler(event)).rejects.toMatchObject({ statusCode: 403 }) - }) - - it('rejects a tampered signature with 403', async () => { - const signed = buildSignedProxyUrl(PATH, { center: 'Sydney' }, SECRET) - // Flip one char of the sig - const tampered = signed.replace(/sig=([0-9a-f])/, (_m, c) => `sig=${c === 'a' ? 'b' : 'a'}`) - const event = mockEvent(tampered) - await expect(wrappedHandler(event)).rejects.toMatchObject({ statusCode: 403 }) - }) - - it('rejects a signature built with the wrong secret', async () => { - const signedWithOther = buildSignedProxyUrl(PATH, { center: 'Sydney' }, 'different-secret') - const event = mockEvent(signedWithOther) - await expect(wrappedHandler(event)).rejects.toMatchObject({ statusCode: 403 }) - }) - - it('rejects cross-endpoint replay of a signature', async () => { - // Sig is for a different path; presenting it here with the same query shouldn't verify. - const signedForOther = buildSignedProxyUrl('/_scripts/proxy/other', { center: 'Sydney' }, SECRET) - const query = new URL(signedForOther, 'http://localhost').search - const event = mockEvent(`${PATH}${query}`) - await expect(wrappedHandler(event)).rejects.toMatchObject({ statusCode: 403 }) - }) -}) - -describe('withSigning: secret configured — page token mode', () => { - beforeEach(() => { - runtimeConfigMock.current = { 'nuxt-scripts': { proxySecret: SECRET } } - }) - - it('accepts a fresh page token', async () => { - const ts = Math.floor(Date.now() / 1000) - const token = generateProxyToken(SECRET, ts) - const event = mockEvent(`${PATH}?center=Sydney&${PAGE_TOKEN_PARAM}=${token}&${PAGE_TOKEN_TS_PARAM}=${ts}`) - const result = await wrappedHandler(event) - expect(result).toBe(SENTINEL) - }) - - it('rejects an expired page token', async () => { - const staleTs = Math.floor(Date.now() / 1000) - PAGE_TOKEN_MAX_AGE - 10 - const token = generateProxyToken(SECRET, staleTs) - const event = mockEvent(`${PATH}?center=Sydney&${PAGE_TOKEN_PARAM}=${token}&${PAGE_TOKEN_TS_PARAM}=${staleTs}`) - await expect(wrappedHandler(event)).rejects.toMatchObject({ statusCode: 403 }) - }) - - it('rejects a token that does not match its timestamp', async () => { - const ts = Math.floor(Date.now() / 1000) - const token = generateProxyToken(SECRET, ts) - // Present the token with a different `_ts`, simulating forgery. - const event = mockEvent(`${PATH}?center=Sydney&${PAGE_TOKEN_PARAM}=${token}&${PAGE_TOKEN_TS_PARAM}=${ts - 500}`) - await expect(wrappedHandler(event)).rejects.toMatchObject({ statusCode: 403 }) - }) - - it('rejects a token forged with the wrong secret', async () => { - const ts = Math.floor(Date.now() / 1000) - const forgedToken = generateProxyToken('not-the-real-secret', ts) - const event = mockEvent(`${PATH}?center=Sydney&${PAGE_TOKEN_PARAM}=${forgedToken}&${PAGE_TOKEN_TS_PARAM}=${ts}`) - await expect(wrappedHandler(event)).rejects.toMatchObject({ statusCode: 403 }) - }) -}) - -describe('withSigning: pageTokenMaxAge override', () => { - it('applies a tighter max age when configured, rejecting tokens outside the window', async () => { - runtimeConfigMock.current = { - 'nuxt-scripts': { proxySecret: SECRET, pageTokenMaxAge: 5 }, - } - const ts = Math.floor(Date.now() / 1000) - 10 // 10s old; default would accept, maxAge=5 should not - const token = generateProxyToken(SECRET, ts) - const event = mockEvent(`${PATH}?center=Sydney&${PAGE_TOKEN_PARAM}=${token}&${PAGE_TOKEN_TS_PARAM}=${ts}`) - await expect(wrappedHandler(event)).rejects.toMatchObject({ statusCode: 403 }) - }) - - it('extends the window when a larger max age is configured', async () => { - runtimeConfigMock.current = { - 'nuxt-scripts': { proxySecret: SECRET, pageTokenMaxAge: PAGE_TOKEN_MAX_AGE * 24 }, - } - const ts = Math.floor(Date.now() / 1000) - (PAGE_TOKEN_MAX_AGE + 100) // 1h+ old; default rejects, extended accepts - const token = generateProxyToken(SECRET, ts) - const event = mockEvent(`${PATH}?center=Sydney&${PAGE_TOKEN_PARAM}=${token}&${PAGE_TOKEN_TS_PARAM}=${ts}`) - const result = await wrappedHandler(event) - expect(result).toBe(SENTINEL) - }) -})