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.254cloud-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
ServerNameto 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_timeoutand the TLS handshake bytls_handshake_timeout. A customDialTLSContextbypassesnet/http.Transport's ownTLSHandshakeTimeout, 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)).
🔗 References
- 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)