ADR-026: DNS-rebinding defence via CURLOPT_RESOLVE
Table of contents
Status
Accepted
Date
2026-05-22
Context
The SSRF defence in SecureHttpClientFactory (see
ADR-010: Secure Outbound inside nr-vault) rejects requests whose host
resolves into private / loopback / link-local / multicast /
cloud-metadata ranges. The host is resolved once via
dns_get_record(), every record is checked, and the request is
rejected if any answer falls in a dangerous range.
This defence has a documented but unaddressed gap that the comment
on isDangerousIpLiteral flagged from the start:
Caveat: this defence is bypassable by DNS rebinding when the upstream HTTP client (Guzzle/curl) re-resolves at connect-time. For full protection, callers must pin to the resolved IP via curl
CURLOPT_RESOLVE; that is a follow-up.
The TOCTOU race:
- Code:
gethostbyname('evil.attacker.com')→93.184.216.34(harmless). - Code:
isDangerousIpLiteral('93.184.216.34')→ false. OK to send. - Code:
$guzzle->post('https://evil.attacker.com/token', ...) - Guzzle/curl:
gethostbyname('evil.attacker.com')→192.168.1.1(rebound; TTL = 1s). - HTTP request goes to
192.168.1.1.
The defence checks the result of lookup #1; curl uses the result of lookup #2 a second later.
Decision
Push a Guzzle middleware (ssrf-dns-pin) onto the
HandlerStack inside SecureHttpClientFactory::create(). The
middleware runs per outgoing request:
- Read URI host + port (normalised via the existing
normaliseHost()so IPv6 brackets[::1]are stripped before validation). - Resolve via
DnsResolverInterface::resolve()(DefaultDnsResolverwrapsdns_get_record(A | AAAA); in-memory test double exists for deterministic tests). - Validate EACH returned record against the existing
isDangerousIpLiteral()defence. - If any answer is dangerous, reject the request with
RequestExceptionbefore the socket opens. (Defends split-horizon rebinding: a malicious resolver returning one safe + one internal IP can't trick curl into picking the internal one.) - If all answers are safe, pin them via curl's
CURLOPT_RESOLVEoption (host:port:ipfor IPv4,host:port:[ipv6]for IPv6 — colons in v6 require brackets in CURLOPT_RESOLVE's field-delimiter format). - IP literals (already validated by
isHostAllowed) and unresolvable hosts pass through without a pin.
curl then skips its own DNS step and connects to the IP we just validated. No second resolution, no rebinding window.
ext-curl absence
HandlerStack::create() falls back to StreamHandler when
ext-curl is missing — StreamHandler ignores the curl option.
The factory logs a warning when curl_init is unavailable so
operators notice the gap. The pre-request validation
(buildResolveEntries() rejecting dangerous IPs) still fires on
StreamHandler — only the race-free pinning is lost.
Consequences
Positive
- TOCTOU race closed: curl uses the IP we validated, not a newly-resolved one.
- Split-horizon rebinding handled (any dangerous answer kills the request).
- PSR-7
getHost()IPv6 bracket normalisation closes a separate security regression flagged by Gemini ([::1]would have bypassed the literal-IP guard otherwise).
Negative
- curl-specific. Stream handler users get the original (pre-pin) defence only.
- Dual-stack hosts where the v4 and v6 records have different trust levels (uncommon) get both pinned; curl's per-family interface selection picks one — current behaviour is "pin both, trust both".
Verified
- Unit tests cover the four resolution outcomes (safe IPs → pin, any-dangerous → reject, IP literal → no pin, unresolvable → no pin).
- Integration tests assert the
ssrf-dns-pinmiddleware is registered on every factory-builtHandlerStack. - Regression test for the IPv6-bracket normalisation.
References
- Pull request: #144
- Related: ADR-010: Secure Outbound inside nr-vault, ADR-008: HTTP client