diff --git a/docs/content/docs/1.guides/2.first-party.md b/docs/content/docs/1.guides/2.first-party.md index 517af6a04..3de1d3d36 100644 --- a/docs/content/docs/1.guides/2.first-party.md +++ b/docs/content/docs/1.guides/2.first-party.md @@ -256,144 +256,15 @@ Platform-level rewrites bypass the privacy anonymization layer. The proxy handle ## Proxy Endpoint Security -Some proxy endpoints inject server-side API keys or forward arbitrary resource requests. This includes Google Static Maps, Geocode, Gravatar, and embed image proxies. Anyone can call an unprotected endpoint directly and consume your API quota. - -### HMAC URL Signing - -Optional HMAC signing accepts either an exact URL generated during SSR or prerender, or a request carrying a valid page token. Requests with neither credential receive a `403`. The [signing implementation](https://github.com/nuxt/scripts/blob/main/packages/script/src/runtime/server/utils/sign.ts) canonicalizes each URL before generating its HMAC-SHA256 signature. - -#### Setup - -Generate a signing secret with the CLI: - -```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, - } - } -}) -``` - -#### Verification Modes - -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. - -Page tokens are deliberately broader than URL signatures: anyone who can read a valid token can change the parameters and signed proxy path until it expires. Treat them as short-lived authorization for the proxy group, not proof that a request matches an exact server-generated URL. - -#### Development - -In development, the module generates a secret and writes it to `.env` on the first run. - -#### 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` | - -The generic analytics proxy does not use signing. It accepts only upstream domains registered at build time and does not inject the protected API keys used by the signed endpoints above. - -#### 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, - // Page-token lifetime in seconds (default: 3600). - pageTokenMaxAge: 3600, - } - } -}) -``` - -To disable proxy security entirely, set `security` to `false`: - -```ts [nuxt.config.ts] -export default defineNuxtConfig({ - scripts: { - // No secret is resolved or auto-generated, no page token is added to the - // SSR payload, and proxy endpoints pass requests through unverified. - security: false, - } -}) -``` - -Disable security when you need a deterministic SSR payload, such as one used to compute a stable response `etag`. Without it, proxy endpoints still work but remain open to quota abuse and arbitrary requests to their allowlisted upstreams. +Embed, avatar, and analytics proxy routes are public resources. They do not contain server-side API keys. Each route accepts only the upstream hosts and request shapes declared by its integration. ::callout{type="warning"} Runtime proxy fetches validate the initial upstream URL and every redirect target before requesting it. Direct local, private, link-local, and reserved targets are rejected on every runtime; Node deployments also validate and pin DNS results before opening the socket. Image routes reject active content types such as HTML and SVG. The Instagram embed route restricts post and stylesheet hosts, then sanitizes the returned fragment before client rendering. :: -#### 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 by default. If a user leaves a tab open longer than `security.pageTokenMaxAge`, client-side proxy requests will start returning 403. The page will recover on the next navigation or refresh. +Nuxt Scripts does not proxy Google Maps requests. Static Maps loads from Google with the public browser key, while location lookup uses the Maps JavaScript Places service. Apply website and API restrictions to the key, then configure Google Cloud quotas to cap spend. See [Google Maps Platform security guidance](https://developers.google.com/maps/api-security-best-practices). -**Proxy token changes the response payload on every request** - -The module injects a per-request page token into the SSR payload, so the response hash differs each request. If you compute a stable `etag`, set `security: false` to disable proxy security entirely. Proxy endpoints then pass requests through without signature verification, so only do this if you accept the wider request-authorization boundary described above. - -#### 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 such as GitHub Pages. If you need proxy endpoints alongside prerendered pages, deploy to a server target that supports runtime request handling; [Vercel supports both static and server-rendered Nuxt deployments](https://vercel.com/docs/frameworks/full-stack/nuxt). - -**`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"} -The module skips signing setup and logs a build warning in both cases. In SPA mode with a deployed server, registered endpoints remain available without signature checks. A fully static host has no runtime endpoint to receive the request. -:: +If public embed traffic needs request limits, configure them at your deployment edge or add application middleware. Nitro 2 route rules do not provide a portable rate limiter. ## Supported Scripts diff --git a/docs/content/docs/3.api/5.nuxt-config.md b/docs/content/docs/3.api/5.nuxt-config.md index b48426441..aa7bdc524 100644 --- a/docs/content/docs/3.api/5.nuxt-config.md +++ b/docs/content/docs/3.api/5.nuxt-config.md @@ -107,22 +107,6 @@ export default defineNuxtConfig({ }) ``` -## `security`{lang="ts"} - -- Type: `false | { secret?: string, autoGenerateSecret?: boolean, pageTokenMaxAge?: number }`{lang="ts"} -- Default: `undefined` (the module configures signing after it registers a protected endpoint) - -Configures HMAC protection for proxy endpoints that expose server-side API keys or forward arbitrary external resources. The secret falls back to `NUXT_SCRIPTS_PROXY_SECRET`. In development, the module generates and persists a secret when you enable a signed endpoint without providing one. - -Production does not auto-generate a secret. If the module finds a protected endpoint without one, it warns and leaves that endpoint functional but unsigned. URL signing is also unavailable for `ssr: false` and static Nitro presets because they have no server runtime to verify signatures. - -- `security.secret`{lang="ts"}: the HMAC secret -- `security.autoGenerateSecret`{lang="ts"}: whether development may create the secret; defaults to `true` -- `security.pageTokenMaxAge`{lang="ts"}: client page-token lifetime in seconds; defaults to `3600` -- `security: false`{lang="ts"}: disables signing and lets registered endpoints accept unsigned requests - -See [Proxy Endpoint Security](/docs/guides/first-party#proxy-endpoint-security) for setup and deployment constraints. - ## Partytown (Web Worker) :badge[Experimental]{color="amber"} Load individual scripts in a web worker using [Partytown](https://partytown.qwik.dev/). A registry `trigger` is still required to generate a global call, but it does not defer the Partytown tag: the current implementation writes that tag into the server-rendered HTML. @@ -250,10 +234,3 @@ Cache duration for bundled scripts in milliseconds. Scripts older than this will Generates a Subresource Integrity (SRI) hash for each bundled script and adds `integrity` with `crossorigin="anonymous"`. Browsers compare the downloaded script with its declared hash before executing it; see MDN's [Subresource Integrity](https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Subresource_Integrity) guide. - -## `googleStaticMapsProxy`{lang="ts"} - -- Type: `{ enabled?: boolean, cacheMaxAge?: number }`{lang="ts"} -- Default: `{ enabled: false, cacheMaxAge: 3600 }` - -Controls the legacy Google Static Maps proxy switch and its response cache duration in seconds. Registering `googleMaps` also enables the Static Maps and geocoding endpoints. See the [Google Maps Static Map API](/scripts/google-maps/api/static-map) for component usage. diff --git a/docs/content/scripts/bluesky-embed.md b/docs/content/scripts/bluesky-embed.md index e57d54a26..85bb39265 100644 --- a/docs/content/scripts/bluesky-embed.md +++ b/docs/content/scripts/bluesky-embed.md @@ -18,10 +18,6 @@ links: ::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. -:: - Enabling the integration registers `/_scripts/embed/bluesky` for post data and `/_scripts/embed/bluesky-image` for 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 9a0c4279e..5f6bfea39 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 @@ -1,18 +1,10 @@ --- title: -description: Render a Google Maps Static API image directly or through the built-in server proxy. +description: Render a Google Maps Static API image directly from Google. --- 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. -:: - -::callout{color="amber"} -Google's [Maps Platform FAQ](https://developers.google.com/maps/faq#static_map) requires browser pages to load Static Maps images directly from Google rather than store and serve copies. Because the built-in proxy caches and serves the image, pass an explicit `api-key` to bypass it and review the current Google Maps terms for your use case. -:: - ::script-types{script-key="google-maps" filter="ScriptGoogleMapsStaticMap"} :: @@ -75,7 +67,7 @@ Use inside [``{lang="html"}](/scripts/google-maps/api/script-g | `language` | `string` | | Language code for map labels. | | `region` | `string` | | Region bias. | | `signature` | `string` | | [Google Maps URL signature](https://developers.google.com/maps/digital-signature) for a direct request. Use it with an explicit `apiKey`; a signature does not replace the key. | -| `apiKey` | `string` | | API key override. Takes priority over the proxy; the component falls back to the server-side key when omitted. | +| `apiKey` | `string` | | API key override. Falls back to the public `googleMaps` registry key when omitted. | | `width` | `number \| string` | `640` | CSS width for the container. | | `height` | `number \| string` | `400` | CSS height for the container. | | `loading` | `'eager' \| 'lazy'` | `'lazy'` | Image loading strategy. | @@ -92,14 +84,12 @@ When `size` is not provided, the component: Set `size` explicitly to bypass auto-measurement. -## Proxy Support - -Configuring `googleMaps` in `scripts.registry` enables a server-side proxy automatically. The component routes requests through it unless you provide an explicit `apiKey` prop. Passing the prop produces a direct `https://maps.googleapis.com/maps/api/staticmap` image URL. +## API Key Security -The built-in proxy cannot use a client-provided `signature` because the server appends the API key after it receives the query. Pass both `apiKey` and `signature` to make a signed direct request. +The component loads the image directly from `maps.googleapis.com`. Restrict the key to your website and the Maps Static API before production use. See [Google Maps Platform security guidance](https://developers.google.com/maps/api-security-best-practices). ::callout{color="amber"} -The component currently builds its proxy URL with the default `/_scripts` prefix. If you configure a custom `scripts.prefix`, static-map proxy requests still use `/_scripts/proxy/google-static-maps` and will miss the relocated route. Pass `apiKey` to use Google's direct URL, or keep the default prefix until this is fixed. +Google digital signatures cover the exact request URL. When passing `signature`, also pass an explicit `size` and keep every signed parameter stable. Generate the signature on a trusted server. Never expose the URL signing secret to the browser. :: ## Slots diff --git a/docs/content/scripts/google-maps/index.md b/docs/content/scripts/google-maps/index.md index f3357afa7..be5254e73 100644 --- a/docs/content/scripts/google-maps/index.md +++ b/docs/content/scripts/google-maps/index.md @@ -1,6 +1,6 @@ --- title: Google Maps -description: Load interactive maps on demand and proxy Static Maps or geocoding requests. +description: Load interactive maps, static maps, and location search on demand. links: - label: useScriptGoogleMaps icon: i-simple-icons-github @@ -47,23 +47,10 @@ export default defineNuxtConfig({ NUXT_PUBLIC_SCRIPTS_GOOGLE_MAPS_API_KEY= ``` -Registering Google Maps also adds server proxy routes that keep the key out of static-map and geocoding request URLs: - -- `/_scripts/proxy/google-static-maps` for placeholder images -- `/_scripts/proxy/google-maps-geocode` for location search - Add `trigger: 'onNuxtReady'` to the registry entry only when you want the interactive Maps API to load globally. It bypasses the component's default interaction delay because the shared script instance is already loading. ::callout{color="amber"} -The Maps JavaScript API still sends the key to the browser when the interactive map loads. Follow Google's [API security guidance](https://developers.google.com/maps/api-security-best-practices): restrict keys by application and API, and use separate keys for client-side and server-side services when possible. Passing `api-key` directly on ``{lang="html"} also exposes it in the client bundle, whereas runtime config lets you vary the key by deployment. -:: - -::callout{color="amber"} -Google's [Maps Platform FAQ](https://developers.google.com/maps/faq#static_map) requires browser pages to load Static Maps images directly from Google. The current static-map proxy caches and serves those images, so pass an explicit `api-key` to ``{lang="html"} to bypass the proxy and review the Maps Platform terms before using that component. -:: - -::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. +The Maps JavaScript and Static Maps APIs send this key to the browser. Follow Google's [API security guidance](https://developers.google.com/maps/api-security-best-practices): apply a Websites application restriction, allow only the APIs this site uses, and configure quota limits. Runtime config keeps deployment values configurable; it does not make a `NUXT_PUBLIC_` key secret. :: See [Billing & Permissions](/scripts/google-maps/guides/billing) for API costs and required permissions. diff --git a/docs/content/scripts/gravatar.md b/docs/content/scripts/gravatar.md index 9b7df38db..a3111612f 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. The avatar image request is proxied through your server, so Gravatar does not receive the user's IP address from that request. diff --git a/docs/content/scripts/instagram-embed.md b/docs/content/scripts/instagram-embed.md index d58b54d3a..796222b6d 100644 --- a/docs/content/scripts/instagram-embed.md +++ b/docs/content/scripts/instagram-embed.md @@ -18,10 +18,6 @@ links: ::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 16b102d7d..9d4e47d7d 100644 --- a/docs/content/scripts/x-embed.md +++ b/docs/content/scripts/x-embed.md @@ -18,10 +18,6 @@ links: ::script-docs{embed} :: -::callout{type="info"} -This script's proxy endpoints use [HMAC URL signing](/docs/guides/first-party#proxy-endpoint-security) when you configure a `NUXT_SCRIPTS_PROXY_SECRET`. See the [security guide](/docs/guides/first-party#proxy-endpoint-security) for setup instructions. -:: - This registers the required server API routes (`/_scripts/embed/x` and `/_scripts/embed/x-image`) that handle fetching tweet data and proxying images. ## [``{lang="html"}](/scripts/x-embed){lang="html"} diff --git a/packages/script/bin/cli.mjs b/packages/script/bin/cli.mjs index 4a3bf36f0..2743dc982 100755 --- a/packages/script/bin/cli.mjs +++ b/packages/script/bin/cli.mjs @@ -1,2 +1,8 @@ #!/usr/bin/env node -import('../dist/cli.mjs') +import process from 'node:process' +import { runCli } from '../dist/cli.mjs' + +process.exitCode = runCli(process.argv.slice(2), { + writeStdout: value => process.stdout.write(value), + writeStderr: value => process.stderr.write(value), +}) diff --git a/packages/script/src/cli.ts b/packages/script/src/cli.ts index d94c98de4..afd2fffdb 100644 --- a/packages/script/src/cli.ts +++ b/packages/script/src/cli.ts @@ -1,67 +1,51 @@ /** * @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. + * Keep this entrypoint dependency-free so it can host migration commands and + * codemods without making normal module startup heavier. */ -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'), - ) +export interface CliIo { + writeStdout: (value: string) => void + writeStderr: (value: string) => void } -function showHelp(): void { - process.stdout.write( - [ - '', - ' @nuxt/scripts CLI', - '', - ' Usage: npx @nuxt/scripts ', - '', - ' Commands:', - ' generate-secret Generate a signing secret for proxy URL tamper protection', - ' help Show this help', - '', - '', - ].join('\n'), - ) -} +export type CliResult + = | { _tag: 'Success', output: string } + | { _tag: 'Failure', output: string } -const command = process.argv[2] +const help = [ + '', + ' @nuxt/scripts CLI', + '', + ' Usage: npx @nuxt/scripts ', + '', + ' Commands:', + ' help Show this help', + '', + '', +].join('\n') -if (!command || command === 'help' || command === '--help' || command === '-h') { - showHelp() -} -else if (command === 'generate-secret') { - generateSecret() +export function resolveCliCommand(args: string[]): CliResult { + const command = args[0] + + if (!command || command === 'help' || command === '--help' || command === '-h') + return { _tag: 'Success', output: help } + + return { + _tag: 'Failure', + output: `Unknown command: ${command}\n${help}`, + } } -else { - process.stderr.write(`Unknown command: ${command}\n`) - showHelp() - process.exit(1) + +export function runCli(args: string[], io: CliIo): 0 | 1 { + const result = resolveCliCommand(args) + + if (result._tag === 'Success') { + io.writeStdout(result.output) + return 0 + } + + io.writeStderr(result.output) + return 1 } diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index 23d7c57a4..4385897ae 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -14,15 +14,11 @@ import type { RegistryScripts, ResolvedProxyAutoInject, } from './runtime/types' -import { randomBytes } from 'node:crypto' -import { appendFileSync, existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' -import { open as openFile, stat, unlink } from 'node:fs/promises' -import { setTimeout as delay } from 'node:timers/promises' +import { existsSync, readdirSync, readFileSync } from 'node:fs' import { addBuildPlugin, addComponentsDir, addImports, - addPlugin, addPluginTemplate, addServerHandler, addTemplate, @@ -126,150 +122,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 -const PROXY_SECRET_LOCK_RETRY_MS = 10 -const PROXY_SECRET_LOCK_TIMEOUT_MS = 2000 - -async function withProxySecretFileLock(envPath: string, effect: () => T): Promise { - const lockPath = `${envPath}.nuxt-scripts.lock` - const deadline = Date.now() + PROXY_SECRET_LOCK_TIMEOUT_MS - let lockHandle: Awaited> | undefined - - while (!lockHandle) { - const acquisition = await openFile(lockPath, 'wx') - .then(handle => ({ _tag: 'Acquired' as const, handle })) - .catch((error: NodeJS.ErrnoException) => { - if (error.code === 'EEXIST') - return { _tag: 'Busy' as const } - throw error - }) - - if (acquisition._tag === 'Acquired') { - lockHandle = acquisition.handle - break - } - - const existingLock = await stat(lockPath) - .then(lockStat => ({ _tag: 'Found' as const, mtimeMs: lockStat.mtimeMs })) - .catch((error: NodeJS.ErrnoException) => { - if (error.code === 'ENOENT') - return { _tag: 'Missing' as const } - throw error - }) - if (existingLock._tag === 'Found' && Date.now() - existingLock.mtimeMs >= PROXY_SECRET_LOCK_TIMEOUT_MS) { - await unlink(lockPath).catch((error: NodeJS.ErrnoException) => { - if (error.code !== 'ENOENT') - throw error - }) - continue - } - if (Date.now() >= deadline) - throw Object.assign(new Error('Timed out waiting for proxy secret file lock'), { code: 'ETIMEDOUT' }) - await delay(PROXY_SECRET_LOCK_RETRY_MS) - } - - let effectResult: { _tag: 'Success', value: T } | { _tag: 'Failure', error: unknown } - try { - effectResult = { _tag: 'Success', value: effect() } - } - catch (error) { - effectResult = { _tag: 'Failure', error } - } - - const closeResult = await lockHandle.close() - .then(() => ({ _tag: 'Success' as const })) - .catch((error: Error) => ({ _tag: 'Failure' as const, error })) - const unlinkResult = await unlink(lockPath) - .then(() => ({ _tag: 'Success' as const })) - .catch((error: NodeJS.ErrnoException) => error.code === 'ENOENT' - ? { _tag: 'Success' as const } - : { _tag: 'Failure' as const, error }) - - if (closeResult._tag === 'Failure') - logger.warn(`[security] Failed to close the proxy secret lock: ${closeResult.error.message}`) - if (unlinkResult._tag === 'Failure') - logger.warn(`[security] Failed to remove the proxy secret lock: ${unlinkResult.error.message}`) - if (effectResult._tag === 'Failure') - throw effectResult.error - return effectResult.value -} - -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 async function resolveProxySecret( - rootDir: string, - isDev: boolean, - configSecret?: string, - autoGenerate: boolean = true, -): Promise { - 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 { - const persistedSecret = await withProxySecretFileLock(envPath, () => { - if (existsSync(envPath)) { - const contents = readFileSync(envPath, 'utf-8') - const existingSecret = contents.match(PROXY_SECRET_ENV_VALUE_RE)?.[1]?.trim() - if (existingSecret) - return existingSecret - if (PROXY_SECRET_ENV_LINE_RE.test(contents)) { - // An empty declaration suppresses dotenv fallback on future starts. - // Replace it in place so the generated secret remains stable. - writeFileSync(envPath, contents.replace(PROXY_SECRET_ENV_LINE_RE, `${PROXY_SECRET_ENV_KEY}=${secret}`)) - } - else { - appendFileSync(envPath, contents.endsWith('\n') ? line : `\n${line}`) - } - } - else { - writeFileSync(envPath, `# Generated by @nuxt/scripts\n${line}`) - } - return secret - }) - // 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] = persistedSecret - return { secret: persistedSecret, 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, @@ -463,73 +315,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`. - * - * Set to `false` to disable proxy security entirely: no secret is resolved or - * auto-generated, no page token is injected into the SSR payload, and proxy - * endpoints pass requests through without signature verification. - */ - security?: false | { - /** - * 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. - */ - googleStaticMapsProxy?: { - /** - * Enable proxying Google Static Maps through your own origin. - * @default false - */ - enabled?: boolean - /** - * Cache duration for static map images in seconds. - * @default 3600 (1 hour) - */ - cacheMaxAge?: number - } /** * Enable standalone devtools mode. * When enabled, exposes a dev-only API endpoint that bridges script state @@ -578,10 +363,6 @@ export default defineNuxtModule({ timeout: 15_000, // Configures the maximum time (in milliseconds) allowed for each fetch attempt. }, }, - googleStaticMapsProxy: { - enabled: false, - cacheMaxAge: 3600, - }, enabled: true, debug: false, }, @@ -688,25 +469,14 @@ export default defineNuxtModule({ Object.keys(config.globals || {}), ) - // Setup runtimeConfig for proxies and devtools. - // Must run AFTER env var resolution above so the API key is populated. - const googleMapsEnabled = config.googleStaticMapsProxy?.enabled || !!config.registry?.googleMaps nuxt.options.runtimeConfig['nuxt-scripts'] = { version: version!, - // Private proxy config with API key (server-side only) - googleStaticMapsProxy: googleMapsEnabled - ? { apiKey: (nuxt.options.runtimeConfig.public.scripts as any)?.googleMaps?.apiKey } - : undefined, } as any nuxt.options.runtimeConfig.public['nuxt-scripts'] = { // expose for devtools version: nuxt.options.dev ? version : undefined, prefix: config.prefix || '/_scripts', defaultScriptOptions: config.defaultScriptOptions as any, - // Only expose enabled and cacheMaxAge to client, not apiKey - googleStaticMapsProxy: googleMapsEnabled - ? { enabled: true, cacheMaxAge: config.googleStaticMapsProxy?.cacheMaxAge ?? 3600 } - : undefined, } as any // Build-time constants are replaced inline so internal capability choices @@ -749,7 +519,6 @@ export default defineNuxtModule({ const composables = [ 'useScript', 'useScriptEventPage', - 'useScriptProxyToken', 'useScriptProxyUrl', 'useScriptTriggerConsent', 'useScriptTriggerElement', @@ -1100,7 +869,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.', ) } @@ -1159,15 +928,11 @@ 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 - // googleMaps uses googleStaticMapsProxy config for backward compat - const isEnabled = script.registryKey === 'googleMaps' - ? config.googleStaticMapsProxy?.enabled || config.registry?.googleMaps - : config.registry?.[script.registryKey as keyof typeof config.registry] + const isEnabled = config.registry?.[script.registryKey as keyof typeof config.registry] if (!isEnabled) continue @@ -1180,8 +945,6 @@ export default defineNuxtModule({ handler: handler.handler, middleware: handler.middleware, }) - if (handler.requiresSigning) - anyHandlerRequiresSigning = true } // Script-specific runtimeConfig setup @@ -1193,12 +956,6 @@ export default defineNuxtModule({ nuxt.options.runtimeConfig.public['nuxt-scripts'] as any, ) as any } - if (script.registryKey === 'googleMaps') { - nuxt.options.runtimeConfig['nuxt-scripts'] = defu( - { googleMapsGeocodeProxy: { apiKey: (nuxt.options.runtimeConfig.public.scripts as any)?.googleMaps?.apiKey } }, - nuxt.options.runtimeConfig['nuxt-scripts'] as any, - ) as any - } } // Nitro's default memory storage has no eviction policy. Every proxy and @@ -1211,62 +968,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 - - // Proxy security explicitly disabled: skip secret resolution and the page - // token plugin. `withSigning` passes requests through unverified. - if (config.security === false) { - if (anyHandlerRequiresSigning && !nuxt.options.dev) { - logger.info('[security] Proxy security disabled via `security: false`. Proxy endpoints will pass requests through without signature verification.') - } - } - else 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 = await 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 f0d5bbf2c..d5dc31567 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -695,10 +695,6 @@ export async function registry(resolve?: (path: string) => Promise): Pro label: 'Google Maps', 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 }, - ], }), def('leaflet', { schema: LeafletOptions, @@ -721,8 +717,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', { @@ -731,9 +727,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', { @@ -742,8 +738,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 @@ -876,7 +872,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' }, ], }), def('speedcurve', { diff --git a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue index bd9d624e4..306bec8ef 100644 --- a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue +++ b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue @@ -97,7 +97,7 @@ export interface ScriptGoogleMapsExpose { map: ShallowRef /** * Utility function to resolve a location query (e.g. "New York, NY") to latitude/longitude coordinates. - * Uses a caching mechanism and a server-side proxy to avoid unnecessary client-side API calls. + * Uses the client Places service and caches results for the current page session. */ resolveQueryToLatLng: (query: string) => Promise /** @@ -151,11 +151,11 @@ export interface ScriptGoogleMapsSlots { ']).stream(), - headers: new Headers({ 'content-type': 'text/html' }), - status: 200, - }) - - const response = await fetch(`http://127.0.0.1:${proxyPort}/?center=Melbourne`) - - expect(response.status).toBe(415) - }) -}) diff --git a/test/unit/proxy-url.test.ts b/test/unit/proxy-url.test.ts index a0a0bf003..e7d5d445f 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,11 @@ 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) + const queryA = { a: '1', b: ['x', 'y'] } + const queryB = { a: '1', b: ['x', 'y'] } + expect(buildProxyUrl('/api/proxy', queryA)).toBe(buildProxyUrl('/api/proxy', queryB)) + expect(queryA).toEqual({ a: '1', b: ['x', 'y'] }) }) }) diff --git a/test/unit/resolve-proxy-secret.test.ts b/test/unit/resolve-proxy-secret.test.ts deleted file mode 100644 index 9ab11eb97..000000000 --- a/test/unit/resolve-proxy-secret.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { mkdirSync, readFileSync, rmSync, utimesSync, 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', async () => { - process.env[ENV_KEY] = 'env-secret' - const result = await 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', async () => { - process.env[ENV_KEY] = 'env-secret' - const result = await resolveProxySecret(testDir, true) - expect(result).toEqual({ secret: 'env-secret', ephemeral: false, source: 'env' }) - }) - - it('returns undefined in prod when no secret is available', async () => { - const result = await resolveProxySecret(testDir, false) - expect(result).toBeUndefined() - }) - - it('returns undefined when autoGenerate is false even in dev', async () => { - const result = await resolveProxySecret(testDir, true, undefined, false) - expect(result).toBeUndefined() - }) - - it('auto-generates and writes to .env in dev when file does not exist', async () => { - const result = await 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', async () => { - writeFileSync(join(testDir, '.env'), 'OTHER_VAR=value\n') - const result = await 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', async () => { - writeFileSync(join(testDir, '.env'), `${ENV_KEY}=existing-secret-value\n`) - const result = await 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('waits for another process to persist the secret while its lock is held', async () => { - const envPath = join(testDir, '.env') - const lockPath = `${envPath}.nuxt-scripts.lock` - writeFileSync(lockPath, '') - - const persisted = new Promise((resolve) => { - setTimeout(() => { - writeFileSync(envPath, `${ENV_KEY}=shared-process-secret\n`) - rmSync(lockPath) - resolve() - }, 20) - }) - - const result = await resolveProxySecret(testDir, true) - await persisted - - expect(result).toEqual({ secret: 'shared-process-secret', ephemeral: false, source: 'dotenv-generated' }) - }) - - it('recovers a stale lock left by a crashed process', async () => { - const envPath = join(testDir, '.env') - const lockPath = `${envPath}.nuxt-scripts.lock` - writeFileSync(lockPath, '') - const staleTime = new Date(Date.now() - 3000) - utimesSync(lockPath, staleTime, staleTime) - - const result = await resolveProxySecret(testDir, true) - - expect(result?.source).toBe('dotenv-generated') - expect(result?.ephemeral).toBe(false) - expect(readFileSync(envPath, 'utf-8')).toContain(`${ENV_KEY}=${result?.secret}`) - }) - - it('replaces an empty persisted secret instead of returning an ephemeral value', async () => { - writeFileSync(join(testDir, '.env'), `${ENV_KEY}=\n`) - - const result = await resolveProxySecret(testDir, true) - - expect(result?.source).toBe('dotenv-generated') - expect(result?.secret).toHaveLength(64) - expect(readFileSync(join(testDir, '.env'), 'utf-8')).toBe(`${ENV_KEY}=${result?.secret}\n`) - }) - - it('replaces a whitespace-only persisted secret', async () => { - writeFileSync(join(testDir, '.env'), `${ENV_KEY}= \n`) - - const result = await resolveProxySecret(testDir, true) - - expect(result?.source).toBe('dotenv-generated') - expect(result?.secret).toHaveLength(64) - expect(readFileSync(join(testDir, '.env'), 'utf-8')).toBe(`${ENV_KEY}=${result?.secret}\n`) - }) - - it('populates process.env after generating a new secret', async () => { - await 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', async () => { - // Use a non-existent deeply nested path that can't be written - const result = await 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', async () => { - writeFileSync(join(testDir, '.env'), 'OTHER_VAR=value') - await 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 319836ba9..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('#nuxt-scripts/nitro', () => ({ - useRuntimeConfig: () => runtimeConfigMock.current, -})) - -// Import after installing the runtime config mock. -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) - }) -})