Summary
Reader validates the user-supplied URL once via MiscService.assertNormalizedUrl, which resolves the hostname and rejects non-public IP ranges. After that single check the fetch follows HTTP redirects with no per-hop re-validation, so an attacker-controlled public host can 30x-redirect Reader into the internal network or the cloud metadata endpoint, and the redirect target's body is returned to the caller. Both the curl/sideLoad path and the browser path are affected. Confirmed against the compiled CurlControl with the production SSRF guard active: an internal secret was exfiltrated through a redirect, and the IP guard was never consulted on the redirect hop.
Details
The only SSRF gate, assertNormalizedUrl (src/services/misc.ts:36-124), validates the initial URL once via isIPInNonPublicRange (src/utils/ip.ts:158). The fetch paths then follow redirects without re-checking:
-
curl/sideLoad path (src/services/curl.ts): urlToFile (~315-398) and urlToBlob (~456-552) manually follow redirects (FOLLOWLOCATION off, ~139) via nextHopUrl = new URL(location || '', nextHopUrl) (~376, ~521) and loop back into urlToStream with zero calls to assertNormalizedUrl/isIPInNonPublicRange on any hop. Reached from CrawlerHost via curlControl.sideLoadBlob(...) (src/api/crawler.ts:1075, :1123) on the default AUTO engine and explicitly via engine=curl.
-
Browser path (src/services/puppeteer.ts:865-956): the request interceptor governing nav, redirects, and sub-resources only blocks localhost and 127.* (~917-926), never the full-range isIPInNonPublicRange, so 169.254.169.254, 10.x, 192.168.x, 172.16.x, 0.0.0.0, [::1], decimal 2130706433, etc. are reachable in-browser.
A secondary precedence bug in the gate (src/services/misc.ts:70-74) parses as (privateIpNotAcceptable && hostname==='localhost') || (isIp && isIPInNonPublicRange(...)), so the literal-IP branch ignores the privateIpNotAcceptable flag.
The boundary crossed is an unauthenticated network attacker supplying a URL whose attacker-public host redirects to an internal-only IP/metadata endpoint, whose response body is read back to the attacker. Caveat: the private-IP protections are gated by privateIpNotAcceptable = NODE_ENV~=prod && GCLOUD_PROJECT (src/services/misc.ts:15), i.e. the hosted production config; the PoC forced that flag ON to prove the bypass survives the production guard.
PoC
[env] privateIpNotAcceptable = true
attacker URL http://127.0.0.1:44547/start -> 302 Location: http://127.0.0.1:43679/latest/meta-data/iam/security-credentials
CURL [302] http://127.0.0.1:44547 -> CURL [200] http://127.0.0.1:43679
final status: 200
returned body: "SSRF-REACHED-INTERNAL secret=INTERNAL-METADATA-TOKEN-nifd3ztxsq path=/latest/meta-data/iam/security-credentials"
isIPInNonPublicRange invocations during redirect-follow: 0
>>> INTERNAL TARGET REACHED VIA REDIRECT: true
The internal secret was exfiltrated through the redirect with the production SSRF guard active and never consulted on the redirect hop.
Impact
An unauthenticated client of a Reader instance reads internal-only services and cloud instance-metadata credentials by supplying a URL that an attacker-controlled public host redirects to an internal address. Scope is changed (pivot into the internal network / metadata credential theft).
Remediation
Re-run the full validation (assertNormalizedUrl/isIPInNonPublicRange) on every redirect hop in urlToFile/urlToBlob before issuing urlToStream(nextHopUrl, ...), and pin the connection to the validated IP (curl RESOLVE or a socket-open callback rejecting non-public sockets) to also close DNS rebinding. In the puppeteer interceptor (~917), replace the localhost/127.* substring check with isIP(host) && isIPInNonPublicRange(host) plus a DNS-resolve-and-check for hostnames. Fix the &&/|| precedence in misc.ts:70-74.
Affected Versions: confirmed on current main (commit 1574bfd, ~11k stars)
CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:L/A:L (8.3 High)
CWE: CWE-918 Server-Side Request Forgery (with CWE-697 incomplete comparison)
Summary
Reader validates the user-supplied URL once via
MiscService.assertNormalizedUrl, which resolves the hostname and rejects non-public IP ranges. After that single check the fetch follows HTTP redirects with no per-hop re-validation, so an attacker-controlled public host can 30x-redirect Reader into the internal network or the cloud metadata endpoint, and the redirect target's body is returned to the caller. Both the curl/sideLoad path and the browser path are affected. Confirmed against the compiledCurlControlwith the production SSRF guard active: an internal secret was exfiltrated through a redirect, and the IP guard was never consulted on the redirect hop.Details
The only SSRF gate,
assertNormalizedUrl(src/services/misc.ts:36-124), validates the initial URL once viaisIPInNonPublicRange(src/utils/ip.ts:158). The fetch paths then follow redirects without re-checking:curl/sideLoad path (
src/services/curl.ts):urlToFile(~315-398) andurlToBlob(~456-552) manually follow redirects (FOLLOWLOCATIONoff, ~139) vianextHopUrl = new URL(location || '', nextHopUrl)(~376, ~521) and loop back intourlToStreamwith zero calls toassertNormalizedUrl/isIPInNonPublicRangeon any hop. Reached fromCrawlerHostviacurlControl.sideLoadBlob(...)(src/api/crawler.ts:1075,:1123) on the default AUTO engine and explicitly viaengine=curl.Browser path (
src/services/puppeteer.ts:865-956): the request interceptor governing nav, redirects, and sub-resources only blockslocalhostand127.*(~917-926), never the full-rangeisIPInNonPublicRange, so169.254.169.254,10.x,192.168.x,172.16.x,0.0.0.0,[::1], decimal2130706433, etc. are reachable in-browser.A secondary precedence bug in the gate (
src/services/misc.ts:70-74) parses as(privateIpNotAcceptable && hostname==='localhost') || (isIp && isIPInNonPublicRange(...)), so the literal-IP branch ignores theprivateIpNotAcceptableflag.The boundary crossed is an unauthenticated network attacker supplying a URL whose attacker-public host redirects to an internal-only IP/metadata endpoint, whose response body is read back to the attacker. Caveat: the private-IP protections are gated by
privateIpNotAcceptable = NODE_ENV~=prod && GCLOUD_PROJECT(src/services/misc.ts:15), i.e. the hosted production config; the PoC forced that flag ON to prove the bypass survives the production guard.PoC
The internal secret was exfiltrated through the redirect with the production SSRF guard active and never consulted on the redirect hop.
Impact
An unauthenticated client of a Reader instance reads internal-only services and cloud instance-metadata credentials by supplying a URL that an attacker-controlled public host redirects to an internal address. Scope is changed (pivot into the internal network / metadata credential theft).
Remediation
Re-run the full validation (
assertNormalizedUrl/isIPInNonPublicRange) on every redirect hop inurlToFile/urlToBlobbefore issuingurlToStream(nextHopUrl, ...), and pin the connection to the validated IP (curlRESOLVEor a socket-open callback rejecting non-public sockets) to also close DNS rebinding. In the puppeteer interceptor (~917), replace thelocalhost/127.*substring check withisIP(host) && isIPInNonPublicRange(host)plus a DNS-resolve-and-check for hostnames. Fix the&&/||precedence inmisc.ts:70-74.Affected Versions: confirmed on current main (commit 1574bfd, ~11k stars)
CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:L/A:L (8.3 High)
CWE: CWE-918 Server-Side Request Forgery (with CWE-697 incomplete comparison)