Skip to content

Commit cd17e4b

Browse files
committed
fix(proxy): block unsafe redirects and local targets (#840)
1 parent 35ca671 commit cd17e4b

37 files changed

Lines changed: 2343 additions & 289 deletions

docs/content/docs/1.guides/2.first-party.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ export default defineNuxtConfig({
358358
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.
359359

360360
::callout{type="warning"}
361-
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.
361+
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.
362362
::
363363

364364
#### Troubleshooting
@@ -381,7 +381,7 @@ Page tokens are valid for 1 hour by default. If a user leaves a tab open longer
381381

382382
**Proxy token changes the response payload on every request**
383383

384-
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.
384+
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.
385385

386386
#### Static Generation and SPA Mode
387387

packages/script/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@
130130
"std-env": "catalog:",
131131
"ufo": "catalog:",
132132
"ultrahtml": "catalog:",
133+
"undici": "catalog:",
133134
"unplugin": "catalog:",
134135
"unstorage": "catalog:",
135136
"valibot": "catalog:"

packages/script/src/module.ts

Lines changed: 98 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import type {
1616
} from './runtime/types'
1717
import { randomBytes } from 'node:crypto'
1818
import { appendFileSync, existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
19+
import { open as openFile, stat, unlink } from 'node:fs/promises'
20+
import { setTimeout as delay } from 'node:timers/promises'
1921
import {
2022
addBuildPlugin,
2123
addComponentsDir,
@@ -42,6 +44,7 @@ import { generateInterceptPluginContents } from './plugins/intercept'
4244
import { NuxtScriptBundleTransformer } from './plugins/transform'
4345
import { aliasProxyValue, buildDomainAliasMap, invertAliasMap, isSafeAliasSegment } from './proxy-alias'
4446
import { buildProxyConfigsFromRegistry, generatePartytownResolveUrl, getPartytownForwards, registry, resolveCapabilities } from './registry'
47+
import { isPublicNetworkHostname } from './runtime/server/utils/network-host'
4548
import { registerTypeTemplates, templatePlugin, templateTriggerResolver } from './templates'
4649
import { validateScriptsEnvVars } from './validate-env'
4750

@@ -121,8 +124,74 @@ const UPPER_RE = /([A-Z])/g
121124
const toScreamingSnake = (s: string) => s.replace(UPPER_RE, '_$1').toUpperCase()
122125

123126
const PROXY_SECRET_ENV_KEY = 'NUXT_SCRIPTS_PROXY_SECRET'
124-
const PROXY_SECRET_ENV_LINE_RE = /^NUXT_SCRIPTS_PROXY_SECRET=/m
127+
const PROXY_SECRET_ENV_LINE_RE = /^NUXT_SCRIPTS_PROXY_SECRET=.*$/m
125128
const PROXY_SECRET_ENV_VALUE_RE = /^NUXT_SCRIPTS_PROXY_SECRET=(.+)$/m
129+
const PROXY_SECRET_LOCK_RETRY_MS = 10
130+
const PROXY_SECRET_LOCK_TIMEOUT_MS = 2000
131+
132+
async function withProxySecretFileLock<T>(envPath: string, effect: () => T): Promise<T> {
133+
const lockPath = `${envPath}.nuxt-scripts.lock`
134+
const deadline = Date.now() + PROXY_SECRET_LOCK_TIMEOUT_MS
135+
let lockHandle: Awaited<ReturnType<typeof openFile>> | undefined
136+
137+
while (!lockHandle) {
138+
const acquisition = await openFile(lockPath, 'wx')
139+
.then(handle => ({ _tag: 'Acquired' as const, handle }))
140+
.catch((error: NodeJS.ErrnoException) => {
141+
if (error.code === 'EEXIST')
142+
return { _tag: 'Busy' as const }
143+
throw error
144+
})
145+
146+
if (acquisition._tag === 'Acquired') {
147+
lockHandle = acquisition.handle
148+
break
149+
}
150+
151+
const existingLock = await stat(lockPath)
152+
.then(lockStat => ({ _tag: 'Found' as const, mtimeMs: lockStat.mtimeMs }))
153+
.catch((error: NodeJS.ErrnoException) => {
154+
if (error.code === 'ENOENT')
155+
return { _tag: 'Missing' as const }
156+
throw error
157+
})
158+
if (existingLock._tag === 'Found' && Date.now() - existingLock.mtimeMs >= PROXY_SECRET_LOCK_TIMEOUT_MS) {
159+
await unlink(lockPath).catch((error: NodeJS.ErrnoException) => {
160+
if (error.code !== 'ENOENT')
161+
throw error
162+
})
163+
continue
164+
}
165+
if (Date.now() >= deadline)
166+
throw Object.assign(new Error('Timed out waiting for proxy secret file lock'), { code: 'ETIMEDOUT' })
167+
await delay(PROXY_SECRET_LOCK_RETRY_MS)
168+
}
169+
170+
let effectResult: { _tag: 'Success', value: T } | { _tag: 'Failure', error: unknown }
171+
try {
172+
effectResult = { _tag: 'Success', value: effect() }
173+
}
174+
catch (error) {
175+
effectResult = { _tag: 'Failure', error }
176+
}
177+
178+
const closeResult = await lockHandle.close()
179+
.then(() => ({ _tag: 'Success' as const }))
180+
.catch((error: Error) => ({ _tag: 'Failure' as const, error }))
181+
const unlinkResult = await unlink(lockPath)
182+
.then(() => ({ _tag: 'Success' as const }))
183+
.catch((error: NodeJS.ErrnoException) => error.code === 'ENOENT'
184+
? { _tag: 'Success' as const }
185+
: { _tag: 'Failure' as const, error })
186+
187+
if (closeResult._tag === 'Failure')
188+
logger.warn(`[security] Failed to close the proxy secret lock: ${closeResult.error.message}`)
189+
if (unlinkResult._tag === 'Failure')
190+
logger.warn(`[security] Failed to remove the proxy secret lock: ${unlinkResult.error.message}`)
191+
if (effectResult._tag === 'Failure')
192+
throw effectResult.error
193+
return effectResult.value
194+
}
126195

127196
export interface ResolvedProxySecret {
128197
secret: string
@@ -141,12 +210,12 @@ export interface ResolvedProxySecret {
141210
* 3. Dev-only auto-generation: write to `.env` (or keep in memory as last resort)
142211
* 4. Empty string (prod without secret; caller decides whether this is fatal)
143212
*/
144-
export function resolveProxySecret(
213+
export async function resolveProxySecret(
145214
rootDir: string,
146215
isDev: boolean,
147216
configSecret?: string,
148217
autoGenerate: boolean = true,
149-
): ResolvedProxySecret | undefined {
218+
): Promise<ResolvedProxySecret | undefined> {
150219
if (configSecret)
151220
return { secret: configSecret, ephemeral: false, source: 'config' }
152221

@@ -165,25 +234,30 @@ export function resolveProxySecret(
165234
const line = `${PROXY_SECRET_ENV_KEY}=${secret}\n`
166235

167236
try {
168-
if (existsSync(envPath)) {
169-
const contents = readFileSync(envPath, 'utf-8')
170-
// Safety: don't append if another process already wrote one between the read above
171-
// and this branch. The regex check is cheap and idempotent.
172-
if (PROXY_SECRET_ENV_LINE_RE.test(contents)) {
173-
// Another instance already wrote it. Re-read and return that value.
174-
const match = contents.match(PROXY_SECRET_ENV_VALUE_RE)
175-
if (match?.[1])
176-
return { secret: match[1].trim(), ephemeral: false, source: 'dotenv-generated' }
237+
const persistedSecret = await withProxySecretFileLock(envPath, () => {
238+
if (existsSync(envPath)) {
239+
const contents = readFileSync(envPath, 'utf-8')
240+
const existingSecret = contents.match(PROXY_SECRET_ENV_VALUE_RE)?.[1]?.trim()
241+
if (existingSecret)
242+
return existingSecret
243+
if (PROXY_SECRET_ENV_LINE_RE.test(contents)) {
244+
// An empty declaration suppresses dotenv fallback on future starts.
245+
// Replace it in place so the generated secret remains stable.
246+
writeFileSync(envPath, contents.replace(PROXY_SECRET_ENV_LINE_RE, `${PROXY_SECRET_ENV_KEY}=${secret}`))
247+
}
248+
else {
249+
appendFileSync(envPath, contents.endsWith('\n') ? line : `\n${line}`)
250+
}
177251
}
178-
appendFileSync(envPath, contents.endsWith('\n') ? line : `\n${line}`)
179-
}
180-
else {
181-
writeFileSync(envPath, `# Generated by @nuxt/scripts\n${line}`)
182-
}
252+
else {
253+
writeFileSync(envPath, `# Generated by @nuxt/scripts\n${line}`)
254+
}
255+
return secret
256+
})
183257
// Also populate process.env so that anything reading it later in the same
184258
// dev process (e.g. child workers) sees the value without a restart.
185-
process.env[PROXY_SECRET_ENV_KEY] = secret
186-
return { secret, ephemeral: false, source: 'dotenv-generated' }
259+
process.env[PROXY_SECRET_ENV_KEY] = persistedSecret
260+
return { secret: persistedSecret, ephemeral: false, source: 'dotenv-generated' }
187261
}
188262
catch {
189263
// Writing .env failed (read-only FS, permission denied). Fall back to
@@ -251,7 +325,10 @@ function resolveConfiguredProxyDomain(value: unknown): string | undefined {
251325
return
252326

253327
try {
254-
return new URL(trimmed, 'https://nuxt-scripts.local').hostname || undefined
328+
const url = new URL(trimmed, 'https://nuxt-scripts.local')
329+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
330+
return
331+
return isPublicNetworkHostname(url.hostname) ? url.hostname : undefined
255332
}
256333
catch {
257334
// Invalid user-provided proxy domains cannot be normalized.
@@ -1126,7 +1203,7 @@ export default defineNuxtModule<ModuleOptions>({
11261203
// Resolve the HMAC signing secret only when at least one handler needs it
11271204
// and a server runtime can actually verify signatures.
11281205
else if (anyHandlerRequiresSigning) {
1129-
const proxySecretResolved = resolveProxySecret(
1206+
const proxySecretResolved = await resolveProxySecret(
11301207
nuxt.options.rootDir,
11311208
!!nuxt.options.dev,
11321209
config.security?.secret,

packages/script/src/plugins/intercept.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export default defineNuxtPlugin({
2020
enforce: 'pre',
2121
setup() {
2222
const proxyPrefix = ${JSON.stringify(proxyPrefix)};
23-
const domainAliases = ${JSON.stringify(options?.domainAliases ?? {})};
23+
const domainAliases = Object.assign(Object.create(null), ${JSON.stringify(options?.domainAliases ?? {})});
2424
const origBeacon = typeof navigator !== 'undefined' && navigator.sendBeacon
2525
? navigator.sendBeacon.bind(navigator)
2626
: () => false;
@@ -29,11 +29,11 @@ export default defineNuxtPlugin({
2929
function proxyUrl(url) {
3030
try {
3131
const parsed = new URL(url, location.origin);
32-
if (parsed.origin !== location.origin) {
32+
if ((parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.origin !== location.origin) {
3333
const seg = domainAliases[parsed.host] || parsed.host;
3434
return location.origin + proxyPrefix + '/' + seg + parsed.pathname + parsed.search;
3535
}
36-
} catch {}
36+
} catch { /* Invalid URL inputs retain native behavior. */ }
3737
return url;
3838
}
3939

packages/script/src/proxy-alias.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const SAFE_ALIAS_SEGMENT_RE = /^[\w.-]+$/
1818

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

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

4343
/** Build a `domain → alias` map for the given proxied domains. */
4444
export function buildDomainAliasMap(domains: Iterable<string>, alias: ProxyAliasConfig): Record<string, string> {
45-
const map: Record<string, string> = {}
45+
const entries: Array<[string, string]> = []
4646
for (const domain of domains) {
4747
const value = aliasForDomain(domain, alias)
4848
if (value)
49-
map[domain] = value
49+
entries.push([domain, value])
5050
}
51-
return map
51+
return Object.fromEntries(entries)
5252
}
5353

5454
/** Invert a `domain → alias` map into the `alias → domain` map the proxy handler resolves with. */
5555
export function invertAliasMap(map: Record<string, string>): Record<string, string> {
56-
const out: Record<string, string> = {}
57-
for (const [domain, alias] of Object.entries(map))
58-
out[alias] = domain
59-
return out
56+
return Object.fromEntries(Object.entries(map).map(([domain, alias]) => [alias, domain]))
6057
}
6158

6259
/**

packages/script/src/registry.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -882,8 +882,8 @@ export async function registry(resolve?: (path: string) => Promise<string>): Pro
882882
*/
883883
export function generatePartytownResolveUrl(proxyPrefix: string, domainAliases: Record<string, string> = {}): string {
884884
return `function(url, location, type) {
885-
if (url.origin !== location.origin) {
886-
var aliases = ${JSON.stringify(domainAliases)};
885+
if ((url.protocol === 'http:' || url.protocol === 'https:') && url.origin !== location.origin) {
886+
var aliases = Object.assign(Object.create(null), ${JSON.stringify(domainAliases)});
887887
var seg = aliases[url.host] || url.host;
888888
return new URL(${JSON.stringify(proxyPrefix)} + '/' + seg + url.pathname + url.search, location.origin);
889889
}

packages/script/src/runtime/server/bluesky-embed.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createError, defineEventHandler, getQuery, setHeader } from '#nuxt-scripts/h3'
22
import { useRuntimeConfig } from '#nuxt-scripts/nitro'
3-
import { createCachedJsonFetch } from './utils/cached-upstream'
3+
import { createCachedJsonFetch, isSafeHttpsUrl } from './utils/cached-upstream'
44
import { rewriteBlueskyPostImages } from './utils/embed-rewriters'
55
import { withSigning } from './utils/withSigning'
66

@@ -25,6 +25,7 @@ interface PostThreadResponse {
2525

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

2930
// Handle → DID resolution is stable for the lifetime of the handle (renames
3031
// are rare); cache for 24h so repeated embeds of the same author skip the
@@ -33,6 +34,10 @@ const cachedProfileFetch = createCachedJsonFetch<{ did: string }>(
3334
'nuxt-scripts-bsky-profile',
3435
86400,
3536
url => url,
37+
{
38+
allowUrl: allowBlueskyApiUrl,
39+
contentTypePrefixes: ['application/json'],
40+
},
3641
)
3742

3843
// Post threads are semi-fresh (like counts, reply counts change); 10min keeps
@@ -41,6 +46,10 @@ const cachedPostFetch = createCachedJsonFetch<PostThreadResponse>(
4146
'nuxt-scripts-bsky-post',
4247
600,
4348
url => url,
49+
{
50+
allowUrl: allowBlueskyApiUrl,
51+
contentTypePrefixes: ['application/json'],
52+
},
4453
)
4554

4655
export default withSigning(defineEventHandler(async (event) => {

packages/script/src/runtime/server/google-maps-geocode-proxy.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { withQuery } from 'ufo'
22
import { createError, defineEventHandler, getQuery, setHeader } from '#nuxt-scripts/h3'
33
import { useRuntimeConfig } from '#nuxt-scripts/nitro'
4-
import { createCachedJsonFetch } from './utils/cached-upstream'
4+
import { createCachedJsonFetch, isSafeHttpsUrl } from './utils/cached-upstream'
5+
import { stripProxyAuthQuery } from './utils/proxy-query'
56
import { withSigning } from './utils/withSigning'
67

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

1621
export default withSigning(defineEventHandler(async (event) => {
@@ -25,7 +30,7 @@ export default withSigning(defineEventHandler(async (event) => {
2530
})
2631
}
2732

28-
const query = getQuery(event)
33+
const query = stripProxyAuthQuery(getQuery(event))
2934
const { key: _clientKey, ...safeQuery } = query
3035

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

0 commit comments

Comments
 (0)