Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/content/docs/1.guides/2.first-party.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,10 +355,10 @@ export default defineNuxtConfig({
})
```

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

::callout{type="warning"}
The shared [image-proxy handler](https://github.com/nuxt/scripts/blob/main/packages/script/src/runtime/server/utils/image-proxy.ts) checks the initial URL's scheme and allowed hostname. Several embed image and asset routes then follow upstream redirects without checking each redirect target again. This is an implementation boundary, not evidence that a configured vendor host is exploitable: keep proxy security enabled and do not treat the initial-host allowlist as complete redirect-chain validation.
Runtime proxy fetches validate the initial upstream URL and every redirect target before requesting it. Image routes reject active content types such as HTML and SVG. The Instagram embed route restricts post and stylesheet hosts, then sanitizes the returned fragment before client rendering.
::

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

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

The module injects a per-request page token into the SSR payload, so the response hash differs each request. If you compute a stable `etag`, set `security: false` to disable proxy security entirely. Proxy endpoints then pass requests through without signature verification, so only do this if you accept the wider request and redirect-validation boundaries described above.
The module injects a per-request page token into the SSR payload, so the response hash differs each request. If you compute a stable `etag`, set `security: false` to disable proxy security entirely. Signed proxy endpoints then pass requests through without signature verification, so only do this if you accept the wider request-authorization boundary described above.

#### Static Generation and SPA Mode

Expand Down
12 changes: 10 additions & 2 deletions packages/script/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,8 +173,13 @@ export function resolveProxySecret(
const match = contents.match(PROXY_SECRET_ENV_VALUE_RE)
if (match?.[1])
return { secret: match[1].trim(), ephemeral: false, source: 'dotenv-generated' }
// An empty declaration suppresses dotenv fallback on future starts.
// Replace it in place so the generated secret remains stable.
writeFileSync(envPath, contents.replace(PROXY_SECRET_ENV_LINE_RE, `${PROXY_SECRET_ENV_KEY}=${secret}`))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
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}`)
Expand Down Expand Up @@ -250,7 +255,10 @@ function resolveConfiguredProxyDomain(value: unknown): string | undefined {
return

try {
return new URL(trimmed, 'https://nuxt-scripts.local').hostname || undefined
const url = new URL(trimmed, 'https://nuxt-scripts.local')
if (url.protocol !== 'http:' && url.protocol !== 'https:')
return
return url.hostname || undefined
}
catch {
// Invalid user-provided proxy domains cannot be normalized.
Expand Down
4 changes: 2 additions & 2 deletions packages/script/src/plugins/intercept.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,11 @@ export function generateInterceptPluginContents(proxyPrefix: string, options?: {
function proxyUrl(url) {
try {
const parsed = new URL(url, location.origin);
if (parsed.origin !== location.origin) {
if ((parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.origin !== location.origin) {
const seg = domainAliases[parsed.host] || parsed.host;
return location.origin + proxyPrefix + '/' + seg + parsed.pathname + parsed.search;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
} catch {}
} catch { /* Invalid URL inputs retain native behavior. */ }
return url;
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

const geocodeUrl = withQuery('https://maps.googleapis.com/maps/api/geocode/json', {
Expand Down
32 changes: 18 additions & 14 deletions packages/script/src/runtime/server/google-static-maps-proxy.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -35,12 +33,8 @@ export default withSigning(defineEventHandler(async (event) => {
})
}

const query = getQuery(event)
const safeQuery: Record<string, unknown> = {}
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,
Expand All @@ -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
}))
61 changes: 52 additions & 9 deletions packages/script/src/runtime/server/gravatar-proxy.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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,
})
Expand All @@ -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
}))
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Loading
Loading