Skip to content

Commit 243cac2

Browse files
authored
fix: prevent unauthenticated SSRF via font URL parameter (#637)
1 parent 5aa6dbf commit 243cac2

7 files changed

Lines changed: 402 additions & 18 deletions

File tree

src/runtime/server/og-image/bindings/font-assets/cloudflare.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,27 @@ import type { H3Event } from 'h3'
22
import type { FontConfig } from '../../../../types'
33
import { useRuntimeConfig } from 'nitropack/runtime'
44
import { withBase } from 'ufo'
5+
import { getSiteConfig } from '#site-config/server/composables'
56
import { getCloudflareAssets } from '../../../util/cloudflareAssets'
67
import { fetchLocalAsset } from '../../../util/fetchLocalAsset'
78
import { getFetchTimeout } from '../../../util/fetchTimeout'
89
import { useOgImageRuntimeConfig } from '../../../utils'
10+
import { fetchSpecialFontUrl, isDataFontUrl, isExternalFontUrl } from './external-url'
911

1012
export async function resolve(event: H3Event, font: FontConfig) {
1113
const path = font.src || font.localPath
14+
const runtimeConfig = useOgImageRuntimeConfig()
15+
const timeout = getFetchTimeout(runtimeConfig)
1216
const { app } = useRuntimeConfig()
1317
const fullPath = withBase(path, app.baseURL)
14-
const timeout = getFetchTimeout(useOgImageRuntimeConfig())
18+
19+
// `data:` and external font URLs are attacker-reachable via the `fonts` URL
20+
// param (GHSA-q8hw-4fvp-9rwv). fetchLocalAsset would otherwise fall back to an
21+
// unvalidated $fetch against the absolute URL. `data:` is decoded inline;
22+
// external URLs are unsupported (use @nuxt/fonts) except the site's own origin,
23+
// fetched through the SSRF guard.
24+
if (path && (isDataFontUrl(path) || isExternalFontUrl(path)))
25+
return fetchSpecialFontUrl(path, getSiteConfig(event).url, timeout)
1526

1627
const ab = await fetchLocalAsset(event, fullPath, { fetchTimeout: timeout })
1728
if (ab)

src/runtime/server/og-image/bindings/font-assets/dev-prerender.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ import { useRuntimeConfig } from 'nitropack/runtime'
66
import { join } from 'pathe'
77
import { withBase } from 'ufo'
88
import { buildDir, rootDir } from '#og-image-virtual/build-dir.mjs'
9+
import { getSiteConfig } from '#site-config/server/composables'
910
import { getFetchTimeout } from '../../../util/fetchTimeout'
11+
import { fetchWithRedirectValidation } from '../../../util/ssrf'
1012
import { useOgImageRuntimeConfig } from '../../../utils'
13+
import { fetchSpecialFontUrl, isDataFontUrl, isExternalFontUrl } from './external-url'
1114

1215
let fontUrlMapping: Record<string, string> | undefined
1316

@@ -21,7 +24,8 @@ async function loadFontUrlMapping(): Promise<Record<string, string>> {
2124

2225
export async function resolve(event: H3Event, font: FontConfig): Promise<Buffer> {
2326
const path = font.src || font.localPath
24-
const timeout = getFetchTimeout(useOgImageRuntimeConfig())
27+
const runtimeConfig = useOgImageRuntimeConfig()
28+
const timeout = getFetchTimeout(runtimeConfig)
2529

2630
// Static bundled fonts — read directly from absolute path
2731
if (font.absolutePath) {
@@ -30,6 +34,14 @@ export async function resolve(event: H3Event, font: FontConfig): Promise<Buffer>
3034
return data
3135
}
3236

37+
// `data:` and external font URLs are attacker-reachable via the `fonts` URL
38+
// param (GHSA-q8hw-4fvp-9rwv). None of the relative-path branches below match
39+
// them, so without this gate they fall through to an unvalidated fetch/$fetch.
40+
// `data:` is decoded inline; external URLs are unsupported (use @nuxt/fonts)
41+
// except the site's own origin, fetched through the SSRF guard.
42+
if (path && (isDataFontUrl(path) || isExternalFontUrl(path)))
43+
return fetchSpecialFontUrl(path, getSiteConfig(event).url, timeout)
44+
3345
if (import.meta.prerender) {
3446
// Static font downloads (separate from @nuxt/fonts to avoid conflicts)
3547
if (path.startsWith('/_og-static-fonts/')) {
@@ -101,10 +113,13 @@ export async function resolve(event: H3Event, font: FontConfig): Promise<Buffer>
101113
if (import.meta.dev) {
102114
const reqUrl = getRequestURL(event)
103115
const origin = `${reqUrl.protocol}//${reqUrl.host}`
104-
const url = new URL(withBase(path, app.baseURL), origin).href
105-
const res = await fetch(url, { signal: AbortSignal.timeout(timeout) }).catch(() => null)
106-
if (res?.ok) {
107-
return Buffer.from(await res.arrayBuffer())
116+
const target = new URL(withBase(path, app.baseURL), origin)
117+
// Same-origin dev fetch: trust our own (loopback) host but re-validate any
118+
// redirect that leaves it, so an open redirect can't reach an internal
119+
// target via the font path (GHSA-q8hw-4fvp-9rwv).
120+
const ab = await fetchWithRedirectValidation(target.href, { timeout, trustedHost: target.host }).catch(() => null)
121+
if (ab) {
122+
return Buffer.from(ab)
108123
}
109124
}
110125
const fullPath = withBase(path, app.baseURL)
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { fetchWithRedirectValidation } from '../../../util/ssrf'
2+
3+
// Sentinel origin used purely to canonicalize + classify a font path with the
4+
// same WHATWG parser `fetch` uses. `.invalid` is reserved (RFC 2606) and never
5+
// resolves, so it can't be a useful SSRF target even in the (unreachable)
6+
// case where a path's own authority happens to equal it.
7+
const SENTINEL_ORIGIN = 'http://font-asset.invalid/'
8+
const SENTINEL_HOST = 'font-asset.invalid'
9+
10+
/** True for an inline `data:` font URI (no network — safe to decode directly). */
11+
export function isDataFontUrl(path: string): boolean {
12+
return path.trimStart().toLowerCase().startsWith('data:')
13+
}
14+
15+
/**
16+
* True when a font path carries its own authority (`//host`, `http://host`,
17+
* `\\host`) or a non-http scheme — i.e. it would not resolve same-origin.
18+
*
19+
* Classification resolves the path against a sentinel origin through the WHATWG
20+
* URL parser, so it sees exactly what `fetch` will: leading C0 controls/spaces,
21+
* embedded tab/CR/LF, and backslash folding are all normalized first. This
22+
* closes the bypass class where " //127.0.0.1" or " //169.254.169.254" looks
23+
* relative to a naive scheme check yet resolves cross-origin (GHSA-q8hw-4fvp-9rwv).
24+
* Check `isDataFontUrl` first — `data:` is non-http and would report true here.
25+
*/
26+
export function isExternalFontUrl(path: string): boolean {
27+
let resolved: URL
28+
try {
29+
resolved = new URL(path, SENTINEL_ORIGIN)
30+
}
31+
catch {
32+
// Unparseable even with a base: not a usable relative asset path. Force it
33+
// through the guard (which rejects it) rather than a raw same-origin fetch.
34+
return true
35+
}
36+
return resolved.protocol !== 'http:' || resolved.host !== SENTINEL_HOST
37+
}
38+
39+
/**
40+
* Resolve an authority-bearing font URL to the href to fetch, but only when it
41+
* is same-origin with the configured site URL. Returns null otherwise.
42+
*
43+
* Runtime external font URLs are unsupported — `@nuxt/fonts` is the only
44+
* supported way to load custom fonts, and it serves them same-origin. The single
45+
* allowed exception is the site's own origin, gated on an explicitly configured
46+
* site URL. Resolving against that origin also collapses canonicalization
47+
* bypasses (a crafted " //127.0.0.1" resolves cross-origin → rejected).
48+
*/
49+
export function resolveSameOriginFontUrl(path: string, siteUrl: string | undefined): string | null {
50+
if (!siteUrl)
51+
return null
52+
let siteOrigin: string
53+
try {
54+
siteOrigin = new URL(siteUrl).origin
55+
}
56+
catch {
57+
return null
58+
}
59+
let target: URL
60+
try {
61+
target = new URL(path, siteOrigin)
62+
}
63+
catch {
64+
return null
65+
}
66+
return target.origin === siteOrigin ? target.href : null
67+
}
68+
69+
/**
70+
* Fetch a `data:` or authority-bearing font URL. `data:` is decoded directly
71+
* (no network). An external URL is fetched only when same-origin with the site
72+
* URL, and always through the SSRF guard (scheme allowlist, private-network
73+
* block, per-hop redirect re-validation) so the site's own host can't be used
74+
* to relay into its internal network via an open redirect.
75+
*
76+
* Throws on an unsupported/blocked/unreachable URL so the caller falls back to a
77+
* bundled font; blocked targets stay indistinguishable from missing fonts.
78+
*/
79+
export async function fetchSpecialFontUrl(path: string, siteUrl: string | undefined, timeout: number): Promise<Buffer> {
80+
if (isDataFontUrl(path)) {
81+
const res = await fetch(path.trimStart(), { signal: AbortSignal.timeout(timeout) }).catch(() => null)
82+
if (res?.ok)
83+
return Buffer.from(await res.arrayBuffer())
84+
throw new Error('[Nuxt OG Image] Invalid data: font URL.')
85+
}
86+
87+
const href = resolveSameOriginFontUrl(path, siteUrl)
88+
if (!href)
89+
throw new Error('[Nuxt OG Image] External font URLs are not supported. Load custom fonts via @nuxt/fonts.')
90+
91+
const ab = await fetchWithRedirectValidation(href, { timeout })
92+
if (!ab)
93+
throw new Error('[Nuxt OG Image] Font URL blocked or unreachable.')
94+
return Buffer.from(ab)
95+
}

src/runtime/server/og-image/bindings/font-assets/node.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,36 @@ import type { H3Event } from 'h3'
22
import type { FontConfig } from '../../../../types'
33
import { useRuntimeConfig } from 'nitropack/runtime'
44
import { withBase } from 'ufo'
5-
import { getNitroOrigin } from '#site-config/server/composables'
5+
import { getNitroOrigin, getSiteConfig } from '#site-config/server/composables'
66
import { getFetchTimeout } from '../../../util/fetchTimeout'
7+
import { fetchWithRedirectValidation } from '../../../util/ssrf'
78
import { useOgImageRuntimeConfig } from '../../../utils'
9+
import { fetchSpecialFontUrl, isDataFontUrl, isExternalFontUrl } from './external-url'
810

911
export async function resolve(event: H3Event, font: FontConfig) {
1012
const path = font.src || font.localPath
13+
const runtimeConfig = useOgImageRuntimeConfig()
14+
const timeout = getFetchTimeout(runtimeConfig)
1115
const { app } = useRuntimeConfig()
12-
const fullPath = withBase(path, app.baseURL)
1316
const origin = getNitroOrigin(event)
14-
const timeout = getFetchTimeout(useOgImageRuntimeConfig())
15-
const res = await fetch(new URL(fullPath, origin).href, { signal: AbortSignal.timeout(timeout) }).catch(() => null)
16-
if (res?.ok) {
17-
return Buffer.from(await res.arrayBuffer())
17+
const fullPath = withBase(path, app.baseURL)
18+
19+
// `data:` and external font URLs are attacker-reachable via the `fonts` URL
20+
// param (GHSA-q8hw-4fvp-9rwv). `data:` is decoded inline; external URLs are
21+
// unsupported (use @nuxt/fonts) except the site's own origin, fetched through
22+
// the SSRF guard. Classification is canonicalized so crafted paths like
23+
// " //127.0.0.1" can't slip past as relative into the raw fetch below.
24+
if (path && (isDataFontUrl(path) || isExternalFontUrl(path)))
25+
return fetchSpecialFontUrl(path, getSiteConfig(event).url, timeout)
26+
27+
// Same-origin asset fetch. `trustedHost` exempts our own origin from the
28+
// block classifier (it may be loopback behind a proxy) while still
29+
// re-validating any redirect that leaves the origin — so an open redirect on
30+
// the app can't bounce the font fetch to an internal target (GHSA-q8hw-4fvp-9rwv).
31+
const target = new URL(fullPath, origin)
32+
const ab = await fetchWithRedirectValidation(target.href, { timeout, trustedHost: target.host }).catch(() => null)
33+
if (ab) {
34+
return Buffer.from(ab)
1835
}
1936
// Fallback to Nitro's internal handler when origin is unreachable
2037
// (behind a proxy, serverless, or server not fully started)

src/runtime/server/og-image/fonts.ts

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -248,20 +248,51 @@ export async function loadAllFonts(event: H3Event, options: LoadFontsOptions): P
248248
return loaded
249249
}
250250

251+
// Defence-in-depth bounds for attacker-controlled `defineOgImage({ fonts })`
252+
// (the `fonts` param is decoded from the URL). Cap the count so a single request
253+
// can't trigger an unbounded fan-out of font fetches, and cap the path length so
254+
// an oversized URL can't be smuggled in. `data:` URIs carry the font inline so
255+
// they're legitimately long; they're exempt from the path cap and bounded by the
256+
// HTTP request-size limit instead (no network fetch happens for them).
257+
const MAX_DEFINED_FONTS = 10
258+
const MAX_FONT_PATH_LENGTH = 2048
259+
260+
function isDataUri(path: string): boolean {
261+
return path.trimStart().toLowerCase().startsWith('data:')
262+
}
263+
264+
/** Coerce an attacker-supplied weight to a sane numeric CSS weight. */
265+
function normalizeFontWeight(weight: unknown): number {
266+
const n = typeof weight === 'number' ? weight : typeof weight === 'string' ? Number(weight) : Number.NaN
267+
if (!Number.isFinite(n))
268+
return 400
269+
return Math.min(Math.max(Math.round(n), 1), 1000)
270+
}
271+
251272
/**
252273
* Load additional fonts specified via defineOgImage({ fonts: [...] }).
253274
* Only object format ({ name, weight, path }) with a path is supported.
275+
*
276+
* Field values arrive from the attacker-controlled `fonts` URL param, so each is
277+
* parsed at this boundary (require string name/path, coerce weight) and the
278+
* array is bounded before any font is fetched.
254279
*/
255280
export async function loadDefinedFonts(event: OgImageRenderEventContext, fontDefs: any[]): Promise<RuntimeFontConfig[]> {
256281
const results: RuntimeFontConfig[] = []
257-
for (const def of fontDefs) {
258-
if (!def || typeof def !== 'object' || !def.path)
282+
const defs = Array.isArray(fontDefs) ? fontDefs : []
283+
if (import.meta.dev && defs.length > MAX_DEFINED_FONTS)
284+
logger.warn(`defineOgImage fonts capped at ${MAX_DEFINED_FONTS}; ${defs.length - MAX_DEFINED_FONTS} ignored.`)
285+
for (const def of defs.slice(0, MAX_DEFINED_FONTS)) {
286+
if (!def || typeof def !== 'object')
287+
continue
288+
289+
const name = typeof def.name === 'string' ? def.name.trim() : ''
290+
const path = typeof def.path === 'string' ? def.path : ''
291+
if (!name || !path || (path.length > MAX_FONT_PATH_LENGTH && !isDataUri(path)))
259292
continue
260293

261-
const name: string = def.name
262-
const weight: number = def.weight || 400
294+
const weight = normalizeFontWeight(def.weight)
263295
const style: 'normal' | 'italic' = def.style === 'italic' ? 'italic' : 'normal'
264-
const path: string = def.path
265296

266297
const fontConfig = { family: name, weight, style, src: path, localPath: path } satisfies FontConfig
267298
const data = await resolve(event.e, fontConfig).catch(() => null)

src/runtime/server/util/ssrf.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,12 +227,22 @@ export interface SafeFetchOptions {
227227
timeout: number
228228
headers?: Record<string, string>
229229
signal?: AbortSignal
230+
/**
231+
* A host (hostname + optional port) that is exempt from `isBlockedUrl`. Used
232+
* for same-origin asset fetches whose own origin may legitimately be loopback
233+
* (dev) or otherwise "private" — the initial host is trusted, but any redirect
234+
* that leaves it is still re-validated, closing the open-redirect SSRF where
235+
* the app's own origin 30x's to an internal target.
236+
*/
237+
trustedHost?: string
230238
}
231239

232240
/**
233241
* Fetch a URL with manual redirect handling. Each hop (including the initial
234242
* URL) is run through `isBlockedUrl` before the request is dispatched, so an
235243
* allowed origin returning a 30x to an internal IP cannot complete the SSRF.
244+
* A hop whose host matches `opts.trustedHost` skips the block check (see
245+
* SafeFetchOptions.trustedHost).
236246
*
237247
* Returns `null` on any failure (block, network error, non-2xx, redirect
238248
* limit). The caller treats null as a soft failure and falls back to the
@@ -255,7 +265,18 @@ export async function fetchWithRedirectValidation(
255265
try {
256266
let url = initialUrl
257267
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
258-
if (isBlockedUrl(url))
268+
// The trusted (same-origin) host is exempt; every other host — including
269+
// any cross-origin redirect target — must clear the block classifier.
270+
let isTrusted = false
271+
if (opts.trustedHost) {
272+
try {
273+
isTrusted = new URL(url).host === opts.trustedHost
274+
}
275+
catch {
276+
return null
277+
}
278+
}
279+
if (!isTrusted && isBlockedUrl(url))
259280
return null
260281
const res = await fetch(url, {
261282
redirect: 'manual',

0 commit comments

Comments
 (0)