@@ -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'
1718import {
1819 addBuildPlugin ,
1920 addComponentsDir ,
@@ -114,6 +115,80 @@ function fixSelfClosingScriptComponents(nuxt: any) {
114115const UPPER_RE = / ( [ A - Z ] ) / g
115116const 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 = / ^ N U X T _ S C R I P T S _ P R O X Y _ S E C R E T = / m
120+ const PROXY_SECRET_ENV_VALUE_RE = / ^ N U X T _ S C R I P T S _ P R O X Y _ S E C R E T = ( .+ ) $ / 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+
117192export 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