ADR-026: DNS-rebinding defence via CURLOPT_RESOLVE
Table of contents
Status
Accepted (amended 2026-09-17 — an unresolvable host is refused, see ADR-038: A host we cannot resolve is refused, not handed to curl)
Date
2026-05-22, amended 2026-09-17
Context
The SSRF defence in Secure (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_, 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 is 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_; that is a follow-up.RESOLVE
The TOCTOU race:
- Code:
gethostbyname→('evil. attacker. com') 93.(harmless).184. 216. 34 - Code:
is→ false. OK to send.Dangerous Ip Literal ('93. 184. 216. 34') - Code:
$guzzle->post('https:// evil. attacker. com/ token', ...) - Guzzle/curl:
gethostbyname→('evil. attacker. com') 192.(rebound; TTL = 1s).168. 1. 1 - 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-) onto the
Handler inside Secure. The
middleware runs per outgoing request:
- Read URI host + port (normalised via the existing
normaliseso IPv6 bracketsHost () [::are stripped before validation).1] - Resolve via
Dns(Resolver Interface:: resolve () DefaultwrapsDns Resolver dns_; in-memory test double exists for deterministic tests).get_ record (A | AAAA) - Validate EACH returned record against the existing
isdefence.Dangerous Ip Literal () - If any answer is dangerous, reject the request with
Requestbefore 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.)Exception - If all answers are safe, pin them via curl's
CURLOPT_option. The current implementation combines every vetted address into oneRESOLVE host:entry, with IPv6 addresses bracketed. Multiple entries for the same host and port would replace one another in curl's cache and lose address fallback; the combined entry retains it.port: addr1 [,addr2,...] - IP literals need no pin, but are range-checked here as well
(
is, honouring an explicitDangerous Ip Literal () allowed_entry): the middleware sits below Guzzle's redirect middleware, so it also runs for redirect hops, and those never pass the caller'shosts isgate — only the first request URI does. Unresolvable hosts pass through without a pin. Superseded. A host that yields no usable address is now refused unless it carries a literalHost Allowed () allowed_entry — see ADR-038: A host we cannot resolve is refused, not handed to curl, which also records why the assumption behind the pass-through was wrong.hosts
curl then skips its own DNS step and connects to the IP we just validated. No second resolution, no rebinding window.
Pinless transfers
Handler falls back to Stream when
ext-curl is missing — StreamHandler ignores the curl option.
Guzzle also selects that handler for a request with a truthy stream option,
even when ext-curl is installed.
The factory logs a warning when curl_ is unavailable so
operators notice the gap. The pre-request validation
(build rejecting dangerous IPs) still fires on
StreamHandler, using a fresh lookup rather than the DNS memo. The later
transport resolution cannot be pinned on these paths, so 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
getIPv6 bracket normalisation closes a separate security regression flagged by Gemini (Host () [::would have bypassed the literal-IP guard otherwise).1]
Negative
- curl-specific. Stream handler users get the original (pre-pin) defence only.
- Every usable IPv4 and IPv6 answer must pass the same range policy. A dangerous answer rejects the whole request unless the exact hostname is explicitly allowlisted. Accepted dual-stack addresses share one pin entry so curl retains its normal address fallback.
Verified
- Unit tests cover the resolution outcomes (safe IPs → pin,
any-dangerous → reject, safe or allowlisted IP literal → no pin,
dangerous IP literal → reject, unresolvable → reject since
ADR-038: A host we cannot resolve is refused, not handed to curl) plus a
redirect hop to
169.at middleware level.254. 169. 254 - Integration tests assert the
ssrf-middleware is registered on every factory-builtdns- pin Handler.Stack - Regression test for the IPv6-bracket normalisation.
References
- Pull request: #144
- Related: ADR-010: Secure Outbound inside nr-vault, ADR-008: HTTP client