From f4502a230c78197cedda24a9be426d8f58a674ff Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 22 Jul 2026 14:42:42 +1000 Subject: [PATCH 1/5] fix(proxy): harden upstream boundaries --- docs/content/docs/1.guides/2.first-party.md | 6 +- packages/script/src/module.ts | 12 +- packages/script/src/plugins/intercept.ts | 4 +- packages/script/src/proxy-alias.ts | 13 +- packages/script/src/registry.ts | 2 +- .../src/runtime/server/bluesky-embed.ts | 11 +- .../server/google-maps-geocode-proxy.ts | 9 +- .../server/google-static-maps-proxy.ts | 32 +-- .../src/runtime/server/gravatar-proxy.ts | 61 +++++- .../runtime/server/instagram-embed-asset.ts | 2 +- .../src/runtime/server/instagram-embed.ts | 154 ++++---------- .../src/runtime/server/proxy-handler.ts | 76 +++++-- .../runtime/server/utils/cached-upstream.ts | 136 ++++++++++++- .../src/runtime/server/utils/image-proxy.ts | 54 +++-- .../runtime/server/utils/instagram-embed.ts | 191 ++++++++++++++++++ .../src/runtime/server/utils/privacy.ts | 59 ++++-- .../src/runtime/server/utils/proxy-query.ts | 13 ++ packages/script/src/runtime/server/x-embed.ts | 6 +- test/unit/cached-upstream.test.ts | 84 +++++++- test/unit/google-geocode-proxy.test.ts | 67 ++++++ test/unit/google-static-map-proxy.test.ts | 79 ++++++++ test/unit/gravatar-proxy-handler.test.ts | 71 +++++++ test/unit/image-proxy-security.test.ts | 92 +++++++++ test/unit/instagram-embed.test.ts | 46 +++++ test/unit/proxy-alias-generators.test.ts | 8 + test/unit/proxy-alias.test.ts | 19 ++ test/unit/proxy-handler-body.test.ts | 67 +++++- test/unit/proxy-privacy.test.ts | 32 ++- test/unit/resolve-proxy-secret.test.ts | 10 + test/unit/setup.test.ts | 7 + 30 files changed, 1196 insertions(+), 227 deletions(-) create mode 100644 packages/script/src/runtime/server/utils/proxy-query.ts create mode 100644 test/unit/google-geocode-proxy.test.ts create mode 100644 test/unit/google-static-map-proxy.test.ts create mode 100644 test/unit/gravatar-proxy-handler.test.ts create mode 100644 test/unit/image-proxy-security.test.ts diff --git a/docs/content/docs/1.guides/2.first-party.md b/docs/content/docs/1.guides/2.first-party.md index bc8a0351e..7aa0f73cc 100644 --- a/docs/content/docs/1.guides/2.first-party.md +++ b/docs/content/docs/1.guides/2.first-party.md @@ -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 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 @@ -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 diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index cdc99f7c5..3c5eac281 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -173,8 +173,13 @@ export function resolveProxySecret( const match = contents.match(PROXY_SECRET_ENV_VALUE_RE) if (match?.[1]) return { secret: match[1].trim(), ephemeral: false, source: 'dotenv-generated' } + // 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}`) @@ -250,7 +255,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 url.hostname || undefined } catch { // Invalid user-provided proxy domains cannot be normalized. diff --git a/packages/script/src/plugins/intercept.ts b/packages/script/src/plugins/intercept.ts index 7e7e3e225..e85755c82 100644 --- a/packages/script/src/plugins/intercept.ts +++ b/packages/script/src/plugins/intercept.ts @@ -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; } - } catch {} + } catch { /* Invalid URL inputs retain native behavior. */ } return url; } diff --git a/packages/script/src/proxy-alias.ts b/packages/script/src/proxy-alias.ts index a49675d29..2ea5362f4 100644 --- a/packages/script/src/proxy-alias.ts +++ b/packages/script/src/proxy-alias.ts @@ -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) } /** @@ -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, alias: ProxyAliasConfig): Record { - const map: Record = {} + 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): Record { - const out: Record = {} - for (const [domain, alias] of Object.entries(map)) - out[alias] = domain - return out + return Object.fromEntries(Object.entries(map).map(([domain, alias]) => [alias, domain])) } /** diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index 591d7889b..51ae6f6fb 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -882,7 +882,7 @@ export async function registry(resolve?: (path: string) => Promise): Pro */ export function generatePartytownResolveUrl(proxyPrefix: string, domainAliases: Record = {}): string { return `function(url, location, type) { - if (url.origin !== location.origin) { + if ((url.protocol === 'http:' || url.protocol === 'https:') && url.origin !== location.origin) { var aliases = ${JSON.stringify(domainAliases)}; var seg = aliases[url.host] || url.host; return new URL(${JSON.stringify(proxyPrefix)} + '/' + seg + url.pathname + url.search, location.origin); diff --git a/packages/script/src/runtime/server/bluesky-embed.ts b/packages/script/src/runtime/server/bluesky-embed.ts index 45090faa1..e7d7fd15c 100644 --- a/packages/script/src/runtime/server/bluesky-embed.ts +++ b/packages/script/src/runtime/server/bluesky-embed.ts @@ -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' @@ -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 @@ -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 @@ -41,6 +46,10 @@ const cachedPostFetch = createCachedJsonFetch( 'nuxt-scripts-bsky-post', 600, url => url, + { + allowUrl: allowBlueskyApiUrl, + contentTypePrefixes: ['application/json'], + }, ) export default withSigning(defineEventHandler(async (event) => { diff --git a/packages/script/src/runtime/server/google-maps-geocode-proxy.ts b/packages/script/src/runtime/server/google-maps-geocode-proxy.ts index 79df25499..dda09fbd5 100644 --- a/packages/script/src/runtime/server/google-maps-geocode-proxy.ts +++ b/packages/script/src/runtime/server/google-maps-geocode-proxy.ts @@ -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 @@ -11,6 +12,10 @@ const cachedGeocodeFetch = createCachedJsonFetch( 'nuxt-scripts-geocode', 2592000, url => url, + { + allowUrl: url => isSafeHttpsUrl(url) && url.hostname === 'maps.googleapis.com', + contentTypePrefixes: ['application/json'], + }, ) export default withSigning(defineEventHandler(async (event) => { @@ -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', { diff --git a/packages/script/src/runtime/server/google-static-maps-proxy.ts b/packages/script/src/runtime/server/google-static-maps-proxy.ts index e79486f99..988d93d5d 100644 --- a/packages/script/src/runtime/server/google-static-maps-proxy.ts +++ b/packages/script/src/runtime/server/google-static-maps-proxy.ts @@ -1,18 +1,16 @@ import { createError, defineEventHandler, getQuery, setHeader } from 'h3' import { useRuntimeConfig } from 'nitropack/runtime' import { withQuery } from 'ufo' -import { createCachedBinaryFetch } from './utils/cached-upstream' -import { PAGE_TOKEN_PARAM, PAGE_TOKEN_TS_PARAM, SIG_PARAM } from './utils/sign-constants' +import { createCachedBinaryFetch, isSafeHttpsUrl } from './utils/cached-upstream' +import { stripProxyAuthQuery } from './utils/proxy-query' import { withSigning } from './utils/withSigning' // Static maps by (center, zoom, size, style, markers, ...) are essentially // immutable; a 7-day cache drastically reduces billable map loads for the // common "same map on every page visit" case. -const cachedMapFetch = createCachedBinaryFetch('nuxt-scripts-static-map', 604800) - -// Strip query params that vary per-request (auth artefacts + client-provided -// API key) so the cache key is pinned to the actual map being requested. -const STRIP_PARAMS = new Set([SIG_PARAM, PAGE_TOKEN_PARAM, PAGE_TOKEN_TS_PARAM, 'key']) +const cachedMapFetch = createCachedBinaryFetch('nuxt-scripts-static-map', 604800, { + allowUrl: url => isSafeHttpsUrl(url) && url.hostname === 'maps.googleapis.com', +}) export default withSigning(defineEventHandler(async (event) => { const runtimeConfig = useRuntimeConfig() @@ -35,12 +33,8 @@ export default withSigning(defineEventHandler(async (event) => { }) } - const query = getQuery(event) - const safeQuery: Record = {} - for (const [k, v] of Object.entries(query)) { - if (!STRIP_PARAMS.has(k)) - safeQuery[k] = v - } + const query = stripProxyAuthQuery(getQuery(event)) + const { key: _clientKey, ...safeQuery } = query const googleMapsUrl = withQuery('https://maps.googleapis.com/maps/api/staticmap', { ...safeQuery, @@ -56,10 +50,20 @@ export default withSigning(defineEventHandler(async (event) => { }) }) + const contentType = result.contentType?.split(';', 1)[0]?.trim().toLowerCase() + if (!contentType?.startsWith('image/') || contentType === 'image/svg+xml') { + throw createError({ + statusCode: 415, + statusMessage: 'Unsupported upstream content type', + }) + } + const cacheMaxAge = publicConfig.cacheMaxAge || 3600 - setHeader(event, 'Content-Type', result.contentType || 'image/png') + setHeader(event, 'Content-Type', result.contentType!) setHeader(event, 'Cache-Control', `public, max-age=${cacheMaxAge}, s-maxage=${cacheMaxAge}`) setHeader(event, 'Vary', 'Accept-Encoding') + setHeader(event, 'Content-Security-Policy', 'sandbox; default-src \'none\'; base-uri \'none\'; form-action \'none\'') + setHeader(event, 'X-Content-Type-Options', 'nosniff') return result.body })) diff --git a/packages/script/src/runtime/server/gravatar-proxy.ts b/packages/script/src/runtime/server/gravatar-proxy.ts index 693ae3a59..270270755 100644 --- a/packages/script/src/runtime/server/gravatar-proxy.ts +++ b/packages/script/src/runtime/server/gravatar-proxy.ts @@ -1,21 +1,39 @@ import { createError, defineEventHandler, getQuery, setHeader } from 'h3' import { useRuntimeConfig } from 'nitropack/runtime' import { withQuery } from 'ufo' -import { createCachedBinaryFetch } from './utils/cached-upstream' +import { createCachedBinaryFetch, isSafeHttpsUrl } from './utils/cached-upstream' import { withSigning } from './utils/withSigning' // Gravatar avatars keyed on `hash + sizing/default/rating` are essentially // immutable for the hour timescale; a 1-hour cache balances freshness (users // rotating avatars) against origin-shielding upstream traffic. -const cachedGravatarFetch = createCachedBinaryFetch('nuxt-scripts-gravatar', 3600) +const GRAVATAR_HASH_RE = /^[a-f\d]{64}$/i +const GRAVATAR_RATINGS = new Set(['g', 'pg', 'r', 'x']) +function firstString(value: unknown): string | undefined { + return Array.isArray(value) + ? (typeof value[0] === 'string' ? value[0] : undefined) + : (typeof value === 'string' ? value : undefined) +} + +const cachedGravatarFetch = createCachedBinaryFetch('nuxt-scripts-gravatar', 3600, { + allowUrl: url => isSafeHttpsUrl(url) + && (url.hostname === 'gravatar.com' || url.hostname.endsWith('.gravatar.com')), +}) export default withSigning(defineEventHandler(async (event) => { const runtimeConfig = useRuntimeConfig() const proxyConfig = (runtimeConfig.public['nuxt-scripts'] as any)?.gravatarProxy const query = getQuery(event) - let hash = query.hash as string | undefined - const email = query.email as string | undefined + let hash = firstString(query.hash) + const email = firstString(query.email) + + if (hash && !GRAVATAR_HASH_RE.test(hash)) { + throw createError({ + statusCode: 400, + statusMessage: 'Hash must be a 64-character SHA-256 hex digest', + }) + } // Server-side hashing: email never leaves your server if (!hash && email) { @@ -34,12 +52,26 @@ export default withSigning(defineEventHandler(async (event) => { } // Build Gravatar URL with query params - const size = query.s as string || '80' - const defaultImg = query.d as string || 'mp' - const rating = query.r as string || 'g' + const rawSize = firstString(query.s) || '80' + const size = Number(rawSize) + const defaultImg = firstString(query.d) || 'mp' + const rating = (firstString(query.r) || 'g').toLowerCase() + + if (!Number.isInteger(size) || size < 1 || size > 2048) { + throw createError({ + statusCode: 400, + statusMessage: 'Gravatar size must be an integer from 1 to 2048', + }) + } + if (!GRAVATAR_RATINGS.has(rating)) { + throw createError({ + statusCode: 400, + statusMessage: 'Invalid Gravatar rating', + }) + } const gravatarUrl = withQuery(`https://www.gravatar.com/avatar/${hash}`, { - s: size, + s: String(size), d: defaultImg, r: rating, }) @@ -54,9 +86,20 @@ export default withSigning(defineEventHandler(async (event) => { }) const cacheMaxAge = proxyConfig?.cacheMaxAge ?? 3600 - setHeader(event, 'Content-Type', result.contentType || 'image/jpeg') + const responseContentType = result.contentType + const upstreamContentType = responseContentType?.split(';', 1)[0]?.trim().toLowerCase() + if (!responseContentType || !upstreamContentType?.startsWith('image/') || upstreamContentType === 'image/svg+xml') { + throw createError({ + statusCode: 415, + statusMessage: 'Unsupported upstream content type', + }) + } + + setHeader(event, 'Content-Type', responseContentType) setHeader(event, 'Cache-Control', `public, max-age=${cacheMaxAge}, s-maxage=${cacheMaxAge}`) + setHeader(event, 'Content-Security-Policy', 'sandbox; default-src \'none\'') setHeader(event, 'Vary', 'Accept-Encoding') + setHeader(event, 'X-Content-Type-Options', 'nosniff') return result.body })) diff --git a/packages/script/src/runtime/server/instagram-embed-asset.ts b/packages/script/src/runtime/server/instagram-embed-asset.ts index 378fa5401..14aa75dc3 100644 --- a/packages/script/src/runtime/server/instagram-embed-asset.ts +++ b/packages/script/src/runtime/server/instagram-embed-asset.ts @@ -5,6 +5,6 @@ export default createImageProxyHandler({ accept: '*/*', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36', cacheMaxAge: 86400, - contentType: 'application/octet-stream', + contentTypePrefixes: ['image/', 'font/', 'application/font', 'application/x-font', 'application/vnd.ms-fontobject', 'application/octet-stream'], decodeAmpersands: true, }) diff --git a/packages/script/src/runtime/server/instagram-embed.ts b/packages/script/src/runtime/server/instagram-embed.ts index 56d207bda..3e29a0f77 100644 --- a/packages/script/src/runtime/server/instagram-embed.ts +++ b/packages/script/src/runtime/server/instagram-embed.ts @@ -1,46 +1,36 @@ import { createError, defineEventHandler, getQuery, setHeader } from 'h3' -import { defineCachedFunction, useRuntimeConfig } from 'nitropack/runtime' -import { $fetch } from 'ofetch' -import { hash } from 'ohash' -import { ELEMENT_NODE, parse, renderSync, TEXT_NODE, walkSync } from 'ultrahtml' +import { useRuntimeConfig } from 'nitropack/runtime' import { createCachedJsonFetch } from './utils/cached-upstream' -import { isEmbedShell, proxyAssetUrl, rewriteUrl, rewriteUrlsInText, RSRC_RE, scopeCss } from './utils/instagram-embed' +import { isEmbedShell, isSafeInstagramCssUrl, isSafeInstagramEmbedUrl, isSafeInstagramPostUrl, sanitizeInstagramEmbedCss, sanitizeInstagramEmbedHtml } from './utils/instagram-embed' import { withSigning } from './utils/withSigning' export { proxyAssetUrl, proxyImageUrl, rewriteUrl, rewriteUrlsInText, scopeCss } from './utils/instagram-embed' const EMBED_INSTAGRAM_SUFFIX_RE = /\/embed\/instagram$/ -const SRCSET_SPLIT_RE = /\s+/ // Instagram embed HTML is semi-fresh (likes, captions may update); 10min // matches the outbound Cache-Control header and dedupes per post+captions. // Throws on shell responses so nitro doesn't cache them. -const cachedEmbedFetch = defineCachedFunction( - async (url: string, headers: Record): Promise => { - const html = await $fetch(url, { timeout: 10000, headers }) - if (isEmbedShell(html)) { - throw createError({ - statusCode: 502, - statusMessage: 'Instagram returned an empty embed shell (post unavailable or upstream rate-limiting)', - }) - } - return html +const cachedEmbedFetch = createCachedJsonFetch( + 'nuxt-scripts-instagram-embed-v3', + 600, + (url, opts) => { + const parts = [url] + for (const [key, value] of Object.entries(opts?.headers || {}).sort(([a], [b]) => a.localeCompare(b))) + parts.push(`${key}=${value}`) + return parts.join('\n') }, { - // v2 — bump to evict any v1 entries that cached the empty JS shell - // before the shell-detection / UA fix landed. - name: 'nuxt-scripts-instagram-embed-v2', - maxAge: 600, - swr: true, - staleMaxAge: 600, - // Vary on headers too — Instagram's response is UA-dependent, so - // different callers (e.g. unit tests, future UA changes) must not - // collide on the same key. - getKey: (url: string, headers: Record) => { - const parts = [url] - for (const [k, v] of Object.entries(headers).sort(([a], [b]) => a.localeCompare(b))) - parts.push(`${k}=${v}`) - return hash(parts) + allowUrl: isSafeInstagramEmbedUrl, + responseType: 'text', + contentTypePrefixes: ['text/html'], + validateResponse: (html) => { + if (isEmbedShell(html)) { + throw createError({ + statusCode: 502, + statusMessage: 'Instagram returned an empty embed shell (post unavailable or upstream rate-limiting)', + }) + } }, }, ) @@ -51,20 +41,13 @@ const cachedCssFetch = createCachedJsonFetch( 'nuxt-scripts-instagram-css', 86400, url => url, + { + allowUrl: isSafeInstagramCssUrl, + responseType: 'text', + contentTypePrefixes: ['text/css'], + }, ) -/** - * Remove a node from the AST by converting it to an empty text node. - * Setting node.name = '' produces broken HTML (attributes still render as `< attr="...">`). - */ -function removeNode(node: any): void { - node.type = TEXT_NODE - node.value = '' - node.name = undefined - node.attributes = {} - node.children = [] -} - export default withSigning(defineEventHandler(async (event) => { // Derive the scripts prefix from the handler's own route path. // The route is registered as `/embed/instagram`, so strip `/embed/instagram`. @@ -94,7 +77,7 @@ export default withSigning(defineEventHandler(async (event) => { }) } - if (!['instagram.com', 'www.instagram.com'].includes(parsedUrl.hostname)) { + if (!isSafeInstagramPostUrl(parsedUrl)) { throw createError({ statusCode: 400, statusMessage: 'Invalid Instagram URL', @@ -106,12 +89,14 @@ export default withSigning(defineEventHandler(async (event) => { const embedUrl = `${cleanUrl}embed/${captions ? 'captioned/' : ''}` const html = await cachedEmbedFetch(embedUrl, { - 'Accept': 'text/html', - // Meta's own crawler UA. Googlebot's UA is also accepted by Instagram - // but is IP-verified, so it fails from hosts outside Google's ranges - // (e.g. Cloudflare/Vercel) and Instagram serves the JS shell instead - // of the SSR'd post. - 'User-Agent': 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)', + headers: { + 'Accept': 'text/html', + // Meta's own crawler UA. Googlebot's UA is also accepted by Instagram + // but is IP-verified, so it fails from hosts outside Google's ranges + // (e.g. Cloudflare/Vercel) and Instagram serves the JS shell instead + // of the SSR'd post. + 'User-Agent': 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)', + }, }).catch((error: any) => { throw createError({ statusCode: error.statusCode || 500, @@ -119,77 +104,20 @@ export default withSigning(defineEventHandler(async (event) => { }) }) - const ast = parse(html) - - // Collect CSS URLs from link[rel=stylesheet] tags, then remove them - const cssUrls: string[] = [] - - walkSync(ast, (node) => { - if (node.type !== ELEMENT_NODE) - return - - if (node.name === 'link' && node.attributes.rel === 'stylesheet' && node.attributes.href) { - cssUrls.push(node.attributes.href) - removeNode(node) - return - } - - if (node.name === 'script' || node.name === 'noscript' || node.name === 'style') { - removeNode(node) - return - } - - for (const attr of ['src', 'poster']) { - if (node.attributes[attr]) - node.attributes[attr] = rewriteUrl(node.attributes[attr], prefix, secret) - } - - if (node.attributes.srcset) { - node.attributes.srcset = node.attributes.srcset - .split(',') - .map((entry: string) => { - const parts = entry.trim().split(SRCSET_SPLIT_RE) - const url = parts[0] - const descriptor = parts.slice(1).join(' ') - return url ? `${rewriteUrl(url, prefix, secret)}${descriptor ? ` ${descriptor}` : ''}` : entry - }) - .join(', ') - } - - if (node.attributes.style) - node.attributes.style = rewriteUrlsInText(node.attributes.style, prefix, secret) - }) - - walkSync(ast, (node) => { - if (node.type === TEXT_NODE && node.value) - node.value = rewriteUrlsInText(node.value, prefix, secret) - }) - - let bodyNode: any = null - walkSync(ast, (node) => { - if (node.type === ELEMENT_NODE && node.name === 'body') - bodyNode = node - }) - - const bodyHtml = bodyNode - ? bodyNode.children.map((child: any) => renderSync(child)).join('') - : renderSync(ast) + const { bodyHtml, cssUrls } = sanitizeInstagramEmbedHtml(html, prefix, secret) const cssContents = await Promise.all( cssUrls.map(url => cachedCssFetch(url, { headers: { Accept: 'text/css' }, - }).catch(() => ''), + }).catch(() => { + // Styles are optional presentation. A failed stylesheet must not hide valid post content. + return '' + }), ), ) - let combinedCss = cssContents.join('\n') - combinedCss = combinedCss.replace( - RSRC_RE, - (_m, path) => `url(${proxyAssetUrl(`https://static.cdninstagram.com/rsrc.php${path}`, prefix, secret)})`, - ) - combinedCss = rewriteUrlsInText(combinedCss, prefix, secret) - combinedCss = scopeCss(combinedCss, '.instagram-embed-root') + const combinedCss = sanitizeInstagramEmbedCss(cssContents.join('\n'), '.instagram-embed-root', prefix, secret) const baseStyles = ` .instagram-embed-root { background: white; max-width: 540px; width: calc(100% - 2px); border-radius: 3px; border: 1px solid rgb(219, 219, 219); display: block; margin: 0px 0px 12px; min-width: 326px; padding: 0px; } @@ -202,6 +130,8 @@ export default withSigning(defineEventHandler(async (event) => { setHeader(event, 'Content-Type', 'text/html') setHeader(event, 'Cache-Control', 'public, max-age=600, s-maxage=600') + setHeader(event, 'Content-Security-Policy', 'sandbox; default-src \'none\'') + setHeader(event, 'X-Content-Type-Options', 'nosniff') return result })) diff --git a/packages/script/src/runtime/server/proxy-handler.ts b/packages/script/src/runtime/server/proxy-handler.ts index 5c272a30b..6fac96b1e 100644 --- a/packages/script/src/runtime/server/proxy-handler.ts +++ b/packages/script/src/runtime/server/proxy-handler.ts @@ -1,5 +1,5 @@ import type { ProxyPrivacyInput, ResolvedProxyPrivacy } from './utils/privacy' -import { createError, defineEventHandler, getHeaders, getQuery, getRequestIP, getRequestWebStream, readBody, readRawBody, setResponseHeader, setResponseStatus } from 'h3' +import { createError, defineEventHandler, getHeaders, getQuery, getRequestIP, getRequestWebStream, readBody, readRawBody, sendStream, setResponseHeader, setResponseStatus } from 'h3' import { useNitroApp, useRuntimeConfig } from 'nitropack/runtime' import { matchDomain } from './utils/match-domain' import { @@ -27,7 +27,27 @@ interface ProxyConfig { const COMPRESSION_RE = /gzip|deflate|br|compress|base64/i const CLIENT_HINT_VERSION_RE = /;v="(\d+)\.[^"]*"/g -const SKIP_RESPONSE_HEADERS = new Set(['set-cookie', 'transfer-encoding', 'content-encoding', 'content-length']) +const SKIP_RESPONSE_HEADERS = new Set([ + 'alt-svc', + 'clear-site-data', + 'connection', + 'content-encoding', + 'content-length', + 'keep-alive', + 'nel', + 'proxy-authenticate', + 'proxy-authorization', + 'report-to', + 'reporting-endpoints', + 'set-cookie', + 'set-cookie2', + 'strict-transport-security', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', + 'www-authenticate', +]) // Hop-by-hop request headers per RFC 7230 §6.1 — must not be forwarded by a proxy export const SKIP_REQUEST_HEADERS = new Set([ 'connection', @@ -51,8 +71,10 @@ function stripQueryFingerprinting( const stripped = stripPayloadFingerprinting(query, privacy) const params = new URLSearchParams() for (const [key, value] of Object.entries(stripped)) { - if (value !== undefined && value !== null) { - params.set(key, typeof value === 'object' ? JSON.stringify(value) : String(value)) + const values = Array.isArray(value) ? value : [value] + for (const item of values) { + if (item !== undefined && item !== null) + params.append(key, typeof item === 'object' ? JSON.stringify(item) : String(item)) } } return { queryString: params.toString(), stripped } @@ -389,16 +411,18 @@ export default defineEventHandler(async (event) => { body: fetchBody, credentials: 'omit', // Don't send cookies to third parties signal: controller.signal, + redirect: 'manual', // @ts-expect-error Node fetch supports duplex for streaming request bodies duplex: passthroughBody ? 'half' : undefined, }) } catch (err) { + clearTimeout(timeoutId) log('[proxy] Upstream error:', err) throw createError({ statusCode: timedOut ? 504 : 502, statusMessage: timedOut ? 'Gateway Timeout' : 'Bad Gateway', - message: `Proxy upstream request failed: ${targetUrl}`, + message: 'Proxy upstream request failed', cause: err, data: { errorName: (err as Error)?.name, @@ -406,29 +430,47 @@ export default defineEventHandler(async (event) => { }, }) } - finally { + log('[proxy] Response:', response.status, response.statusText) + + if (response.status >= 300 && response.status < 400 && response.status !== 304) { clearTimeout(timeoutId) + await response.body?.cancel() + throw createError({ + statusCode: 502, + statusMessage: 'Unsafe upstream redirect', + message: 'Proxy upstream returned a redirect that was not followed', + }) } - log('[proxy] Response:', response.status, response.statusText) + // Headers named by Connection are hop-by-hop too, including non-standard names. + const responseConnectionHeaders = new Set( + (response.headers.get('connection') || '') + .split(',') + .map(header => header.trim().toLowerCase()) + .filter(Boolean), + ) - // Forward response headers (except problematic ones) + // Forward response headers except hop-by-hop, framing, compression, and cookies. response.headers.forEach((value, key) => { - if (!SKIP_RESPONSE_HEADERS.has(key.toLowerCase())) { + const lowerKey = key.toLowerCase() + if (!SKIP_RESPONSE_HEADERS.has(lowerKey) && !responseConnectionHeaders.has(lowerKey)) { setResponseHeader(event, key, value) } }) - setResponseStatus(event, response.status, response.statusText) + // This route can expose broad vendor hosts under the application's origin. + // Sandbox direct document navigations while preserving subresource responses. + setResponseHeader(event, 'Content-Security-Policy', 'sandbox; default-src \'none\'; base-uri \'none\'; form-action \'none\'') + setResponseHeader(event, 'X-Content-Type-Options', 'nosniff') - // Return the body as text for text-based content, otherwise as buffer - const responseContentType = response.headers.get('content-type') || '' - const isTextContent = responseContentType.includes('text') || responseContentType.includes('javascript') || responseContentType.includes('json') + setResponseStatus(event, response.status, response.statusText) - if (isTextContent) { - return await response.text() + if (!response.body) { + clearTimeout(timeoutId) + return null } - // For binary content (images, etc.) - return Buffer.from(await response.arrayBuffer()) + // Stream rather than buffering potentially large upstream responses. This lowers + // memory pressure and lets the browser receive headers and chunks immediately. + return sendStream(event, response.body).finally(() => clearTimeout(timeoutId)) }) diff --git a/packages/script/src/runtime/server/utils/cached-upstream.ts b/packages/script/src/runtime/server/utils/cached-upstream.ts index 931d66f23..39e8595af 100644 --- a/packages/script/src/runtime/server/utils/cached-upstream.ts +++ b/packages/script/src/runtime/server/utils/cached-upstream.ts @@ -1,3 +1,4 @@ +import type { FetchResponse } from 'ofetch' import { Buffer } from 'node:buffer' import { defineCachedFunction } from 'nitropack/runtime' import { $fetch } from 'ofetch' @@ -40,11 +41,81 @@ export interface CachedBinaryFetchOptions { ignoreResponseError?: boolean } +export interface CachedBinaryFetchConfig { + /** Validate the initial URL and every redirect before requesting it. */ + allowUrl?: (url: URL) => boolean + maxRedirects?: number +} + +export interface CachedJsonFetchConfig { + /** Validate the initial URL and every redirect before requesting it. */ + allowUrl: (url: URL) => boolean + maxRedirects?: number + responseType?: 'json' | 'text' + contentTypePrefixes?: string[] + /** Runs before caching. Throw to reject an invalid upstream response. */ + validateResponse?: (data: T) => void +} + export interface CachedBinaryResult extends CachedBinaryResponse { body: Buffer status: number } +export function isSafeHttpsUrl(url: URL): boolean { + return url.protocol === 'https:' + && !url.username + && !url.password + && (!url.port || url.port === '443') +} + +function upstreamError(message: string, statusCode: number, statusMessage: string, cause?: unknown): Error { + return Object.assign(new Error(message, cause === undefined ? undefined : { cause }), { + statusCode, + statusMessage, + }) +} + +function parseUpstreamUrl(url: string): URL { + try { + return new URL(url) + } + catch (cause) { + throw upstreamError('Upstream URL is invalid', 400, 'Invalid upstream URL', cause) + } +} + +function assertAllowedUrl(url: URL, allowUrl: ((url: URL) => boolean) | undefined): void { + if (allowUrl && !allowUrl(url)) + throw upstreamError('Upstream URL is not allowed', 403, 'Upstream URL not allowed') +} + +function resolveRedirect( + response: Pick, + currentUrl: URL, + redirectCount: number, + config: CachedBinaryFetchConfig, +): URL | undefined { + if (response.status < 300 || response.status >= 400 || response.status === 304) + return + + const location = response.headers.get('location') + if (!location) + throw upstreamError('Upstream redirect has no Location header', 502, 'Invalid upstream redirect') + if (redirectCount >= (config.maxRedirects ?? 5)) + throw upstreamError('Upstream redirect limit exceeded', 502, 'Too many upstream redirects') + + let nextUrl: URL + try { + nextUrl = new URL(location, currentUrl) + } + catch (cause) { + throw upstreamError('Upstream redirect URL is invalid', 502, 'Invalid upstream redirect', cause) + } + assertAllowedUrl(nextUrl, config.allowUrl) + return nextUrl +} + /** * Cache upstream binary/image fetches. Returns a helper that restores the * response body as a Buffer so the handler can pipe it straight to the client. @@ -52,16 +123,36 @@ export interface CachedBinaryResult extends CachedBinaryResponse { export function createCachedBinaryFetch( name: string, maxAge: number, + config: CachedBinaryFetchConfig = {}, ): (url: string, opts?: CachedBinaryFetchOptions) => Promise { const cached = defineCachedFunction( async (url: string, opts?: CachedBinaryFetchOptions): Promise => { - const response = await $fetch.raw(url, { - responseType: 'arrayBuffer' as const, - timeout: opts?.timeout ?? 10000, - redirect: opts?.redirect ?? 'follow', - ignoreResponseError: opts?.ignoreResponseError ?? false, - headers: opts?.headers, - }) + let currentUrl = parseUpstreamUrl(url) + assertAllowedUrl(currentUrl, config.allowUrl) + let response: FetchResponse + + for (let redirectCount = 0; ; redirectCount++) { + const redirectMode = opts?.redirect ?? (config.allowUrl ? 'follow' : 'manual') + if (redirectMode === 'follow' && !config.allowUrl) + throw new Error('Automatic redirects require an allowRedirect redirect policy') + const validateRedirect = redirectMode === 'follow' + response = await $fetch.raw(currentUrl.toString(), { + responseType: 'arrayBuffer' as const, + timeout: opts?.timeout ?? 10000, + redirect: validateRedirect ? 'manual' : redirectMode, + ignoreResponseError: opts?.ignoreResponseError ?? false, + headers: opts?.headers, + }) + + if (!validateRedirect) + break + + const nextUrl = resolveRedirect(response, currentUrl, redirectCount, config) + if (!nextUrl) + break + currentUrl = nextUrl + } + const data = response._data as ArrayBuffer | undefined return { base64: data ? Buffer.from(data).toString('base64') : '', @@ -112,13 +203,36 @@ export function createCachedJsonFetch( name: string, maxAge: number, getKey: (url: string, opts?: { headers?: Record }) => string, + config: CachedJsonFetchConfig, ): (url: string, opts?: { headers?: Record, timeout?: number }) => Promise { return defineCachedFunction( async (url: string, opts?: { headers?: Record, timeout?: number }) => { - return await $fetch(url, { - timeout: opts?.timeout ?? 10000, - headers: opts?.headers, - }) + let currentUrl = parseUpstreamUrl(url) + assertAllowedUrl(currentUrl, config.allowUrl) + + for (let redirectCount = 0; ; redirectCount++) { + const response = await $fetch.raw(currentUrl.toString(), { + responseType: config.responseType ?? 'json', + timeout: opts?.timeout ?? 10000, + redirect: 'manual', + headers: opts?.headers, + }) as FetchResponse + const nextUrl = resolveRedirect(response, currentUrl, redirectCount, config) + if (nextUrl) { + currentUrl = nextUrl + continue + } + + if (config.contentTypePrefixes?.length) { + const contentType = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() + if (!contentType || !config.contentTypePrefixes.some(prefix => contentType.startsWith(prefix))) + throw upstreamError('Upstream response content type is not allowed', 415, 'Unsupported upstream content type') + } + + const data = response._data as T + config.validateResponse?.(data) + return data + } }, { name, diff --git a/packages/script/src/runtime/server/utils/image-proxy.ts b/packages/script/src/runtime/server/utils/image-proxy.ts index bb90844b8..f0fba05e5 100644 --- a/packages/script/src/runtime/server/utils/image-proxy.ts +++ b/packages/script/src/runtime/server/utils/image-proxy.ts @@ -9,8 +9,9 @@ export interface ImageProxyConfig { accept?: string userAgent?: string cacheMaxAge?: number - contentType?: string - /** Follow redirects (default: true). Set to false to reject redirects (SSRF protection). */ + /** Allowed response Content-Type prefixes. SVG is always rejected as active content. */ + contentTypePrefixes?: string[] + /** Follow redirects after applying the same URL allowlist to every hop. */ followRedirects?: boolean /** Decode & in URL query parameter */ decodeAmpersands?: boolean @@ -23,7 +24,7 @@ export function createImageProxyHandler(config: ImageProxyConfig) { accept = 'image/webp,image/jpeg,image/png,image/*,*/*;q=0.8', userAgent, cacheMaxAge = 3600, - contentType = 'image/jpeg', + contentTypePrefixes = ['image/'], followRedirects = true, decodeAmpersands = false, cacheName = Array.isArray(config.allowedDomains) @@ -31,7 +32,17 @@ export function createImageProxyHandler(config: ImageProxyConfig) { : 'nuxt-scripts-img:custom', } = config - const cachedFetch = createCachedBinaryFetch(cacheName, cacheMaxAge) + const domainAllowed = (hostname: string) => typeof config.allowedDomains === 'function' + ? config.allowedDomains(hostname) + : config.allowedDomains.includes(hostname) + const urlAllowed = (url: URL) => (url.protocol === 'http:' || url.protocol === 'https:') + && !url.username + && !url.password + && domainAllowed(url.hostname) + + const cachedFetch = createCachedBinaryFetch(cacheName, cacheMaxAge, { + allowUrl: urlAllowed, + }) return withSigning(defineEventHandler(async (event) => { const query = getQuery(event) @@ -58,21 +69,10 @@ export function createImageProxyHandler(config: ImageProxyConfig) { }) } - if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { - throw createError({ - statusCode: 400, - statusMessage: 'Invalid URL scheme', - }) - } - - const domainAllowed = typeof config.allowedDomains === 'function' - ? config.allowedDomains(parsedUrl.hostname) - : config.allowedDomains.includes(parsedUrl.hostname) - - if (!domainAllowed) { + if (!urlAllowed(parsedUrl)) { throw createError({ - statusCode: 403, - statusMessage: 'Domain not allowed', + statusCode: domainAllowed(parsedUrl.hostname) ? 400 : 403, + statusMessage: domainAllowed(parsedUrl.hostname) ? 'Invalid image URL' : 'Domain not allowed', }) } @@ -92,15 +92,29 @@ export function createImageProxyHandler(config: ImageProxyConfig) { }) }) - if (!followRedirects && result.status >= 300 && result.status < 400) { + if (result.status >= 300 && result.status < 400 && result.status !== 304) { throw createError({ statusCode: 403, statusMessage: 'Redirects not allowed', }) } - setHeader(event, 'Content-Type', result.contentType || contentType) + const responseContentType = result.contentType + const upstreamContentType = responseContentType?.split(';', 1)[0]?.trim().toLowerCase() + const contentTypeAllowed = upstreamContentType + && upstreamContentType !== 'image/svg+xml' + && contentTypePrefixes.some(prefix => upstreamContentType.startsWith(prefix)) + if (!responseContentType || !contentTypeAllowed) { + throw createError({ + statusCode: 415, + statusMessage: 'Unsupported upstream content type', + }) + } + + setHeader(event, 'Content-Type', responseContentType) setHeader(event, 'Cache-Control', `public, max-age=${cacheMaxAge}, s-maxage=${cacheMaxAge}`) + setHeader(event, 'Content-Security-Policy', 'sandbox; default-src \'none\'') + setHeader(event, 'X-Content-Type-Options', 'nosniff') return result.body })) diff --git a/packages/script/src/runtime/server/utils/instagram-embed.ts b/packages/script/src/runtime/server/utils/instagram-embed.ts index eb5031f4d..799d10558 100644 --- a/packages/script/src/runtime/server/utils/instagram-embed.ts +++ b/packages/script/src/runtime/server/utils/instagram-embed.ts @@ -1,3 +1,4 @@ +import { ELEMENT_NODE, parse, renderSync, TEXT_NODE, walkSync } from 'ultrahtml' import { buildProxyUrl } from './proxy-url' export const RSRC_RE = /url\(\/rsrc\.php([^)]+)\)/g @@ -21,12 +22,202 @@ export const LOOKASIDE_RE = /https:\/\/lookaside\.instagram\.com[^"'\s),]+/g export const INSTAGRAM_IMAGE_HOSTS = ['scontent.cdninstagram.com', 'lookaside.instagram.com'] export const INSTAGRAM_ASSET_HOST = 'static.cdninstagram.com' +const INSTAGRAM_POST_PATH_RE = /^\/(?:p|reel|tv)\/[^/]+\/?$/ +const INSTAGRAM_EMBED_PATH_RE = /^\/(?:p|reel|tv)\/[^/]+\/embed\/(?:captioned\/)?$/ +const BLOCKED_EMBED_TAGS = new Set([ + 'base', + 'button', + 'embed', + 'form', + 'iframe', + 'input', + 'link', + 'math', + 'meta', + 'noscript', + 'object', + 'option', + 'script', + 'select', + 'style', + 'svg', + 'template', + 'textarea', +]) +const EMBED_URL_ATTRS = new Set(['action', 'formaction', 'href', 'xlink:href']) +const CSS_URL_RE = /url\(([^)]*)\)/gi +const DANGEROUS_STYLE_RE = /expression\s*\(|@import|-moz-binding|behavior\s*:/i +const SRCSET_SPLIT_RE = /\s+/ + const CHARSET_RE = /@charset\s[^;]+;/gi const IMPORT_RE = /@import\s[^;]+;/gi const WHITESPACE_RE = /\s/ const AT_RULE_NAME_RE = /@([\w-]+)/ const MULTI_SPACE_RE = /\s+/g +function isSafeInstagramOrigin(url: URL): boolean { + return url.protocol === 'https:' + && !url.username + && !url.password + && (!url.port || url.port === '443') + && (url.hostname === 'instagram.com' || url.hostname === 'www.instagram.com') +} + +export function isSafeInstagramPostUrl(url: URL): boolean { + return isSafeInstagramOrigin(url) && INSTAGRAM_POST_PATH_RE.test(url.pathname) +} + +export function isSafeInstagramEmbedUrl(url: URL): boolean { + return isSafeInstagramOrigin(url) && INSTAGRAM_EMBED_PATH_RE.test(url.pathname) +} + +export function isSafeInstagramCssUrl(url: URL): boolean { + return url.protocol === 'https:' + && !url.username + && !url.password + && (!url.port || url.port === '443') + && url.hostname === INSTAGRAM_ASSET_HOST +} + +function removeNode(node: any): void { + node.type = TEXT_NODE + node.value = '' + node.name = undefined + node.attributes = {} + node.children = [] +} + +function isSafeNavigationUrl(value: string): boolean { + if (!/^(?:https?:\/\/|\/|#)/i.test(value)) + return false + try { + const url = new URL(value, 'https://www.instagram.com') + return (url.protocol === 'http:' || url.protocol === 'https:') && !url.username && !url.password + } + catch { + return false + } +} + +function sanitizeCssUrls(css: string, proxyPrefix: string): string { + const allowedPrefix = `${proxyPrefix}/embed/instagram-` + return css.replace(CSS_URL_RE, (match, rawValue: string) => { + const trimmed = rawValue.trim() + const first = trimmed[0] + const value = (first === '"' || first === '\'') && trimmed.at(-1) === first + ? trimmed.slice(1, -1).trim() + : trimmed + return value.startsWith(allowedPrefix) ? match : 'url("")' + }) +} + +function rewriteMediaUrl(url: string, prefix: string, secret?: string): string | undefined { + const rewritten = rewriteUrl(url, prefix, secret) + return rewritten === url ? undefined : rewritten +} + +export function sanitizeInstagramEmbedHtml( + html: string, + prefix: string, + secret?: string, +): { bodyHtml: string, cssUrls: string[] } { + const ast = parse(html) + const cssUrls: string[] = [] + + walkSync(ast, (node) => { + if (node.type !== ELEMENT_NODE) + return + + const tag = String(node.name).toLowerCase() + if (tag === 'link' && node.attributes.rel?.toLowerCase() === 'stylesheet' && node.attributes.href) { + try { + const cssUrl = new URL(node.attributes.href) + if (isSafeInstagramCssUrl(cssUrl)) + cssUrls.push(cssUrl.toString()) + } + catch { + // Invalid stylesheet URLs are removed with the link node. + } + } + if (BLOCKED_EMBED_TAGS.has(tag)) { + removeNode(node) + return + } + + for (const [name, value] of Object.entries(node.attributes as Record)) { + const lowerName = name.toLowerCase() + if (lowerName.startsWith('on') || lowerName === 'srcdoc' || lowerName === 'nonce') { + delete node.attributes[name] + continue + } + if (EMBED_URL_ATTRS.has(lowerName) && !isSafeNavigationUrl(value)) { + delete node.attributes[name] + continue + } + if (lowerName === 'src' || lowerName === 'poster') { + const rewritten = rewriteMediaUrl(value, prefix, secret) + if (rewritten) + node.attributes[name] = rewritten + else + delete node.attributes[name] + continue + } + if (lowerName === 'srcset') { + const entries = value.split(',').flatMap((entry) => { + const [url, descriptor, ...rest] = entry.trim().split(SRCSET_SPLIT_RE) + const rewritten = url ? rewriteMediaUrl(url, prefix, secret) : undefined + const safeDescriptor = !descriptor || /^\d+(?:\.\d+)?[wx]$/.test(descriptor) + return rewritten && safeDescriptor && rest.length === 0 + ? [`${rewritten}${descriptor ? ` ${descriptor}` : ''}`] + : [] + }) + if (entries.length) + node.attributes[name] = entries.join(', ') + else + delete node.attributes[name] + continue + } + if (lowerName === 'style') { + const rewritten = rewriteUrlsInText(value, prefix, secret) + if (DANGEROUS_STYLE_RE.test(rewritten)) + delete node.attributes[name] + else + node.attributes[name] = sanitizeCssUrls(rewritten, prefix) + } + } + + if (tag === 'a' && node.attributes.target?.toLowerCase() === '_blank') + node.attributes.rel = 'noopener noreferrer' + }) + + let bodyNode: any + walkSync(ast, (node) => { + if (node.type === ELEMENT_NODE && node.name === 'body') + bodyNode = node + }) + + return { + bodyHtml: bodyNode + ? bodyNode.children.map((child: any) => renderSync(child)).join('') + : renderSync(ast), + cssUrls, + } +} + +export function sanitizeInstagramEmbedCss( + css: string, + scopeSelector: string, + prefix: string, + secret?: string, +): string { + const rewritten = rewriteUrlsInText( + css.replace(RSRC_RE, (_match, path) => `url(${proxyAssetUrl(`https://${INSTAGRAM_ASSET_HOST}/rsrc.php${path}`, prefix, secret)})`), + prefix, + secret, + ) + return sanitizeCssUrls(scopeCss(rewritten, scopeSelector), prefix).replace(/<\/style/gi, '<\\/style') +} + export function proxyImageUrl(url: string, prefix = '/_scripts', secret?: string): string { return buildProxyUrl(`${prefix}/embed/instagram-image`, { url: url.replace(AMP_RE, '&') }, secret) } diff --git a/packages/script/src/runtime/server/utils/privacy.ts b/packages/script/src/runtime/server/utils/privacy.ts index 582bb563d..acc5fbfca 100644 --- a/packages/script/src/runtime/server/utils/privacy.ts +++ b/packages/script/src/runtime/server/utils/privacy.ts @@ -168,13 +168,34 @@ export const NORMALIZE_PARAMS = { userAgent: ['ua', 'useragent', 'user_agent', 'client_user_agent', 'context.useragent'], } +function expandIPv6(address: string): string[] | undefined { + const halves = address.split('::') + if (halves.length > 2) + return + const left = halves[0] ? halves[0].split(':') : [] + const right = halves[1] ? halves[1].split(':') : [] + const valid = (part: string) => /^[\da-f]{1,4}$/i.test(part) + if (!left.every(valid) || !right.every(valid)) + return + if (halves.length === 1) + return left.length === 8 ? left : undefined + const missing = 8 - left.length - right.length + if (missing < 1) + return + return [...left, ...(Array.from({ length: missing }).fill('0') as string[]), ...right] +} + /** * Anonymize an IP address by zeroing trailing segments. */ export function anonymizeIP(ip: string): string { if (ip.includes(':')) { + const mappedIPv4 = ip.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i)?.[1] + if (mappedIPv4) + return `::ffff:${anonymizeIP(mappedIPv4)}` // IPv6: keep first 3 segments (48 bits) — roughly city/ISP-level aggregation - return `${ip.split(':').slice(0, 3).join(':')}::` + const expanded = expandIPv6(ip.split('%', 1)[0] || '') + return expanded ? `${expanded.slice(0, 3).join(':')}::` : ip } // IPv4: zero last octet (/24 subnet — typically ISP/neighborhood-level precision) const parts = ip.split('.') @@ -400,12 +421,20 @@ function matchesParam(key: string, params: string[]): boolean { }) } +function mapValue(value: unknown, transform: (item: unknown) => unknown): unknown { + return Array.isArray(value) ? value.map(transform) : transform(value) +} + +function mapString(value: unknown, transform: (item: string) => string): unknown { + return mapValue(value, item => typeof item === 'string' ? transform(item) : item) +} + export function stripPayloadFingerprinting( payload: Record, privacy?: ResolvedProxyPrivacy, ): Record { const p = privacy || FULL_PRIVACY - const result: Record = {} + const result: Record = Object.create(null) // Pre-scan for screen width to enable paired height bucketing. // When sw is present, sh maps to the paired height for that device class @@ -413,7 +442,8 @@ export function stripPayloadFingerprinting( let deviceClass: DeviceClass | undefined for (const [key, value] of Object.entries(payload)) { if (key.toLowerCase() === 'sw') { - const num = typeof value === 'number' ? value : Number(value) + const firstValue = Array.isArray(value) ? value[0] : value + const num = typeof firstValue === 'number' ? firstValue : Number(firstValue) if (!Number.isNaN(num)) deviceClass = getDeviceClass(num) } @@ -439,14 +469,14 @@ export function stripPayloadFingerprinting( // User-agent params — controlled by userAgent flag const isUserAgentParam = NORMALIZE_PARAMS.userAgent.some(pm => lowerKey === pm.toLowerCase()) - if (isUserAgentParam && typeof value === 'string') { - result[key] = p.userAgent ? normalizeUserAgent(value) : value + if (isUserAgentParam) { + result[key] = p.userAgent ? mapString(value, normalizeUserAgent) : value continue } // Anonymize IP to subnet — controlled by ip flag - if (matchesParam(key, STRIP_PARAMS.ip) && typeof value === 'string') { - result[key] = p.ip ? anonymizeIP(value) : value + if (matchesParam(key, STRIP_PARAMS.ip)) { + result[key] = p.ip ? mapString(value, anonymizeIP) : value continue } @@ -463,33 +493,34 @@ export function stripPayloadFingerprinting( else if (lowerKey === 'sh' && deviceClass) { // Paired: use height from the device class determined by sw const paired = SCREEN_BUCKETS[deviceClass].h - result[key] = typeof value === 'number' ? paired : String(paired) + result[key] = mapValue(value, item => typeof item === 'number' ? paired : String(paired)) } else { - result[key] = generalizeScreen(value, lowerKey === 'sw' ? 'width' : lowerKey === 'sh' ? 'height' : undefined) + const dimension = lowerKey === 'sw' ? 'width' : lowerKey === 'sh' ? 'height' : undefined + result[key] = mapValue(value, item => generalizeScreen(item, dimension)) } continue } // Generalize hardware capabilities (concurrency, memory) — hardware flag if (matchesParam(key, STRIP_PARAMS.hardware)) { - result[key] = p.hardware ? generalizeHardware(value) : value + result[key] = p.hardware ? mapValue(value, generalizeHardware) : value continue } // Generalize version strings to major version — hardware flag // (OS/platform versions are hardware fingerprinting vectors: d_os, uapv) if (matchesParam(key, STRIP_PARAMS.version)) { - result[key] = p.hardware ? generalizeVersion(value) : value + result[key] = p.hardware ? mapValue(value, generalizeVersion) : value continue } // Generalize browser version lists to major versions — hardware flag // (full version lists enable cross-site fingerprinting: d_bvs, uafvl) if (matchesParam(key, STRIP_PARAMS.browserVersion)) { - result[key] = p.hardware ? generalizeBrowserVersions(value) : value + result[key] = p.hardware ? mapValue(value, generalizeBrowserVersions) : value continue } // Generalize timezone — timezone flag if (matchesParam(key, STRIP_PARAMS.location)) { - result[key] = p.timezone ? generalizeTimezone(value) : value + result[key] = p.timezone ? mapValue(value, generalizeTimezone) : value continue } // Replace browser data lists with empty value — hardware flag @@ -499,7 +530,7 @@ export function stripPayloadFingerprinting( } // Anonymize combined device info (parse and generalize components) — hardware flag if (matchesParam(key, STRIP_PARAMS.deviceInfo)) { - result[key] = p.hardware ? (typeof value === 'string' ? anonymizeDeviceInfo(value) : '') : value + result[key] = p.hardware ? mapString(value, anonymizeDeviceInfo) : value continue } // Platform identifiers are low entropy — keep as-is diff --git a/packages/script/src/runtime/server/utils/proxy-query.ts b/packages/script/src/runtime/server/utils/proxy-query.ts new file mode 100644 index 000000000..07bdf819e --- /dev/null +++ b/packages/script/src/runtime/server/utils/proxy-query.ts @@ -0,0 +1,13 @@ +import { PAGE_TOKEN_PARAM, PAGE_TOKEN_TS_PARAM, SIG_PARAM } from './sign-constants' + +const PROXY_AUTH_PARAMS = new Set([SIG_PARAM, PAGE_TOKEN_PARAM, PAGE_TOKEN_TS_PARAM]) + +/** Remove proxy-only credentials before constructing an upstream URL or cache key. */ +export function stripProxyAuthQuery(query: Record): Record { + const upstreamQuery: Record = Object.create(null) + for (const [key, value] of Object.entries(query)) { + if (!PROXY_AUTH_PARAMS.has(key)) + upstreamQuery[key] = value + } + return upstreamQuery +} diff --git a/packages/script/src/runtime/server/x-embed.ts b/packages/script/src/runtime/server/x-embed.ts index bca7c8bd4..622b4df23 100644 --- a/packages/script/src/runtime/server/x-embed.ts +++ b/packages/script/src/runtime/server/x-embed.ts @@ -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 { rewriteTweetImages } from './utils/embed-rewriters' import { withSigning } from './utils/withSigning' @@ -62,6 +62,10 @@ const cachedTweetFetch = createCachedJsonFetch( const match = url.match(TWEET_ID_FROM_URL_RE) return match?.[1] || url }, + { + allowUrl: url => isSafeHttpsUrl(url) && url.hostname === 'cdn.syndication.twimg.com', + contentTypePrefixes: ['application/json'], + }, ) export default withSigning(defineEventHandler(async (event) => { diff --git a/test/unit/cached-upstream.test.ts b/test/unit/cached-upstream.test.ts index b69ccebf6..32d727485 100644 --- a/test/unit/cached-upstream.test.ts +++ b/test/unit/cached-upstream.test.ts @@ -1,8 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { cacheDefinitions, hashMock } = vi.hoisted(() => ({ +const { cacheDefinitions, hashMock, rawFetchMock } = vi.hoisted(() => ({ cacheDefinitions: [] as Array<{ getKey?: (...args: any[]) => string }>, hashMock: vi.fn((value: unknown) => `hashed:${JSON.stringify(value)}`), + rawFetchMock: vi.fn(), })) vi.mock('nitropack/runtime', () => ({ @@ -17,7 +18,7 @@ vi.mock('ohash', () => ({ })) vi.mock('ofetch', () => ({ - $fetch: Object.assign(vi.fn(), { raw: vi.fn() }), + $fetch: Object.assign(vi.fn(), { raw: rawFetchMock }), })) const { createCachedBinaryFetch, createCachedJsonFetch } = await import( @@ -27,12 +28,15 @@ const { createCachedBinaryFetch, createCachedJsonFetch } = await import( beforeEach(() => { cacheDefinitions.length = 0 hashMock.mockClear() + rawFetchMock.mockReset() }) describe('upstream cache keys', () => { it('hashes normalized JSON cache keys before passing them to Nitro storage', () => { const normalizeKey = vi.fn((url: string) => new URL(url).searchParams.get('actor') || url) - createCachedJsonFetch('profile', 60, normalizeKey) + createCachedJsonFetch('profile', 60, normalizeKey, { + allowUrl: url => url.hostname === 'public.api.bsky.app', + }) const getKey = cacheDefinitions[0]?.getKey expect(getKey).toBeTypeOf('function') @@ -41,6 +45,22 @@ describe('upstream cache keys', () => { expect(hashMock).toHaveBeenCalledWith('nuxt.com') }) + it('rejects cached JSON redirects outside the upstream policy before fetching them', async () => { + rawFetchMock.mockResolvedValueOnce({ + _data: undefined, + headers: new Headers({ location: 'http://127.0.0.1/private' }), + status: 302, + }) + const fetchJson = createCachedJsonFetch('profile', 60, url => url, { + allowUrl: url => url.protocol === 'https:' && url.hostname === 'public.api.bsky.app', + }) + + await expect(fetchJson('https://public.api.bsky.app/profile')) + .rejects + .toMatchObject({ statusCode: 403 }) + expect(rawFetchMock).toHaveBeenCalledOnce() + }) + it('hashes binary URLs instead of exposing reserved characters to storage', () => { createCachedBinaryFetch('image', 60) @@ -68,10 +88,62 @@ describe('upstream cache keys', () => { const getKey = cacheDefinitions[0]?.getKey const key = getKey?.('https://www.instagram.com/p/example/embed/captioned/', { - 'User-Agent': 'Nuxt Scripts', - 'Accept': 'text/html', + headers: { + 'User-Agent': 'Nuxt Scripts', + 'Accept': 'text/html', + }, + }) + + expect(key).toBe('hashed:"https://www.instagram.com/p/example/embed/captioned/\\nAccept=text/html\\nUser-Agent=Nuxt Scripts"') + }) + + it('checks every redirect target before fetching it', async () => { + rawFetchMock.mockResolvedValueOnce({ + _data: undefined, + headers: new Headers({ location: 'http://127.0.0.1/private' }), + status: 302, }) + const fetchBinary = createCachedBinaryFetch('image', 60, { + allowUrl: url => url.hostname === 'cdn.example.com', + }) + + await expect(fetchBinary('https://cdn.example.com/image', { redirect: 'follow' })) + .rejects + .toMatchObject({ statusCode: 403 }) + expect(rawFetchMock).toHaveBeenCalledOnce() + expect(rawFetchMock).toHaveBeenCalledWith('https://cdn.example.com/image', expect.objectContaining({ redirect: 'manual' })) + }) + + it('refuses automatic redirects when the factory has no redirect policy', async () => { + const fetchBinary = createCachedBinaryFetch('image', 60) + + await expect(fetchBinary('https://cdn.example.com/image', { redirect: 'follow' })) + .rejects + .toThrow('redirect policy') + expect(rawFetchMock).not.toHaveBeenCalled() + }) + + it('follows an allowlisted redirect and returns the final response', async () => { + rawFetchMock + .mockResolvedValueOnce({ + _data: undefined, + headers: new Headers({ location: '/final' }), + status: 302, + }) + .mockResolvedValueOnce({ + _data: new TextEncoder().encode('image').buffer, + headers: new Headers({ 'content-type': 'image/png' }), + status: 200, + }) + const fetchBinary = createCachedBinaryFetch('image', 60, { + allowUrl: url => url.hostname === 'cdn.example.com', + }) + + const result = await fetchBinary('https://cdn.example.com/image', { redirect: 'follow' }) - expect(key).toBe('hashed:["https://www.instagram.com/p/example/embed/captioned/","Accept=text/html","User-Agent=Nuxt Scripts"]') + expect(result.body.toString()).toBe('image') + expect(result.contentType).toBe('image/png') + expect(rawFetchMock).toHaveBeenCalledTimes(2) + expect(rawFetchMock).toHaveBeenLastCalledWith('https://cdn.example.com/final', expect.objectContaining({ redirect: 'manual' })) }) }) diff --git a/test/unit/google-geocode-proxy.test.ts b/test/unit/google-geocode-proxy.test.ts new file mode 100644 index 000000000..6d1a46795 --- /dev/null +++ b/test/unit/google-geocode-proxy.test.ts @@ -0,0 +1,67 @@ +import type { Server } from 'node:http' +import { createServer } from 'node:http' +import { createApp, toNodeListener } from 'h3' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { PAGE_TOKEN_PARAM, PAGE_TOKEN_TS_PARAM, SIG_PARAM } from '../../packages/script/src/runtime/server/utils/sign-constants' + +const { rawFetchMock } = vi.hoisted(() => ({ rawFetchMock: vi.fn() })) + +vi.mock('nitropack/runtime', () => ({ + defineCachedFunction: (handler: (...args: any[]) => any) => handler, + useRuntimeConfig: () => ({ + 'nuxt-scripts': { googleMapsGeocodeProxy: { apiKey: 'server-key' } }, + 'public': {}, + }), +})) + +vi.mock('ofetch', () => ({ + $fetch: Object.assign(vi.fn(), { raw: rawFetchMock }), +})) + +const geocodeHandler = (await import('../../packages/script/src/runtime/server/google-maps-geocode-proxy')).default + +describe('google geocode proxy', () => { + let proxyServer: Server + let proxyPort: number + + beforeAll(async () => { + const app = createApp() + app.use(geocodeHandler) + proxyServer = createServer(toNodeListener(app)) + await new Promise(resolve => proxyServer.listen(0, '127.0.0.1', resolve)) + proxyPort = (proxyServer.address() as { port: number }).port + }) + + beforeEach(() => { + rawFetchMock.mockReset() + rawFetchMock.mockResolvedValue({ + _data: { status: 'OK', results: [] }, + headers: new Headers({ 'content-type': 'application/json' }), + status: 200, + }) + }) + + afterAll(async () => { + await new Promise(resolve => proxyServer.close(() => resolve())) + }) + + it('never forwards proxy credentials or a client API key upstream', async () => { + const query = new URLSearchParams({ + address: 'Melbourne', + key: 'client-key', + [SIG_PARAM]: 'signature', + [PAGE_TOKEN_PARAM]: 'page-token', + [PAGE_TOKEN_TS_PARAM]: '123', + }) + + const response = await fetch(`http://127.0.0.1:${proxyPort}/?${query}`) + + expect(response.status).toBe(200) + const upstreamUrl = new URL(rawFetchMock.mock.calls[0]![0] as string) + expect(upstreamUrl.searchParams.get('address')).toBe('Melbourne') + expect(upstreamUrl.searchParams.get('key')).toBe('server-key') + expect(upstreamUrl.searchParams.has(SIG_PARAM)).toBe(false) + expect(upstreamUrl.searchParams.has(PAGE_TOKEN_PARAM)).toBe(false) + expect(upstreamUrl.searchParams.has(PAGE_TOKEN_TS_PARAM)).toBe(false) + }) +}) diff --git a/test/unit/google-static-map-proxy.test.ts b/test/unit/google-static-map-proxy.test.ts new file mode 100644 index 000000000..86a34d11e --- /dev/null +++ b/test/unit/google-static-map-proxy.test.ts @@ -0,0 +1,79 @@ +import type { Server } from 'node:http' +import { createServer } from 'node:http' +import { createApp, toNodeListener } from 'h3' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { PAGE_TOKEN_PARAM, PAGE_TOKEN_TS_PARAM, SIG_PARAM } from '../../packages/script/src/runtime/server/utils/sign-constants' + +const { rawFetchMock } = vi.hoisted(() => ({ rawFetchMock: vi.fn() })) + +vi.mock('nitropack/runtime', () => ({ + defineCachedFunction: (handler: (...args: any[]) => any) => handler, + useRuntimeConfig: () => ({ + 'nuxt-scripts': { googleStaticMapsProxy: { apiKey: 'server-key' } }, + 'public': { 'nuxt-scripts': { googleStaticMapsProxy: { enabled: true, cacheMaxAge: 60 } } }, + }), +})) + +vi.mock('ofetch', () => ({ + $fetch: Object.assign(vi.fn(), { raw: rawFetchMock }), +})) + +const staticMapHandler = (await import('../../packages/script/src/runtime/server/google-static-maps-proxy')).default + +describe('google static map proxy', () => { + let proxyServer: Server + let proxyPort: number + + beforeAll(async () => { + const app = createApp() + app.use(staticMapHandler) + proxyServer = createServer(toNodeListener(app)) + await new Promise(resolve => proxyServer.listen(0, '127.0.0.1', resolve)) + proxyPort = (proxyServer.address() as { port: number }).port + }) + + beforeEach(() => { + rawFetchMock.mockReset() + rawFetchMock.mockResolvedValue({ + _data: new TextEncoder().encode('image').buffer, + headers: new Headers({ 'content-type': 'image/png' }), + status: 200, + }) + }) + + afterAll(async () => { + await new Promise(resolve => proxyServer.close(() => resolve())) + }) + + it('strips proxy credentials and the client key from the upstream URL', async () => { + const query = new URLSearchParams({ + center: 'Melbourne', + key: 'client-key', + [SIG_PARAM]: 'signature', + [PAGE_TOKEN_PARAM]: 'page-token', + [PAGE_TOKEN_TS_PARAM]: '123', + }) + + const response = await fetch(`http://127.0.0.1:${proxyPort}/?${query}`) + + expect(response.status).toBe(200) + const upstreamUrl = new URL(rawFetchMock.mock.calls[0]![0] as string) + expect(upstreamUrl.searchParams.get('center')).toBe('Melbourne') + expect(upstreamUrl.searchParams.get('key')).toBe('server-key') + expect(upstreamUrl.searchParams.has(SIG_PARAM)).toBe(false) + expect(upstreamUrl.searchParams.has(PAGE_TOKEN_PARAM)).toBe(false) + expect(upstreamUrl.searchParams.has(PAGE_TOKEN_TS_PARAM)).toBe(false) + }) + + it('rejects active content returned by the map upstream', async () => { + rawFetchMock.mockResolvedValueOnce({ + _data: new TextEncoder().encode('').buffer, + headers: new Headers({ 'content-type': 'text/html' }), + status: 200, + }) + + const response = await fetch(`http://127.0.0.1:${proxyPort}/?center=Melbourne`) + + expect(response.status).toBe(415) + }) +}) diff --git a/test/unit/gravatar-proxy-handler.test.ts b/test/unit/gravatar-proxy-handler.test.ts new file mode 100644 index 000000000..24e8ec721 --- /dev/null +++ b/test/unit/gravatar-proxy-handler.test.ts @@ -0,0 +1,71 @@ +import type { Server } from 'node:http' +import { createServer } from 'node:http' +import { createApp, toNodeListener } from 'h3' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { rawFetchMock } = vi.hoisted(() => ({ rawFetchMock: vi.fn() })) + +vi.mock('nitropack/runtime', () => ({ + defineCachedFunction: (handler: (...args: any[]) => any) => handler, + useRuntimeConfig: () => ({ public: { 'nuxt-scripts': { gravatarProxy: { cacheMaxAge: 60 } } } }), +})) + +vi.mock('ofetch', () => ({ + $fetch: Object.assign(vi.fn(), { raw: rawFetchMock }), +})) + +const gravatarHandler = (await import('../../packages/script/src/runtime/server/gravatar-proxy')).default +const VALID_HASH = 'a'.repeat(64) + +describe('gravatar proxy input and response boundaries', () => { + let proxyServer: Server + let proxyPort: number + + beforeAll(async () => { + const app = createApp() + app.use(gravatarHandler) + proxyServer = createServer(toNodeListener(app)) + await new Promise(resolve => proxyServer.listen(0, '127.0.0.1', resolve)) + proxyPort = (proxyServer.address() as { port: number }).port + }) + + beforeEach(() => { + rawFetchMock.mockReset() + rawFetchMock.mockResolvedValue({ + _data: new TextEncoder().encode('image').buffer, + headers: new Headers({ 'content-type': 'image/png' }), + status: 200, + }) + }) + + afterAll(async () => { + await new Promise(resolve => proxyServer.close(() => resolve())) + }) + + function request(query: string) { + return fetch(`http://127.0.0.1:${proxyPort}/?${query}`) + } + + it.each([ + [`hash=${'a'.repeat(63)}`, 'invalid hash'], + [`hash=${VALID_HASH}&s=2049`, 'oversized image'], + [`hash=${VALID_HASH}&r=unrated`, 'invalid rating'], + ])('rejects %s before calling Gravatar', async (query) => { + const response = await request(query) + + expect(response.status).toBe(400) + expect(rawFetchMock).not.toHaveBeenCalled() + }) + + it('rejects active content returned by the image upstream', async () => { + rawFetchMock.mockResolvedValueOnce({ + _data: new TextEncoder().encode('').buffer, + headers: new Headers({ 'content-type': 'text/html' }), + status: 200, + }) + + const response = await request(`hash=${VALID_HASH}`) + + expect(response.status).toBe(415) + }) +}) diff --git a/test/unit/image-proxy-security.test.ts b/test/unit/image-proxy-security.test.ts new file mode 100644 index 000000000..daa33b29b --- /dev/null +++ b/test/unit/image-proxy-security.test.ts @@ -0,0 +1,92 @@ +import type { Server } from 'node:http' +import { createServer } from 'node:http' +import { createApp, toNodeListener } from 'h3' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +vi.mock('nitropack/runtime', () => ({ + defineCachedFunction: (handler: (...args: any[]) => any) => handler, + useRuntimeConfig: () => ({}), +})) + +const { createImageProxyHandler } = await import('../../packages/script/src/runtime/server/utils/image-proxy') + +describe('image proxy security', () => { + let upstreamServer: Server + let proxyServer: Server + let upstreamPort: number + let proxyPort: number + let disallowedServer: Server + let disallowedPort: number + let disallowedRequests = 0 + + beforeAll(async () => { + disallowedServer = createServer((_req, res) => { + disallowedRequests++ + res.writeHead(200, { 'content-type': 'image/png' }) + res.end('private') + }) + await new Promise(resolve => disallowedServer.listen(0, '127.0.0.1', resolve)) + disallowedPort = (disallowedServer.address() as { port: number }).port + + upstreamServer = createServer((req, res) => { + if (req.url === '/same-host-redirect') { + res.writeHead(302, { location: '/image' }) + res.end() + return + } + if (req.url === '/cross-host-redirect') { + res.writeHead(302, { location: `http://localhost:${disallowedPort}/private` }) + res.end() + return + } + if (req.url === '/html') { + res.writeHead(200, { 'content-type': 'text/html' }) + res.end('') + return + } + res.writeHead(200, { 'content-type': 'image/png' }) + res.end('image') + }) + await new Promise(resolve => upstreamServer.listen(0, '127.0.0.1', resolve)) + upstreamPort = (upstreamServer.address() as { port: number }).port + + const app = createApp() + app.use(createImageProxyHandler({ allowedDomains: ['127.0.0.1'] })) + proxyServer = createServer(toNodeListener(app)) + await new Promise(resolve => proxyServer.listen(0, '127.0.0.1', resolve)) + proxyPort = (proxyServer.address() as { port: number }).port + }) + + afterAll(async () => { + await Promise.all([ + new Promise(resolve => upstreamServer.close(() => resolve())), + new Promise(resolve => proxyServer.close(() => resolve())), + new Promise(resolve => disallowedServer.close(() => resolve())), + ]) + }) + + function proxyUrl(path: string): string { + const target = `http://127.0.0.1:${upstreamPort}${path}` + return `http://127.0.0.1:${proxyPort}/?url=${encodeURIComponent(target)}` + } + + it('follows redirects only while every target remains allowlisted', async () => { + const response = await fetch(proxyUrl('/same-host-redirect')) + + expect(response.status).toBe(200) + expect(await response.text()).toBe('image') + }) + + it('rejects a redirect to a hostname outside the allowlist before fetching it', async () => { + const response = await fetch(proxyUrl('/cross-host-redirect')) + + expect(response.status).toBe(403) + expect(disallowedRequests).toBe(0) + }) + + it('rejects active upstream content from an image route', async () => { + const response = await fetch(proxyUrl('/html')) + + expect(response.status).toBe(415) + }) +}) diff --git a/test/unit/instagram-embed.test.ts b/test/unit/instagram-embed.test.ts index cdef13d67..4a2e58e1a 100644 --- a/test/unit/instagram-embed.test.ts +++ b/test/unit/instagram-embed.test.ts @@ -2,12 +2,58 @@ import { ELEMENT_NODE, parse, renderSync, TEXT_NODE, walkSync } from 'ultrahtml' import { describe, expect, it } from 'vitest' import { isEmbedShell, + isSafeInstagramEmbedUrl, + isSafeInstagramPostUrl, proxyImageUrl, rewriteUrl, rewriteUrlsInText, + sanitizeInstagramEmbedCss, + sanitizeInstagramEmbedHtml, scopeCss, } from '../../packages/script/src/runtime/server/utils/instagram-embed' +describe('instagram-embed: active content isolation', () => { + it('accepts only canonical HTTPS post URLs', () => { + expect(isSafeInstagramPostUrl(new URL('https://www.instagram.com/p/ABC123/'))).toBe(true) + expect(isSafeInstagramPostUrl(new URL('http://www.instagram.com/p/ABC123/'))).toBe(false) + expect(isSafeInstagramPostUrl(new URL('https://user@www.instagram.com/p/ABC123/'))).toBe(false) + expect(isSafeInstagramPostUrl(new URL('https://www.instagram.com/accounts/login/'))).toBe(false) + expect(isSafeInstagramEmbedUrl(new URL('https://www.instagram.com/p/ABC123/embed/captioned/'))).toBe(true) + }) + + it('removes executable nodes and attributes before v-html rendering', () => { + const html = [ + '', + '
safe
', + '', + '', + '', + '', + 'bad link', + '', + '', + '', + ].join('') + + const result = sanitizeInstagramEmbedHtml(html, '/_scripts') + + expect(result.bodyHtml).not.toMatch(/onclick|onerror| { + const css = '.safe { background: url(https://static.cdninstagram.com/a.png) }\n' + + '.leak { background: url(https://evil.example/pixel) }' + + const result = sanitizeInstagramEmbedCss(css, '.instagram-embed-root', '/_scripts') + + expect(result).not.toContain(' { it('proxies scontent CDN image URLs', () => { const url = 'https://scontent-lax3-1.cdninstagram.com/v/t51.2885-15/photo.jpg?stp=dst-jpg' diff --git a/test/unit/proxy-alias-generators.test.ts b/test/unit/proxy-alias-generators.test.ts index 5833f21dd..5f369903a 100644 --- a/test/unit/proxy-alias-generators.test.ts +++ b/test/unit/proxy-alias-generators.test.ts @@ -26,6 +26,13 @@ describe('proxy alias - generated runtime code (#814)', () => { ) expect(out.pathname).toBe('/_scripts/p/eu.i.posthog.com/e/') }) + + it('leaves non-HTTP protocols unresolved', () => { + // eslint-disable-next-line no-eval + const resolveUrl = eval(`(${generatePartytownResolveUrl('/_scripts/p')})`) + + expect(resolveUrl(new URL('data:text/plain,hello'), new URL('https://my-site.test/'))).toBeUndefined() + }) }) describe('generateInterceptPluginContents', () => { @@ -33,6 +40,7 @@ describe('proxy alias - generated runtime code (#814)', () => { const code = generateInterceptPluginContents('/_scripts/p', { domainAliases: ALIASES }) expect(code).toContain('const domainAliases = {"us.i.posthog.com":"ph"}') expect(code).toContain('domainAliases[parsed.host] || parsed.host') + expect(code).toContain('parsed.protocol === \'http:\' || parsed.protocol === \'https:\'') }) it('defaults to an empty alias map', () => { diff --git a/test/unit/proxy-alias.test.ts b/test/unit/proxy-alias.test.ts index 60d7284d8..8600ca174 100644 --- a/test/unit/proxy-alias.test.ts +++ b/test/unit/proxy-alias.test.ts @@ -49,6 +49,16 @@ describe('proxy-alias', () => { const map = buildDomainAliasMap(['a.example.com', '*.example.com'], true) expect(Object.keys(map)).toEqual(['a.example.com']) }) + + it('stores prototype-like domains as ordinary keys', () => { + const aliases: Record = Object.create(null) + Object.defineProperty(aliases, '__proto__', { value: 'proto', enumerable: true }) + const map = buildDomainAliasMap(['__proto__'], aliases) + + expect(Object.getPrototypeOf(map)).toBe(Object.prototype) + expect(Object.hasOwn(map, '__proto__')).toBe(true) + expect(Object.getOwnPropertyDescriptor(map, '__proto__')?.value).toBe('proto') + }) }) describe('isSafeAliasSegment', () => { @@ -64,6 +74,8 @@ describe('proxy-alias', () => { expect(isSafeAliasSegment('a?b')).toBe(false) expect(isSafeAliasSegment('a#b')).toBe(false) expect(isSafeAliasSegment('a b')).toBe(false) + expect(isSafeAliasSegment('.')).toBe(false) + expect(isSafeAliasSegment('..')).toBe(false) expect(isSafeAliasSegment('')).toBe(false) }) }) @@ -72,6 +84,13 @@ describe('proxy-alias', () => { it('inverts domain → alias into alias → domain', () => { expect(invertAliasMap({ 'us.i.posthog.com': 'ph' })).toEqual({ ph: 'us.i.posthog.com' }) }) + + it('stores prototype-like aliases as ordinary keys', () => { + const map = invertAliasMap({ 'us.i.posthog.com': '__proto__' }) + + expect(Object.getPrototypeOf(map)).toBe(Object.prototype) + expect(Object.getOwnPropertyDescriptor(map, '__proto__')?.value).toBe('us.i.posthog.com') + }) }) describe('aliasProxyValue', () => { diff --git a/test/unit/proxy-handler-body.test.ts b/test/unit/proxy-handler-body.test.ts index f1932946f..fe8a23d06 100644 --- a/test/unit/proxy-handler-body.test.ts +++ b/test/unit/proxy-handler-body.test.ts @@ -1,7 +1,7 @@ import type { Server } from 'node:http' import { createServer } from 'node:http' import { gzipSync } from 'node:zlib' -import { createApp, defineEventHandler, readRawBody, toNodeListener } from 'h3' +import { createApp, defineEventHandler, getRequestURL, readRawBody, sendRedirect, setHeader, toNodeListener } from 'h3' import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import proxyHandler from '../../packages/script/src/runtime/server/proxy-handler' @@ -28,12 +28,33 @@ describe('proxy handler request bodies (#836)', () => { let capturedBody = Buffer.alloc(0) let capturedContentLength: string | undefined let capturedContentType: string | undefined + let capturedUrl = '' + let releaseStream: (() => void) | undefined const realFetch = globalThis.fetch beforeAll(async () => { const upstreamApp = createApp() upstreamApp.use('/', defineEventHandler(async (event) => { - const rawBody = await readRawBody(event, false) + capturedUrl = getRequestURL(event).pathname + getRequestURL(event).search + if (getRequestURL(event).pathname === '/redirect') + return sendRedirect(event, '/redirected', 302) + if (getRequestURL(event).pathname === '/response-hop-headers') { + setHeader(event, 'Connection', 'x-upstream-hop') + setHeader(event, 'Clear-Site-Data', '"*"') + setHeader(event, 'Strict-Transport-Security', 'max-age=0') + setHeader(event, 'X-Upstream-Hop', 'must-not-forward') + setHeader(event, 'X-End-To-End', 'forward-me') + } + if (getRequestURL(event).pathname === '/stream') { + event.node.res.writeHead(200, { 'content-type': 'text/plain' }) + event.node.res.write('first') + await new Promise((resolve) => { + releaseStream = resolve + }) + event.node.res.end('second') + return + } + const rawBody = event.method === 'GET' ? undefined : await readRawBody(event, false) capturedBody = rawBody ? Buffer.from(rawBody) : Buffer.alloc(0) capturedContentLength = event.headers.get('content-length') ?? undefined capturedContentType = event.headers.get('content-type') ?? undefined @@ -65,6 +86,8 @@ describe('proxy handler request bodies (#836)', () => { capturedBody = Buffer.alloc(0) capturedContentLength = undefined capturedContentType = undefined + capturedUrl = '' + releaseStream = undefined }) afterAll(async () => { @@ -156,4 +179,44 @@ describe('proxy handler request bodies (#836)', () => { expect(response.status).toBe(400) expect(capturedBody).toHaveLength(0) }) + + it('preserves repeated query parameters while applying privacy transforms', async () => { + const response = await realFetch(`http://127.0.0.1:${proxyPort}/_scripts/p/upstream.test/collect?tag=a&tag=b&hardwareConcurrency=128`) + + expect(response.status).toBe(200) + expect(capturedUrl).toBe('/collect?tag=a&tag=b&hardwareConcurrency=16') + }) + + it('rejects upstream redirects instead of following an unchecked target', async () => { + const response = await realFetch(`http://127.0.0.1:${proxyPort}/_scripts/p/upstream.test/redirect`) + + expect(response.status).toBe(502) + expect(capturedUrl).toBe('/redirect') + }) + + it('strips response headers named by the upstream Connection header', async () => { + const response = await realFetch(`http://127.0.0.1:${proxyPort}/_scripts/p/upstream.test/response-hop-headers`) + + expect(response.status).toBe(200) + expect(response.headers.get('x-upstream-hop')).toBeNull() + expect(response.headers.get('clear-site-data')).toBeNull() + expect(response.headers.get('strict-transport-security')).toBeNull() + expect(response.headers.get('x-end-to-end')).toBe('forward-me') + expect(response.headers.get('content-security-policy')).toBe('sandbox; default-src \'none\'; base-uri \'none\'; form-action \'none\'') + expect(response.headers.get('x-content-type-options')).toBe('nosniff') + }) + + it('starts the downstream response before the upstream body completes', async () => { + const responsePromise = realFetch(`http://127.0.0.1:${proxyPort}/_scripts/p/upstream.test/stream`) + const receivedHeadersBeforeCompletion = await Promise.race([ + responsePromise.then(() => true), + new Promise(resolve => setTimeout(resolve, 100, false)), + ]) + + releaseStream?.() + const response = await responsePromise + + expect(receivedHeadersBeforeCompletion).toBe(true) + expect(await response.text()).toBe('firstsecond') + }) }) diff --git a/test/unit/proxy-privacy.test.ts b/test/unit/proxy-privacy.test.ts index 7dc73ece2..dbc909dcc 100644 --- a/test/unit/proxy-privacy.test.ts +++ b/test/unit/proxy-privacy.test.ts @@ -1,6 +1,6 @@ import type { ResolvedProxyPrivacy } from '../../packages/script/src/runtime/server/utils/privacy' import { describe, expect, it } from 'vitest' -import { mergePrivacy, resolvePrivacy, stripPayloadFingerprinting } from '../../packages/script/src/runtime/server/utils/privacy' +import { anonymizeIP, mergePrivacy, resolvePrivacy, stripPayloadFingerprinting } from '../../packages/script/src/runtime/server/utils/privacy' import { ALLOWED_PARAMS, NORMALIZE_PARAMS, @@ -515,4 +515,34 @@ describe('selective privacy in stripPayloadFingerprinting', () => { expect(result.sr).toBe('1920x1080') expect(result.timezone).toBe('UTC') }) + + it('anonymizes every repeated fingerprinting value', () => { + const result = stripPayloadFingerprinting({ + uip: ['192.168.1.100', '10.20.30.40'], + ua: ['Chrome/120.0.0.0', 'Firefox/121.0'], + }) + + expect(result.uip).toEqual(['192.168.1.0', '10.20.30.0']) + expect(result.ua).toEqual([ + 'Mozilla/5.0 (compatible; Chrome/120.0)', + 'Mozilla/5.0 (compatible; Firefox/121.0)', + ]) + }) + + it('preserves dangerous property names as data without mutating the result prototype', () => { + const payload = JSON.parse('{"__proto__":{"polluted":true},"constructor":"value"}') as Record + const result = stripPayloadFingerprinting(payload) + + expect(Object.getPrototypeOf(result)).toBeNull() + expect(Object.hasOwn(result, '__proto__')).toBe(true) + expect(Object.getOwnPropertyDescriptor(result, '__proto__')?.value).toEqual({ polluted: true }) + expect(result.constructor).toBe('value') + }) +}) + +describe('anonymizeIP', () => { + it('returns a valid IPv6 /48 network for compressed and expanded addresses', () => { + expect(anonymizeIP('2001:db8::1')).toBe('2001:db8:0::') + expect(anonymizeIP('2001:0db8:1234:5678:9abc:def0:1234:5678')).toBe('2001:0db8:1234::') + }) }) diff --git a/test/unit/resolve-proxy-secret.test.ts b/test/unit/resolve-proxy-secret.test.ts index 21a3315ff..19c2461e7 100644 --- a/test/unit/resolve-proxy-secret.test.ts +++ b/test/unit/resolve-proxy-secret.test.ts @@ -80,6 +80,16 @@ describe('resolveProxySecret', () => { expect(matches).toHaveLength(1) }) + it('replaces an empty persisted secret instead of returning an ephemeral value', () => { + writeFileSync(join(testDir, '.env'), `${ENV_KEY}=\n`) + + const result = resolveProxySecret(testDir, true) + + expect(result?.source).toBe('dotenv-generated') + expect(result?.secret).toHaveLength(64) + expect(readFileSync(join(testDir, '.env'), 'utf-8')).toBe(`${ENV_KEY}=${result?.secret}\n`) + }) + it('populates process.env after generating a new secret', () => { resolveProxySecret(testDir, true) expect(process.env[ENV_KEY]).toBeDefined() diff --git a/test/unit/setup.test.ts b/test/unit/setup.test.ts index 7338d67ca..6075948f3 100644 --- a/test/unit/setup.test.ts +++ b/test/unit/setup.test.ts @@ -118,6 +118,13 @@ describe('resolveConfiguredProxyDomains', () => { }, umamiProxyConfig)).toEqual([]) }) + it('ignores configured domains that do not use HTTP', () => { + expect(resolveConfiguredProxyDomains({ + hostUrl: 'ftp://analytics.example.com/events', + scriptInput: { src: 'javascript://scripts.example.com/payload' }, + }, umamiProxyConfig)).toEqual([]) + }) + it('deduplicates equivalent domains', () => { expect(resolveConfiguredProxyDomains({ hostUrl: 'https://analytics.example.com', From 185a5cddf0207e883755299bf2b618d4133dd7a2 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 22 Jul 2026 14:54:33 +1000 Subject: [PATCH 2/5] fix(proxy): reject local network targets --- docs/content/docs/1.guides/2.first-party.md | 2 +- packages/script/src/module.ts | 3 +- .../src/runtime/server/proxy-handler.ts | 9 +++ .../runtime/server/utils/cached-upstream.ts | 2 + .../src/runtime/server/utils/image-proxy.ts | 2 + .../src/runtime/server/utils/network-host.ts | 70 +++++++++++++++++++ test/unit/image-proxy-security.test.ts | 26 ++++++- test/unit/network-host.test.ts | 31 ++++++++ test/unit/proxy-handler-body.test.ts | 7 ++ test/unit/setup.test.ts | 7 ++ 10 files changed, 154 insertions(+), 5 deletions(-) create mode 100644 packages/script/src/runtime/server/utils/network-host.ts create mode 100644 test/unit/network-host.test.ts diff --git a/docs/content/docs/1.guides/2.first-party.md b/docs/content/docs/1.guides/2.first-party.md index 7aa0f73cc..4140ee127 100644 --- a/docs/content/docs/1.guides/2.first-party.md +++ b/docs/content/docs/1.guides/2.first-party.md @@ -358,7 +358,7 @@ export default defineNuxtConfig({ 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"} -Runtime proxy fetches 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. +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 diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index 3c5eac281..2bd4fca6c 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -41,6 +41,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' @@ -258,7 +259,7 @@ function resolveConfiguredProxyDomain(value: unknown): string | undefined { const url = new URL(trimmed, 'https://nuxt-scripts.local') if (url.protocol !== 'http:' && url.protocol !== 'https:') return - return url.hostname || undefined + return isPublicNetworkHostname(url.hostname) ? url.hostname : undefined } catch { // Invalid user-provided proxy domains cannot be normalized. diff --git a/packages/script/src/runtime/server/proxy-handler.ts b/packages/script/src/runtime/server/proxy-handler.ts index 6fac96b1e..68253e73e 100644 --- a/packages/script/src/runtime/server/proxy-handler.ts +++ b/packages/script/src/runtime/server/proxy-handler.ts @@ -2,6 +2,7 @@ import type { ProxyPrivacyInput, ResolvedProxyPrivacy } from './utils/privacy' import { createError, defineEventHandler, getHeaders, getQuery, getRequestIP, getRequestWebStream, readBody, readRawBody, sendStream, setResponseHeader, setResponseStatus } from 'h3' import { useNitroApp, useRuntimeConfig } from 'nitropack/runtime' import { matchDomain } from './utils/match-domain' +import { isPublicNetworkHostname } from './utils/network-host' import { anonymizeIP, mergePrivacy, @@ -125,6 +126,14 @@ export default defineEventHandler(async (event) => { }) } + if (!isPublicNetworkHostname(domain)) { + log('[proxy] Rejected local or non-public target:', domain) + throw createError({ + statusCode: 403, + statusMessage: 'Local network targets are not allowed', + }) + } + // Find privacy config by matching domain (exact, parent domain, or wildcard pattern) let perScriptInput: ProxyPrivacyInput | undefined for (const [configDomain, privacyInput] of Object.entries(domainPrivacy)) { diff --git a/packages/script/src/runtime/server/utils/cached-upstream.ts b/packages/script/src/runtime/server/utils/cached-upstream.ts index 39e8595af..983e9e74b 100644 --- a/packages/script/src/runtime/server/utils/cached-upstream.ts +++ b/packages/script/src/runtime/server/utils/cached-upstream.ts @@ -3,6 +3,7 @@ import { Buffer } from 'node:buffer' import { defineCachedFunction } from 'nitropack/runtime' import { $fetch } from 'ofetch' import { hash } from 'ohash' +import { isPublicNetworkHostname } from './network-host' /** * Server-side caches for upstream proxy fetches. @@ -67,6 +68,7 @@ export function isSafeHttpsUrl(url: URL): boolean { && !url.username && !url.password && (!url.port || url.port === '443') + && isPublicNetworkHostname(url.hostname) } function upstreamError(message: string, statusCode: number, statusMessage: string, cause?: unknown): Error { diff --git a/packages/script/src/runtime/server/utils/image-proxy.ts b/packages/script/src/runtime/server/utils/image-proxy.ts index f0fba05e5..68c7f4a84 100644 --- a/packages/script/src/runtime/server/utils/image-proxy.ts +++ b/packages/script/src/runtime/server/utils/image-proxy.ts @@ -1,5 +1,6 @@ import { createError, defineEventHandler, getQuery, setHeader } from 'h3' import { createCachedBinaryFetch } from './cached-upstream' +import { isPublicNetworkHostname } from './network-host' import { withSigning } from './withSigning' const AMP_RE = /&/g @@ -38,6 +39,7 @@ export function createImageProxyHandler(config: ImageProxyConfig) { const urlAllowed = (url: URL) => (url.protocol === 'http:' || url.protocol === 'https:') && !url.username && !url.password + && isPublicNetworkHostname(url.hostname) && domainAllowed(url.hostname) const cachedFetch = createCachedBinaryFetch(cacheName, cacheMaxAge, { diff --git a/packages/script/src/runtime/server/utils/network-host.ts b/packages/script/src/runtime/server/utils/network-host.ts new file mode 100644 index 000000000..7fe59ab95 --- /dev/null +++ b/packages/script/src/runtime/server/utils/network-host.ts @@ -0,0 +1,70 @@ +const LOCAL_HOST_SUFFIXES = [ + 'home', + 'internal', + 'lan', + 'local', + 'localdomain', + 'localhost', +] + +function parseIPv4(hostname: string): [number, number, number, number] | undefined { + const parts = hostname.split('.') + if (parts.length !== 4 || parts.some(part => !/^\d{1,3}$/.test(part))) + return + const octets = parts.map(Number) + return octets.every(octet => octet >= 0 && octet <= 255) + ? octets as [number, number, number, number] + : undefined +} + +function isPublicIPv4([a, b, c]: [number, number, number, number]): boolean { + return a !== 0 + && a !== 10 + && a !== 127 + && !(a === 100 && b >= 64 && b <= 127) + && !(a === 169 && b === 254) + && !(a === 172 && b >= 16 && b <= 31) + && !(a === 192 && b === 0 && c === 0) + && !(a === 192 && b === 0 && c === 2) + && !(a === 192 && b === 88 && c === 99) + && !(a === 192 && b === 168) + && !(a === 198 && (b === 18 || b === 19)) + && !(a === 198 && b === 51 && c === 100) + && !(a === 203 && b === 0 && c === 113) + && a < 224 +} + +function isPublicIPv6(hostname: string): boolean { + const groups = hostname.split(':') + const firstGroup = Number.parseInt(groups[0] || '0', 16) + if (!Number.isInteger(firstGroup) || firstGroup < 0x2000 || firstGroup > 0x3FFF) + return false + // Documentation, Teredo, and 6to4 ranges can encode or route to non-public targets. + const secondGroup = Number.parseInt(groups[1] || '0', 16) + return !(firstGroup === 0x2001 && (secondGroup === 0 || secondGroup === 0xDB8)) + && firstGroup !== 0x2002 +} + +/** Reject hostnames that directly address local, private, link-local, or reserved networks. */ +export function isPublicNetworkHostname(input: string): boolean { + const hostname = input + .trim() + .toLowerCase() + .replace(/^\[|\]$/g, '') + .split('%', 1)[0]! + .replace(/\.$/, '') + if (!hostname) + return false + + const ipv4 = parseIPv4(hostname) + if (ipv4) + return isPublicIPv4(ipv4) + if (hostname.includes(':')) + return isPublicIPv6(hostname) + + const labels = hostname.split('.') + if (labels.length < 2) + return false + const suffix = labels.at(-1)! + return !LOCAL_HOST_SUFFIXES.includes(suffix) +} diff --git a/test/unit/image-proxy-security.test.ts b/test/unit/image-proxy-security.test.ts index daa33b29b..181c760fa 100644 --- a/test/unit/image-proxy-security.test.ts +++ b/test/unit/image-proxy-security.test.ts @@ -18,6 +18,7 @@ describe('image proxy security', () => { let disallowedServer: Server let disallowedPort: number let disallowedRequests = 0 + const realFetch = globalThis.fetch beforeAll(async () => { disallowedServer = createServer((_req, res) => { @@ -35,7 +36,7 @@ describe('image proxy security', () => { return } if (req.url === '/cross-host-redirect') { - res.writeHead(302, { location: `http://localhost:${disallowedPort}/private` }) + res.writeHead(302, { location: `http://evil.example.com:${disallowedPort}/private` }) res.end() return } @@ -50,14 +51,25 @@ describe('image proxy security', () => { await new Promise(resolve => upstreamServer.listen(0, '127.0.0.1', resolve)) upstreamPort = (upstreamServer.address() as { port: number }).port + globalThis.fetch = (input, init) => { + const requestUrl = input instanceof Request ? input.url : String(input) + const url = new URL(requestUrl) + if (url.hostname === 'cdn.example.com') + return realFetch(`http://127.0.0.1:${upstreamPort}${url.pathname}${url.search}`, init) + if (url.hostname === 'evil.example.com') + return realFetch(`http://127.0.0.1:${disallowedPort}${url.pathname}${url.search}`, init) + return realFetch(input, init) + } + const app = createApp() - app.use(createImageProxyHandler({ allowedDomains: ['127.0.0.1'] })) + app.use(createImageProxyHandler({ allowedDomains: ['cdn.example.com'] })) proxyServer = createServer(toNodeListener(app)) await new Promise(resolve => proxyServer.listen(0, '127.0.0.1', resolve)) proxyPort = (proxyServer.address() as { port: number }).port }) afterAll(async () => { + globalThis.fetch = realFetch await Promise.all([ new Promise(resolve => upstreamServer.close(() => resolve())), new Promise(resolve => proxyServer.close(() => resolve())), @@ -66,7 +78,7 @@ describe('image proxy security', () => { }) function proxyUrl(path: string): string { - const target = `http://127.0.0.1:${upstreamPort}${path}` + const target = `http://cdn.example.com${path}` return `http://127.0.0.1:${proxyPort}/?url=${encodeURIComponent(target)}` } @@ -84,6 +96,14 @@ describe('image proxy security', () => { expect(disallowedRequests).toBe(0) }) + it('rejects a direct local network target before fetching it', async () => { + const target = `http://127.0.0.1:${disallowedPort}/private` + const response = await realFetch(`http://127.0.0.1:${proxyPort}/?url=${encodeURIComponent(target)}`) + + expect(response.status).toBe(403) + expect(disallowedRequests).toBe(0) + }) + it('rejects active upstream content from an image route', async () => { const response = await fetch(proxyUrl('/html')) diff --git a/test/unit/network-host.test.ts b/test/unit/network-host.test.ts new file mode 100644 index 000000000..b49e6aafd --- /dev/null +++ b/test/unit/network-host.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { isPublicNetworkHostname } from '../../packages/script/src/runtime/server/utils/network-host' + +describe('public network hostname boundary', () => { + it.each([ + 'localhost', + 'api.localhost', + 'router.local', + '127.0.0.1', + '10.0.0.1', + '100.64.0.1', + '169.254.169.254', + '172.16.0.1', + '192.168.1.1', + '[::1]', + '[fc00::1]', + '[fe80::1]', + '[::ffff:127.0.0.1]', + '[2001:db8::1]', + ])('rejects local or non-public target %s', (hostname) => { + expect(isPublicNetworkHostname(hostname)).toBe(false) + }) + + it.each([ + 'cdn.example.com', + '8.8.8.8', + '[2606:4700:4700::1111]', + ])('accepts public target %s', (hostname) => { + expect(isPublicNetworkHostname(hostname)).toBe(true) + }) +}) diff --git a/test/unit/proxy-handler-body.test.ts b/test/unit/proxy-handler-body.test.ts index fe8a23d06..2de0ab407 100644 --- a/test/unit/proxy-handler-body.test.ts +++ b/test/unit/proxy-handler-body.test.ts @@ -10,6 +10,7 @@ vi.mock('nitropack/runtime', () => ({ 'nuxt-scripts-proxy': { proxyPrefix: '/_scripts/p', domainPrivacy: { + '127.0.0.1': true, 'upstream.test': true, }, debug: false, @@ -112,6 +113,12 @@ describe('proxy handler request bodies (#836)', () => { expect(capturedContentType).toBe('text/plain') }) + it('rejects an allowlisted local network target before the upstream fetch', async () => { + const response = await realFetch(`http://127.0.0.1:${proxyPort}/_scripts/p/127.0.0.1/private`) + + expect(response.status).toBe(403) + }) + it('preserves form encoding when privacy transforms are active', async () => { const formBody = 'tag=a&event=%24pageview&tag=b&hardwareConcurrency=128' const transformedBody = 'tag=a&event=%24pageview&tag=b&hardwareConcurrency=16' diff --git a/test/unit/setup.test.ts b/test/unit/setup.test.ts index 6075948f3..cad748875 100644 --- a/test/unit/setup.test.ts +++ b/test/unit/setup.test.ts @@ -125,6 +125,13 @@ describe('resolveConfiguredProxyDomains', () => { }, umamiProxyConfig)).toEqual([]) }) + it('ignores configured local network targets', () => { + expect(resolveConfiguredProxyDomains({ + hostUrl: 'http://127.0.0.1:3000/events', + scriptInput: { src: 'http://metadata.local/latest' }, + }, umamiProxyConfig)).toEqual([]) + }) + it('deduplicates equivalent domains', () => { expect(resolveConfiguredProxyDomains({ hostUrl: 'https://analytics.example.com', From 2168eb664989c5a3c8ad5e96cfa46348a2ba3b94 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 22 Jul 2026 15:23:13 +1000 Subject: [PATCH 3/5] fix(proxy): close review edge cases --- packages/script/package.json | 1 + packages/script/src/module.ts | 8 +- packages/script/src/plugins/intercept.ts | 2 +- packages/script/src/registry.ts | 2 +- .../src/runtime/server/proxy-handler.ts | 22 ++-- .../runtime/server/utils/cached-upstream.ts | 110 ++++++++++-------- .../src/runtime/server/utils/network-host.ts | 95 +++++++++++++++ .../src/runtime/server/utils/privacy.ts | 38 +++--- pnpm-lock.yaml | 12 ++ pnpm-workspace.yaml | 1 + test/unit/cached-upstream.test.ts | 4 +- test/unit/google-geocode-proxy.test.ts | 2 +- test/unit/google-static-map-proxy.test.ts | 2 +- test/unit/gravatar-proxy-handler.test.ts | 2 +- test/unit/image-proxy-security.test.ts | 11 ++ test/unit/network-host.test.ts | 54 ++++++++- test/unit/proxy-alias-generators.test.ts | 58 ++++++++- test/unit/proxy-handler-alias.test.ts | 11 ++ test/unit/proxy-handler-body.test.ts | 37 ++++++ test/unit/proxy-handler-hop-by-hop.test.ts | 11 ++ test/unit/proxy-privacy.test.ts | 19 +++ test/unit/resolve-proxy-secret.test.ts | 10 ++ 22 files changed, 427 insertions(+), 85 deletions(-) diff --git a/packages/script/package.json b/packages/script/package.json index e823ef881..0d0deb037 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -130,6 +130,7 @@ "std-env": "catalog:", "ufo": "catalog:", "ultrahtml": "catalog:", + "undici": "catalog:", "unplugin": "catalog:", "unstorage": "catalog:", "valibot": "catalog:" diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index 2bd4fca6c..614a7e0a8 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -121,7 +121,7 @@ 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 export interface ResolvedProxySecret { @@ -171,9 +171,9 @@ export function resolveProxySecret( // 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 existingSecret = contents.match(PROXY_SECRET_ENV_VALUE_RE)?.[1]?.trim() + if (existingSecret) + return { secret: existingSecret, ephemeral: false, source: 'dotenv-generated' } // 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}`)) diff --git a/packages/script/src/plugins/intercept.ts b/packages/script/src/plugins/intercept.ts index e85755c82..d24bd6c6a 100644 --- a/packages/script/src/plugins/intercept.ts +++ b/packages/script/src/plugins/intercept.ts @@ -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; diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index 51ae6f6fb..990809137 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -883,7 +883,7 @@ export async function registry(resolve?: (path: string) => Promise): Pro export function generatePartytownResolveUrl(proxyPrefix: string, domainAliases: Record = {}): string { return `function(url, location, type) { if ((url.protocol === 'http:' || url.protocol === 'https:') && url.origin !== location.origin) { - var aliases = ${JSON.stringify(domainAliases)}; + 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); } diff --git a/packages/script/src/runtime/server/proxy-handler.ts b/packages/script/src/runtime/server/proxy-handler.ts index 68253e73e..8bbf60577 100644 --- a/packages/script/src/runtime/server/proxy-handler.ts +++ b/packages/script/src/runtime/server/proxy-handler.ts @@ -2,7 +2,7 @@ import type { ProxyPrivacyInput, ResolvedProxyPrivacy } from './utils/privacy' import { createError, defineEventHandler, getHeaders, getQuery, getRequestIP, getRequestWebStream, readBody, readRawBody, sendStream, setResponseHeader, setResponseStatus } from 'h3' import { useNitroApp, useRuntimeConfig } from 'nitropack/runtime' import { matchDomain } from './utils/match-domain' -import { isPublicNetworkHostname } from './utils/network-host' +import { createPublicNetworkDispatcher, isPrivateNetworkResolutionError, isPublicNetworkHostname } from './utils/network-host' import { anonymizeIP, mergePrivacy, @@ -413,24 +413,29 @@ export default defineEventHandler(async (event) => { } let response: Response + let network: Awaited> | undefined try { - response = await fetch(targetUrl, { + network = await createPublicNetworkDispatcher() + const requestInit: RequestInit & { duplex?: 'half' } = { method: method || 'GET', headers, body: fetchBody, credentials: 'omit', // Don't send cookies to third parties signal: controller.signal, redirect: 'manual', - // @ts-expect-error Node fetch supports duplex for streaming request bodies duplex: passthroughBody ? 'half' : undefined, - }) + } + response = await network.fetch(targetUrl, requestInit) + clearTimeout(timeoutId) } catch (err) { clearTimeout(timeoutId) + await network?.close() log('[proxy] Upstream error:', err) + const blockedPrivateNetwork = isPrivateNetworkResolutionError(err) throw createError({ - statusCode: timedOut ? 504 : 502, - statusMessage: timedOut ? 'Gateway Timeout' : 'Bad Gateway', + statusCode: blockedPrivateNetwork ? 403 : timedOut ? 504 : 502, + statusMessage: blockedPrivateNetwork ? 'Local network targets are not allowed' : timedOut ? 'Gateway Timeout' : 'Bad Gateway', message: 'Proxy upstream request failed', cause: err, data: { @@ -444,6 +449,7 @@ export default defineEventHandler(async (event) => { if (response.status >= 300 && response.status < 400 && response.status !== 304) { clearTimeout(timeoutId) await response.body?.cancel() + await network?.close() throw createError({ statusCode: 502, statusMessage: 'Unsafe upstream redirect', @@ -475,11 +481,11 @@ export default defineEventHandler(async (event) => { setResponseStatus(event, response.status, response.statusText) if (!response.body) { - clearTimeout(timeoutId) + await network?.close() return null } // Stream rather than buffering potentially large upstream responses. This lowers // memory pressure and lets the browser receive headers and chunks immediately. - return sendStream(event, response.body).finally(() => clearTimeout(timeoutId)) + return sendStream(event, response.body).finally(() => network?.close()) }) diff --git a/packages/script/src/runtime/server/utils/cached-upstream.ts b/packages/script/src/runtime/server/utils/cached-upstream.ts index 983e9e74b..96ae3066a 100644 --- a/packages/script/src/runtime/server/utils/cached-upstream.ts +++ b/packages/script/src/runtime/server/utils/cached-upstream.ts @@ -1,9 +1,9 @@ import type { FetchResponse } from 'ofetch' import { Buffer } from 'node:buffer' import { defineCachedFunction } from 'nitropack/runtime' -import { $fetch } from 'ofetch' +import { createFetch } from 'ofetch' import { hash } from 'ohash' -import { isPublicNetworkHostname } from './network-host' +import { createPublicNetworkDispatcher, isPublicNetworkHostname } from './network-host' /** * Server-side caches for upstream proxy fetches. @@ -132,34 +132,41 @@ export function createCachedBinaryFetch( let currentUrl = parseUpstreamUrl(url) assertAllowedUrl(currentUrl, config.allowUrl) let response: FetchResponse + const network = await createPublicNetworkDispatcher() + const safeFetch = createFetch({ fetch: network.fetch }) - for (let redirectCount = 0; ; redirectCount++) { - const redirectMode = opts?.redirect ?? (config.allowUrl ? 'follow' : 'manual') - if (redirectMode === 'follow' && !config.allowUrl) - throw new Error('Automatic redirects require an allowRedirect redirect policy') - const validateRedirect = redirectMode === 'follow' - response = await $fetch.raw(currentUrl.toString(), { - responseType: 'arrayBuffer' as const, - timeout: opts?.timeout ?? 10000, - redirect: validateRedirect ? 'manual' : redirectMode, - ignoreResponseError: opts?.ignoreResponseError ?? false, - headers: opts?.headers, - }) - - if (!validateRedirect) - break - - const nextUrl = resolveRedirect(response, currentUrl, redirectCount, config) - if (!nextUrl) - break - currentUrl = nextUrl - } + try { + for (let redirectCount = 0; ; redirectCount++) { + const redirectMode = opts?.redirect ?? (config.allowUrl ? 'follow' : 'manual') + if (redirectMode === 'follow' && !config.allowUrl) + throw new Error('Automatic redirects require an allowUrl redirect policy') + const validateRedirect = redirectMode === 'follow' + response = await safeFetch.raw(currentUrl.toString(), { + responseType: 'arrayBuffer' as const, + timeout: opts?.timeout ?? 10000, + redirect: validateRedirect ? 'manual' : redirectMode, + ignoreResponseError: opts?.ignoreResponseError ?? false, + headers: opts?.headers, + }) + + if (!validateRedirect) + break + + const nextUrl = resolveRedirect(response, currentUrl, redirectCount, config) + if (!nextUrl) + break + currentUrl = nextUrl + } - const data = response._data as ArrayBuffer | undefined - return { - base64: data ? Buffer.from(data).toString('base64') : '', - contentType: response.headers.get('content-type'), - status: response.status, + const data = response._data as ArrayBuffer | undefined + return { + base64: data ? Buffer.from(data).toString('base64') : '', + contentType: response.headers.get('content-type'), + status: response.status, + } + } + finally { + await network.close() } }, { @@ -211,29 +218,36 @@ export function createCachedJsonFetch( async (url: string, opts?: { headers?: Record, timeout?: number }) => { let currentUrl = parseUpstreamUrl(url) assertAllowedUrl(currentUrl, config.allowUrl) + const network = await createPublicNetworkDispatcher() + const safeFetch = createFetch({ fetch: network.fetch }) - for (let redirectCount = 0; ; redirectCount++) { - const response = await $fetch.raw(currentUrl.toString(), { - responseType: config.responseType ?? 'json', - timeout: opts?.timeout ?? 10000, - redirect: 'manual', - headers: opts?.headers, - }) as FetchResponse - const nextUrl = resolveRedirect(response, currentUrl, redirectCount, config) - if (nextUrl) { - currentUrl = nextUrl - continue - } + try { + for (let redirectCount = 0; ; redirectCount++) { + const response = await safeFetch.raw(currentUrl.toString(), { + responseType: config.responseType ?? 'json', + timeout: opts?.timeout ?? 10000, + redirect: 'manual', + headers: opts?.headers, + }) as FetchResponse + const nextUrl = resolveRedirect(response, currentUrl, redirectCount, config) + if (nextUrl) { + currentUrl = nextUrl + continue + } - if (config.contentTypePrefixes?.length) { - const contentType = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() - if (!contentType || !config.contentTypePrefixes.some(prefix => contentType.startsWith(prefix))) - throw upstreamError('Upstream response content type is not allowed', 415, 'Unsupported upstream content type') - } + if (config.contentTypePrefixes?.length) { + const contentType = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() + if (!contentType || !config.contentTypePrefixes.some(prefix => contentType.startsWith(prefix))) + throw upstreamError('Upstream response content type is not allowed', 415, 'Unsupported upstream content type') + } - const data = response._data as T - config.validateResponse?.(data) - return data + const data = response._data as T + config.validateResponse?.(data) + return data + } + } + finally { + await network.close() } }, { diff --git a/packages/script/src/runtime/server/utils/network-host.ts b/packages/script/src/runtime/server/utils/network-host.ts index 7fe59ab95..9dddf2c77 100644 --- a/packages/script/src/runtime/server/utils/network-host.ts +++ b/packages/script/src/runtime/server/utils/network-host.ts @@ -1,3 +1,6 @@ +import type { LookupFunction } from 'node:net' +import { runtime } from 'std-env' + const LOCAL_HOST_SUFFIXES = [ 'home', 'internal', @@ -7,6 +10,27 @@ const LOCAL_HOST_SUFFIXES = [ 'localhost', ] +interface NetworkAddress { + address: string + family: 4 | 6 +} + +type ResolveNetworkHostname = ( + hostname: string, + callback: (error: Error | null, addresses: NetworkAddress[]) => void, +) => void + +type NetworkLookup = ( + hostname: string, + options: { all?: boolean, family?: number | 'IPv4' | 'IPv6' }, + callback: (error: NodeJS.ErrnoException | null, address: string | NetworkAddress[], family?: number) => void, +) => void + +export interface PublicNetworkDispatcher { + fetch: typeof globalThis.fetch + close: () => Promise +} + function parseIPv4(hostname: string): [number, number, number, number] | undefined { const parts = hostname.split('.') if (parts.length !== 4 || parts.some(part => !/^\d{1,3}$/.test(part))) @@ -68,3 +92,74 @@ export function isPublicNetworkHostname(input: string): boolean { const suffix = labels.at(-1)! return !LOCAL_HOST_SUFFIXES.includes(suffix) } + +/** Resolve once inside the socket connection, reject mixed/private answers, then pin the selected address. */ +export function createPublicNetworkLookup(resolveHostname: ResolveNetworkHostname): LookupFunction { + const networkLookup: NetworkLookup = (hostname, options, callback) => { + resolveHostname(hostname, (error, addresses) => { + if (error) { + callback(error, '') + return + } + if (!addresses.length || addresses.some(({ address }) => !isPublicNetworkHostname(address))) { + callback(Object.assign(new Error('Upstream hostname resolved to a non-public address'), { + code: 'ERR_NUXT_SCRIPTS_PRIVATE_ADDRESS', + }), '') + return + } + + if (options.all) { + callback(null, addresses) + return + } + + const requestedFamily = options.family === 'IPv4' + ? 4 + : options.family === 'IPv6' + ? 6 + : options.family === 4 || options.family === 6 ? options.family : undefined + const selected = addresses.find(({ family }) => family === requestedFamily) ?? addresses[0]! + callback(null, selected.address, selected.family) + }) + } + return networkLookup as LookupFunction +} + +/** Create a Node fetch dispatcher whose socket lookup validates and pins every DNS answer. */ +export async function createPublicNetworkDispatcher(resolveHostnameOverride?: ResolveNetworkHostname): Promise { + if (runtime !== 'node') + return { fetch: globalThis.fetch, close: async () => {} } + + // Loaded only on Node. Edge runtimes use their platform-isolated fetch implementation. + const { Agent, fetch } = await import('undici') + let resolveHostname = resolveHostnameOverride + if (!resolveHostname) { + const { lookup } = await import('node:dns') + resolveHostname = (hostname, callback) => lookup(hostname, { all: true, verbatim: true }, (error, addresses) => { + callback(error, addresses as NetworkAddress[]) + }) + } + const dispatcher = new Agent({ + connect: { + lookup: createPublicNetworkLookup(resolveHostname), + }, + }) + return { + fetch: ((input, init) => fetch(input as string | URL, { + ...(init as unknown as NonNullable[1]>), + dispatcher, + }) as unknown as Promise) as typeof globalThis.fetch, + close: () => dispatcher.close(), + } +} + +/** Detect the tagged DNS policy error through fetch/ofetch cause wrappers. */ +export function isPrivateNetworkResolutionError(error: unknown): boolean { + let current = error + for (let depth = 0; depth < 5 && current && typeof current === 'object'; depth++) { + if ((current as NodeJS.ErrnoException).code === 'ERR_NUXT_SCRIPTS_PRIVATE_ADDRESS') + return true + current = (current as { cause?: unknown }).cause + } + return false +} diff --git a/packages/script/src/runtime/server/utils/privacy.ts b/packages/script/src/runtime/server/utils/privacy.ts index acc5fbfca..c0b461a67 100644 --- a/packages/script/src/runtime/server/utils/privacy.ts +++ b/packages/script/src/runtime/server/utils/privacy.ts @@ -190,12 +190,13 @@ function expandIPv6(address: string): string[] | undefined { */ export function anonymizeIP(ip: string): string { if (ip.includes(':')) { - const mappedIPv4 = ip.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i)?.[1] + const normalized = ip.split('%', 1)[0] || '' + const mappedIPv4 = normalized.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i)?.[1] if (mappedIPv4) return `::ffff:${anonymizeIP(mappedIPv4)}` // IPv6: keep first 3 segments (48 bits) — roughly city/ISP-level aggregation - const expanded = expandIPv6(ip.split('%', 1)[0] || '') - return expanded ? `${expanded.slice(0, 3).join(':')}::` : ip + const expanded = expandIPv6(normalized) + return expanded ? `${expanded.slice(0, 3).join(':')}::` : normalized } // IPv4: zero last octet (/24 subnet — typically ISP/neighborhood-level precision) const parts = ip.split('.') @@ -439,13 +440,14 @@ export function stripPayloadFingerprinting( // Pre-scan for screen width to enable paired height bucketing. // When sw is present, sh maps to the paired height for that device class // (e.g., sw=1280 → desktop → sh becomes 1080, not independently bucketed). - let deviceClass: DeviceClass | undefined + let deviceClasses: Array = [] for (const [key, value] of Object.entries(payload)) { if (key.toLowerCase() === 'sw') { - const firstValue = Array.isArray(value) ? value[0] : value - const num = typeof firstValue === 'number' ? firstValue : Number(firstValue) - if (!Number.isNaN(num)) - deviceClass = getDeviceClass(num) + const widths = Array.isArray(value) ? value : [value] + deviceClasses = widths.map((width) => { + const num = typeof width === 'number' ? width : Number(width) + return Number.isNaN(num) ? undefined : getDeviceClass(num) + }) } } @@ -490,10 +492,18 @@ export function stripPayloadFingerprinting( if (['sd', 'colordepth', 'pixelratio'].includes(lowerKey)) { result[key] = value } - else if (lowerKey === 'sh' && deviceClass) { - // Paired: use height from the device class determined by sw - const paired = SCREEN_BUCKETS[deviceClass].h - result[key] = mapValue(value, item => typeof item === 'number' ? paired : String(paired)) + else if (lowerKey === 'sh' && deviceClasses.length > 0) { + // Pair repeated heights with their matching widths. A scalar width applies to all heights. + const generalizePairedHeight = (item: unknown, index: number) => { + const deviceClass = deviceClasses[index] ?? deviceClasses[0] + if (!deviceClass) + return generalizeScreen(item, 'height') + const paired = SCREEN_BUCKETS[deviceClass].h + return typeof item === 'number' ? paired : String(paired) + } + result[key] = Array.isArray(value) + ? value.map(generalizePairedHeight) + : generalizePairedHeight(value, 0) } else { const dimension = lowerKey === 'sw' ? 'width' : lowerKey === 'sh' ? 'height' : undefined @@ -530,7 +540,9 @@ export function stripPayloadFingerprinting( } // Anonymize combined device info (parse and generalize components) — hardware flag if (matchesParam(key, STRIP_PARAMS.deviceInfo)) { - result[key] = p.hardware ? mapString(value, anonymizeDeviceInfo) : value + result[key] = p.hardware + ? mapValue(value, item => typeof item === 'string' ? anonymizeDeviceInfo(item) : '') + : value continue } // Platform identifiers are low entropy — keep as-is diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5722ac124..a9a33691c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,6 +144,9 @@ catalogs: unbuild: specifier: ^3.6.1 version: 3.6.1 + undici: + specifier: ^6.27.0 + version: 6.27.0 unimport: specifier: ^6.3.0 version: 6.3.0 @@ -391,6 +394,9 @@ importers: ultrahtml: specifier: 'catalog:' version: 1.7.0 + undici: + specifier: 'catalog:' + version: 6.27.0 unplugin: specifier: 'catalog:' version: 3.3.0(esbuild@0.28.1)(rolldown@1.2.0)(rollup@4.62.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) @@ -7228,6 +7234,10 @@ packages: undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici@6.27.0: + resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==} + engines: {node: '>=18.17'} + undici@8.7.0: resolution: {integrity: sha512-N7iQtfyLhIMOFgQubvmLV26svHpO0bqKnAiWotTQCVKCmWrcGbBotPuW1x+xwYZ2VHdSTVUfPQQnlEt1/LouTQ==} engines: {node: '>=22.19.0'} @@ -15998,6 +16008,8 @@ snapshots: undici-types@8.3.0: {} + undici@6.27.0: {} + undici@8.7.0: {} unenv@2.0.0-rc.24: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 62a3025b5..7f5582033 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -92,6 +92,7 @@ catalog: ufo: ^1.6.4 ultrahtml: ^1.7.0 unbuild: ^3.6.1 + undici: ^6.27.0 unimport: ^6.3.0 unplugin: ^3.3.0 unstorage: ^1.17.5 diff --git a/test/unit/cached-upstream.test.ts b/test/unit/cached-upstream.test.ts index 32d727485..832488321 100644 --- a/test/unit/cached-upstream.test.ts +++ b/test/unit/cached-upstream.test.ts @@ -18,7 +18,7 @@ vi.mock('ohash', () => ({ })) vi.mock('ofetch', () => ({ - $fetch: Object.assign(vi.fn(), { raw: rawFetchMock }), + createFetch: vi.fn(() => Object.assign(vi.fn(), { raw: rawFetchMock })), })) const { createCachedBinaryFetch, createCachedJsonFetch } = await import( @@ -119,7 +119,7 @@ describe('upstream cache keys', () => { await expect(fetchBinary('https://cdn.example.com/image', { redirect: 'follow' })) .rejects - .toThrow('redirect policy') + .toThrow('allowUrl redirect policy') expect(rawFetchMock).not.toHaveBeenCalled() }) diff --git a/test/unit/google-geocode-proxy.test.ts b/test/unit/google-geocode-proxy.test.ts index 6d1a46795..39def91e2 100644 --- a/test/unit/google-geocode-proxy.test.ts +++ b/test/unit/google-geocode-proxy.test.ts @@ -15,7 +15,7 @@ vi.mock('nitropack/runtime', () => ({ })) vi.mock('ofetch', () => ({ - $fetch: Object.assign(vi.fn(), { raw: rawFetchMock }), + createFetch: vi.fn(() => Object.assign(vi.fn(), { raw: rawFetchMock })), })) const geocodeHandler = (await import('../../packages/script/src/runtime/server/google-maps-geocode-proxy')).default diff --git a/test/unit/google-static-map-proxy.test.ts b/test/unit/google-static-map-proxy.test.ts index 86a34d11e..ec4c3d306 100644 --- a/test/unit/google-static-map-proxy.test.ts +++ b/test/unit/google-static-map-proxy.test.ts @@ -15,7 +15,7 @@ vi.mock('nitropack/runtime', () => ({ })) vi.mock('ofetch', () => ({ - $fetch: Object.assign(vi.fn(), { raw: rawFetchMock }), + createFetch: vi.fn(() => Object.assign(vi.fn(), { raw: rawFetchMock })), })) const staticMapHandler = (await import('../../packages/script/src/runtime/server/google-static-maps-proxy')).default diff --git a/test/unit/gravatar-proxy-handler.test.ts b/test/unit/gravatar-proxy-handler.test.ts index 24e8ec721..a250a57e8 100644 --- a/test/unit/gravatar-proxy-handler.test.ts +++ b/test/unit/gravatar-proxy-handler.test.ts @@ -11,7 +11,7 @@ vi.mock('nitropack/runtime', () => ({ })) vi.mock('ofetch', () => ({ - $fetch: Object.assign(vi.fn(), { raw: rawFetchMock }), + createFetch: vi.fn(() => Object.assign(vi.fn(), { raw: rawFetchMock })), })) const gravatarHandler = (await import('../../packages/script/src/runtime/server/gravatar-proxy')).default diff --git a/test/unit/image-proxy-security.test.ts b/test/unit/image-proxy-security.test.ts index 181c760fa..4311db0e7 100644 --- a/test/unit/image-proxy-security.test.ts +++ b/test/unit/image-proxy-security.test.ts @@ -8,6 +8,17 @@ vi.mock('nitropack/runtime', () => ({ useRuntimeConfig: () => ({}), })) +vi.mock('../../packages/script/src/runtime/server/utils/network-host', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createPublicNetworkDispatcher: async () => ({ + fetch: (...args: Parameters) => globalThis.fetch(...args), + close: async () => {}, + }), + } +}) + const { createImageProxyHandler } = await import('../../packages/script/src/runtime/server/utils/image-proxy') describe('image proxy security', () => { diff --git a/test/unit/network-host.test.ts b/test/unit/network-host.test.ts index b49e6aafd..73bb25231 100644 --- a/test/unit/network-host.test.ts +++ b/test/unit/network-host.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from 'vitest' -import { isPublicNetworkHostname } from '../../packages/script/src/runtime/server/utils/network-host' +import { describe, expect, it, vi } from 'vitest' +import { createPublicNetworkDispatcher, createPublicNetworkLookup, isPrivateNetworkResolutionError, isPublicNetworkHostname } from '../../packages/script/src/runtime/server/utils/network-host' describe('public network hostname boundary', () => { it.each([ @@ -28,4 +28,54 @@ describe('public network hostname boundary', () => { ])('accepts public target %s', (hostname) => { expect(isPublicNetworkHostname(hostname)).toBe(true) }) + + it('pins a validated public DNS result into the connection lookup', async () => { + const resolveHostname = vi.fn((_hostname, callback) => callback(null, [ + { address: '2606:4700:4700::1111', family: 6 as const }, + { address: '1.1.1.1', family: 4 as const }, + ])) + const lookup = createPublicNetworkLookup(resolveHostname) + + const result = await new Promise>((resolve, reject) => { + lookup('one.one.one.one', { all: true }, (error, addresses) => { + if (error) + reject(error) + else + resolve(addresses as Array<{ address: string, family: number }>) + }) + }) + + expect(result).toEqual([ + { address: '2606:4700:4700::1111', family: 6 }, + { address: '1.1.1.1', family: 4 }, + ]) + expect(resolveHostname).toHaveBeenCalledOnce() + }) + + it('rejects a hostname when any DNS result can reach a non-public address', async () => { + const lookup = createPublicNetworkLookup((_hostname, callback) => callback(null, [ + { address: '93.184.216.34', family: 4 }, + { address: '127.0.0.1', family: 4 }, + ])) + + const error = await new Promise((resolve) => { + lookup('rebind.example.com', {}, error => resolve(error)) + }) + + expect(error).toMatchObject({ code: 'ERR_NUXT_SCRIPTS_PRIVATE_ADDRESS' }) + }) + + it('uses the validated lookup for the actual Node fetch connection', async () => { + const network = await createPublicNetworkDispatcher((_hostname, callback) => callback(null, [ + { address: '127.0.0.1', family: 4 }, + ])) + + try { + const error = await network.fetch('http://rebind.example.com').catch(error => error) + expect(isPrivateNetworkResolutionError(error)).toBe(true) + } + finally { + await network.close() + } + }) }) diff --git a/test/unit/proxy-alias-generators.test.ts b/test/unit/proxy-alias-generators.test.ts index 5f369903a..e96d12887 100644 --- a/test/unit/proxy-alias-generators.test.ts +++ b/test/unit/proxy-alias-generators.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { generateInterceptPluginContents } from '../../packages/script/src/plugins/intercept' import { generatePartytownResolveUrl } from '../../packages/script/src/registry' @@ -27,6 +27,20 @@ describe('proxy alias - generated runtime code (#814)', () => { expect(out.pathname).toBe('/_scripts/p/eu.i.posthog.com/e/') }) + it.each(['constructor', 'toString', '__proto__'])( + 'treats inherited property name %s as an unaliased host', + (host) => { + // eslint-disable-next-line no-eval + const resolveUrl = eval(`(${generatePartytownResolveUrl('/_scripts/p')})`) + const out = resolveUrl( + new URL(`https://${host}/collect`), + new URL('https://my-site.test/'), + ) + + expect(out.pathname).toBe(`/_scripts/p/${new URL(`https://${host}`).hostname}/collect`) + }, + ) + it('leaves non-HTTP protocols unresolved', () => { // eslint-disable-next-line no-eval const resolveUrl = eval(`(${generatePartytownResolveUrl('/_scripts/p')})`) @@ -38,14 +52,52 @@ describe('proxy alias - generated runtime code (#814)', () => { describe('generateInterceptPluginContents', () => { it('embeds the alias map into the client intercept plugin', () => { const code = generateInterceptPluginContents('/_scripts/p', { domainAliases: ALIASES }) - expect(code).toContain('const domainAliases = {"us.i.posthog.com":"ph"}') + expect(code).toContain('const domainAliases = Object.assign(Object.create(null), {"us.i.posthog.com":"ph"})') expect(code).toContain('domainAliases[parsed.host] || parsed.host') expect(code).toContain('parsed.protocol === \'http:\' || parsed.protocol === \'https:\'') }) it('defaults to an empty alias map', () => { const code = generateInterceptPluginContents('/_scripts/p') - expect(code).toContain('const domainAliases = {}') + expect(code).toContain('const domainAliases = Object.assign(Object.create(null), {})') + }) + + it('leaves non-HTTP and same-origin URLs unchanged at runtime', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response()) + const defineNuxtPlugin = (plugin: { setup: () => void }) => plugin + const navigator = { sendBeacon: vi.fn() } + const location = { origin: 'https://my-site.test' } + const XMLHttpRequest = class { open() {} } + const Image = class {} + const HTMLImageElement = class {} + const Request = globalThis.Request + const generated = generateInterceptPluginContents('/_scripts/p').replace('export default ', '') + void [defineNuxtPlugin, navigator, location, XMLHttpRequest, Image, HTMLImageElement, Request] + + try { + // eslint-disable-next-line no-eval + const plugin = eval(generated) as { setup: () => void } + plugin.setup() + const runtime = (globalThis as typeof globalThis & { + __nuxtScripts: { fetch: (url: string) => Promise } + }).__nuxtScripts + + await runtime.fetch('data:text/plain,hello') + await runtime.fetch('javascript:void(0)') + await runtime.fetch('https://my-site.test/collect') + await runtime.fetch('https://constructor/collect') + + expect(fetchSpy.mock.calls.map(([url]) => url)).toEqual([ + 'data:text/plain,hello', + 'javascript:void(0)', + 'https://my-site.test/collect', + 'https://my-site.test/_scripts/p/constructor/collect', + ]) + } + finally { + fetchSpy.mockRestore() + delete (globalThis as typeof globalThis & { __nuxtScripts?: unknown }).__nuxtScripts + } }) }) }) diff --git a/test/unit/proxy-handler-alias.test.ts b/test/unit/proxy-handler-alias.test.ts index 4b5be523b..ba41ce359 100644 --- a/test/unit/proxy-handler-alias.test.ts +++ b/test/unit/proxy-handler-alias.test.ts @@ -28,6 +28,17 @@ vi.mock('nitropack/runtime', () => ({ }), })) +vi.mock('../../packages/script/src/runtime/server/utils/network-host', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createPublicNetworkDispatcher: async () => ({ + fetch: (...args: Parameters) => globalThis.fetch(...args), + close: async () => {}, + }), + } +}) + describe('proxy handler - path aliases (#814)', () => { let proxyServer: Server let proxyPort: number diff --git a/test/unit/proxy-handler-body.test.ts b/test/unit/proxy-handler-body.test.ts index 2de0ab407..07d2f33b8 100644 --- a/test/unit/proxy-handler-body.test.ts +++ b/test/unit/proxy-handler-body.test.ts @@ -21,6 +21,17 @@ vi.mock('nitropack/runtime', () => ({ }), })) +vi.mock('../../packages/script/src/runtime/server/utils/network-host', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createPublicNetworkDispatcher: async () => ({ + fetch: (...args: Parameters) => globalThis.fetch(...args), + close: async () => {}, + }), + } +}) + describe('proxy handler request bodies (#836)', () => { let upstreamServer: Server let proxyServer: Server @@ -226,4 +237,30 @@ describe('proxy handler request bodies (#836)', () => { expect(receivedHeadersBeforeCompletion).toBe(true) expect(await response.text()).toBe('firstsecond') }) + + it('does not apply the connection timeout after upstream headers arrive', async () => { + const realSetTimeout = globalThis.setTimeout + let connectionTimeout: ReturnType | undefined + const timeoutSpy = vi.spyOn(globalThis, 'setTimeout').mockImplementation(((callback, delay, ...args) => { + const timeout = realSetTimeout(callback, delay, ...args) + if (delay === 15000) + connectionTimeout = timeout + return timeout + }) as typeof setTimeout) + const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout') + + try { + const response = await realFetch(`http://127.0.0.1:${proxyPort}/_scripts/p/upstream.test/stream`) + expect(connectionTimeout).toBeDefined() + expect(clearTimeoutSpy).toHaveBeenCalledWith(connectionTimeout) + releaseStream?.() + + expect(await response.text()).toBe('firstsecond') + } + finally { + clearTimeoutSpy.mockRestore() + timeoutSpy.mockRestore() + releaseStream?.() + } + }) }) diff --git a/test/unit/proxy-handler-hop-by-hop.test.ts b/test/unit/proxy-handler-hop-by-hop.test.ts index 7ddaf12b1..f5d48d32a 100644 --- a/test/unit/proxy-handler-hop-by-hop.test.ts +++ b/test/unit/proxy-handler-hop-by-hop.test.ts @@ -29,6 +29,17 @@ vi.mock('nitropack/runtime', () => ({ }), })) +vi.mock('../../packages/script/src/runtime/server/utils/network-host', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createPublicNetworkDispatcher: async () => ({ + fetch: (...args: Parameters) => globalThis.fetch(...args), + close: async () => {}, + }), + } +}) + describe('proxy handler - hop-by-hop request headers (#791)', () => { let proxyServer: Server let proxyPort: number diff --git a/test/unit/proxy-privacy.test.ts b/test/unit/proxy-privacy.test.ts index dbc909dcc..5bb7839a8 100644 --- a/test/unit/proxy-privacy.test.ts +++ b/test/unit/proxy-privacy.test.ts @@ -386,6 +386,21 @@ describe('stripFingerprintingFromPayload', () => { // Combined sr format — mobile const srMobile = stripFingerprintingFromPayload({ sr: '375x667' }) expect(srMobile.sr).toBe('360x640') + + const repeatedResult = stripFingerprintingFromPayload({ sw: [375, 1280], sh: [667, 720] }) + expect(repeatedResult.sw).toEqual([360, 1920]) + expect(repeatedResult.sh).toEqual([640, 1080]) + }) + + it('removes non-string device info values', () => { + const result = stripFingerprintingFromPayload({ + dv: [ + 'Australia/Melbourne&en-GB&Google Inc.&Linux x86_64', + { timezone: 'Australia/Melbourne', screen: '2560x1440' }, + ], + }) + + expect(result.dv).toEqual(['UTC&en-GB&Google Inc.&Linux x86_64', '']) }) }) }) @@ -545,4 +560,8 @@ describe('anonymizeIP', () => { expect(anonymizeIP('2001:db8::1')).toBe('2001:db8:0::') expect(anonymizeIP('2001:0db8:1234:5678:9abc:def0:1234:5678')).toBe('2001:0db8:1234::') }) + + it('anonymizes IPv4-mapped IPv6 addresses with zone identifiers', () => { + expect(anonymizeIP('::ffff:192.168.1.100%eth0')).toBe('::ffff:192.168.1.0') + }) }) diff --git a/test/unit/resolve-proxy-secret.test.ts b/test/unit/resolve-proxy-secret.test.ts index 19c2461e7..d79c8412a 100644 --- a/test/unit/resolve-proxy-secret.test.ts +++ b/test/unit/resolve-proxy-secret.test.ts @@ -90,6 +90,16 @@ describe('resolveProxySecret', () => { expect(readFileSync(join(testDir, '.env'), 'utf-8')).toBe(`${ENV_KEY}=${result?.secret}\n`) }) + it('replaces a whitespace-only persisted secret', () => { + writeFileSync(join(testDir, '.env'), `${ENV_KEY}= \n`) + + const result = resolveProxySecret(testDir, true) + + expect(result?.source).toBe('dotenv-generated') + expect(result?.secret).toHaveLength(64) + expect(readFileSync(join(testDir, '.env'), 'utf-8')).toBe(`${ENV_KEY}=${result?.secret}\n`) + }) + it('populates process.env after generating a new secret', () => { resolveProxySecret(testDir, true) expect(process.env[ENV_KEY]).toBeDefined() From 1783639d8cd7c09eab180a21d5c56e097f25ca78 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 22 Jul 2026 15:58:37 +1000 Subject: [PATCH 4/5] fix(proxy): bound streams and secret generation --- packages/script/src/module.ts | 79 ++++++++++++++----- .../src/runtime/server/proxy-handler.ts | 62 ++++++++++++++- test/unit/proxy-alias-generators.test.ts | 41 ++++++---- test/unit/proxy-handler-body.test.ts | 31 +++++++- test/unit/resolve-proxy-secret.test.ts | 67 ++++++++++------ 5 files changed, 217 insertions(+), 63 deletions(-) diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index 614a7e0a8..3c9c60ca3 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -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, unlink } from 'node:fs/promises' +import { setTimeout as delay } from 'node:timers/promises' import { addBuildPlugin, addComponentsDir, @@ -123,6 +125,43 @@ 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 +const PROXY_SECRET_LOCK_RETRY_MS = 10 +const PROXY_SECRET_LOCK_TIMEOUT_MS = 2000 + +async function withProxySecretFileLock(envPath: string, effect: () => T): Promise { + const lockPath = `${envPath}.nuxt-scripts.lock` + const deadline = Date.now() + PROXY_SECRET_LOCK_TIMEOUT_MS + let lockHandle: Awaited> | 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 + } + 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) + } + + try { + return effect() + } + finally { + await lockHandle.close() + await unlink(lockPath).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') + throw error + }) + } +} export interface ResolvedProxySecret { secret: string @@ -141,12 +180,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 { if (configSecret) return { secret: configSecret, ephemeral: false, source: 'config' } @@ -165,30 +204,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 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 { secret: existingSecret, ephemeral: false, source: 'dotenv-generated' } - // 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}`)) + 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}`) + } } else { - appendFileSync(envPath, contents.endsWith('\n') ? line : `\n${line}`) + 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 @@ -1133,7 +1172,7 @@ export default defineNuxtModule({ // 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, diff --git a/packages/script/src/runtime/server/proxy-handler.ts b/packages/script/src/runtime/server/proxy-handler.ts index 8bbf60577..44d467aa2 100644 --- a/packages/script/src/runtime/server/proxy-handler.ts +++ b/packages/script/src/runtime/server/proxy-handler.ts @@ -28,6 +28,7 @@ interface ProxyConfig { const COMPRESSION_RE = /gzip|deflate|br|compress|base64/i const CLIENT_HINT_VERSION_RE = /;v="(\d+)\.[^"]*"/g +const UPSTREAM_TIMEOUT_MS = 15000 const SKIP_RESPONSE_HEADERS = new Set([ 'alt-svc', 'clear-site-data', @@ -61,6 +62,62 @@ export const SKIP_REQUEST_HEADERS = new Set([ 'upgrade', ]) +export function withResponseBodyIdleTimeout( + body: ReadableStream, + timeoutMs: number, + onTimeout: () => void, +): ReadableStream { + const reader = body.getReader() + let stopped = false + let timeoutId: ReturnType | undefined + + const clearIdleTimeout = () => { + if (timeoutId !== undefined) { + clearTimeout(timeoutId) + timeoutId = undefined + } + } + + return new ReadableStream({ + async pull(controller) { + timeoutId = setTimeout(() => { + stopped = true + const error = createError({ + statusCode: 504, + statusMessage: 'Gateway Timeout', + message: 'Upstream response body timed out', + }) + onTimeout() + controller.error(error) + void reader.cancel(error).catch((cancelError) => { + Object.assign(error, { cause: cancelError }) + }) + }, timeoutMs) + + const result = await reader.read().catch((error) => { + clearIdleTimeout() + if (!stopped) + controller.error(error) + return undefined + }) + clearIdleTimeout() + if (!result || stopped) + return + if (result.done) { + stopped = true + controller.close() + return + } + controller.enqueue(result.value) + }, + async cancel(reason) { + stopped = true + clearIdleTimeout() + await reader.cancel(reason) + }, + }) +} + /** * Strip fingerprinting from URL query string. * Returns both the query string and the stripped record (to avoid re-computing for hooks). @@ -401,7 +458,7 @@ export default defineEventHandler(async (event) => { const timeoutId = setTimeout(() => { timedOut = true controller.abort() - }, 15000) // 15s timeout + }, UPSTREAM_TIMEOUT_MS) // Resolve the fetch body: passthrough streams the raw request, otherwise serialize let fetchBody: BodyInit | undefined @@ -487,5 +544,6 @@ export default defineEventHandler(async (event) => { // Stream rather than buffering potentially large upstream responses. This lowers // memory pressure and lets the browser receive headers and chunks immediately. - return sendStream(event, response.body).finally(() => network?.close()) + const guardedBody = withResponseBodyIdleTimeout(response.body, UPSTREAM_TIMEOUT_MS, () => controller.abort()) + return sendStream(event, guardedBody).finally(() => network?.close()) }) diff --git a/test/unit/proxy-alias-generators.test.ts b/test/unit/proxy-alias-generators.test.ts index e96d12887..ddee785de 100644 --- a/test/unit/proxy-alias-generators.test.ts +++ b/test/unit/proxy-alias-generators.test.ts @@ -1,14 +1,21 @@ +import { runInNewContext } from 'node:vm' import { describe, expect, it, vi } from 'vitest' import { generateInterceptPluginContents } from '../../packages/script/src/plugins/intercept' import { generatePartytownResolveUrl } from '../../packages/script/src/registry' const ALIASES = { 'us.i.posthog.com': 'ph' } +function evaluateResolveUrl(source: string) { + return runInNewContext(`(${source})`, { URL }) as ( + url: URL, + location: URL, + ) => URL | undefined +} + describe('proxy alias - generated runtime code (#814)', () => { describe('generatePartytownResolveUrl', () => { it('rewrites a third-party host to its alias', () => { - // eslint-disable-next-line no-eval - const resolveUrl = eval(`(${generatePartytownResolveUrl('/_scripts/p', ALIASES)})`) + const resolveUrl = evaluateResolveUrl(generatePartytownResolveUrl('/_scripts/p', ALIASES)) const out = resolveUrl( new URL('https://us.i.posthog.com/e/?x=1'), new URL('https://my-site.test/'), @@ -18,8 +25,7 @@ describe('proxy alias - generated runtime code (#814)', () => { }) it('falls back to the verbatim host when no alias is configured', () => { - // eslint-disable-next-line no-eval - const resolveUrl = eval(`(${generatePartytownResolveUrl('/_scripts/p')})`) + const resolveUrl = evaluateResolveUrl(generatePartytownResolveUrl('/_scripts/p')) const out = resolveUrl( new URL('https://eu.i.posthog.com/e/'), new URL('https://my-site.test/'), @@ -30,8 +36,7 @@ describe('proxy alias - generated runtime code (#814)', () => { it.each(['constructor', 'toString', '__proto__'])( 'treats inherited property name %s as an unaliased host', (host) => { - // eslint-disable-next-line no-eval - const resolveUrl = eval(`(${generatePartytownResolveUrl('/_scripts/p')})`) + const resolveUrl = evaluateResolveUrl(generatePartytownResolveUrl('/_scripts/p')) const out = resolveUrl( new URL(`https://${host}/collect`), new URL('https://my-site.test/'), @@ -42,8 +47,7 @@ describe('proxy alias - generated runtime code (#814)', () => { ) it('leaves non-HTTP protocols unresolved', () => { - // eslint-disable-next-line no-eval - const resolveUrl = eval(`(${generatePartytownResolveUrl('/_scripts/p')})`) + const resolveUrl = evaluateResolveUrl(generatePartytownResolveUrl('/_scripts/p')) expect(resolveUrl(new URL('data:text/plain,hello'), new URL('https://my-site.test/'))).toBeUndefined() }) @@ -72,15 +76,23 @@ describe('proxy alias - generated runtime code (#814)', () => { const HTMLImageElement = class {} const Request = globalThis.Request const generated = generateInterceptPluginContents('/_scripts/p').replace('export default ', '') - void [defineNuxtPlugin, navigator, location, XMLHttpRequest, Image, HTMLImageElement, Request] + const sandbox = { + defineNuxtPlugin, + navigator, + location, + XMLHttpRequest, + Image, + HTMLImageElement, + Request, + URL, + fetch: globalThis.fetch, + __nuxtScripts: undefined as { fetch: (url: string) => Promise } | undefined, + } try { - // eslint-disable-next-line no-eval - const plugin = eval(generated) as { setup: () => void } + const plugin = runInNewContext(generated, sandbox) as { setup: () => void } plugin.setup() - const runtime = (globalThis as typeof globalThis & { - __nuxtScripts: { fetch: (url: string) => Promise } - }).__nuxtScripts + const runtime = sandbox.__nuxtScripts! await runtime.fetch('data:text/plain,hello') await runtime.fetch('javascript:void(0)') @@ -96,7 +108,6 @@ describe('proxy alias - generated runtime code (#814)', () => { } finally { fetchSpy.mockRestore() - delete (globalThis as typeof globalThis & { __nuxtScripts?: unknown }).__nuxtScripts } }) }) diff --git a/test/unit/proxy-handler-body.test.ts b/test/unit/proxy-handler-body.test.ts index 07d2f33b8..507b75713 100644 --- a/test/unit/proxy-handler-body.test.ts +++ b/test/unit/proxy-handler-body.test.ts @@ -3,7 +3,7 @@ import { createServer } from 'node:http' import { gzipSync } from 'node:zlib' import { createApp, defineEventHandler, getRequestURL, readRawBody, sendRedirect, setHeader, toNodeListener } from 'h3' import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -import proxyHandler from '../../packages/script/src/runtime/server/proxy-handler' +import proxyHandler, { withResponseBodyIdleTimeout } from '../../packages/script/src/runtime/server/proxy-handler' vi.mock('nitropack/runtime', () => ({ useRuntimeConfig: () => ({ @@ -243,7 +243,7 @@ describe('proxy handler request bodies (#836)', () => { let connectionTimeout: ReturnType | undefined const timeoutSpy = vi.spyOn(globalThis, 'setTimeout').mockImplementation(((callback, delay, ...args) => { const timeout = realSetTimeout(callback, delay, ...args) - if (delay === 15000) + if (delay === 15000 && connectionTimeout === undefined) connectionTimeout = timeout return timeout }) as typeof setTimeout) @@ -263,4 +263,31 @@ describe('proxy handler request bodies (#836)', () => { releaseStream?.() } }) + + it('cancels an upstream response body that stops producing chunks', async () => { + vi.useFakeTimers() + const cancel = vi.fn() + const abort = vi.fn() + const source = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('first')) + }, + cancel, + }) + const reader = withResponseBodyIdleTimeout(source, 15000, abort).getReader() + + try { + expect(new TextDecoder().decode((await reader.read()).value)).toBe('first') + const timedOutRead = expect(reader.read()).rejects.toThrow('Upstream response body timed out') + + await vi.advanceTimersByTimeAsync(15000) + + await timedOutRead + expect(abort).toHaveBeenCalledOnce() + expect(cancel).toHaveBeenCalledOnce() + } + finally { + vi.useRealTimers() + } + }) }) diff --git a/test/unit/resolve-proxy-secret.test.ts b/test/unit/resolve-proxy-secret.test.ts index d79c8412a..4a47ef245 100644 --- a/test/unit/resolve-proxy-secret.test.ts +++ b/test/unit/resolve-proxy-secret.test.ts @@ -25,30 +25,30 @@ describe('resolveProxySecret', () => { delete process.env[ENV_KEY] }) - it('returns config secret with highest priority', () => { + it('returns config secret with highest priority', async () => { process.env[ENV_KEY] = 'env-secret' - const result = resolveProxySecret(testDir, true, 'config-secret') + const result = await resolveProxySecret(testDir, true, 'config-secret') expect(result).toEqual({ secret: 'config-secret', ephemeral: false, source: 'config' }) }) - it('falls back to env var when no config secret', () => { + it('falls back to env var when no config secret', async () => { process.env[ENV_KEY] = 'env-secret' - const result = resolveProxySecret(testDir, true) + const result = await resolveProxySecret(testDir, true) expect(result).toEqual({ secret: 'env-secret', ephemeral: false, source: 'env' }) }) - it('returns undefined in prod when no secret is available', () => { - const result = resolveProxySecret(testDir, false) + it('returns undefined in prod when no secret is available', async () => { + const result = await resolveProxySecret(testDir, false) expect(result).toBeUndefined() }) - it('returns undefined when autoGenerate is false even in dev', () => { - const result = resolveProxySecret(testDir, true, undefined, false) + it('returns undefined when autoGenerate is false even in dev', async () => { + const result = await resolveProxySecret(testDir, true, undefined, false) expect(result).toBeUndefined() }) - it('auto-generates and writes to .env in dev when file does not exist', () => { - const result = resolveProxySecret(testDir, true) + it('auto-generates and writes to .env in dev when file does not exist', async () => { + const result = await resolveProxySecret(testDir, true) expect(result).toBeDefined() expect(result!.source).toBe('dotenv-generated') expect(result!.ephemeral).toBe(false) @@ -59,9 +59,9 @@ describe('resolveProxySecret', () => { expect(envContent).toContain('# Generated by @nuxt/scripts') }) - it('appends to existing .env in dev', () => { + it('appends to existing .env in dev', async () => { writeFileSync(join(testDir, '.env'), 'OTHER_VAR=value\n') - const result = resolveProxySecret(testDir, true) + const result = await resolveProxySecret(testDir, true) expect(result!.source).toBe('dotenv-generated') const envContent = readFileSync(join(testDir, '.env'), 'utf-8') @@ -69,9 +69,9 @@ describe('resolveProxySecret', () => { expect(envContent).toContain(`${ENV_KEY}=`) }) - it('returns existing secret from .env without generating a new one', () => { + it('returns existing secret from .env without generating a new one', async () => { writeFileSync(join(testDir, '.env'), `${ENV_KEY}=existing-secret-value\n`) - const result = resolveProxySecret(testDir, true) + const result = await resolveProxySecret(testDir, true) expect(result).toEqual({ secret: 'existing-secret-value', ephemeral: false, source: 'dotenv-generated' }) // Should not have written a second line @@ -80,44 +80,63 @@ describe('resolveProxySecret', () => { expect(matches).toHaveLength(1) }) - it('replaces an empty persisted secret instead of returning an ephemeral value', () => { + it('waits for another process to persist the secret while its lock is held', async () => { + const envPath = join(testDir, '.env') + const lockPath = `${envPath}.nuxt-scripts.lock` + writeFileSync(lockPath, '') + + const persisted = new Promise((resolve) => { + setTimeout(() => { + writeFileSync(envPath, `${ENV_KEY}=shared-process-secret\n`) + rmSync(lockPath) + resolve() + }, 20) + }) + + const result = await resolveProxySecret(testDir, true) + await persisted + + expect(result).toEqual({ secret: 'shared-process-secret', ephemeral: false, source: 'dotenv-generated' }) + }) + + it('replaces an empty persisted secret instead of returning an ephemeral value', async () => { writeFileSync(join(testDir, '.env'), `${ENV_KEY}=\n`) - const result = resolveProxySecret(testDir, true) + const result = await resolveProxySecret(testDir, true) expect(result?.source).toBe('dotenv-generated') expect(result?.secret).toHaveLength(64) expect(readFileSync(join(testDir, '.env'), 'utf-8')).toBe(`${ENV_KEY}=${result?.secret}\n`) }) - it('replaces a whitespace-only persisted secret', () => { + it('replaces a whitespace-only persisted secret', async () => { writeFileSync(join(testDir, '.env'), `${ENV_KEY}= \n`) - const result = resolveProxySecret(testDir, true) + const result = await resolveProxySecret(testDir, true) expect(result?.source).toBe('dotenv-generated') expect(result?.secret).toHaveLength(64) expect(readFileSync(join(testDir, '.env'), 'utf-8')).toBe(`${ENV_KEY}=${result?.secret}\n`) }) - it('populates process.env after generating a new secret', () => { - resolveProxySecret(testDir, true) + it('populates process.env after generating a new secret', async () => { + await resolveProxySecret(testDir, true) expect(process.env[ENV_KEY]).toBeDefined() expect(process.env[ENV_KEY]).toHaveLength(64) }) - it('falls back to in-memory when .env dir is read-only', () => { + it('falls back to in-memory when .env dir is read-only', async () => { // Use a non-existent deeply nested path that can't be written - const result = resolveProxySecret('/proc/nonexistent/path', true) + const result = await resolveProxySecret('/proc/nonexistent/path', true) expect(result).toBeDefined() expect(result!.source).toBe('memory-generated') expect(result!.ephemeral).toBe(true) expect(result!.secret).toHaveLength(64) }) - it('adds newline before appending when .env does not end with newline', () => { + it('adds newline before appending when .env does not end with newline', async () => { writeFileSync(join(testDir, '.env'), 'OTHER_VAR=value') - resolveProxySecret(testDir, true) + await resolveProxySecret(testDir, true) const envContent = readFileSync(join(testDir, '.env'), 'utf-8') // Should have a newline between existing content and new key expect(envContent).toMatch(/value\n.*NUXT_SCRIPTS_PROXY_SECRET=/) From 29790f542b26fcc10f50945e377e097fd178c38e Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 22 Jul 2026 16:08:26 +1000 Subject: [PATCH 5/5] fix(proxy): recover stale secret locks --- packages/script/src/module.ts | 45 +++++++++++++++++++++----- test/unit/resolve-proxy-secret.test.ts | 16 ++++++++- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index 3c9c60ca3..87e6b4491 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -16,7 +16,7 @@ import type { } from './runtime/types' import { randomBytes } from 'node:crypto' import { appendFileSync, existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' -import { open as openFile, unlink } from 'node:fs/promises' +import { open as openFile, stat, unlink } from 'node:fs/promises' import { setTimeout as delay } from 'node:timers/promises' import { addBuildPlugin, @@ -146,21 +146,50 @@ async function withProxySecretFileLock(envPath: string, effect: () => T): Pro 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 { - return effect() + effectResult = { _tag: 'Success', value: effect() } } - finally { - await lockHandle.close() - await unlink(lockPath).catch((error: NodeJS.ErrnoException) => { - if (error.code !== 'ENOENT') - throw error - }) + 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 } export interface ResolvedProxySecret { diff --git a/test/unit/resolve-proxy-secret.test.ts b/test/unit/resolve-proxy-secret.test.ts index 4a47ef245..9ab11eb97 100644 --- a/test/unit/resolve-proxy-secret.test.ts +++ b/test/unit/resolve-proxy-secret.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -99,6 +99,20 @@ describe('resolveProxySecret', () => { expect(result).toEqual({ secret: 'shared-process-secret', ephemeral: false, source: 'dotenv-generated' }) }) + it('recovers a stale lock left by a crashed process', async () => { + const envPath = join(testDir, '.env') + const lockPath = `${envPath}.nuxt-scripts.lock` + writeFileSync(lockPath, '') + const staleTime = new Date(Date.now() - 3000) + utimesSync(lockPath, staleTime, staleTime) + + const result = await resolveProxySecret(testDir, true) + + expect(result?.source).toBe('dotenv-generated') + expect(result?.ephemeral).toBe(false) + expect(readFileSync(envPath, 'utf-8')).toContain(`${ENV_KEY}=${result?.secret}`) + }) + it('replaces an empty persisted secret instead of returning an ephemeral value', async () => { writeFileSync(join(testDir, '.env'), `${ENV_KEY}=\n`)