Skip to content

Commit e872906

Browse files
fix: harden SSRF guard against trailing dots, redirects, and DNS (#777)
Strip FQDN trailing dots, block .internal hosts, validate each redirect hop, and reject hostnames that resolve to private IPs on Node. Long term we're going to have a open source repo for SSRF safe fetches that I can migrate satori to. --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent a8f8aae commit e872906

3 files changed

Lines changed: 452 additions & 67 deletions

File tree

‎src/handler/image.ts‎

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ function parsePNG(buf: ArrayBuffer) {
140140
}
141141

142142
import { createLRU, parseViewBox } from '../utils.js'
143-
import { assertSafeServerFetchUrl } from './url-safety.js'
143+
import { safeServerFetch } from './url-safety.js'
144144

145145
type ResolvedImageData = [string, number?, number?] | readonly []
146146
export const cache = createLRU<ResolvedImageData>(500)
@@ -334,14 +334,14 @@ export async function resolveImageData(
334334
}
335335

336336
const url = src
337-
// Block SSRF to private/loopback/link-local addresses before fetching.
338-
// Server-only: in the browser, fetching localhost is the user's own machine,
339-
// not a server-side request-forgery surface. Fails closed (throws), matching
340-
// the absolute-URL validation above.
341-
if (typeof window === 'undefined') {
342-
assertSafeServerFetchUrl(url)
343-
}
344-
const promise = fetch(url)
337+
// Server-only SSRF guard: literal host + DNS (when node:dns exists) +
338+
// per-hop redirect validation. In the browser, fetching localhost is the
339+
// user's own machine — not a server-side request-forgery surface.
340+
const doFetch =
341+
typeof window === 'undefined'
342+
? () => safeServerFetch(url)
343+
: () => fetch(url)
344+
const promise = doFetch()
345345
.then((res): Promise<string | ArrayBuffer> => {
346346
const type = res.headers.get('content-type')
347347

@@ -372,6 +372,11 @@ export async function resolveImageData(
372372
return result
373373
})
374374
.catch((err) => {
375+
// SSRF blocks must fail closed to the caller — do not cache an empty
376+
// result that would mask a blocked internal URL as a missing image.
377+
if (err instanceof Error && err.message.includes('SSRF protection')) {
378+
throw err
379+
}
375380
console.error(`Can't load image ${url}: ` + err.message)
376381
cache.set(url, [])
377382
return [] as const

0 commit comments

Comments
 (0)