fix: add SSRF protection for outbound HTTP requests#117
Conversation
jacktuck
left a comment
There was a problem hiding this comment.
Does this handle SSRF via redirection?
Good catch, no, the current PR doesn't re-validate redirect targets, which is a real gap. node-fetch follows up to I'll update the PR to set Will push the change shortly. |
|
Pushed the redirect-handling fix. New commit:
Full suite: 63 passed, 1 skipped (network test). The current implementation does a fresh DNS lookup per hop, so a |
|
Hey, just bumping this in case it got buried. Happy to clarify anything or tweak the PoC. No rush. |
|
Also, did we ever actually return anything meaningful during the SSRF? Since unfurl only extracts specific details, it may not have returned anything useful to an attacker. |
Co-authored-by: Jack <8209433+jacktuck@users.noreply.github.com>
Co-authored-by: Jack <8209433+jacktuck@users.noreply.github.com>
Fair point, for the direct path, you're right that the text/html content-type check throws before parsing non-HTML responses, so vanilla AWS metadata wouldn't leak much. The cases where it still has teeth:
Severity probably depends on weighting those scenarios. I'd defer to your judgment on the CVSS. Pushed review feedback as 3baf210. |
|
Hey Jack, following up on #117. Everything's review-ready: 4 checks passing, no conflicts, and I addressed all the feedback (redirect SSRF handling in fb31116, plus the review fixes in 3baf210/c3967d3). On your severity question, here's a proposed CVSS 3.1 to save you the work: Vector: AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:N/A:N → 6.5 (Medium) Reasoning: remotely triggerable with no auth or user interaction (the reflected oEmbed path fires without the caller's knowledge), scope changed since unfurl is used to reach internal services, and confidentiality Low rather than High because extraction is partial, title/OG tags and matching JSON keys, not full response bodies. If you weight the internal-JSON-API leakage more heavily, bumping C to High puts it around 7.7. Your call. I'd like to get a CVE assigned for this. Cleanest path is through GitHub once you publish a security advisory, I'm happy to draft the full advisory text so all you'd need to do is review and click publish. If you'd rather not, I'll request one through MITRE next week instead. Let me know which you prefer, and if anything's still blocking the merge I'll take care of it. Thanks! |
Closes #116.
Summary
Adds destination IP validation to all outbound HTTP requests issued by
unfurl, preventing SSRF attacks via user-supplied URLs and via auto-followed
oEmbed
hrefreferences.Why
Currently
getPageandgetRemoteMetadatapass URLs straight tonode-fetchwithout resolving + checking the destination. This permitstwo SSRF primitives:
attacker can target loopback, private networks, or cloud metadata
endpoints (e.g.
169.254.169.254).unfurl auto-fetches
<link rel="alternate" type="application/json+oembed">references discovered inside fetched HTML. An attacker hosting a public
page with a malicious oEmbed reference can pivot the request to internal
targets without the caller's knowledge.
Changes
src/ssrfGuard.ts— exportsassertSafeURL(url)which:http(s)protocolsprivate, loopback, link-local, multicast, or reserved ranges
src/index.ts— callsassertSafeURLbefore each outbound fetchin
getPageandgetRemoteMetadata.allowPrivateIPsoption onOptsfor callers (or tests) thatneed to opt out — defaults to
false.test/ssrf/test.ts— 29 unit + integration tests covering IPclassification, URL validation, and end-to-end behavior.
allowPrivateIPs: truesince they usenockagainstlocalhost.Test plan
npx jest→ 61 passed, 1 skipped (network test)ranges, IPv4-mapped IPv6, protocol filtering, hostname resolution,
end-to-end unfurl rejection of internal targets
Notes
ssrf-req-filter)to keep the patch minimal. The IP classification logic is small and
testable.
attempt DNS rebinding (resolving public on the validation lookup, then
private on the actual fetch). Defending against that requires pinning
the resolved IP and forcing
node-fetchto use it — happy to follow upin a separate PR if you'd like that.
cc @jacktuck