Skip to content

fix(ssrf): avoid DNS resolution at construction time in NewSSRFSafeTransport#3540

Merged
dgageot merged 2 commits into
docker:mainfrom
dgageot:worktree-board-87979f1e33071010
Jul 8, 2026
Merged

fix(ssrf): avoid DNS resolution at construction time in NewSSRFSafeTransport#3540
dgageot merged 2 commits into
docker:mainfrom
dgageot:worktree-board-87979f1e33071010

Conversation

@dgageot

@dgageot dgageot commented Jul 8, 2026

Copy link
Copy Markdown
Member

NewSSRFSafeTransport is reached early — at runtime construction, not at request time — via NewLocalRuntime when the http_post hook builtin sets up its default client. If HTTP_PROXY, HTTPS_PROXY, or ALL_PROXY pointed at a hostname rather than a bare IP, the constructor synchronously resolved each one with a 5-second LookupIPAddr call per env var. On machines without a fast or reliable DNS path this made agent startup noticeably slow, and on machines where DNS is unavailable it silently failed to populate the allowlist, breaking proxy support entirely.

The fix changes how the proxy allowlist is built and matched. Instead of resolving hostnames eagerly and storing IP addresses, the allowlist now stores each proxy's host:port string verbatim. Transport.DialContext already receives the proxy's pre-resolved literal address, so a simple string comparison at dial time is sufficient. Trusted proxy dials bypass the SSRF hook dialer; everything else is still checked against the post-resolution SSRF control as before.

A new test (TestProxyDialAllowlist_NoDNSAtConstruction) asserts the constructor returns instantly even when the proxy env vars name an unresolvable host, and the existing suite was updated to match the new matching semantics. A companion lint cop extends the existing ConstructorNetworkIO check to flag net.Lookup* / Resolver.Lookup* calls in New* constructors going forward, since this case escaped detection both because DNS lookups were not in the flagged-call list and because the cop only inspects the constructor body directly, not transitive helpers.

dgageot added 2 commits July 8, 2026 17:39
HTTP_PROXY/HTTPS_PROXY/ALL_PROXY hostnames were synchronously resolved
during transport construction, blocking callers (e.g. NewLocalRuntime)
for up to 5 s per proxy env var. The allowlist now stores literal
pre-resolution host:port strings; matching moves to DialContext, which
receives the proxy's literal address before the OS resolves it.

Assisted-By: Claude (Anthropic)
@dgageot dgageot requested a review from a team as a code owner July 8, 2026 15:41

@docker-agent docker-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assessment: 🟡 NEEDS ATTENTION

PR: fix(ssrf): avoid DNS resolution at construction time in NewSSRFSafeTransport
Author: dgageot

The fix correctly eliminates the blocking DNS call at construction time by storing proxy host:port strings verbatim and matching at dial time — the core goal is achieved and the implementation is sound for the common case. One edge case in the allowlist-bypass logic warrants attention before merge.

Severity Category Finding
🟡 MEDIUM security Verbatim hostname allowlist can bypass SSRF guard for direct connections when NO_PROXY excludes the proxy host

Summary: When HTTP_PROXY=http://proxy.internal:3128 and NO_PROXY=proxy.internal are both set (common to prevent proxy-of-proxy loops), requests to http://proxy.internal:3128/ are dialed directly — DialContext receives proxy.internal:3128, the allowlist matches, and SSRFDialControl is skipped entirely via the unguarded trusted dialer. The TestNewSSRFSafeTransport_AllowlistFrozenAtConstruction test implicitly confirms that DialContext is called with the target host:port for direct connections. The scenario requires the attacker to influence a URL pointing to the exact proxy address, so exploitation is constrained — but it is a real bypass path introduced by this PR's design.

Comment thread pkg/httpclient/ssrf.go
// transport dials the proxy's literal host:port, so the allowlist can
// be matched by string without ever resolving the proxy hostname.
t.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
if _, ok := proxies[canonicalHostPort(address)]; ok {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM/LIKELY] Verbatim hostname allowlist can bypass SSRF guard for direct connections when NO_PROXY excludes the proxy host

The new allowlist stores proxy entries as verbatim host:port strings (e.g. proxy.internal:3128), and DialContext checks this allowlist before deciding whether to use the unguarded trusted dialer or the SSRF-protected guarded dialer. This is correct for the proxied-connection case — Go's http.Transport calls DialContext with the proxy's literal address when routing through a proxy.

However, DialContext is also called with the target's host:port for direct connections (when NO_PROXY or the absence of a proxy causes http.Transport to bypass the proxy). The test TestNewSSRFSafeTransport_AllowlistFrozenAtConstruction directly demonstrates this: a request to http://10.0.0.1:3128/ goes through DialContext with address="10.0.0.1:3128" and is blocked by the SSRF guard — which means if that address were in the allowlist, the block would be skipped.

Attack scenario (requires two concurrent conditions):

  1. HTTP_PROXY=http://proxy.internal:3128 is set (adds proxy.internal:3128 to allowlist)
  2. NO_PROXY=proxy.internal is also set — common practice to prevent proxy-of-proxy loops — causing Go's transport to dial proxy.internal directly, bypassing the proxy
  3. Attacker influences a request URL to point to http://proxy.internal:3128/

The result: DialContext is called with proxy.internal:3128, the allowlist matches, trusted.DialContext is used (no SSRFDialControl), and the connection succeeds unchecked.

The old IP-based approach had an analogous structural issue (if the proxy IP matched a target IP, SSRF was bypassed), so the attack surface class is not entirely new, but this PR introduces the verbatim string-based allowlist mechanism that makes the bypass deterministic and predictable for hostname-based proxies.

Suggested mitigation: Track whether the dial is to the proxy itself (via a transport-level Proxy func wrapper that records which addresses are being used as proxies for the current request) or check NO_PROXY exclusions when building the allowlist — e.g. if a proxy host is also in NO_PROXY, do not add it to the trusted allowlist.

@dgageot dgageot merged commit 07e4f9b into docker:main Jul 8, 2026
11 of 12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants