diff --git a/docs/content/docs/1.guides/2.first-party.md b/docs/content/docs/1.guides/2.first-party.md index bc8a0351..4140ee12 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 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 @@ -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/package.json b/packages/script/package.json index e823ef88..0d0deb03 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 cdc99f7c..87e6b449 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, stat, unlink } from 'node:fs/promises' +import { setTimeout as delay } from 'node:timers/promises' import { addBuildPlugin, addComponentsDir, @@ -41,6 +43,7 @@ import { generateInterceptPluginContents } from './plugins/intercept' import { NuxtScriptBundleTransformer } from './plugins/transform' import { aliasProxyValue, buildDomainAliasMap, invertAliasMap, isSafeAliasSegment } from './proxy-alias' import { buildProxyConfigsFromRegistry, generatePartytownResolveUrl, getPartytownForwards, registry, resolveCapabilities } from './registry' +import { isPublicNetworkHostname } from './runtime/server/utils/network-host' import { registerTypeTemplates, templatePlugin, templateTriggerResolver } from './templates' import { validateScriptsEnvVars } from './validate-env' @@ -120,8 +123,74 @@ const UPPER_RE = /([A-Z])/g const toScreamingSnake = (s: string) => s.replace(UPPER_RE, '_$1').toUpperCase() const PROXY_SECRET_ENV_KEY = 'NUXT_SCRIPTS_PROXY_SECRET' -const PROXY_SECRET_ENV_LINE_RE = /^NUXT_SCRIPTS_PROXY_SECRET=/m +const PROXY_SECRET_ENV_LINE_RE = /^NUXT_SCRIPTS_PROXY_SECRET=.*$/m const PROXY_SECRET_ENV_VALUE_RE = /^NUXT_SCRIPTS_PROXY_SECRET=(.+)$/m +const PROXY_SECRET_LOCK_RETRY_MS = 10 +const PROXY_SECRET_LOCK_TIMEOUT_MS = 2000 + +async function withProxySecretFileLock(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 + } + + const existingLock = await stat(lockPath) + .then(lockStat => ({ _tag: 'Found' as const, mtimeMs: lockStat.mtimeMs })) + .catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') + return { _tag: 'Missing' as const } + throw error + }) + if (existingLock._tag === 'Found' && Date.now() - existingLock.mtimeMs >= PROXY_SECRET_LOCK_TIMEOUT_MS) { + await unlink(lockPath).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') + throw error + }) + continue + } + if (Date.now() >= deadline) + throw Object.assign(new Error('Timed out waiting for proxy secret file lock'), { code: 'ETIMEDOUT' }) + await delay(PROXY_SECRET_LOCK_RETRY_MS) + } + + let effectResult: { _tag: 'Success', value: T } | { _tag: 'Failure', error: unknown } + try { + effectResult = { _tag: 'Success', value: effect() } + } + catch (error) { + effectResult = { _tag: 'Failure', error } + } + + const closeResult = await lockHandle.close() + .then(() => ({ _tag: 'Success' as const })) + .catch((error: Error) => ({ _tag: 'Failure' as const, error })) + const unlinkResult = await unlink(lockPath) + .then(() => ({ _tag: 'Success' as const })) + .catch((error: NodeJS.ErrnoException) => error.code === 'ENOENT' + ? { _tag: 'Success' as const } + : { _tag: 'Failure' as const, error }) + + if (closeResult._tag === 'Failure') + logger.warn(`[security] Failed to close the proxy secret lock: ${closeResult.error.message}`) + if (unlinkResult._tag === 'Failure') + logger.warn(`[security] Failed to remove the proxy secret lock: ${unlinkResult.error.message}`) + if (effectResult._tag === 'Failure') + throw effectResult.error + return effectResult.value +} export interface ResolvedProxySecret { secret: string @@ -140,12 +209,12 @@ export interface ResolvedProxySecret { * 3. Dev-only auto-generation: write to `.env` (or keep in memory as last resort) * 4. Empty string (prod without secret; caller decides whether this is fatal) */ -export function resolveProxySecret( +export async function resolveProxySecret( rootDir: string, isDev: boolean, configSecret?: string, autoGenerate: boolean = true, -): ResolvedProxySecret | undefined { +): Promise { if (configSecret) return { secret: configSecret, ephemeral: false, source: 'config' } @@ -164,25 +233,30 @@ export function resolveProxySecret( const line = `${PROXY_SECRET_ENV_KEY}=${secret}\n` try { - if (existsSync(envPath)) { - const contents = readFileSync(envPath, 'utf-8') - // Safety: don't append if another process already wrote one between the read above - // and this branch. The regex check is cheap and idempotent. - if (PROXY_SECRET_ENV_LINE_RE.test(contents)) { - // Another instance already wrote it. Re-read and return that value. - const match = contents.match(PROXY_SECRET_ENV_VALUE_RE) - if (match?.[1]) - return { secret: match[1].trim(), ephemeral: false, source: 'dotenv-generated' } + const persistedSecret = await withProxySecretFileLock(envPath, () => { + if (existsSync(envPath)) { + const contents = readFileSync(envPath, 'utf-8') + const existingSecret = contents.match(PROXY_SECRET_ENV_VALUE_RE)?.[1]?.trim() + if (existingSecret) + return existingSecret + if (PROXY_SECRET_ENV_LINE_RE.test(contents)) { + // An empty declaration suppresses dotenv fallback on future starts. + // Replace it in place so the generated secret remains stable. + writeFileSync(envPath, contents.replace(PROXY_SECRET_ENV_LINE_RE, `${PROXY_SECRET_ENV_KEY}=${secret}`)) + } + else { + appendFileSync(envPath, contents.endsWith('\n') ? line : `\n${line}`) + } } - appendFileSync(envPath, contents.endsWith('\n') ? line : `\n${line}`) - } - else { - writeFileSync(envPath, `# Generated by @nuxt/scripts\n${line}`) - } + else { + writeFileSync(envPath, `# Generated by @nuxt/scripts\n${line}`) + } + return secret + }) // Also populate process.env so that anything reading it later in the same // dev process (e.g. child workers) sees the value without a restart. - process.env[PROXY_SECRET_ENV_KEY] = secret - return { secret, ephemeral: false, source: 'dotenv-generated' } + process.env[PROXY_SECRET_ENV_KEY] = persistedSecret + return { secret: persistedSecret, ephemeral: false, source: 'dotenv-generated' } } catch { // Writing .env failed (read-only FS, permission denied). Fall back to @@ -250,7 +324,10 @@ function resolveConfiguredProxyDomain(value: unknown): string | undefined { return try { - return new URL(trimmed, 'https://nuxt-scripts.local').hostname || undefined + const url = new URL(trimmed, 'https://nuxt-scripts.local') + if (url.protocol !== 'http:' && url.protocol !== 'https:') + return + return isPublicNetworkHostname(url.hostname) ? url.hostname : undefined } catch { // Invalid user-provided proxy domains cannot be normalized. @@ -1124,7 +1201,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/plugins/intercept.ts b/packages/script/src/plugins/intercept.ts index 7e7e3e22..d24bd6c6 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; @@ -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 a49675d2..2ea5362f 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 591d7889..99080913 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -882,8 +882,8 @@ 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) { - var aliases = ${JSON.stringify(domainAliases)}; + if ((url.protocol === 'http:' || url.protocol === 'https:') && url.origin !== location.origin) { + var aliases = Object.assign(Object.create(null), ${JSON.stringify(domainAliases)}); var seg = aliases[url.host] || url.host; return new URL(${JSON.stringify(proxyPrefix)} + '/' + seg + url.pathname + url.search, location.origin); } diff --git a/packages/script/src/runtime/server/bluesky-embed.ts b/packages/script/src/runtime/server/bluesky-embed.ts index 45090faa..e7d7fd15 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 79df2549..dda09fbd 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 e79486f9..988d93d5 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 693ae3a5..27027075 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 378fa540..14aa75dc 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 56d207bd..3e29a0f7 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 5c272a30..44d467aa 100644 --- a/packages/script/src/runtime/server/proxy-handler.ts +++ b/packages/script/src/runtime/server/proxy-handler.ts @@ -1,7 +1,8 @@ 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 { createPublicNetworkDispatcher, isPrivateNetworkResolutionError, isPublicNetworkHostname } from './utils/network-host' import { anonymizeIP, mergePrivacy, @@ -27,7 +28,28 @@ 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 UPSTREAM_TIMEOUT_MS = 15000 +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', @@ -40,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). @@ -51,8 +129,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 } @@ -103,6 +183,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)) { @@ -370,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 @@ -382,23 +470,30 @@ 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, - // @ts-expect-error Node fetch supports duplex for streaming request bodies + redirect: 'manual', 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', - message: `Proxy upstream request failed: ${targetUrl}`, + 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: { errorName: (err as Error)?.name, @@ -406,29 +501,49 @@ 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() + await network?.close() + 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) { + await network?.close() + 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. + const guardedBody = withResponseBodyIdleTimeout(response.body, UPSTREAM_TIMEOUT_MS, () => controller.abort()) + return sendStream(event, guardedBody).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 931d66f2..96ae3066 100644 --- a/packages/script/src/runtime/server/utils/cached-upstream.ts +++ b/packages/script/src/runtime/server/utils/cached-upstream.ts @@ -1,7 +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 { createPublicNetworkDispatcher, isPublicNetworkHostname } from './network-host' /** * Server-side caches for upstream proxy fetches. @@ -40,11 +42,82 @@ 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') + && isPublicNetworkHostname(url.hostname) +} + +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,21 +125,48 @@ 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, - }) - const data = response._data as ArrayBuffer | undefined - return { - base64: data ? Buffer.from(data).toString('base64') : '', - contentType: response.headers.get('content-type'), - status: response.status, + let currentUrl = parseUpstreamUrl(url) + assertAllowedUrl(currentUrl, config.allowUrl) + let response: FetchResponse + const network = await createPublicNetworkDispatcher() + const safeFetch = createFetch({ fetch: network.fetch }) + + 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, + } + } + finally { + await network.close() } }, { @@ -112,13 +212,43 @@ 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) + const network = await createPublicNetworkDispatcher() + const safeFetch = createFetch({ fetch: network.fetch }) + + 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') + } + + const data = response._data as T + config.validateResponse?.(data) + return data + } + } + finally { + await network.close() + } }, { name, diff --git a/packages/script/src/runtime/server/utils/image-proxy.ts b/packages/script/src/runtime/server/utils/image-proxy.ts index bb90844b..68c7f4a8 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 @@ -9,8 +10,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 +25,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 +33,18 @@ 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 + && isPublicNetworkHostname(url.hostname) + && domainAllowed(url.hostname) + + const cachedFetch = createCachedBinaryFetch(cacheName, cacheMaxAge, { + allowUrl: urlAllowed, + }) return withSigning(defineEventHandler(async (event) => { const query = getQuery(event) @@ -58,21 +71,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 +94,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 eb5031f4..799d1055 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/network-host.ts b/packages/script/src/runtime/server/utils/network-host.ts new file mode 100644 index 00000000..9dddf2c7 --- /dev/null +++ b/packages/script/src/runtime/server/utils/network-host.ts @@ -0,0 +1,165 @@ +import type { LookupFunction } from 'node:net' +import { runtime } from 'std-env' + +const LOCAL_HOST_SUFFIXES = [ + 'home', + 'internal', + 'lan', + 'local', + 'localdomain', + '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))) + 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) +} + +/** 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 582bb563..c0b461a6 100644 --- a/packages/script/src/runtime/server/utils/privacy.ts +++ b/packages/script/src/runtime/server/utils/privacy.ts @@ -168,13 +168,35 @@ 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 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 - return `${ip.split(':').slice(0, 3).join(':')}::` + 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('.') @@ -400,22 +422,32 @@ 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 // (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 num = typeof value === 'number' ? value : Number(value) - 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) + }) } } @@ -439,14 +471,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 } @@ -460,36 +492,45 @@ 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] = typeof value === '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 { - 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 +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 ? (typeof value === 'string' ? anonymizeDeviceInfo(value) : '') : 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/packages/script/src/runtime/server/utils/proxy-query.ts b/packages/script/src/runtime/server/utils/proxy-query.ts new file mode 100644 index 00000000..07bdf819 --- /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 bca7c8bd..622b4df2 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/pnpm-lock.yaml b/pnpm-lock.yaml index 5722ac12..a9a33691 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 62a3025b..7f558203 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 b69ccebf..83248832 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() }), + createFetch: vi.fn(() => 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('allowUrl 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 00000000..39def91e --- /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', () => ({ + createFetch: vi.fn(() => 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 00000000..ec4c3d30 --- /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', () => ({ + createFetch: vi.fn(() => 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 00000000..a250a57e --- /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', () => ({ + createFetch: vi.fn(() => 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 00000000..4311db0e --- /dev/null +++ b/test/unit/image-proxy-security.test.ts @@ -0,0 +1,123 @@ +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: () => ({}), +})) + +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', () => { + let upstreamServer: Server + let proxyServer: Server + let upstreamPort: number + let proxyPort: number + let disallowedServer: Server + let disallowedPort: number + let disallowedRequests = 0 + const realFetch = globalThis.fetch + + 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://evil.example.com:${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 + + 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: ['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())), + new Promise(resolve => disallowedServer.close(() => resolve())), + ]) + }) + + function proxyUrl(path: string): string { + const target = `http://cdn.example.com${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 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')) + + expect(response.status).toBe(415) + }) +}) diff --git a/test/unit/instagram-embed.test.ts b/test/unit/instagram-embed.test.ts index cdef13d6..4a2e58e1 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/network-host.test.ts b/test/unit/network-host.test.ts new file mode 100644 index 00000000..73bb2523 --- /dev/null +++ b/test/unit/network-host.test.ts @@ -0,0 +1,81 @@ +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([ + '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) + }) + + 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 5833f21d..ddee785d 100644 --- a/test/unit/proxy-alias-generators.test.ts +++ b/test/unit/proxy-alias-generators.test.ts @@ -1,14 +1,21 @@ -import { describe, expect, it } from 'vitest' +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,26 +25,90 @@ 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/'), ) 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) => { + const resolveUrl = evaluateResolveUrl(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', () => { + const resolveUrl = evaluateResolveUrl(generatePartytownResolveUrl('/_scripts/p')) + + expect(resolveUrl(new URL('data:text/plain,hello'), new URL('https://my-site.test/'))).toBeUndefined() + }) }) 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 ', '') + const sandbox = { + defineNuxtPlugin, + navigator, + location, + XMLHttpRequest, + Image, + HTMLImageElement, + Request, + URL, + fetch: globalThis.fetch, + __nuxtScripts: undefined as { fetch: (url: string) => Promise } | undefined, + } + + try { + const plugin = runInNewContext(generated, sandbox) as { setup: () => void } + plugin.setup() + const runtime = sandbox.__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() + } }) }) }) diff --git a/test/unit/proxy-alias.test.ts b/test/unit/proxy-alias.test.ts index 60d7284d..8600ca17 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-alias.test.ts b/test/unit/proxy-handler-alias.test.ts index 4b5be523..ba41ce35 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 f1932946..507b7571 100644 --- a/test/unit/proxy-handler-body.test.ts +++ b/test/unit/proxy-handler-body.test.ts @@ -1,15 +1,16 @@ 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' +import proxyHandler, { withResponseBodyIdleTimeout } from '../../packages/script/src/runtime/server/proxy-handler' vi.mock('nitropack/runtime', () => ({ useRuntimeConfig: () => ({ 'nuxt-scripts-proxy': { proxyPrefix: '/_scripts/p', domainPrivacy: { + '127.0.0.1': true, 'upstream.test': true, }, debug: false, @@ -20,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 @@ -28,12 +40,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 +98,8 @@ describe('proxy handler request bodies (#836)', () => { capturedBody = Buffer.alloc(0) capturedContentLength = undefined capturedContentType = undefined + capturedUrl = '' + releaseStream = undefined }) afterAll(async () => { @@ -89,6 +124,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' @@ -156,4 +197,97 @@ 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') + }) + + 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 === undefined) + 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?.() + } + }) + + 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/proxy-handler-hop-by-hop.test.ts b/test/unit/proxy-handler-hop-by-hop.test.ts index 7ddaf12b..f5d48d32 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 7dc73ece..5bb7839a 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, @@ -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', '']) }) }) }) @@ -515,4 +530,38 @@ 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::') + }) + + 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 21a3315f..9ab11eb9 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' @@ -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,24 +80,77 @@ describe('resolveProxySecret', () => { expect(matches).toHaveLength(1) }) - it('populates process.env after generating a new secret', () => { - resolveProxySecret(testDir, true) + 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('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`) + + 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', async () => { + writeFileSync(join(testDir, '.env'), `${ENV_KEY}= \n`) + + 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', 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=/) diff --git a/test/unit/setup.test.ts b/test/unit/setup.test.ts index 7338d67c..cad74887 100644 --- a/test/unit/setup.test.ts +++ b/test/unit/setup.test.ts @@ -118,6 +118,20 @@ 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('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',