Skip to content

fix(http): resolve and reject prohibited destinations for named targets - #116

Open
lgahdl wants to merge 1 commit into
nullislabs:mainfrom
bleu:fix/http-dns-rebinding-ip-pinning
Open

fix(http): resolve and reject prohibited destinations for named targets#116
lgahdl wants to merge 1 commit into
nullislabs:mainfrom
bleu:fix/http-dns-rebinding-ip-pinning

Conversation

@lgahdl

@lgahdl lgahdl commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Closes #63.

What

HttpGate::admit() only ever checks a request's hostname string against the module's [capabilities.http].allow list, before any DNS resolution. The actual connection opens later via 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 with DestinationIpProhibited if any resolved address lands in that space: loopback, RFC 1918 private, RFC 3927 link-local (this covers the 169.254.169.254 cloud 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_allowed only 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 through default_send_request_handler's fixed signature: it derives both the TCP-connect target and the TLS domain from one host:port 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, 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 with DestinationIpProhibited, even though it passes admit()'s string-match check exactly as any other allowlisted name would.

cargo fmt --check and cargo clippy --lib --tests -- -D warnings both 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.

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.
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.

HTTP capability allowlist is hostname-based and pre-resolution: no DNS-rebinding/IP-pinning defense

1 participant