Skip to content

fix: resolve DNS before SSRF checks to close rebinding bypass (CWE-918) - #342

Open
Matthew-Selvam wants to merge 1 commit into
Conway-Research:mainfrom
Matthew-Selvam:fix/183-ssrf-dns-rebinding
Open

fix: resolve DNS before SSRF checks to close rebinding bypass (CWE-918)#342
Matthew-Selvam wants to merge 1 commit into
Conway-Research:mainfrom
Matthew-Selvam:fix/183-ssrf-dns-rebinding

Conversation

@Matthew-Selvam

Copy link
Copy Markdown

Summary

Fixes #183. isInternalNetwork() validated the hostname string, not the resolved IP address. An attacker-registered domain that resolves to a private IP (e.g. 127.0.0.1) bypassed the SSRF check entirely, since isAllowedUri() never performed DNS resolution before allowing a fetch.

Also fixes the related gaps called out in the issue:

  • IPv6-mapped IPv4 addresses (::ffff:127.0.0.1 and the compressed hex form ::ffff:7f00:1) were not matched by any blocklist regex.
  • Decimal/hex IPv4 notation (e.g. 2130706433 for 127.0.0.1) — verified this is actually already normalized to dotted-quad by the WHATWG URL parser before reaching isInternalNetwork, so no additional handling was needed there, but I left a note in this PR body in case reviewers want to double check that assumption on their Node version.

Fix

  • isAllowedUri() is now async and resolves the hostname via dns.lookup(hostname, { all: true }), checking every returned address against the private-range blocklist before allowing the request. Fails closed (blocks) if resolution errors.
  • isInternalNetwork() now unwraps IPv6-mapped IPv4 forms before the regex check.
  • Updated the one production caller (fetchAgentCard, already async) to await the call.

Testing

  • tsc --noEmit passes.
  • Updated all isAllowedUri call sites across data-layer.test.ts, discovery-data-uri.test.ts, and social.test.ts to await the now-async function, with node:dns/promises mocked so tests stay hermetic (no real network access required).
  • Added regression tests: DNS-rebinding bypass (hostname resolves to 127.0.0.1), multi-A-record response where only one address is private, fail-closed behavior on DNS resolution error, and both IPv6-mapped-IPv4 forms.
  • Full suite: 131/131 passing across the three affected test files.

isInternalNetwork() only checked the literal hostname string, not the
resolved IP, so an attacker-controlled domain resolving to a private
address bypassed the check entirely. isAllowedUri() is now async and
resolves the hostname via dns.lookup(), checking every returned address
against the private-range blocklist before allowing a fetch. Fails
closed if resolution errors.

Also unwraps IPv6-mapped IPv4 addresses (::ffff:127.0.0.1 and the
compressed hex form) before the range check, since neither was
previously matched by the blocklist regexes.

Updates the one caller (fetchAgentCard) and the isAllowedUri call
sites in tests to await the now-async function, with DNS mocked for
determinism, plus new regression tests for the rebinding bypass,
multi-A-record responses, resolution failures (fail-closed), and the
IPv6-mapped-IPv4 forms.

Fixes Conway-Research#183
@Matthew-Selvam

Copy link
Copy Markdown
Author

This is ready for maintainer review whenever you get a chance — happy to make any changes requested.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Security: DNS rebinding bypass in SSRF protection (CWE-918)

1 participant