Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/script/bin/cli.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/usr/bin/env node
import('../dist/cli.mjs')
1 change: 1 addition & 0 deletions packages/script/build.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export default defineBuildConfig({
'./src/registry',
'./src/stats',
'./src/types-source',
'./src/cli',
],
externals: [
'nuxt',
Expand Down
4 changes: 4 additions & 0 deletions packages/script/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@
]
}
},
"bin": {
"nuxt-scripts": "./bin/cli.mjs"
},
"files": [
"bin",
"dist"
],
"scripts": {
Expand Down
67 changes: 67 additions & 0 deletions packages/script/src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* @nuxt/scripts CLI.
*
* Currently hosts a single command, `generate-secret`, which produces a
* cryptographically random HMAC secret for `NUXT_SCRIPTS_PROXY_SECRET`. This
* is an alternative to letting the module auto-write a secret into `.env`,
* for users who want explicit control (e.g. teams that commit secrets to a
* vault rather than `.env`).
*
* Keep this file zero-dependency: it runs standalone via `npx @nuxt/scripts`
* and should boot instantly.
*/

import { randomBytes } from 'node:crypto'
import process from 'node:process'

function generateSecret(): void {
const secret = randomBytes(32).toString('hex')
process.stdout.write(
[
'',
' @nuxt/scripts: proxy signing secret',
'',
` Secret: ${secret}`,
'',
' Add this to your environment:',
` NUXT_SCRIPTS_PROXY_SECRET=${secret}`,
'',
' The secret is automatically picked up by the module via runtime config.',
' It must be the same across all deployments and prerender builds so that',
' signed URLs remain valid.',
'',
'',
].join('\n'),
)
}

function showHelp(): void {
process.stdout.write(
[
'',
' @nuxt/scripts CLI',
'',
' Usage: npx @nuxt/scripts <command>',
'',
' Commands:',
' generate-secret Generate a signing secret for proxy URL tamper protection',
' help Show this help',
'',
'',
].join('\n'),
)
}

const command = process.argv[2]

if (!command || command === 'help' || command === '--help' || command === '-h') {
showHelp()
}
else if (command === 'generate-secret') {
generateSecret()
}
else {
process.stderr.write(`Unknown command: ${command}\n`)
showHelp()
process.exit(1)
}
142 changes: 140 additions & 2 deletions packages/script/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import type {
RegistryScripts,
ResolvedProxyAutoInject,
} from './runtime/types'
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { randomBytes } from 'node:crypto'
import { appendFileSync, existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
import {
addBuildPlugin,
addComponentsDir,
Expand Down Expand Up @@ -114,6 +115,79 @@ function fixSelfClosingScriptComponents(nuxt: any) {
const UPPER_RE = /([A-Z])/g
const toScreamingSnake = (s: string) => s.replace(UPPER_RE, '_$1').toUpperCase()

const PROXY_SECRET_ENV_KEY = 'NUXT_SCRIPTS_PROXY_SECRET'
const PROXY_SECRET_ENV_LINE_RE = /^NUXT_SCRIPTS_PROXY_SECRET=/m
const PROXY_SECRET_ENV_VALUE_RE = /^NUXT_SCRIPTS_PROXY_SECRET=(.+)$/m
Comment thread
harlan-zw marked this conversation as resolved.

export interface ResolvedProxySecret {
secret: string
/** True when the secret exists only in memory (dev-only fallback; won't survive restarts). */
ephemeral: boolean
/** Where the secret came from, for logging. */
source: 'config' | 'env' | 'dotenv-generated' | 'memory-generated'
}

/**
* Resolve the HMAC signing secret used for proxy URL signing.
*
* Precedence:
* 1. `scripts.security.secret` in nuxt.config
* 2. `NUXT_SCRIPTS_PROXY_SECRET` env var
* 3. Dev-only auto-generation: write to `.env` (or keep in memory as last resort)
* 4. Empty string (prod without secret; caller decides whether this is fatal)
*/
export function resolveProxySecret(
rootDir: string,
isDev: boolean,
configSecret?: string,
autoGenerate: boolean = true,
): ResolvedProxySecret | undefined {
if (configSecret)
return { secret: configSecret, ephemeral: false, source: 'config' }

const envSecret = process.env[PROXY_SECRET_ENV_KEY]
if (envSecret)
return { secret: envSecret, ephemeral: false, source: 'env' }

if (!isDev || !autoGenerate)
return undefined

// Dev fallback: generate a 32-byte hex secret and try to persist to .env.
// Persisting matters because the same dev machine restarts many times and
// we don't want signed URLs cached in the browser to stop working across HMR.
const secret = randomBytes(32).toString('hex')
const envPath = resolvePath_(rootDir, '.env')
const line = `${PROXY_SECRET_ENV_KEY}=${secret}\n`

try {
if (existsSync(envPath)) {
const contents = readFileSync(envPath, 'utf-8')
// Safety: don't append if another process already wrote one between the read above
// and this branch. The regex check is cheap and idempotent.
if (PROXY_SECRET_ENV_LINE_RE.test(contents)) {
// Another instance already wrote it. Re-read and return that value.
const match = contents.match(PROXY_SECRET_ENV_VALUE_RE)
if (match?.[1])
return { secret: match[1].trim(), ephemeral: false, source: 'dotenv-generated' }
}
appendFileSync(envPath, contents.endsWith('\n') ? line : `\n${line}`)
}
else {
writeFileSync(envPath, `# Generated by @nuxt/scripts\n${line}`)
}
// Also populate process.env so that anything reading it later in the same
// dev process (e.g. child workers) sees the value without a restart.
process.env[PROXY_SECRET_ENV_KEY] = secret
return { secret, ephemeral: false, source: 'dotenv-generated' }
}
catch {
// Writing .env failed (read-only FS, permission denied). Fall back to
// in-memory only; URLs signed this session won't verify after restart.
process.env[PROXY_SECRET_ENV_KEY] = secret
return { secret, ephemeral: true, source: 'memory-generated' }
}
}

export function isProxyDisabled(
registryKey: string,
registry?: NuxtConfigScriptRegistry,
Expand Down Expand Up @@ -227,6 +301,38 @@ export interface ModuleOptions {
*/
integrity?: boolean | 'sha256' | 'sha384' | 'sha512'
}
/**
* Proxy endpoint security.
*
* Several proxy endpoints (Google Static Maps, Geocode, Gravatar, embed image proxies)
* inject server-side API keys or forward requests to third-party services. Without
* signing, these are open to cost/quota abuse. Enable signing to require that only
* URLs generated server-side (during SSR/prerender, or via `/_scripts/sign`) are
* accepted.
*
* The secret must be deterministic across deployments so that prerendered URLs
* remain valid. Set it via `NUXT_SCRIPTS_PROXY_SECRET` or `security.secret`.
*/
security?: {
/**
* HMAC secret used to sign proxy URLs.
*
* Falls back to `process.env.NUXT_SCRIPTS_PROXY_SECRET` if unset. In dev,
* the module auto-generates a secret into your `.env` file when neither is
* provided (disable via `autoGenerateSecret: false`). In production, a
* missing secret is a fatal error when any signed endpoint is registered.
*
* Generate one with: `npx @nuxt/scripts generate-secret`
*/
secret?: string
/**
* Automatically generate and persist a signing secret to `.env` when running
* `nuxt dev` without one configured.
*
* @default true
*/
autoGenerateSecret?: boolean
}
/**
* Google Static Maps proxy configuration.
* Proxies static map images through your server to fix CORS issues and enable caching.
Expand Down Expand Up @@ -374,11 +480,28 @@ export default defineNuxtModule<ModuleOptions>({
)
}

// Resolve the HMAC signing secret used to lock down proxy endpoints.
// Deterministic across deploys is mandatory: signed URLs embedded in prerendered
// HTML must still verify against the runtime server. The module auto-generates
// and persists one into `.env` in dev so users don't hit friction on first run.
const proxySecretResolved = resolveProxySecret(
nuxt.options.rootDir,
!!nuxt.options.dev,
config.security?.secret,
config.security?.autoGenerateSecret !== false,
)
if (proxySecretResolved?.source === 'dotenv-generated')
logger.info(`[security] Generated ${PROXY_SECRET_ENV_KEY} in .env for signed proxy URLs.`)
else if (proxySecretResolved?.source === 'memory-generated')
logger.warn(`[security] Generated an in-memory ${PROXY_SECRET_ENV_KEY} (could not write .env). Signed URLs will break across restarts.`)

// 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!,
// HMAC secret for signed proxy URLs (server-only private config)
proxySecret: proxySecretResolved?.secret || '',
// Private proxy config with API key (server-side only)
googleStaticMapsProxy: googleMapsEnabled
? { apiKey: (nuxt.options.runtimeConfig.public.scripts as any)?.googleMaps?.apiKey }
Expand Down Expand Up @@ -697,6 +820,7 @@ export default defineNuxtModule<ModuleOptions>({
// Register server handlers for enabled registry scripts
const scriptsPrefix = config.prefix || '/_scripts'
const enabledEndpoints: Record<string, boolean> = {}
let anyHandlerRequiresSigning = false
for (const script of scripts) {
if (!script.serverHandlers?.length || !script.registryKey)
continue
Expand All @@ -711,11 +835,14 @@ export default defineNuxtModule<ModuleOptions>({

enabledEndpoints[script.registryKey] = true
for (const handler of script.serverHandlers) {
const resolvedRoute = handler.route.replace('/_scripts', scriptsPrefix)
addServerHandler({
route: handler.route.replace('/_scripts', scriptsPrefix),
route: resolvedRoute,
handler: handler.handler,
middleware: handler.middleware,
})
if (handler.requiresSigning)
anyHandlerRequiresSigning = true
}

// Script-specific runtimeConfig setup
Expand All @@ -740,5 +867,16 @@ export default defineNuxtModule<ModuleOptions>({
{ endpoints: enabledEndpoints },
nuxt.options.runtimeConfig.public['nuxt-scripts'] as any,
) as any

// Fail hard if a signed endpoint is enabled in production without a secret.
// Dev falls back to auto-generated secrets above, so this only trips real
// deployments that forgot to set the env var.
if (anyHandlerRequiresSigning && !proxySecretResolved?.secret && !nuxt.options.dev) {
throw new Error(
`[@nuxt/scripts] ${PROXY_SECRET_ENV_KEY} is required in production when signed proxy endpoints are enabled.\n`
+ 'Generate one with: npx @nuxt/scripts generate-secret\n'
+ `Then set the env var: ${PROXY_SECRET_ENV_KEY}=<secret>`,
)
}
},
})
20 changes: 10 additions & 10 deletions packages/script/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -621,8 +621,8 @@ export async function registry(resolve?: (path: string) => Promise<string>): Pro
envDefaults: { apiKey: '' },
category: 'content',
serverHandlers: [
{ route: '/_scripts/proxy/google-static-maps', handler: './runtime/server/google-static-maps-proxy' },
{ route: '/_scripts/proxy/google-maps-geocode', handler: './runtime/server/google-maps-geocode-proxy' },
{ route: '/_scripts/proxy/google-static-maps', handler: './runtime/server/google-static-maps-proxy', requiresSigning: true },
{ route: '/_scripts/proxy/google-maps-geocode', handler: './runtime/server/google-maps-geocode-proxy', requiresSigning: true },
],
}),
def('blueskyEmbed', {
Expand All @@ -631,8 +631,8 @@ export async function registry(resolve?: (path: string) => Promise<string>): Pro
label: 'Bluesky Embed',
category: 'content',
serverHandlers: [
{ route: '/_scripts/embed/bluesky', handler: './runtime/server/bluesky-embed' },
{ route: '/_scripts/embed/bluesky-image', handler: './runtime/server/bluesky-embed-image' },
{ route: '/_scripts/embed/bluesky', handler: './runtime/server/bluesky-embed', requiresSigning: true },
{ route: '/_scripts/embed/bluesky-image', handler: './runtime/server/bluesky-embed-image', requiresSigning: true },
],
}),
def('instagramEmbed', {
Expand All @@ -641,9 +641,9 @@ export async function registry(resolve?: (path: string) => Promise<string>): Pro
label: 'Instagram Embed',
category: 'content',
serverHandlers: [
{ route: '/_scripts/embed/instagram', handler: './runtime/server/instagram-embed' },
{ route: '/_scripts/embed/instagram-image', handler: './runtime/server/instagram-embed-image' },
{ route: '/_scripts/embed/instagram-asset', handler: './runtime/server/instagram-embed-asset' },
{ route: '/_scripts/embed/instagram', handler: './runtime/server/instagram-embed', requiresSigning: true },
{ route: '/_scripts/embed/instagram-image', handler: './runtime/server/instagram-embed-image', requiresSigning: true },
{ route: '/_scripts/embed/instagram-asset', handler: './runtime/server/instagram-embed-asset', requiresSigning: true },
],
}),
def('xEmbed', {
Expand All @@ -652,8 +652,8 @@ export async function registry(resolve?: (path: string) => Promise<string>): Pro
label: 'X Embed',
category: 'content',
serverHandlers: [
{ route: '/_scripts/embed/x', handler: './runtime/server/x-embed' },
{ route: '/_scripts/embed/x-image', handler: './runtime/server/x-embed-image' },
{ route: '/_scripts/embed/x', handler: './runtime/server/x-embed', requiresSigning: true },
{ route: '/_scripts/embed/x-image', handler: './runtime/server/x-embed-image', requiresSigning: true },
],
}),
// support
Expand Down Expand Up @@ -757,7 +757,7 @@ export async function registry(resolve?: (path: string) => Promise<string>): Pro
privacy: PRIVACY_IP_ONLY,
},
serverHandlers: [
{ route: '/_scripts/proxy/gravatar', handler: './runtime/server/gravatar-proxy' },
{ route: '/_scripts/proxy/gravatar', handler: './runtime/server/gravatar-proxy', requiresSigning: true },
],
}),
])
Expand Down
5 changes: 3 additions & 2 deletions packages/script/src/runtime/server/bluesky-embed.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createError, defineEventHandler, getQuery, setHeader } from 'h3'
import { $fetch } from 'ofetch'
import { withSigning } from './utils/withSigning'

interface PostThreadResponse {
thread: {
Expand All @@ -22,7 +23,7 @@ interface PostThreadResponse {

const BSKY_POST_URL_RE = /^https:\/\/bsky\.app\/profile\/([^/]+)\/post\/([^/?]+)$/

export default defineEventHandler(async (event) => {
export default withSigning(defineEventHandler(async (event) => {
const query = getQuery(event)
const postUrl = query.url as string

Expand Down Expand Up @@ -92,4 +93,4 @@ export default defineEventHandler(async (event) => {
setHeader(event, 'Cache-Control', 'public, max-age=600, s-maxage=600')

return post
})
}))
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { useRuntimeConfig } from '#imports'
import { createError, defineEventHandler, getQuery, setHeader } from 'h3'
import { $fetch } from 'ofetch'
import { withQuery } from 'ufo'
import { withSigning } from './utils/withSigning'

export default defineEventHandler(async (event) => {
export default withSigning(defineEventHandler(async (event) => {
const runtimeConfig = useRuntimeConfig()
const privateConfig = (runtimeConfig['nuxt-scripts'] as any)?.googleMapsGeocodeProxy

Expand Down Expand Up @@ -38,4 +39,4 @@ export default defineEventHandler(async (event) => {
setHeader(event, 'Cache-Control', 'public, max-age=86400, s-maxage=86400')

return data
})
}))
Loading
Loading