fix(security): parse IPs into CIDR ranges instead of matching hostname prefixes (#31) #60
No reviewers
Labels
No labels
bug
discussion
enhancement
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
edufeed/edufeed-app!60
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/issue-31-ssrf-ip-ranges"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #31.
isPrivateIp()guarded every server-side fetch path with hostname string prefixes. A verbatim replay of the two old implementations against this PR's fixtures lets 16 of 16 must-block addresses reachfetch, and wrongly blocks 3 of 4 public ones:What changed
Addresses are parsed with
node:netand compared as masked integers against a CIDR table, not string prefixes. IPv4-mapped, IPv4-compatible and NAT64-embedded IPv6 addresses are unwrapped to their v4 payload first, so[::ffff:169.254.169.254]cannot smuggle link-local past an IPv4-only comparison. A syntactically valid but unexpandable v6 address fails closed.There were two implementations, not the one the issue names
src/routes/api/reader/+server.jscarried its own weaker copy: it blocked all of172.*(breaking public 172.32+) while missing the bracketed[::1]form thatparsedUrl.hostnameactually returns — sohttp://[::1]/was reachable through/api/readertoday. Fixing only the shared helper would have left that route on the old heuristics. The copy is deleted; all five call sites (/api/image,/api/oer/asset,/api/pdf,/api/pdf-thumbnail,/api/reader) share one guard.DNS resolution — the rebinding half
isBlockedHost()runs the synchronous check, thendns.lookup({ all: true })and rejects if any answer is private.A lookup failure stays fail-open on purpose: a name that does not resolve cannot be fetched either, so failing closed would only convert resolver blips into rejected requests. The residual TOCTOU gap (
fetchresolves a second time) needs connect-time pinning and is documented in place;ASSET_PROXY_ALLOWED_DOMAINS+ imgproxy network isolation remain the defence in depth.Cost note: the guarded routes now perform a DNS lookup per request. Literal IPs short-circuit without resolving.
The "exotic encodings" bullet needed no code
The WHATWG parser normalises all of these before our code sees the hostname — asserted in the tests so it stays true:
How the tests avoid the trap they are testing for
Every fixture goes through
new URL()rather than being hand-typed, because the parser hex-compresses IPv4-mapped v6:A guard written against
'::ffff:'plus a dotted quad passes a hand-typed test and still lets the real URL through. That is the same failure class as the two "test asserts the shape the author imagined" bugs fixed in #58.Controls, so a green suite means something:
[::ffff:8.8.8.8],[fec0::1]must stay reachable, so "blocks everything" cannot pass.lookupmust not be called for literal IPs and for hosts the sync check already blocks.Live confirmation that this is not theoretical:
http://[::ffff:127.0.0.1]:18108/healthreturns 200 from the local typesense container.Verification
At
08f988a4: 5177/5177 tests, 468 files,pnpm lintclean,pnpm check0 errors.Out of scope, flagged not fixed
/api/enrichvalidates URLs withparseHttpUrl()and then hands them to the amb-mcp service, which does the fetching. This app never fetches them, so it is a different trust boundary — but an internal URL passed through could make amb-mcp fetch it. Not touched here; worth a separate decision.🤖 Generated with Claude Code