fix(http): resolve and reject prohibited destinations for named targets - #116
Open
lgahdl wants to merge 1 commit into
Open
fix(http): resolve and reject prohibited destinations for named targets#116lgahdl wants to merge 1 commit into
lgahdl wants to merge 1 commit into
Conversation
Closes nullislabs#63. The allowlist gate (admit) only ever checks a request's hostname string, before any DNS resolution; the actual connection opens later via wasmtime-wasi-http's default_send_request_handler, independently, using whatever the resolver answers at that moment. A name that resolves to a private/loopback/link-local/metadata-range address - via DNS rebinding, a subdomain takeover, or a compromised registrar - passed straight through with no check at all. reject_prohibited_destination resolves a *named* target immediately before connecting (inside the same deadline default_send_request_handler already runs under) and rejects it if any answer lands in that space. An IP literal in the URI is passed through untouched: host_allowed only ever admits a literal against an allowlist entry naming that exact literal, so reaching it is the entry's own stated intent, not the DNS-rebinding shape this exists for. This narrows, not closes, the gap: it is a second, independent resolution, not the connection itself, so a rebind timed between this lookup and the real one can still slip through. Real pinning - connect to the address validated here while still presenting the original hostname for TLS SNI - isn't reachable through default_send_request_handler's fixed signature, which derives both the TCP-connect target and the TLS domain from one authority string with no seam to pass a pre-resolved address through separately. Closing that fully would mean reimplementing its connect/TLS/hyper-handshake path rather than calling it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #63.
What
HttpGate::admit()only ever checks a request's hostname string against the module's[capabilities.http].allowlist, before any DNS resolution. The actual connection opens later viadefault_send_request_handler, independently, using whatever the resolver answers at that moment. A name that resolves to a private/loopback/link-local/metadata-range address — via DNS rebinding, a subdomain takeover, or a compromised registrar — passed straight through with no check at all.reject_prohibited_destinationresolves a named target immediately before connecting (inside the same deadlinedefault_send_request_handleralready runs under) and rejects it withDestinationIpProhibitedif any resolved address lands in that space: loopback, RFC 1918 private, RFC 3927 link-local (this covers the169.254.169.254cloud metadata endpoint too, no special case needed), RFC 6598 shared/CGNAT, RFC 4193 unique-local, multicast, unspecified/broadcast — with an IPv4-mapped IPv6 address unwrapped and checked against the same IPv4 rules.An IP literal in the URI is passed through untouched:
host_allowedonly ever admits a literal against an allowlist entry naming that exact literal (never a name, and vice versa), so reaching it is the entry's own stated intent, not the DNS-rebinding shape this check exists for. An operator who wants a module to reach an internal service by a stable address should list that address as a literal rather than a name — which also means the target no longer depends on DNS for a security-relevant decision at all.What this does not do
This narrows, not closes, the gap: it's a second, independent resolution, not the connection itself, so a rebind timed between this lookup and
default_send_request_handler's own later resolution can still slip through. Real pinning — connect to the address validated here while still presenting the original hostname for TLS SNI/cert validation — isn't reachable throughdefault_send_request_handler's fixed signature: it derives both the TCP-connect target and the TLS domain from onehost:portauthority string, with no seam to pass a pre-resolved address through separately. Closing that fully would mean reimplementing its connect/TLS/hyper-handshake path rather than calling it, which felt like a materially bigger and riskier change than this issue asked for — happy to discuss if full pinning is wanted.A resolution failure in this check is not itself a denial: the real connection attempt resolves again and reports its own, more specific error, so a transient resolver hiccup on our side doesn't add a new failure mode.
Testing
cargo test --lib host::http: 40/40 pass, including every pre-existing test (no regressions — an IP literal like the existing loopback test servers use is passed through unchanged) plus 12 new tests: address-range boundary checks for both IPv4 and IPv6 (including the CGNAT and unique-local/link-local mask boundaries, and the IPv4-mapped-IPv6 unwrap), the async resolve-and-reject function in isolation, and one end-to-end test proving the actual fix: an allowlisted hostname (localhost) that resolves to loopback is now rejected withDestinationIpProhibited, even though it passesadmit()'s string-match check exactly as any other allowlisted name would.cargo fmt --checkandcargo clippy --lib --tests -- -D warningsboth clean.AI Assistance: Claude Code was used for the investigation, implementation, and this PR description, following up on a red-team review that filed #63.