Skip to content

Commit e8434f1

Browse files
committed
feat(security): HMAC signing infrastructure for proxy endpoints
Adds the foundation for locking down proxy endpoints (Google Maps, Gravatar, embed image proxies) against cost/quota abuse. Signed URLs are generated server-side during SSR/prerender and verified at the endpoint so only legitimate requests reach upstream APIs. - sign/verify primitives with canonical query form and constant-time compare - withSigning handler wrapper (opt-in per endpoint via requiresSigning flag) - Secret management: env var > config > dev auto-generate to .env - /_scripts/sign endpoint for reactive client-side URLs (origin + rate-limited) - nuxt-scripts CLI with generate-secret command for explicit secret setup No existing endpoints are wired to signing yet; this PR is infrastructure only.
1 parent 3fa134f commit e8434f1

10 files changed

Lines changed: 729 additions & 2 deletions

File tree

packages/script/bin/cli.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
#!/usr/bin/env node
2+
import('../dist/cli.mjs')

packages/script/build.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export default defineBuildConfig({
66
'./src/registry',
77
'./src/stats',
88
'./src/types-source',
9+
'./src/cli',
910
],
1011
externals: [
1112
'nuxt',

packages/script/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,11 @@
4545
]
4646
}
4747
},
48+
"bin": {
49+
"nuxt-scripts": "./bin/cli.mjs"
50+
},
4851
"files": [
52+
"bin",
4953
"dist"
5054
],
5155
"scripts": {

packages/script/src/cli.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* @nuxt/scripts CLI.
3+
*
4+
* Currently hosts a single command — `generate-secret` — which produces a
5+
* cryptographically random HMAC secret for `NUXT_SCRIPTS_PROXY_SECRET`. This
6+
* is an alternative to letting the module auto-write a secret into `.env`,
7+
* for users who want explicit control (e.g. teams that commit secrets to a
8+
* vault rather than `.env`).
9+
*
10+
* Keep this file zero-dependency: it runs standalone via `npx @nuxt/scripts`
11+
* and should boot instantly.
12+
*/
13+
14+
import { randomBytes } from 'node:crypto'
15+
import process from 'node:process'
16+
17+
function generateSecret(): void {
18+
const secret = randomBytes(32).toString('hex')
19+
process.stdout.write(
20+
[
21+
'',
22+
' @nuxt/scripts — proxy signing secret',
23+
'',
24+
` Secret: ${secret}`,
25+
'',
26+
' Add this to your environment:',
27+
` NUXT_SCRIPTS_PROXY_SECRET=${secret}`,
28+
'',
29+
' The secret is automatically picked up by the module via runtime config.',
30+
' It must be the same across all deployments and prerender builds so that',
31+
' signed URLs remain valid.',
32+
'',
33+
'',
34+
].join('\n'),
35+
)
36+
}
37+
38+
function showHelp(): void {
39+
process.stdout.write(
40+
[
41+
'',
42+
' @nuxt/scripts CLI',
43+
'',
44+
' Usage: npx @nuxt/scripts <command>',
45+
'',
46+
' Commands:',
47+
' generate-secret Generate a signing secret for proxy URL tamper protection',
48+
' help Show this help',
49+
'',
50+
'',
51+
].join('\n'),
52+
)
53+
}
54+
55+
const command = process.argv[2]
56+
57+
if (!command || command === 'help' || command === '--help' || command === '-h') {
58+
showHelp()
59+
}
60+
else if (command === 'generate-secret') {
61+
generateSecret()
62+
}
63+
else {
64+
process.stderr.write(`Unknown command: ${command}\n`)
65+
showHelp()
66+
process.exit(1)
67+
}

packages/script/src/module.ts

Lines changed: 163 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ import type {
1313
RegistryScripts,
1414
ResolvedProxyAutoInject,
1515
} from './runtime/types'
16-
import { existsSync, readdirSync, readFileSync } from 'node:fs'
16+
import { randomBytes } from 'node:crypto'
17+
import { appendFileSync, existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
1718
import {
1819
addBuildPlugin,
1920
addComponentsDir,
@@ -114,6 +115,80 @@ function fixSelfClosingScriptComponents(nuxt: any) {
114115
const UPPER_RE = /([A-Z])/g
115116
const toScreamingSnake = (s: string) => s.replace(UPPER_RE, '_$1').toUpperCase()
116117

118+
const PROXY_SECRET_ENV_KEY = 'NUXT_SCRIPTS_PROXY_SECRET'
119+
const PROXY_SECRET_ENV_LINE_RE = /^NUXT_SCRIPTS_PROXY_SECRET=/m
120+
const PROXY_SECRET_ENV_VALUE_RE = /^NUXT_SCRIPTS_PROXY_SECRET=(.+)$/m
121+
const WILDCARD_SUFFIX_RE = /\/\*\*$/
122+
123+
export interface ResolvedProxySecret {
124+
secret: string
125+
/** True when the secret exists only in memory (dev-only fallback; won't survive restarts). */
126+
ephemeral: boolean
127+
/** Where the secret came from, for logging. */
128+
source: 'config' | 'env' | 'dotenv-generated' | 'memory-generated'
129+
}
130+
131+
/**
132+
* Resolve the HMAC signing secret used for proxy URL signing.
133+
*
134+
* Precedence:
135+
* 1. `scripts.security.secret` in nuxt.config
136+
* 2. `NUXT_SCRIPTS_PROXY_SECRET` env var
137+
* 3. Dev-only auto-generation: write to `.env` (or keep in memory as last resort)
138+
* 4. Empty string (prod without secret — caller decides whether this is fatal)
139+
*/
140+
export function resolveProxySecret(
141+
rootDir: string,
142+
isDev: boolean,
143+
configSecret?: string,
144+
autoGenerate: boolean = true,
145+
): ResolvedProxySecret | undefined {
146+
if (configSecret)
147+
return { secret: configSecret, ephemeral: false, source: 'config' }
148+
149+
const envSecret = process.env[PROXY_SECRET_ENV_KEY]
150+
if (envSecret)
151+
return { secret: envSecret, ephemeral: false, source: 'env' }
152+
153+
if (!isDev || !autoGenerate)
154+
return undefined
155+
156+
// Dev fallback: generate a 32-byte hex secret and try to persist to .env.
157+
// Persisting matters because the same dev machine restarts many times and
158+
// we don't want signed URLs cached in the browser to stop working across HMR.
159+
const secret = randomBytes(32).toString('hex')
160+
const envPath = resolvePath_(rootDir, '.env')
161+
const line = `${PROXY_SECRET_ENV_KEY}=${secret}\n`
162+
163+
try {
164+
if (existsSync(envPath)) {
165+
const contents = readFileSync(envPath, 'utf-8')
166+
// Safety: don't append if another process already wrote one between the read above
167+
// and this branch. The regex check is cheap and idempotent.
168+
if (PROXY_SECRET_ENV_LINE_RE.test(contents)) {
169+
// Another instance already wrote it — re-read and return that value
170+
const match = contents.match(PROXY_SECRET_ENV_VALUE_RE)
171+
if (match?.[1])
172+
return { secret: match[1].trim(), ephemeral: false, source: 'dotenv-generated' }
173+
}
174+
appendFileSync(envPath, contents.endsWith('\n') ? line : `\n${line}`)
175+
}
176+
else {
177+
writeFileSync(envPath, `# Generated by @nuxt/scripts\n${line}`)
178+
}
179+
// Also populate process.env so that anything reading it later in the same
180+
// dev process (e.g. child workers) sees the value without a restart.
181+
process.env[PROXY_SECRET_ENV_KEY] = secret
182+
return { secret, ephemeral: false, source: 'dotenv-generated' }
183+
}
184+
catch {
185+
// Writing .env failed (read-only FS, permission denied). Fall back to
186+
// in-memory only — URLs signed this session won't verify after restart.
187+
process.env[PROXY_SECRET_ENV_KEY] = secret
188+
return { secret, ephemeral: true, source: 'memory-generated' }
189+
}
190+
}
191+
117192
export function isProxyDisabled(
118193
registryKey: string,
119194
registry?: NuxtConfigScriptRegistry,
@@ -227,6 +302,38 @@ export interface ModuleOptions {
227302
*/
228303
integrity?: boolean | 'sha256' | 'sha384' | 'sha512'
229304
}
305+
/**
306+
* Proxy endpoint security.
307+
*
308+
* Several proxy endpoints (Google Static Maps, Geocode, Gravatar, embed image proxies)
309+
* inject server-side API keys or forward requests to third-party services. Without
310+
* signing, these are open to cost/quota abuse. Enable signing to require that only
311+
* URLs generated server-side (during SSR/prerender, or via `/_scripts/sign`) are
312+
* accepted.
313+
*
314+
* The secret must be deterministic across deployments so that prerendered URLs
315+
* remain valid. Set it via `NUXT_SCRIPTS_PROXY_SECRET` or `security.secret`.
316+
*/
317+
security?: {
318+
/**
319+
* HMAC secret used to sign proxy URLs.
320+
*
321+
* Falls back to `process.env.NUXT_SCRIPTS_PROXY_SECRET` if unset. In dev,
322+
* the module auto-generates a secret into your `.env` file when neither is
323+
* provided (disable via `autoGenerateSecret: false`). In production, a
324+
* missing secret is a fatal error when any signed endpoint is registered.
325+
*
326+
* Generate one with: `npx @nuxt/scripts generate-secret`
327+
*/
328+
secret?: string
329+
/**
330+
* Automatically generate and persist a signing secret to `.env` when running
331+
* `nuxt dev` without one configured.
332+
*
333+
* @default true
334+
*/
335+
autoGenerateSecret?: boolean
336+
}
230337
/**
231338
* Google Static Maps proxy configuration.
232339
* Proxies static map images through your server to fix CORS issues and enable caching.
@@ -374,11 +481,28 @@ export default defineNuxtModule<ModuleOptions>({
374481
)
375482
}
376483

484+
// Resolve the HMAC signing secret used to lock down proxy endpoints.
485+
// Deterministic across deploys is mandatory: signed URLs embedded in prerendered
486+
// HTML must still verify against the runtime server. The module auto-generates
487+
// and persists one into `.env` in dev so users don't hit friction on first run.
488+
const proxySecretResolved = resolveProxySecret(
489+
nuxt.options.rootDir,
490+
!!nuxt.options.dev,
491+
config.security?.secret,
492+
config.security?.autoGenerateSecret !== false,
493+
)
494+
if (proxySecretResolved?.source === 'dotenv-generated')
495+
logger.info(`[security] Generated ${PROXY_SECRET_ENV_KEY} in .env for signed proxy URLs.`)
496+
else if (proxySecretResolved?.source === 'memory-generated')
497+
logger.warn(`[security] Generated an in-memory ${PROXY_SECRET_ENV_KEY} (could not write .env). Signed URLs will break across restarts.`)
498+
377499
// Setup runtimeConfig for proxies and devtools.
378500
// Must run AFTER env var resolution above so the API key is populated.
379501
const googleMapsEnabled = config.googleStaticMapsProxy?.enabled || !!config.registry?.googleMaps
380502
nuxt.options.runtimeConfig['nuxt-scripts'] = {
381503
version: version!,
504+
// HMAC secret for signed proxy URLs (server-only private config)
505+
proxySecret: proxySecretResolved?.secret || '',
382506
// Private proxy config with API key (server-side only)
383507
googleStaticMapsProxy: googleMapsEnabled
384508
? { apiKey: (nuxt.options.runtimeConfig.public.scripts as any)?.googleMaps?.apiKey }
@@ -697,6 +821,9 @@ export default defineNuxtModule<ModuleOptions>({
697821
// Register server handlers for enabled registry scripts
698822
const scriptsPrefix = config.prefix || '/_scripts'
699823
const enabledEndpoints: Record<string, boolean> = {}
824+
/** Signable routes that the `/_scripts/sign` endpoint is allowed to sign. */
825+
const signableRoutes: string[] = []
826+
let anyHandlerRequiresSigning = false
700827
for (const script of scripts) {
701828
if (!script.serverHandlers?.length || !script.registryKey)
702829
continue
@@ -711,11 +838,17 @@ export default defineNuxtModule<ModuleOptions>({
711838

712839
enabledEndpoints[script.registryKey] = true
713840
for (const handler of script.serverHandlers) {
841+
const resolvedRoute = handler.route.replace('/_scripts', scriptsPrefix)
714842
addServerHandler({
715-
route: handler.route.replace('/_scripts', scriptsPrefix),
843+
route: resolvedRoute,
716844
handler: handler.handler,
717845
middleware: handler.middleware,
718846
})
847+
if (handler.requiresSigning) {
848+
anyHandlerRequiresSigning = true
849+
// Store the non-wildcard prefix so `/sign` can exact-match against it.
850+
signableRoutes.push(resolvedRoute.replace(WILDCARD_SUFFIX_RE, ''))
851+
}
719852
}
720853

721854
// Script-specific runtimeConfig setup
@@ -740,5 +873,33 @@ export default defineNuxtModule<ModuleOptions>({
740873
{ endpoints: enabledEndpoints },
741874
nuxt.options.runtimeConfig.public['nuxt-scripts'] as any,
742875
) as any
876+
877+
// Fail hard if a signed endpoint is enabled in production without a secret.
878+
// Dev falls back to auto-generated secrets above, so this only trips real
879+
// deployments that forgot to set the env var.
880+
if (anyHandlerRequiresSigning && !proxySecretResolved?.secret && !nuxt.options.dev) {
881+
throw new Error(
882+
`[@nuxt/scripts] ${PROXY_SECRET_ENV_KEY} is required in production when signed proxy endpoints are enabled.\n`
883+
+ 'Generate one with: npx @nuxt/scripts generate-secret\n'
884+
+ `Then set the env var: ${PROXY_SECRET_ENV_KEY}=<secret>`,
885+
)
886+
}
887+
888+
// Publish the signable routes list to server runtime so `/sign` knows what
889+
// paths it's allowed to sign on behalf of clients.
890+
if (anyHandlerRequiresSigning) {
891+
nuxt.options.runtimeConfig['nuxt-scripts'] = defu(
892+
{ signableRoutes },
893+
nuxt.options.runtimeConfig['nuxt-scripts'] as any,
894+
) as any
895+
896+
// Register the `/_scripts/sign` endpoint so reactive client-side URL
897+
// changes (e.g. Google Static Maps size recomputed on mount) can get a
898+
// fresh signature without exposing the secret.
899+
addServerHandler({
900+
route: `${scriptsPrefix}/sign`,
901+
handler: await resolvePath('./runtime/server/sign-proxy'),
902+
})
903+
}
743904
},
744905
})

0 commit comments

Comments
 (0)