@@ -8,74 +8,10 @@ import { decodeHtml } from '../../../util/encoding'
88import { fetchLocalAsset } from '../../../util/fetchLocalAsset'
99import { getFetchTimeout } from '../../../util/fetchTimeout'
1010import { logger } from '../../../util/logger'
11+ import { fetchWithRedirectValidation , isBlockedUrl } from '../../../util/ssrf'
1112import { getImageDimensions } from '../../utils/image-detector'
1213import { 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 = / ^ : : f f f f : ( \d + \. \d + \. \d + \. \d + ) $ /
17- const RE_DIGIT_ONLY = / ^ \d + $ /
18- const RE_INT_IP = / ^ (?: 0 x [ \d a - 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-
7915const RE_URL_LEADING = / ^ u r l \( [ ' " ] ? /
8016const 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] )
0 commit comments