Skip to content

Commit 79db60f

Browse files
authored
fix(security): harden SSRF guard with redirect validation and IPv6 coverage (#594)
1 parent 611af82 commit 79db60f

4 files changed

Lines changed: 474 additions & 75 deletions

File tree

src/runtime/server/og-image/core/plugins/imageSrc.ts

Lines changed: 41 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -8,74 +8,10 @@ import { decodeHtml } from '../../../util/encoding'
88
import { fetchLocalAsset } from '../../../util/fetchLocalAsset'
99
import { getFetchTimeout } from '../../../util/fetchTimeout'
1010
import { logger } from '../../../util/logger'
11+
import { fetchWithRedirectValidation, isBlockedUrl } from '../../../util/ssrf'
1112
import { getImageDimensions } from '../../utils/image-detector'
1213
import { defineTransformer } from '../plugins'
1314

14-
// SSRF prevention: block private/loopback URLs outside dev mode
15-
const RE_IPV6_BRACKETS = /^\[|\]$/g
16-
const RE_MAPPED_V4 = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/
17-
const RE_DIGIT_ONLY = /^\d+$/
18-
const RE_INT_IP = /^(?:0x[\da-f]+|\d+)$/i
19-
20-
function isPrivateIPv4(a: number, b: number): boolean {
21-
if (a === 127)
22-
return true // loopback
23-
if (a === 10)
24-
return true // 10.0.0.0/8
25-
if (a === 172 && b >= 16 && b <= 31)
26-
return true // 172.16.0.0/12
27-
if (a === 192 && b === 168)
28-
return true // 192.168.0.0/16
29-
if (a === 169 && b === 254)
30-
return true // link-local
31-
if (a === 0)
32-
return true // 0.0.0.0/8
33-
return false
34-
}
35-
36-
/**
37-
* Block URLs targeting internal/private networks.
38-
* Handles standard IPs, hex (0x7f000001), decimal (2130706433),
39-
* IPv6-mapped IPv4 (::ffff:127.0.0.1), and localhost.
40-
* Only http/https protocols are allowed.
41-
*/
42-
function isBlockedUrl(url: string): boolean {
43-
let parsed: URL
44-
try {
45-
parsed = new URL(url)
46-
}
47-
catch {
48-
return true
49-
}
50-
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
51-
return true
52-
const hostname = parsed.hostname.toLowerCase()
53-
const bare = hostname.replace(RE_IPV6_BRACKETS, '')
54-
if (bare === 'localhost' || bare.endsWith('.localhost'))
55-
return true
56-
// Normalize IPv6-mapped IPv4 (::ffff:1.2.3.4)
57-
const mappedV4 = bare.match(RE_MAPPED_V4)
58-
const ip = mappedV4 ? mappedV4[1]! : bare
59-
// Standard dotted-decimal IPv4
60-
const parts = ip.split('.')
61-
if (parts.length === 4 && parts.every(p => RE_DIGIT_ONLY.test(p))) {
62-
const octets = parts.map(Number)
63-
if (octets.some(o => o > 255))
64-
return true
65-
return isPrivateIPv4(octets[0]!, octets[1]!)
66-
}
67-
// Single integer (decimal/hex) IP: e.g. 2130706433 or 0x7f000001
68-
if (RE_INT_IP.test(ip)) {
69-
const num = Number(ip)
70-
if (!Number.isNaN(num) && num >= 0 && num <= 0xFFFFFFFF)
71-
return isPrivateIPv4((num >> 24) & 0xFF, (num >> 16) & 0xFF)
72-
}
73-
// IPv6 private ranges
74-
if (bare === '::1' || bare.startsWith('fc') || bare.startsWith('fd') || bare.startsWith('fe80'))
75-
return true
76-
return false
77-
}
78-
7915
const RE_URL_LEADING = /^url\(['"]?/
8016
const RE_URL_TRAILING = /['"]?\)$/
8117

@@ -173,12 +109,28 @@ async function doResolveSrcToBuffer(
173109
return { blocked: true }
174110
}
175111
const end = timings.start('image-fetch')
176-
const buffer = (await $fetch(decodedSrc, {
177-
responseType: 'arrayBuffer',
178-
timeout: fetchTimeout,
179-
}).catch((err) => {
180-
logFailure(decodedSrc, err)
181-
}).finally(end)) as BufferSource | undefined
112+
// In dev we keep ofetch's default redirect-follow behaviour so loopback /
113+
// proxy setups work. Outside dev, redirects are followed manually with
114+
// host re-validation on every hop to close the redirect-bypass class.
115+
let buffer: BufferSource | undefined
116+
if (import.meta.dev) {
117+
buffer = (await $fetch(decodedSrc, {
118+
responseType: 'arrayBuffer',
119+
timeout: fetchTimeout,
120+
}).catch((err) => {
121+
logFailure(decodedSrc, err)
122+
}).finally(end)) as BufferSource | undefined
123+
}
124+
else {
125+
const ab = await fetchWithRedirectValidation(decodedSrc, {
126+
timeout: fetchTimeout,
127+
}).catch((err) => {
128+
logFailure(decodedSrc, err)
129+
return null
130+
}).finally(end)
131+
if (ab)
132+
buffer = new Uint8Array(ab)
133+
}
182134
return buffer ? { buffer } : {}
183135
}
184136

@@ -239,9 +191,18 @@ export default defineTransformer([
239191
return
240192
}
241193
// relative src couldn't be fetched — fall back to an absolute URL so
242-
// satori/takumi may attempt to resolve at render time
243-
if (isRelative)
194+
// satori/takumi may attempt to resolve at render time (against the app's
195+
// own origin, not an attacker-controlled one)
196+
if (isRelative) {
244197
node.props.src = withBase(src, `${getNitroOrigin(ctx.e)}`)
198+
return
199+
}
200+
// Absolute external URL whose validated fetch failed. Drop it in
201+
// production: letting it survive would invite the renderer (satori
202+
// fetches <img> URLs internally; takumi fetches via extractResourceUrls)
203+
// to re-issue the request without SSRF/redirect validation.
204+
if (!import.meta.dev)
205+
delete node.props.src
245206
},
246207
},
247208
// fix style="background-image: url('')"
@@ -257,8 +218,14 @@ export default defineTransformer([
257218
delete node.props.style!.backgroundImage
258219
return
259220
}
260-
if (result.buffer)
221+
if (result.buffer) {
261222
node.props.style!.backgroundImage = `url(${toBufferSourceAsBase64(result.buffer)})`
223+
return
224+
}
225+
// Same reasoning as the <img> branch: an absolute external URL that
226+
// didn't inline must not be left for the renderer to fetch unvalidated.
227+
if (!import.meta.dev && !src.startsWith('/'))
228+
delete node.props.style!.backgroundImage
262229
},
263230
},
264231
])

src/runtime/server/og-image/takumi/renderer.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { withBase } from 'ufo'
55
import { logger } from '../../../logger'
66
import { fetchLocalAsset } from '../../util/fetchLocalAsset'
77
import { getFetchTimeout } from '../../util/fetchTimeout'
8+
import { fetchWithRedirectValidation, isBlockedUrl } from '../../util/ssrf'
89
import { buildSubsetFamilyChain, extractCodepoints, getDefaultFontFamily, loadFontsForRenderer, resolveSubsetChain } from '../fonts'
910
import { getExtractResourceUrls, getTakumi } from './instances'
1011
import { createTakumiNodes } from './nodes'
@@ -205,14 +206,23 @@ async function createImage(event: OgImageRenderEventContext, format: 'png' | 'jp
205206
includeExternalFallback: true,
206207
})
207208
}
208-
else {
209+
else if (import.meta.dev) {
209210
data = await $fetch(src, {
210211
responseType: 'arrayBuffer',
211212
signal: AbortSignal.timeout(fetchTimeout),
212213
timeout: fetchTimeout,
213214
headers,
214215
}).catch(() => undefined) as ArrayBuffer | undefined
215216
}
217+
else if (!isBlockedUrl(src)) {
218+
// Defense-in-depth: any URL surviving the imageSrc transformer is
219+
// re-validated here, with redirects followed manually so 30x →
220+
// internal-IP cannot complete the SSRF.
221+
data = (await fetchWithRedirectValidation(src, {
222+
timeout: fetchTimeout,
223+
headers,
224+
})) ?? undefined
225+
}
216226
if (data)
217227
fetchedResources.push({ src, data: new Uint8Array(data) })
218228
})))

0 commit comments

Comments
 (0)