#1252·reader

Unauthenticated SSRF via unvalidated HTTP redirects (single-shot SSRF gate not re-applied per redirect hop)

Author: geo-chenCreated Jun 12, 2026Updated Sep 4, 2026

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:

  1. 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.

  2. 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) 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)