Unauthenticated SSRF via unvalidated HTTP redirects (single-shot SSRF gate not re-applied per redirect hop)
- Dominant language
- TypeScript
- Stars
- 12k
- Forks
- 882
- PR merge metrics
- No merged PRs in 30d
Description
### 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)
Contributor guide
Assessment
This issue has not been assessed yet.