Skip to content

Commit 2d3af44

Browse files
dnukumamrasclaude
andauthored
fix: SSRF guard for server-side image fetches + pluggable fetcher (#769)
## What Adds an SSRF guard for server-side image loads. Satori resolves `<img src>`, SVG `<image href>` and CSS `background-image` URLs by calling `fetch()` on the server. Without a guard, an attacker who controls one of those URLs can point it at internal addresses (`127.0.0.1`, `169.254.169.254` cloud metadata, RFC-1918 ranges) and — because SVG responses are base64-inlined into the output — read the body back in-band. ## Changes - **`src/handler/url-safety.ts`** (new): dependency-free, runtime-agnostic literal-host classifier. Blocks loopback, link-local/metadata, RFC-1918, CGNAT, multicast/reserved, and the IPv6 equivalents. Leans on WHATWG `URL` to normalize obfuscated IPv4 forms (`0x7f000001`, `2130706433`, `127.1`) and decodes IPv4 embedded in IPv6 (mapped `::ffff:`, NAT64 `64:ff9b::`, compatible `::`). Fails closed on unparseable input and non-`http(s)` protocols. - **`src/handler/image.ts`**: runs the guard server-only before fetching. **Fails closed (throws)** — consistent with the existing absolute-URL validation. ## Scope / known limits - The guard is **literal-host only**. Hostnames that resolve to private IPs (DNS rebinding) and HTTP redirects to private addresses are **not** covered — full coverage needs a connect-time-pinning fetcher (e.g. `@vercel/safe-fetch`) at the host layer. Documented in the module header. - A pluggable `fetcher` option was removed per review (`typeof fetch` isn't compatible with safe-fetch libraries' redirect policing); it can be added later with a compatible signature. ## Tests - `test/url-safety.test.ts`: reported SSRF vectors, obfuscated IPv4, NAT64/IPv4-compatible IPv6, non-`http(s)` protocols, and public URLs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9feec94 commit 2d3af44

3 files changed

Lines changed: 188 additions & 0 deletions

File tree

src/handler/image.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ function parsePNG(buf: ArrayBuffer) {
5959
}
6060

6161
import { createLRU, parseViewBox } from '../utils.js'
62+
import { assertSafeServerFetchUrl } from './url-safety.js'
6263

6364
type ResolvedImageData = [string, number?, number?] | readonly []
6465
export const cache = createLRU<ResolvedImageData>(500)
@@ -246,6 +247,13 @@ export async function resolveImageData(
246247
}
247248

248249
const url = src
250+
// Block SSRF to private/loopback/link-local addresses before fetching.
251+
// Server-only: in the browser, fetching localhost is the user's own machine,
252+
// not a server-side request-forgery surface. Fails closed (throws), matching
253+
// the absolute-URL validation above.
254+
if (typeof window === 'undefined') {
255+
assertSafeServerFetchUrl(url)
256+
}
249257
const promise = fetch(url)
250258
.then((res): Promise<string | ArrayBuffer> => {
251259
const type = res.headers.get('content-type')

src/handler/url-safety.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/**
2+
* SSRF guard for server-side image fetches.
3+
*
4+
* Satori resolves `<img src>`, SVG `<image href>` and CSS `background-image`
5+
* URLs by calling `fetch()` on the server. Without a guard, an attacker who
6+
* controls that URL can point it at internal addresses (`127.0.0.1`,
7+
* `169.254.169.254` cloud metadata, RFC-1918 ranges) and — because Satori
8+
* base64-inlines `image/svg+xml` responses into its output — read the body
9+
* back in-band. This blocks the unsafe address ranges before the fetch.
10+
*
11+
* This is intentionally dependency-free and runtime-agnostic (browser, edge,
12+
* Node) — it cannot use `node:dns`/`node:net`, so it only classifies the
13+
* literal host. WHATWG `URL` normalizes obfuscated IPv4 forms
14+
* (`http://0x7f.1`, `http://2130706433`) to dotted-decimal for us, closing the
15+
* classic blocklist bypasses.
16+
*
17+
* ponytail: literal-host classification only; a hostname that *resolves* to a
18+
* private IP (DNS rebinding) is not covered here. Hosts needing full coverage
19+
* should run Satori behind an SSRF-safe fetcher (e.g. `@vercel/safe-fetch`,
20+
* which DNS-resolves and pins the connect-time IP).
21+
*/
22+
23+
const IPV4_RE = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/
24+
25+
/**
26+
* IPv4 ranges unsafe for server-side outbound fetches: `0.0.0.0/8`,
27+
* `10/8`, `100.64/10` (CGNAT), `127/8` (loopback), `169.254/16` (link-local,
28+
* incl. cloud metadata), `172.16/12`, `192.0.0/24` (IETF), `192.168/16`,
29+
* `198.18/15` (benchmark), and `224/4`–`255/4` (multicast/reserved/broadcast).
30+
*/
31+
function isUnsafeIpv4(v4: string): boolean {
32+
const octets = v4.split('.').map((p) => Number.parseInt(p, 10))
33+
if (
34+
octets.length !== 4 ||
35+
octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255)
36+
) {
37+
return true // malformed — fail closed
38+
}
39+
const [a, b, c] = octets as [number, number, number, number]
40+
if (a === 0) return true
41+
if (a === 10) return true
42+
if (a === 100 && b >= 64 && b <= 127) return true
43+
if (a === 127) return true
44+
if (a === 169 && b === 254) return true
45+
if (a === 172 && b >= 16 && b <= 31) return true
46+
if (a === 192 && b === 0 && c === 0) return true
47+
if (a === 192 && b === 168) return true
48+
if (a === 198 && (b === 18 || b === 19)) return true
49+
if (a >= 224) return true
50+
return false
51+
}
52+
53+
/**
54+
* Decodes the IPv4 destination embedded in an IPv6 literal, or null if none.
55+
* Covers every form whose low 32 bits route to an IPv4 address:
56+
* `::ffff:a.b.c.d` / `::ffff:HI:LO` IPv4-mapped
57+
* `64:ff9b::a.b.c.d` / `64:ff9b::HI:LO` NAT64 well-known prefix (RFC 6052)
58+
* `::a.b.c.d` / `::HI:LO` IPv4-compatible (deprecated)
59+
* WHATWG `URL` normalizes all of these to the hex `HI:LO` form; the dotted
60+
* branch is kept for non-normalized inputs. Scoped to these prefixes so a
61+
* public IPv6 whose low bits happen to look private isn't mis-decoded.
62+
*/
63+
function embeddedIpv4(host: string): string | null {
64+
const dotted = host.match(
65+
/^(?:::ffff:|64:ff9b::|::)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/
66+
)
67+
if (dotted) return dotted[1]
68+
69+
const hex = host.match(
70+
/^(?:::ffff:|64:ff9b::|::)([0-9a-f]{1,4}):([0-9a-f]{1,4})$/
71+
)
72+
if (hex) {
73+
const hi = Number.parseInt(hex[1], 16)
74+
const lo = Number.parseInt(hex[2], 16)
75+
return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`
76+
}
77+
return null
78+
}
79+
80+
/**
81+
* IPv6 ranges unsafe for server-side fetches. Embedded-IPv4 forms (mapped,
82+
* NAT64, compatible) are decoded and classified as IPv4. Host is already
83+
* lowercased with brackets stripped.
84+
*/
85+
function isUnsafeIpv6(host: string): boolean {
86+
const v4 = embeddedIpv4(host)
87+
if (v4) return isUnsafeIpv4(v4)
88+
89+
if (host === '::' || host === '::1') return true // unspecified, loopback
90+
if (host.startsWith('fc') || host.startsWith('fd')) return true // fc00::/7 ULA
91+
if (/^fe[89ab]/.test(host)) return true // fe80::/10 link-local
92+
if (/^fe[c-f]/.test(host)) return true // fec0::/10 site-local
93+
if (host.startsWith('ff')) return true // ff00::/8 multicast
94+
if (/^2001:0?db8(?::|$)/.test(host)) return true // 2001:db8::/32 docs
95+
return false
96+
}
97+
98+
/**
99+
* Returns `true` if Satori must refuse to `fetch()` this URL server-side.
100+
* Fails closed on anything it can't parse or classify as clearly public.
101+
*/
102+
export function isUnsafeServerFetchUrl(rawUrl: string): boolean {
103+
let url: URL
104+
try {
105+
url = new URL(rawUrl)
106+
} catch {
107+
return true
108+
}
109+
110+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return true
111+
112+
let host = url.hostname.toLowerCase()
113+
if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1)
114+
115+
if (host === 'localhost' || host.endsWith('.localhost')) return true
116+
if (host.endsWith('.local')) return true
117+
118+
if (IPV4_RE.test(host)) return isUnsafeIpv4(host)
119+
if (host.includes(':')) return isUnsafeIpv6(host)
120+
121+
// Regular hostname — DNS resolution isn't available in all runtimes, so we
122+
// can't classify what it resolves to. Allowed (see ponytail note above).
123+
return false
124+
}
125+
126+
/** Throws if `rawUrl` is unsafe for a server-side image fetch. */
127+
export function assertSafeServerFetchUrl(rawUrl: string): void {
128+
if (isUnsafeServerFetchUrl(rawUrl)) {
129+
throw new Error(
130+
`Image source resolves to a blocked address (SSRF protection): ${rawUrl}`
131+
)
132+
}
133+
}

test/url-safety.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { it, describe, expect } from 'vitest'
2+
3+
import { isUnsafeServerFetchUrl } from '../src/handler/url-safety.js'
4+
5+
describe('isUnsafeServerFetchUrl', () => {
6+
it('blocks the reported SSRF vectors', () => {
7+
for (const url of [
8+
'http://169.254.169.254/latest/meta-data/', // AWS/GCP metadata
9+
'http://127.0.0.1:9001/2018-06-01/runtime/invocation/next', // Lambda runtime API
10+
'http://localhost/api/env-dump',
11+
'http://10.0.0.5/',
12+
'http://172.16.3.4/',
13+
'http://192.168.1.1/',
14+
'http://[::1]/',
15+
'http://[fd00::1]/',
16+
'http://[fe80::1]/',
17+
'http://[::ffff:169.254.169.254]/', // IPv4-mapped IPv6
18+
'http://[64:ff9b::169.254.169.254]/', // NAT64 well-known prefix
19+
'http://[::127.0.0.1]/', // IPv4-compatible IPv6 (deprecated)
20+
]) {
21+
expect(isUnsafeServerFetchUrl(url), url).toBe(true)
22+
}
23+
})
24+
25+
it('blocks obfuscated IPv4 forms (WHATWG URL normalization)', () => {
26+
expect(isUnsafeServerFetchUrl('http://2130706433/')).toBe(true) // 127.0.0.1
27+
expect(isUnsafeServerFetchUrl('http://0x7f000001/')).toBe(true) // 127.0.0.1
28+
expect(isUnsafeServerFetchUrl('http://127.1/')).toBe(true) // 127.0.0.1
29+
})
30+
31+
it('blocks non-http(s) protocols and unparseable input', () => {
32+
expect(isUnsafeServerFetchUrl('file:///etc/passwd')).toBe(true)
33+
expect(isUnsafeServerFetchUrl('ftp://example.com/')).toBe(true)
34+
expect(isUnsafeServerFetchUrl('not a url')).toBe(true)
35+
})
36+
37+
it('allows public image URLs', () => {
38+
for (const url of [
39+
'https://example.com/og.png',
40+
'https://images.example.com/a/b/c.svg',
41+
'http://8.8.8.8/img.png',
42+
'https://[2606:4700:4700::1111]/img.png', // public IPv6
43+
]) {
44+
expect(isUnsafeServerFetchUrl(url), url).toBe(false)
45+
}
46+
})
47+
})

0 commit comments

Comments
 (0)