Skip to content

Commit a17c05a

Browse files
authored
fix(proxy): block unsafe redirects and local targets (#840)
1 parent a6c1fac commit a17c05a

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 URLand 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
@@ -143,6 +143,7 @@
143143
"std-env": "catalog:",
144144
"ufo": "catalog:",
145145
"ultrahtml": "catalog:",
146+
"undici": "catalog:",
146147
"unplugin": "catalog:",
147148
"unstorage": "catalog:",
148149
"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{openasopenFile,stat,unlink}from'node:fs/promises'
20+
import{setTimeoutasdelay}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
consttoScreamingSnake=(s: string)=>s.replace(UPPER_RE,'_$1').toUpperCase()
122125

123126
constPROXY_SECRET_ENV_KEY='NUXT_SCRIPTS_PROXY_SECRET'
124-
constPROXY_SECRET_ENV_LINE_RE=/^NUXT_SCRIPTS_PROXY_SECRET=/m
127+
constPROXY_SECRET_ENV_LINE_RE=/^NUXT_SCRIPTS_PROXY_SECRET=.*$/m
125128
constPROXY_SECRET_ENV_VALUE_RE=/^NUXT_SCRIPTS_PROXY_SECRET=(.+)$/m
129+
constPROXY_SECRET_LOCK_RETRY_MS=10
130+
constPROXY_SECRET_LOCK_TIMEOUT_MS=2000
131+
132+
asyncfunctionwithProxySecretFileLock<T>(envPath: string,effect: ()=>T): Promise<T>{
133+
constlockPath=`${envPath}.nuxt-scripts.lock`
134+
constdeadline=Date.now()+PROXY_SECRET_LOCK_TIMEOUT_MS
135+
letlockHandle: Awaited<ReturnType<typeofopenFile>>|undefined
136+
137+
while(!lockHandle){
138+
constacquisition=awaitopenFile(lockPath,'wx')
139+
.then(handle=>({_tag: 'Acquired'asconst, handle }))
140+
.catch((error: NodeJS.ErrnoException)=>{
141+
if(error.code==='EEXIST')
142+
return{_tag: 'Busy'asconst}
143+
throwerror
144+
})
145+
146+
if(acquisition._tag==='Acquired'){
147+
lockHandle=acquisition.handle
148+
break
149+
}
150+
151+
constexistingLock=awaitstat(lockPath)
152+
.then(lockStat=>({_tag: 'Found'asconst,mtimeMs: lockStat.mtimeMs}))
153+
.catch((error: NodeJS.ErrnoException)=>{
154+
if(error.code==='ENOENT')
155+
return{_tag: 'Missing'asconst}
156+
throwerror
157+
})
158+
if(existingLock._tag==='Found'&&Date.now()-existingLock.mtimeMs>=PROXY_SECRET_LOCK_TIMEOUT_MS){
159+
awaitunlink(lockPath).catch((error: NodeJS.ErrnoException)=>{
160+
if(error.code!=='ENOENT')
161+
throwerror
162+
})
163+
continue
164+
}
165+
if(Date.now()>=deadline)
166+
throwObject.assign(newError('Timed out waiting for proxy secret file lock'),{code: 'ETIMEDOUT'})
167+
awaitdelay(PROXY_SECRET_LOCK_RETRY_MS)
168+
}
169+
170+
leteffectResult: {_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+
constcloseResult=awaitlockHandle.close()
179+
.then(()=>({_tag: 'Success'asconst}))
180+
.catch((error: Error)=>({_tag: 'Failure'asconst, error }))
181+
constunlinkResult=awaitunlink(lockPath)
182+
.then(()=>({_tag: 'Success'asconst}))
183+
.catch((error: NodeJS.ErrnoException)=>error.code==='ENOENT'
184+
? {_tag: 'Success'asconst}
185+
: {_tag: 'Failure'asconst, 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+
throweffectResult.error
193+
returneffectResult.value
194+
}
126195

127196
exportinterfaceResolvedProxySecret{
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-
exportfunctionresolveProxySecret(
213+
exportasyncfunctionresolveProxySecret(
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
constline=`${PROXY_SECRET_ENV_KEY}=${secret}\n`
166235

167236
try{
168-
if(existsSync(envPath)){
169-
constcontents=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-
constmatch=contents.match(PROXY_SECRET_ENV_VALUE_RE)
175-
if(match?.[1])
176-
return{secret: match[1].trim(),ephemeral: false,source: 'dotenv-generated'}
237+
constpersistedSecret=awaitwithProxySecretFileLock(envPath,()=>{
238+
if(existsSync(envPath)){
239+
constcontents=readFileSync(envPath,'utf-8')
240+
constexistingSecret=contents.match(PROXY_SECRET_ENV_VALUE_RE)?.[1]?.trim()
241+
if(existingSecret)
242+
returnexistingSecret
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+
returnsecret
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-
returnnewURL(trimmed,'https://nuxt-scripts.local').hostname||undefined
328+
consturl=newURL(trimmed,'https://nuxt-scripts.local')
329+
if(url.protocol!=='http:'&&url.protocol!=='https:')
330+
return
331+
returnisPublicNetworkHostname(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
elseif(anyHandlerRequiresSigning){
1129-
constproxySecretResolved=resolveProxySecret(
1206+
constproxySecretResolved=awaitresolveProxySecret(
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
exportfunctionisSafeAliasSegment(alias: string): boolean{
21-
returnSAFE_ALIAS_SEGMENT_RE.test(alias)
21+
returnalias!=='.'&&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
exportfunctionbuildDomainAliasMap(domains: Iterable<string>,alias: ProxyAliasConfig): Record<string,string>{
45-
constmap: Record<string,string>={}
45+
constentries: Array<[string,string]>=[]
4646
for(constdomainofdomains){
4747
constvalue=aliasForDomain(domain,alias)
4848
if(value)
49-
map[domain]=value
49+
entries.push([domain,value])
5050
}
51-
returnmap
51+
returnObject.fromEntries(entries)
5252
}
5353

5454
/** Invert a `domain → alias` map into the `alias → domain` map the proxy handler resolves with. */
5555
exportfunctioninvertAliasMap(map: Record<string,string>): Record<string,string>{
56-
constout: Record<string,string>={}
57-
for(const[domain,alias]ofObject.entries(map))
58-
out[alias]=domain
59-
returnout
56+
returnObject.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
@@ -901,8 +901,8 @@ export async function registry(resolve?: (path: string) => Promise<string>): Pro
901901
*/
902902
exportfunctiongeneratePartytownResolveUrl(proxyPrefix: string,domainAliases: Record<string,string>={}): string{
903903
return`function(url, location, type) {
904-
if (url.origin !== location.origin) {
905-
var aliases = ${JSON.stringify(domainAliases)};
904+
if ((url.protocol === 'http:' || url.protocol === 'https:') && url.origin !== location.origin) {
905+
var aliases = Object.assign(Object.create(null), ${JSON.stringify(domainAliases)});
906906
var seg = aliases[url.host] || url.host;
907907
return new URL(${JSON.stringify(proxyPrefix)} + '/' + seg + url.pathname + url.search, location.origin);
908908
}

‎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
constBSKY_POST_URL_RE=/^https:\/\/bsky\.app\/profile\/([^/]+)\/post\/([^/?]+)$/
2727
constEMBED_BSKY_SUFFIX_RE=/\/embed\/bluesky$/
28+
constallowBlueskyApiUrl=(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
exportdefaultwithSigning(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
exportdefaultwithSigning(defineEventHandler(async(event)=>{
@@ -25,7 +30,7 @@ export default withSigning(defineEventHandler(async (event) => {
2530
})
2631
}
2732

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

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

0 commit comments

Comments
 (0)