feat(remote): resolved-IP secure upstream dialer (S13 Step 4)

🔐 This step

Step 4 of 15 of the S13 virtual/remote foundation: the resolved-IP secure dialer for the upstream HTTP client. It has no callers yet - Step 5 wires it into the transport and http.Client.

The dialer is the SSRF-hardened DialContext/DialTLSContext the upstream transport plugs into. On every dial it:

  • Resolves the upstream hostname at request time (all A/AAAA records), bounded by dns_resolution_timeout. A literal-IP host is validated directly with no DNS lookup.
  • Validates every resolved address against the deny-list - loopback, the unspecified sentinels, RFC1918, IPv6 site-local and unique-local, link-local (which holds the 169.254.169.254 cloud-metadata service), RFC6598 CGNAT, and the limited broadcast address - normalizing IPv4-mapped IPv6 first so mapped notation cannot bypass the check.
  • Honors the outbound allowlist and deny_all_except_allowed; an allowlisted host or CIDR bypasses the local-network deny-list.
  • Pins and dials the first permitted address, and for TLS sets ServerName to the original hostname so a rebinding upstream cannot swap in a private IP after validation.
  • Caches resolved addresses for dns_resolution_cache_ttl; validation reruns on every dial, so a rebind cannot slip a private address past a cached entry (and is caught again once the entry expires).
  • Bounds the TCP connect by dial_timeout and the TLS handshake by tls_handshake_timeout. A custom DialTLSContext bypasses net/http.Transport's own TLSHandshakeTimeout, so the bound is applied in the dialer.

Blocked dials return a remote.BlockedURLError whose reason is a fixed, network-detail-free enum token, so an attacker controlling an upstream URL cannot map internal reachability from the block reason.

🧪 Tests

Unit tests (stub resolver, recording dialer/handshaker, fake clock) cover the deny-list ranges and boundaries, IPv4-mapped normalization, literal-IP hosts, DNS rebinding within and beyond the cache TTL, the allowlist/deny-all knobs, SNI pinning, mixed permitted/denied resolutions, and the dial/resolve/handshake error and timeout paths. Most of the diff is these table-driven security fixtures (~700 LOC of tests over ~400 LOC of production code).

⚠️ Open item

Mixed single-resolution address selection (one lookup returning both a permitted and a denied address) is spec-silent. This dialer pins the first permitted address and never dials a denied one, blocking only when all are denied; the monolith reference rejects the host if any resolved address is denied. The security invariant (a denied address is never dialed) holds either way. Pending DRI ratification, tracked with the other spec-silent S13 items (#320 (closed)).

  • Plan: docs/plans/2026-07-16-s13-virtual-remote-foundation.md - Step 4
  • Spec: docs/specs/S13-virtual-remote-foundation.md - Upstream HTTP client / Security covers

No e2e scenario applies: this is an internal dialer seam with no request surface until Step 5 wires it.

Related to #325 (closed)

Merge request reports

Loading
Loading