Skip to content

fix: key the dial cache by the pinned ip, add WithResolveOverride - #580

Merged
dogancanbakir merged 4 commits into
projectdiscovery:mainfrom
mazzz3r:fix/pinned-ip-dial-cache
Oct 22, 2026
Merged

dogancanbakir merged 4 commits into
projectdiscovery:mainfrom
mazzz3r:fix/pinned-ip-dial-cache

Conversation

@mazzz3r

@mazzz3r mazzz3r commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Summary

dial() looked up the cached dialer before it read the pinned ip. After the first dial of a host:port, a dial pinned to another ip, even a denied one, went to the first ip, and so did an unpinned dial. That broke nuclei -sa and httpx -pa. A dial through the cached dialer also parsed the address again, so a host:port:ip address sent no SNI and an IDN host sent its Unicode name instead of punycode. Details and a reproduction are in #579.

The IP value also pins dials to other hosts made with the same context, such as a redirect target or an HTTP proxy. WithResolveOverride pins one host the way curl --resolve does; nuclei and httpx use it to pin a target to an ip.

Changes

  • parseAddress splits the address once, before the cache lookup, and pinnedIP picks the pinned ip: the IP value, then the ip of a host:port:ip address, then a resolve override. connHash() includes it, so each pinned ip gets its own DialWrap and goes through the network policy check. Keys of unpinned dials don't change, and dialTimeoutErrors uses the same key.
  • The cached-dialer path no longer parses the address again. The hostname stays in ASCII form for SNI and the dialer history, and an IPv6 hostname is kept without brackets, as SplitHostPort returned it.
  • WithResolveOverride(ctx, host, ip) pins dials to host on any port, with one context key per host. The host is compared case-insensitively and without a trailing dot, and it is still sent as SNI. Dials to other hosts resolve as usual. README has an example.

Testing

  • dialer_pin_test.go uses local listeners and the existing mock DNS server:
    • two pinned ips of one host:port each reach their own listener (skipped where 127.0.0.2 can't be bound, e.g. macOS); with 127.0.0.2 denied, the second dial returns NoAddressAllowedError; an unpinned dial after a pinned one resolves the host;
    • TestDialTLSKeepsSNI: a host:port:ip address, a resolve override, and an IDN host, pinned and resolved, send the ASCII hostname as SNI on the first and the cached dial, and GetDialedIP finds the dial;
    • WithResolveOverride: the matching host is pinned, other hosts resolve, the parent context is unchanged, case and trailing dot are ignored, and the IP value wins;
    • TestDialIPv6Address dials [::1]:port twice and checks the history key ::1. It is skipped here, as this kernel has no IPv6.
  • On main, the TestDialPinnedIP cases and the SNI cases fail: the second pinned dial reads one, the host:port:ip SNI is empty, and the IDN SNI is bücher.test.
  • go test -race ./... passes except TestFastDialerDomainMultiIP/Dial_TCP_Domain_with_port_80, which needs outbound port 80 and fails on main too. The new tests also pass with -race in an offline golang:1.27.1 container. golangci-lint run ./... (v2): 0 issues.
  • With the setup from Pinned dials reuse the dialer cached for the host:port, and cached dials send the wrong SNI #579, in a container without network, v0.5.25 against this branch:
dial v0.5.25 this branch
pinned.test:18301, IP 127.0.0.1 one one
same, IP 127.0.0.2 one two
same, not pinned one three
IP 127.0.0.2, with 127.0.0.2 denied one denied address found for host
TLS pinned.test:18443:127.0.0.1, twice SNI empty SNI pinned.test
TLS bücher.test:18443 SNI bücher.test SNI xn--bcher-kva.test

Closes #579

dial() looked up the cached dialer by network and address before it
read the ip pinned with the IP context value. After the first dial of a
host:port, a dial pinned to another ip, even one the network policy
denies, connected to the first ip, and an unpinned dial connected to the
pinned ip instead of resolving the host. nuclei -sa and httpx -pa pin
each resolved ip this way, so they reached one ip per host:port.

A dial through the cached dialer also took the hostname from the
address again with net.SplitHostPort. That lost the hostname of a
host:port:ip address, so no SNI was sent, and replaced the punycode
hostname of an IDN host with the raw one, which was then sent as the
SNI and used as the key of its dialer history.

parseAddress now splits the address once, before the cache lookup, and
picks the pinned ip, which connHash includes. Each pinned ip gets its
own cached dialer and goes through the network policy check; keys of
unpinned dials don't change.

The IP value pins every dial made with the context, including a
redirect to another host or the dial to an HTTP proxy.
WithResolveOverride(ctx, host, ip) pins dials to one host only, like
curl --resolve. The IP value and a host:port:ip address take
precedence over it.
Copilot AI balanced review requested due to automatic review settings October 11, 2026 16:17

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

mazzz3r added a commit to mazzz3r/nuclei that referenced this pull request Oct 13, 2026
go.mod takes dev's versions, including fastdialer v0.5.25, and keeps
fastdialer replaced by the branch of projectdiscovery/fastdialer#580,
which now includes v0.5.25.
mazzz3r added a commit to mazzz3r/httpx that referenced this pull request Oct 13, 2026
The branch of projectdiscovery/fastdialer#580 now includes v0.5.25.
Through MVS this raises networkpolicy to v0.1.54 and utils to v0.11.9,
which that fastdialer commit requires.
mazzz3r added a commit to mazzz3r/httpx that referenced this pull request Oct 13, 2026
The branch of projectdiscovery/fastdialer#580 now includes v0.5.25.
Through MVS this raises networkpolicy to v0.1.54 and utils to v0.11.9,
which that fastdialer commit requires.
mazzz3r added a commit to mazzz3r/httpx that referenced this pull request Oct 19, 2026
go.mod takes dev's versions and keeps fastdialer replaced by the branch
of projectdiscovery/fastdialer#580. dev already requires the
networkpolicy and utils versions that branch needs.
@dogancanbakir
dogancanbakir merged commit 03f4c5a into projectdiscovery:main Oct 22, 2026
5 checks passed
@mazzz3r
mazzz3r deleted the fix/pinned-ip-dial-cache branch October 22, 2026 11:40
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.

Pinned dials reuse the dialer cached for the host:port, and cached dials send the wrong SNI

3 participants