Unauthenticated SSRF via unvalidated HTTP redirects (single-shot SSRF gate not re-applied per redirect hop)
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) 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 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: trueThe 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)
Source: jina-ai/reader