|
| 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 | +} |
0 commit comments