---
title: "ADR-026: DNS-rebinding defence via CURLOPT_RESOLVE"
manual: "nr-vault"
version: "1.0"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-vault:adr-026-dns-rebinding-defence@1.0"
source: "Developer/Adr/ADR-026-DnsRebindingDefence.rst"
rendered: "2026-09-18T07:37:50+00:00"
---

# ADR-026: DNS-rebinding defence via CURLOPT_RESOLVE {#adr-026-dns-rebinding-defence-via-curlopt-resolve}

**Table of contents**

-   [Status](https://docs.typo3.org/permalink/netresearch/nr-vault:status@1.0)
-   [Date](https://docs.typo3.org/permalink/netresearch/nr-vault:date@1.0)
-   [Context](https://docs.typo3.org/permalink/netresearch/nr-vault:context@1.0)
-   [Decision](https://docs.typo3.org/permalink/netresearch/nr-vault:decision@1.0)
-   [Consequences](https://docs.typo3.org/permalink/netresearch/nr-vault:consequences@1.0)
-   [Verified](https://docs.typo3.org/permalink/netresearch/nr-vault:verified@1.0)
-   [References](https://docs.typo3.org/permalink/netresearch/nr-vault:references@1.0)

## Status {#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](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-038-unresolvable-host-is-refused@1.0))

## Date {#date}

2026-05-22, amended 2026-09-17

## Context {#context}

The SSRF defence in `SecureHttpClientFactory` (see
[ADR-010: Secure Outbound inside nr-vault](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-010-secure-outbound@1.0)) 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:

1.  Code: `gethostbyname('evil.attacker.com')` → `93.184.216.34`
    (harmless).
1.  Code: `isDangerousIpLiteral('93.184.216.34')` → false. OK to
    send.
1.  Code: `$guzzle->post('https://evil.attacker.com/token', ...)`
1.  Guzzle/curl: `gethostbyname('evil.attacker.com')` →
    `192.168.1.1` (rebound; TTL = 1s).
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 {#decision}

Push a Guzzle middleware (`ssrf-dns-pin`) onto the
`HandlerStack` inside `SecureHttpClientFactory::create()`. The
middleware runs per outgoing request:

1.  Read URI host + port (normalised via the existing
    `normaliseHost()` so IPv6 brackets `[::1]` are stripped
    before validation).
1.  Resolve via `DnsResolverInterface::resolve()`
    (`DefaultDnsResolver` wraps `dns_get_record(A | AAAA)`;
    in-memory test double exists for deterministic tests).
1.  Validate EACH returned record against the existing
    `isDangerousIpLiteral()` defence.
1.  If **any** answer is dangerous, reject the request with
    `RequestException` **before 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.)
1.  If all answers are safe, pin them via curl's `CURLOPT_RESOLVE`
    option (`host:port:ip` for IPv4, `host:port:[ipv6]` for
    IPv6 — colons in v6 require brackets in CURLOPT_RESOLVE's
    field-delimiter format).
1.  IP literals need no pin, but are range-checked here as well
    (`isDangerousIpLiteral()`, honouring an explicit
    `allowed_hosts` entry): the middleware sits below Guzzle's
    redirect middleware, so it also runs for redirect hops, and
    those never pass the caller's `isHostAllowed()` gate — 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 literal
    `allowed_hosts` entry — see
    [ADR-038: A host we cannot resolve is refused, not handed to curl](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-038-unresolvable-host-is-refused@1.0), which also records
    why the assumption behind the pass-through was wrong.

curl then skips its own DNS step and connects to the IP we just
validated. No second resolution, no rebinding window.

### ext-curl absence {#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 {#consequences}

### Positive {#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 {#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 {#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](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-038-unresolvable-host-is-refused@1.0)) plus a
    redirect hop to `169.254.169.254` at middleware level.
-   Integration tests assert the `ssrf-dns-pin` middleware is
    registered on every factory-built `HandlerStack`.
-   Regression test for the IPv6-bracket normalisation.

## References {#references}

-   Pull request: [#144](https://github.com/netresearch/t3x-nr-vault/pull/144)
-   Related: [ADR-010: Secure Outbound inside nr-vault](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-010-secure-outbound@1.0),
    [ADR-008: HTTP client](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-008-http-client@1.0)
