Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
6 changes: 3 additions & 3 deletions docs/content/docs/1.guides/2.first-party.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,10 +355,10 @@ export default defineNuxtConfig({
})
```

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.
Disable security when you need a deterministic SSR payload, such as one used to compute a stable response `etag`. Without it, signed proxy endpoints still work but remain open to quota abuse and arbitrary requests to their allowlisted upstreams.

::callout{type="warning"}
The shared [image-proxy handler](https://github.com/nuxt/scripts/blob/main/packages/script/src/runtime/server/utils/image-proxy.ts) checks the initial URL's scheme and allowed hostname. Several embed image and asset routes then follow upstream redirects without checking each redirect target again. This is an implementation boundary, not evidence that a configured vendor host is exploitable: keep proxy security enabled and do not treat the initial-host allowlist as complete redirect-chain validation.
Runtime proxy fetches reject local, private, link-local, and reserved network targets. They also validate the initial upstream URL and every redirect target before requesting it. 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
Expand All @@ -381,7 +381,7 @@ Page tokens are valid for 1 hour by default. If a user leaves a tab open longer

**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 and redirect-validation boundaries described above.
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. Signed 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

Expand Down
1 change: 1 addition & 0 deletions packages/script/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@
"std-env": "catalog:",
"ufo": "catalog:",
"ultrahtml": "catalog:",
"undici": "catalog:",
"unplugin": "catalog:",
"unstorage": "catalog:",
"valibot": "catalog:"
Expand Down
119 changes: 98 additions & 21 deletions packages/script/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import type {
} 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 {
addBuildPlugin,
addComponentsDir,
Expand All @@ -41,6 +43,7 @@ import { generateInterceptPluginContents } from './plugins/intercept'
import { NuxtScriptBundleTransformer } from './plugins/transform'
import { aliasProxyValue, buildDomainAliasMap, invertAliasMap, isSafeAliasSegment } from './proxy-alias'
import { buildProxyConfigsFromRegistry, generatePartytownResolveUrl, getPartytownForwards, registry, resolveCapabilities } from './registry'
import { isPublicNetworkHostname } from './runtime/server/utils/network-host'
import { registerTypeTemplates, templatePlugin, templateTriggerResolver } from './templates'
import { validateScriptsEnvVars } from './validate-env'

Expand Down Expand Up @@ -120,8 +123,74 @@ 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_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<T>(envPath: string, effect: () => T): Promise<T> {
const lockPath = `${envPath}.nuxt-scripts.lock`
const deadline = Date.now() + PROXY_SECRET_LOCK_TIMEOUT_MS
let lockHandle: Awaited<ReturnType<typeof openFile>> | 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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export interface ResolvedProxySecret {
secret: string
Expand All @@ -140,12 +209,12 @@ export interface ResolvedProxySecret {
* 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(
export async function resolveProxySecret(
rootDir: string,
isDev: boolean,
configSecret?: string,
autoGenerate: boolean = true,
): ResolvedProxySecret | undefined {
): Promise<ResolvedProxySecret | undefined> {
if (configSecret)
return { secret: configSecret, ephemeral: false, source: 'config' }

Expand All @@ -164,25 +233,30 @@ export function resolveProxySecret(
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' }
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}`)
}
}
appendFileSync(envPath, contents.endsWith('\n') ? line : `\n${line}`)
}
else {
writeFileSync(envPath, `# Generated by @nuxt/scripts\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] = secret
return { secret, ephemeral: false, source: 'dotenv-generated' }
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
Expand Down Expand Up @@ -250,7 +324,10 @@ function resolveConfiguredProxyDomain(value: unknown): string | undefined {
return

try {
return new URL(trimmed, 'https://nuxt-scripts.local').hostname || undefined
const url = new URL(trimmed, 'https://nuxt-scripts.local')
if (url.protocol !== 'http:' && url.protocol !== 'https:')
return
return isPublicNetworkHostname(url.hostname) ? url.hostname : undefined
}
catch {
// Invalid user-provided proxy domains cannot be normalized.
Expand Down Expand Up @@ -1124,7 +1201,7 @@ export default defineNuxtModule<ModuleOptions>({
// Resolve the HMAC signing secret only when at least one handler needs it
// and a server runtime can actually verify signatures.
else if (anyHandlerRequiresSigning) {
const proxySecretResolved = resolveProxySecret(
const proxySecretResolved = await resolveProxySecret(
nuxt.options.rootDir,
!!nuxt.options.dev,
config.security?.secret,
Expand Down
6 changes: 3 additions & 3 deletions packages/script/src/plugins/intercept.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export function generateInterceptPluginContents(proxyPrefix: string, options?: {
enforce: 'pre',
setup() {
const proxyPrefix = ${JSON.stringify(proxyPrefix)};
const domainAliases = ${JSON.stringify(options?.domainAliases ?? {})};
const domainAliases = Object.assign(Object.create(null), ${JSON.stringify(options?.domainAliases ?? {})});
const origBeacon = typeof navigator !== 'undefined' && navigator.sendBeacon
? navigator.sendBeacon.bind(navigator)
: () => false;
Expand All @@ -27,11 +27,11 @@ export function generateInterceptPluginContents(proxyPrefix: string, options?: {
function proxyUrl(url) {
try {
const parsed = new URL(url, location.origin);
if (parsed.origin !== location.origin) {
if ((parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.origin !== location.origin) {
const seg = domainAliases[parsed.host] || parsed.host;
return location.origin + proxyPrefix + '/' + seg + parsed.pathname + parsed.search;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
} catch {}
} catch { /* Invalid URL inputs retain native behavior. */ }
return url;
}

Expand Down
13 changes: 5 additions & 8 deletions packages/script/src/proxy-alias.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const SAFE_ALIAS_SEGMENT_RE = /^[\w.-]+$/

/** Whether an explicit alias is a single URL-safe path segment. */
export function isSafeAliasSegment(alias: string): boolean {
return SAFE_ALIAS_SEGMENT_RE.test(alias)
return alias !== '.' && alias !== '..' && SAFE_ALIAS_SEGMENT_RE.test(alias)
}

/**
Expand All @@ -42,21 +42,18 @@ export function aliasForDomain(domain: string, alias: ProxyAliasConfig): string

/** Build a `domain β†’ alias` map for the given proxied domains. */
export function buildDomainAliasMap(domains: Iterable<string>, alias: ProxyAliasConfig): Record<string, string> {
const map: Record<string, string> = {}
const entries: Array<[string, string]> = []
for (const domain of domains) {
const value = aliasForDomain(domain, alias)
if (value)
map[domain] = value
entries.push([domain, value])
}
return map
return Object.fromEntries(entries)
}

/** Invert a `domain β†’ alias` map into the `alias β†’ domain` map the proxy handler resolves with. */
export function invertAliasMap(map: Record<string, string>): Record<string, string> {
const out: Record<string, string> = {}
for (const [domain, alias] of Object.entries(map))
out[alias] = domain
return out
return Object.fromEntries(Object.entries(map).map(([domain, alias]) => [alias, domain]))
}

/**
Expand Down
4 changes: 2 additions & 2 deletions packages/script/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -882,8 +882,8 @@ export async function registry(resolve?: (path: string) => Promise<string>): Pro
*/
export function generatePartytownResolveUrl(proxyPrefix: string, domainAliases: Record<string, string> = {}): string {
return `function(url, location, type) {
if (url.origin !== location.origin) {
var aliases = ${JSON.stringify(domainAliases)};
if ((url.protocol === 'http:' || url.protocol === 'https:') && url.origin !== location.origin) {
var aliases = Object.assign(Object.create(null), ${JSON.stringify(domainAliases)});
var seg = aliases[url.host] || url.host;
return new URL(${JSON.stringify(proxyPrefix)} + '/' + seg + url.pathname + url.search, location.origin);
}
Expand Down
11 changes: 10 additions & 1 deletion packages/script/src/runtime/server/bluesky-embed.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createError, defineEventHandler, getQuery, setHeader } from 'h3'
import { useRuntimeConfig } from 'nitropack/runtime'
import { createCachedJsonFetch } from './utils/cached-upstream'
import { createCachedJsonFetch, isSafeHttpsUrl } from './utils/cached-upstream'
import { rewriteBlueskyPostImages } from './utils/embed-rewriters'
import { withSigning } from './utils/withSigning'

Expand All @@ -25,6 +25,7 @@ interface PostThreadResponse {

const BSKY_POST_URL_RE = /^https:\/\/bsky\.app\/profile\/([^/]+)\/post\/([^/?]+)$/
const EMBED_BSKY_SUFFIX_RE = /\/embed\/bluesky$/
const allowBlueskyApiUrl = (url: URL) => isSafeHttpsUrl(url) && url.hostname === 'public.api.bsky.app'

// Handle β†’ DID resolution is stable for the lifetime of the handle (renames
// are rare); cache for 24h so repeated embeds of the same author skip the
Expand All @@ -33,6 +34,10 @@ const cachedProfileFetch = createCachedJsonFetch<{ did: string }>(
'nuxt-scripts-bsky-profile',
86400,
url => url,
{
allowUrl: allowBlueskyApiUrl,
contentTypePrefixes: ['application/json'],
},
)

// Post threads are semi-fresh (like counts, reply counts change); 10min keeps
Expand All @@ -41,6 +46,10 @@ const cachedPostFetch = createCachedJsonFetch<PostThreadResponse>(
'nuxt-scripts-bsky-post',
600,
url => url,
{
allowUrl: allowBlueskyApiUrl,
contentTypePrefixes: ['application/json'],
},
)

export default withSigning(defineEventHandler(async (event) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { createError, defineEventHandler, getQuery, setHeader } from 'h3'
import { useRuntimeConfig } from 'nitropack/runtime'
import { withQuery } from 'ufo'
import { createCachedJsonFetch } from './utils/cached-upstream'
import { createCachedJsonFetch, isSafeHttpsUrl } from './utils/cached-upstream'
import { stripProxyAuthQuery } from './utils/proxy-query'
import { withSigning } from './utils/withSigning'

// Addresses rarely change; a 30-day cache avoids billable geocode lookups for
Expand All @@ -11,6 +12,10 @@ const cachedGeocodeFetch = createCachedJsonFetch<any>(
'nuxt-scripts-geocode',
2592000,
url => url,
{
allowUrl: url => isSafeHttpsUrl(url) && url.hostname === 'maps.googleapis.com',
contentTypePrefixes: ['application/json'],
},
)

export default withSigning(defineEventHandler(async (event) => {
Expand All @@ -25,7 +30,7 @@ export default withSigning(defineEventHandler(async (event) => {
})
}

const query = getQuery(event)
const query = stripProxyAuthQuery(getQuery(event))
const { key: _clientKey, ...safeQuery } = query

const geocodeUrl = withQuery('https://maps.googleapis.com/maps/api/geocode/json', {
Expand Down
Loading
Loading