From 500a560e212d0bdee1755425a695c1d4c5cfec87 Mon Sep 17 00:00:00 2001 From: Georgii Butakov Date: Sat, 10 Oct 2026 12:06:31 +0000 Subject: [PATCH 1/2] feat(runner): probe a target through the ip of a JSON input line Probing a vhost on a given server, or an origin behind a CDN, needs the hostname in the Host header and SNI while the connection goes to another ip, and the same host may need probing on several ips. The vhost,ip form builds the URL from the ip, so it sends no SNI and reports the ip as the URL host, and -pa probes the ips DNS returns. An input line can now be a JSON object with the target and the ip, like curl --resolve: {"target": "https://app.example.com", "ip": "192.0.2.10"} The target is probed with the ip as its CustomIP, and host_ip reports it. Lines are read from -l and stdin; -u splits its value on commas, so it cannot carry them. The ip is validated and canonicalized, and the target must have a hostname. An invalid line stops the run, or is skipped with a warning in stream mode. Equivalent lines are stored once, and -pa does not resolve a pinned target. -proxy, -unsafe, -pipeline, -http2, -jarm, -screenshot and -vhost-input do not connect through the ip, so they are refused with pinned targets. The ip of a pinned line, and each ip of -pa, now pins only the probed host, with fastdialer.WithResolveOverride. The fastdialer.IP value used before pinned every dial of the request, so a redirect to another host followed with -fr went to the same ip. IPv6 ips were passed in brackets, which fastdialer does not accept as an ip, so those -pa probes failed. Favicon and ztls requests keep the ip, -maxhr counts errors per ip, and the ip is checked against the network policy as well as the host. go.mod points fastdialer at a fork commit with WithResolveOverride until a fastdialer release includes it. --- README.md | 6 + common/httpx/tls.go | 3 +- go.mod | 2 + go.sum | 4 +- runner/runner.go | 80 ++++++++----- runner/targetline.go | 153 +++++++++++++++++++++++++ runner/targetline_test.go | 233 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 447 insertions(+), 34 deletions(-) create mode 100644 runner/targetline.go create mode 100644 runner/targetline_test.go diff --git a/README.md b/README.md index 298c87333..db3553c40 100644 --- a/README.md +++ b/README.md @@ -365,6 +365,12 @@ echo target.com | httpx -path '/.well-known/security.txt,/.well-known/change-pas - As default, `httpx` probe with **HTTPS** scheme and fall-back to **HTTP** only if **HTTPS** is not reachable. - Burp Suite XML exports can be used as input with `-l burp-export.xml -im burp` +- A target can be probed through a given IP with a JSON input line, like `curl --resolve`. The hostname is still used for the Host header and SNI, and the connection goes to the IP, which `host_ip` reports. The same host can be listed with several IPs: + ``` + {"target": "https://app.example.com", "ip": "192.0.2.10"} + {"target": "https://app.example.com", "ip": "192.0.2.11"} + ``` + Only the `target` and `ip` keys are accepted. An invalid line stops the run, or is skipped with a warning with `-stream`. Read such lines from `-l` or stdin, as `-u` splits its value on commas. They cannot be combined with `-proxy`, `-unsafe`, `-pipeline`, `-http2`, `-jarm`, `-screenshot` or `-vhost-input`, which do not connect through the IP; a proxy set with `HTTP_PROXY`/`HTTPS_PROXY` resolves the host itself as well. - The `-no-fallback` flag can be used to probe and display both **HTTP** and **HTTPS** result. - Custom scheme for ports can be defined, for example `-ports http:443,http:80,https:8443` - Custom resolver supports multiple protocol (**doh|tcp|udp**) in form of `protocol:resolver:port` (e.g. `udp:127.0.0.1:53`) diff --git a/common/httpx/tls.go b/common/httpx/tls.go index 87cee7444..9ca5e1e9e 100644 --- a/common/httpx/tls.go +++ b/common/httpx/tls.go @@ -63,7 +63,8 @@ func (h *HTTPX) ZTLSGrab(r *http.Response) *clients.Response { } // canonical net concatenation host = net.JoinHostPort(hostname, fmt.Sprint(port)) - ctx, cancel := context.WithTimeout(context.Background(), h.Options.Timeout) + // keeps the ip the request was pinned to, without its cancellation + ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Request.Context()), h.Options.Timeout) defer cancel() tlsConn, err := h.Dialer.DialTLS(ctx, "tcp", host) if err != nil { diff --git a/go.mod b/go.mod index 0845e201d..2966877da 100644 --- a/go.mod +++ b/go.mod @@ -169,3 +169,5 @@ require ( golang.org/x/time v0.15.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect ) + +replace github.com/projectdiscovery/fastdialer => github.com/mazzz3r/fastdialer v0.0.0-20261010115852-eced62301c6f diff --git a/go.sum b/go.sum index ce13bc3e2..d60ac295c 100644 --- a/go.sum +++ b/go.sum @@ -205,6 +205,8 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mazzz3r/fastdialer v0.0.0-20261010115852-eced62301c6f h1:w2j/CqFpNDk4KL6A0pwI7nivKwOqIs/N2NaOjkqrl8w= +github.com/mazzz3r/fastdialer v0.0.0-20261010115852-eced62301c6f/go.mod h1:GybngvpSevg/48Kk/NgPw2HQOB95FngbOS93f1pTJ84= github.com/mfonda/simhash v0.0.0-20151007195837-79f94a1100d6 h1:bjfMeqxWEJ6IRUvGkiTkSwx0a6UdQJsbirRSoXogteY= github.com/mfonda/simhash v0.0.0-20151007195837-79f94a1100d6/go.mod h1:WVJJvUw/pIOcwu2O8ZzHEhmigq2jzwRNfJVRMJB7bR8= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= @@ -256,8 +258,6 @@ github.com/projectdiscovery/clistats v0.1.7 h1:NXf4lUzx1QcbJd6QycNwwYHTv2FsW9Mch github.com/projectdiscovery/clistats v0.1.7/go.mod h1:RxRGxhMzkT344VTTvZWIrojurUXdQQRN1yfwgg/88I8= github.com/projectdiscovery/dsl v0.8.24 h1:rN2w6LSIjA16qKGRjyj70oxdwXGaspe6sAjEkP7F2fk= github.com/projectdiscovery/dsl v0.8.24/go.mod h1:V+EludvS5PS19UNMlM3CuBwZ+sFnMaQhCFGxysDUanQ= -github.com/projectdiscovery/fastdialer v0.5.23 h1:uL0ILvwrOv52BK4XXFM7CyakR+0d3tmc0m1R+i8NelI= -github.com/projectdiscovery/fastdialer v0.5.23/go.mod h1:psWXmdbZhGxmVLNP2o1YI93R8d2YtOwK5XSNhlWxXJ8= github.com/projectdiscovery/fdmax v0.0.4 h1:K9tIl5MUZrEMzjvwn/G4drsHms2aufTn1xUdeVcmhmc= github.com/projectdiscovery/fdmax v0.0.4/go.mod h1:oZLqbhMuJ5FmcoaalOm31B1P4Vka/CqP50nWjgtSz+I= github.com/projectdiscovery/freeport v0.0.7 h1:Q6uXo/j8SaV/GlAHkEYQi8WQoPXyJWxyspx+aFmz9Qk= diff --git a/runner/runner.go b/runner/runner.go index c903054dc..c75dcf336 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -546,6 +546,10 @@ func (r *Runner) prepareInput() { // check if input target host(s) have been provided if len(r.options.InputTargetHost) > 0 { for _, target := range r.options.InputTargetHost { + target, err := r.normalizeInput(target) + if err != nil { + gologger.Fatal().Msgf("Could not read input target: %s\n", err) + } expandedTarget, err := r.countTargetFromRawTarget(target) if err == nil && expandedTarget > 0 { numHosts += expandedTarget @@ -722,6 +726,20 @@ func (r *Runner) streamInput() (chan string, error) { return true } } + // sendLine normalizes, deduplicates and sends an input line, returning + // false if interrupted. The scan has already started, so an invalid + // line is skipped with a warning. + sendLine := func(item string) bool { + item, err := r.normalizeInput(item) + if err != nil { + gologger.Error().Msgf("Skipping input: %s\n", err) + return true + } + if r.options.SkipDedupe || r.testAndSet(item) { + return trySend(item) + } + return true + } if fileutil.FileExists(r.options.InputFile) { // check if input mode is specified for special format handling @@ -744,14 +762,9 @@ func (r *Runner) streamInput() (chan string, error) { } } else { for item, err := range fileutil.Lines(r.options.InputFile) { - if err != nil { + if err != nil || !sendLine(item) { return } - if r.options.SkipDedupe || r.testAndSet(item) { - if !trySend(item) { - return - } - } } } } else if r.options.InputFile != "" { @@ -761,27 +774,17 @@ func (r *Runner) streamInput() (chan string, error) { } for _, file := range files { for item, err := range fileutil.Lines(file) { - if err != nil { + if err != nil || !sendLine(item) { return } - if r.options.SkipDedupe || r.testAndSet(item) { - if !trySend(item) { - return - } - } } } } if fileutil.HasStdin() { for item, err := range fileutil.LinesReader(os.Stdin) { - if err != nil { + if err != nil || !sendLine(item) { return } - if r.options.SkipDedupe || r.testAndSet(item) { - if !trySend(item) { - return - } - } } } }() @@ -791,7 +794,11 @@ func (r *Runner) streamInput() (chan string, error) { func (r *Runner) loadAndCloseFile(finput *os.File) (numTargets int, err error) { scanner := bufio.NewScanner(finput) for scanner.Scan() { - target := strings.TrimSpace(scanner.Text()) + target, err := r.normalizeInput(scanner.Text()) + if err != nil { + _ = finput.Close() + return 0, err + } // Used just to get the exact number of targets expandedTarget, err := r.countTargetFromRawTarget(target) if err == nil && expandedTarget > 0 { @@ -1524,7 +1531,7 @@ func (r *Runner) RunEnumeration() { protocol := r.options.protocol // attempt to parse url as is - if u, err := r.parseURL(k); err == nil { + if u, err := r.parseURL(targetOf(k)); err == nil { if r.options.NoFallbackScheme && u.Scheme == httpx.HTTP || u.Scheme == httpx.HTTPS { protocol = u.Scheme } @@ -1776,6 +1783,13 @@ func (r *Runner) targets(hp *httpx.HTTPX, target string) chan httpx.Target { target = strings.TrimSpace(target) switch { + case isTargetLine(target): + line, err := parseTargetLine(target) + if err != nil { + gologger.Warning().Msgf("%s\n", err) + return + } + results <- httpx.Target{Host: line.Target, CustomIP: line.IP} case stringsutil.HasPrefixAny(target, "*", "."): // A valid target does not contain: // trim * and/or . (prefix) from the target to return the domain instead of wildcard @@ -1852,6 +1866,10 @@ retry: // check if we have to skip the host:port as a result of a previous failure hostPort := net.JoinHostPort(URL.Host, URL.Port()) + if target.CustomIP != "" { + // the ips of a host fail independently + hostPort += "@" + target.CustomIP + } if r.options.HostMaxErrors >= 0 && r.HostErrorsCache.Has(hostPort) { numberOfErrors, err := r.HostErrorsCache.GetIFPresent(hostPort) if err == nil && numberOfErrors >= r.options.HostMaxErrors { @@ -1880,16 +1898,11 @@ retry: req *retryablehttp.Request ctx context.Context ) + ctx = context.Background() if target.CustomIP != "" { - var requestIP string - if iputil.IsIPv6(target.CustomIP) { - requestIP = fmt.Sprintf("[%s]", target.CustomIP) - } else { - requestIP = target.CustomIP - } - ctx = context.WithValue(context.Background(), fastdialer.IP, requestIP) - } else { - ctx = context.Background() + // only dials to the target's host go to the ip, not those to a + // redirect target on another host + ctx = fastdialer.WithResolveOverride(ctx, URL.Hostname(), target.CustomIP) } req, err = hp.NewRequestWithContext(ctx, method, URL.String()) if err != nil { @@ -2761,6 +2774,11 @@ func (r *Runner) skip(URL *urlutil.URL, target httpx.Target, origInput string) ( return true, Result{URL: target.Host, Input: origInput, Err: errors.New("target host is not allowed by network policy")} } + if target.CustomIP != "" && !r.hp.NetworkPolicy.Validate(target.CustomIP) { + gologger.Debug().Msgf("Skipping target due to network policy: %s (%s)\n", URL.Hostname(), target.CustomIP) + return true, Result{URL: target.Host, Input: origInput, Err: errors.New("target ip is not allowed by network policy")} + } + return false, Result{} } @@ -2811,8 +2829,8 @@ func (r *Runner) HandleFaviconHash(hp *httpx.HTTPX, req *retryablehttp.Request, } } - // Clone original request (reuse headers/cookies) - clone := req.Clone(context.Background()) + // Clone original request (reuse headers/cookies, and the pinned ip) + clone := req.Clone(req.Context()) var ( faviconMMH3 string diff --git a/runner/targetline.go b/runner/targetline.go new file mode 100644 index 000000000..d1ae21a22 --- /dev/null +++ b/runner/targetline.go @@ -0,0 +1,153 @@ +package runner + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/netip" + "strings" + + "github.com/projectdiscovery/mapcidr/asn" + iputil "github.com/projectdiscovery/utils/ip" + stringsutil "github.com/projectdiscovery/utils/strings" + urlutil "github.com/projectdiscovery/utils/url" +) + +// targetLine is an input line that pins a target to an ip, like +// curl --resolve: +// +// {"target": "https://app.example", "ip": "192.0.2.10"} +// +// The target keeps its hostname for the Host header and SNI, while the +// connection goes to the ip. +type targetLine struct { + Target string `json:"target"` + IP string `json:"ip,omitempty"` +} + +// isTargetLine reports whether an input line is a target line. +func isTargetLine(line string) bool { + return strings.HasPrefix(strings.TrimSpace(line), "{") +} + +// parseTargetLine decodes a target line. Unknown keys are rejected so that a +// typo cannot silently probe the host through DNS. +func parseTargetLine(line string) (targetLine, error) { + decoder := json.NewDecoder(strings.NewReader(line)) + decoder.DisallowUnknownFields() + var parsed targetLine + if err := decoder.Decode(&parsed); err != nil { + return targetLine{}, fmt.Errorf("invalid target line %q: %w", line, err) + } + if _, err := decoder.Token(); !errors.Is(err, io.EOF) { + return targetLine{}, fmt.Errorf("invalid target line %q: unexpected data after the object", line) + } + + parsed.Target = strings.TrimSpace(parsed.Target) + if parsed.Target == "" { + return targetLine{}, fmt.Errorf("target line %q has no target", line) + } + if parsed.IP == "" { + return parsed, nil + } + ip, err := pinnedIP(parsed.Target, parsed.IP) + if err != nil { + return targetLine{}, fmt.Errorf("target %q: %w", parsed.Target, err) + } + parsed.IP = ip + return parsed, nil +} + +// pinnedIP validates the ip a target is pinned to and returns it in canonical +// form. Only a target with a hostname can be pinned. +func pinnedIP(target, value string) (string, error) { + addr, err := netip.ParseAddr(strings.TrimSpace(value)) + if err != nil || addr.Zone() != "" || addr.IsUnspecified() { + return "", fmt.Errorf("invalid ip %q", value) + } + if iputil.IsCIDR(target) || asn.IsASN(target) { + return "", errors.New("a CIDR or ASN target cannot have an ip") + } + if stringsutil.HasPrefixAny(target, "*", ".") || (!stringsutil.HasPrefixAny(target, "http://", "https://") && strings.Contains(target, ",")) { + return "", errors.New("a wildcard or vhost,ip target cannot have an ip") + } + parsed, err := urlutil.Parse(target) + if err != nil || parsed.Hostname() == "" || iputil.IsIP(parsed.Hostname()) { + return "", errors.New("a target with an ip must have a hostname") + } + return addr.Unmap().String(), nil +} + +// key returns the line as it is stored and deduplicated: the target alone +// when it is not pinned, and a canonical target line otherwise. +func (t targetLine) key() string { + if t.IP == "" { + return t.Target + } + var buf bytes.Buffer + encoder := json.NewEncoder(&buf) + encoder.SetEscapeHTML(false) + _ = encoder.Encode(t) + return strings.TrimSpace(buf.String()) +} + +// targetOf returns the target of an input line: the line itself, or the +// target of a target line. +func targetOf(item string) string { + if !isTargetLine(item) { + return item + } + if line, err := parseTargetLine(item); err == nil { + return line.Target + } + return item +} + +// normalizeInput validates an input line and returns the form it is stored +// and deduplicated in. +func (r *Runner) normalizeInput(item string) (string, error) { + item = strings.TrimSpace(item) + if !isTargetLine(item) { + return item, nil + } + line, err := parseTargetLine(item) + if err != nil { + return "", err + } + if line.IP != "" { + if flags := r.options.pinnedTargetConflicts(); len(flags) > 0 { + return "", fmt.Errorf("target %q: a target with an ip cannot be probed with %s", line.Target, strings.Join(flags, ", ")) + } + } + return line.key(), nil +} + +// pinnedTargetConflicts returns the flags whose probes do not connect through +// the ip a target is pinned to. +func (options *Options) pinnedTargetConflicts() []string { + var flags []string + if options.Unsafe { + flags = append(flags, "-unsafe") + } + if options.Pipeline { + flags = append(flags, "-pipeline") + } + if options.HTTP2Probe { + flags = append(flags, "-http2") + } + if options.Jarm { + flags = append(flags, "-jarm") + } + if options.Screenshot { + flags = append(flags, "-screenshot") + } + if options.VHostInput { + flags = append(flags, "-vhost-input") + } + if options.Proxy != "" || options.HTTPProxy != "" || options.SocksProxy != "" { + flags = append(flags, "-proxy") + } + return flags +} diff --git a/runner/targetline_test.go b/runner/targetline_test.go new file mode 100644 index 000000000..9b505abc9 --- /dev/null +++ b/runner/targetline_test.go @@ -0,0 +1,233 @@ +package runner + +import ( + "crypto/tls" + "fmt" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "sort" + "sync" + "testing" + + "github.com/projectdiscovery/httpx/common/httpx" + "github.com/stretchr/testify/require" +) + +func TestParseTargetLine(t *testing.T) { + valid := map[string]targetLine{ + `{"target": "https://a.example", "ip": "192.0.2.10"}`: {Target: "https://a.example", IP: "192.0.2.10"}, + `{"target": " a.example:8443 ", "ip": "2001:DB8::1"}`: {Target: "a.example:8443", IP: "2001:db8::1"}, + `{"target": "a.example", "ip": "::ffff:192.0.2.10"}`: {Target: "a.example", IP: "192.0.2.10"}, + `{"target": "a.example"}`: {Target: "a.example"}, + } + for line, want := range valid { + t.Run(line, func(t *testing.T) { + got, err := parseTargetLine(line) + require.NoError(t, err) + require.Equal(t, want, got) + }) + } + + invalid := map[string]string{ + "malformed json": `{"target": "a.example"`, + "trailing data": `{"target": "a.example"} x`, + "unknown key": `{"target": "a.example", "ips": "192.0.2.10"}`, + "missing target": `{"ip": "192.0.2.10"}`, + "invalid ip": `{"target": "a.example", "ip": "192.0.2"}`, + "ip with zone": `{"target": "a.example", "ip": "fe80::1%eth0"}`, + "unspecified ip": `{"target": "a.example", "ip": "0.0.0.0"}`, + "ip target": `{"target": "https://192.0.2.1", "ip": "192.0.2.10"}`, + "cidr target": `{"target": "192.0.2.0/24", "ip": "192.0.2.10"}`, + "asn target": `{"target": "AS13335", "ip": "192.0.2.10"}`, + "wildcard": `{"target": "*.a.example", "ip": "192.0.2.10"}`, + "vhost form": `{"target": "a.example,192.0.2.1", "ip": "192.0.2.10"}`, + } + for name, line := range invalid { + t.Run(name, func(t *testing.T) { + _, err := parseTargetLine(line) + require.Error(t, err) + }) + } +} + +func TestRunner_normalizeInput(t *testing.T) { + r := &Runner{options: &Options{}} + for _, line := range []string{ + `{"target": "https://a.example", "ip": "192.0.2.10"}`, + ` {"ip":"::ffff:192.0.2.10","target":"https://a.example"}`, + } { + key, err := r.normalizeInput(line) + require.NoError(t, err) + require.Equal(t, `{"target":"https://a.example","ip":"192.0.2.10"}`, key, "equivalent lines are stored once") + } + + key, err := r.normalizeInput(`{"target": "https://a.example"}`) + require.NoError(t, err) + require.Equal(t, "https://a.example", key, "a line without ip is the plain target") + + _, err = r.normalizeInput(`{"target": "https://a.example", "ip": "192.0.2"}`) + require.Error(t, err) + + r = &Runner{options: &Options{Unsafe: true, Jarm: true}} + _, err = r.normalizeInput(`{"target": "https://a.example", "ip": "192.0.2.10"}`) + require.ErrorContains(t, err, "-unsafe, -jarm") + _, err = r.normalizeInput(`{"target": "https://a.example"}`) + require.NoError(t, err, "only pinned targets conflict") +} + +func TestRunner_targetLine_targets(t *testing.T) { + // -pa must not resolve a pinned target + r, err := New(&Options{ProbeAllIPS: true}) + require.NoError(t, err) + defer r.Close() + + key, err := r.normalizeInput(`{"target": "https://a.example", "ip": "192.0.2.10"}`) + require.NoError(t, err) + var got []httpx.Target + for target := range r.targets(r.hp, key) { + got = append(got, target) + } + require.Equal(t, []httpx.Target{{Host: "https://a.example", CustomIP: "192.0.2.10"}}, got) +} + +func TestRunner_streamInput_skipsInvalidLines(t *testing.T) { + file := filepath.Join(t.TempDir(), "targets.txt") + lines := "a.example\n" + + `{"target": "b.example"` + "\n" + + `{"target": "c.example", "ip": "192.0.2.10"}` + "\n" + require.NoError(t, os.WriteFile(file, []byte(lines), 0o600)) + + r, err := New(&Options{Stream: true, InputFile: file}) + require.NoError(t, err) + defer r.Close() + out, err := r.streamInput() + require.NoError(t, err) + + var got []string + for item := range out { + got = append(got, item) + } + require.Equal(t, []string{"a.example", `{"target":"c.example","ip":"192.0.2.10"}`}, got, "the malformed line is skipped") +} + +// skipIfProxied skips a test whose requests to target go through a proxy set +// in the environment, as the proxy resolves the host itself. +func skipIfProxied(t *testing.T, target string) { + t.Helper() + u, err := url.Parse(target) + require.NoError(t, err) + if proxy, _ := http.ProxyFromEnvironment(&http.Request{URL: u}); proxy != nil { + t.Skipf("%s goes through the proxy %s", target, proxy.Redacted()) + } +} + +// runPinned probes the target lines with a runner and returns the results. +func runPinned(t *testing.T, lines ...string) []Result { + t.Helper() + + var ( + mu sync.Mutex + results []Result + ) + r, err := New(&Options{ + Threads: 1, + RateLimit: 10, + Timeout: 5, + Methods: http.MethodGet, + Delay: -1, + OnResult: func(r Result) { + if r.Err != nil || r.URL == "" { + return + } + mu.Lock() + results = append(results, r) + mu.Unlock() + }, + InputTargetHost: lines, + }) + require.NoError(t, err) + defer r.Close() + r.RunEnumeration() + + mu.Lock() + defer mu.Unlock() + return results +} + +func TestTargetLinePinsTheConnection(t *testing.T) { + serve := func(addr, name string) *httptest.Server { + listener, err := net.Listen("tcp", addr) + if err != nil { + t.Skipf("cannot listen on %s: %v", addr, err) + } + ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprintf(w, "%s %s", name, r.Host) + })) + _ = ts.Listener.Close() + ts.Listener = listener + ts.Start() + t.Cleanup(ts.Close) + return ts + } + + one := serve("127.0.0.1:0", "one") + _, port, err := net.SplitHostPort(one.Listener.Addr().String()) + require.NoError(t, err) + serve(net.JoinHostPort("127.0.0.2", port), "two") + + target := "http://pinned.test:" + port + skipIfProxied(t, target) + results := runPinned(t, + fmt.Sprintf(`{"target": %q, "ip": "127.0.0.1"}`, target), + fmt.Sprintf(`{"target": %q, "ip": "127.0.0.2"}`, target), + ) + + var got []string + for _, result := range results { + got = append(got, result.HostIP+" "+result.Title) + } + sort.Strings(got) + require.Equal(t, []string{ + "127.0.0.1 one pinned.test:" + port, + "127.0.0.2 two pinned.test:" + port, + }, got) +} + +func TestTargetLineKeepsSNI(t *testing.T) { + var ( + mu sync.Mutex + names []string + ) + ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprint(w, "tls") + })) + ts.TLS = &tls.Config{ + GetConfigForClient: func(hello *tls.ClientHelloInfo) (*tls.Config, error) { + mu.Lock() + names = append(names, hello.ServerName) + mu.Unlock() + return nil, nil + }, + } + ts.StartTLS() + defer ts.Close() + _, port, err := net.SplitHostPort(ts.Listener.Addr().String()) + require.NoError(t, err) + + target := "https://pinned.test:" + port + skipIfProxied(t, target) + results := runPinned(t, fmt.Sprintf(`{"target": %q, "ip": "127.0.0.1"}`, target)) + require.Len(t, results, 1) + require.Equal(t, "127.0.0.1", results[0].HostIP) + + mu.Lock() + defer mu.Unlock() + require.NotEmpty(t, names) + for _, name := range names { + require.Equal(t, "pinned.test", name) + } +} From 58ba3c878452d0fde9c9dba7310a1b7167b44ed7 Mon Sep 17 00:00:00 2001 From: Georgii Butakov Date: Tue, 13 Oct 2026 20:21:20 +0000 Subject: [PATCH 2/2] chore(deps): update the fastdialer replace to v0.5.25 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. --- go.mod | 6 +++--- go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 2966877da..8539e42db 100644 --- a/go.mod +++ b/go.mod @@ -29,13 +29,13 @@ require ( github.com/projectdiscovery/gologger v1.1.73 github.com/projectdiscovery/hmap v0.0.102 github.com/projectdiscovery/mapcidr v1.1.97 - github.com/projectdiscovery/networkpolicy v0.1.53 + github.com/projectdiscovery/networkpolicy v0.1.54 github.com/projectdiscovery/ratelimit v0.0.92 github.com/projectdiscovery/rawhttp v0.1.92 github.com/projectdiscovery/retryablehttp-go v1.3.29 github.com/projectdiscovery/tlsx v1.4.0 github.com/projectdiscovery/useragent v0.0.109 - github.com/projectdiscovery/utils v0.11.8 + github.com/projectdiscovery/utils v0.11.9 github.com/projectdiscovery/wappalyzergo v0.3.4 github.com/rs/xid v1.6.0 github.com/spaolacci/murmur3 v1.1.0 @@ -170,4 +170,4 @@ require ( gopkg.in/ini.v1 v1.67.0 // indirect ) -replace github.com/projectdiscovery/fastdialer => github.com/mazzz3r/fastdialer v0.0.0-20261010115852-eced62301c6f +replace github.com/projectdiscovery/fastdialer => github.com/mazzz3r/fastdialer v0.0.0-20261013195407-11ed52b6df6c diff --git a/go.sum b/go.sum index d60ac295c..a0da96382 100644 --- a/go.sum +++ b/go.sum @@ -205,8 +205,8 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mazzz3r/fastdialer v0.0.0-20261010115852-eced62301c6f h1:w2j/CqFpNDk4KL6A0pwI7nivKwOqIs/N2NaOjkqrl8w= -github.com/mazzz3r/fastdialer v0.0.0-20261010115852-eced62301c6f/go.mod h1:GybngvpSevg/48Kk/NgPw2HQOB95FngbOS93f1pTJ84= +github.com/mazzz3r/fastdialer v0.0.0-20261013195407-11ed52b6df6c h1:Bf6RjxuR/g7zniyJX7pJfh6y/FYx+KCPrxRtCVgaxQs= +github.com/mazzz3r/fastdialer v0.0.0-20261013195407-11ed52b6df6c/go.mod h1:CZZ0PwdTw2KCI/PrZTW9wyBY88MIjpT8WhsKWSw3bkw= github.com/mfonda/simhash v0.0.0-20151007195837-79f94a1100d6 h1:bjfMeqxWEJ6IRUvGkiTkSwx0a6UdQJsbirRSoXogteY= github.com/mfonda/simhash v0.0.0-20151007195837-79f94a1100d6/go.mod h1:WVJJvUw/pIOcwu2O8ZzHEhmigq2jzwRNfJVRMJB7bR8= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= @@ -280,8 +280,8 @@ github.com/projectdiscovery/machineid v0.0.0-20250715113114-c77eb3567582 h1:eR+0 github.com/projectdiscovery/machineid v0.0.0-20250715113114-c77eb3567582/go.mod h1:3G3BRKui7nMuDFAZKR/M2hiOLtaOmyukT20g88qRQjI= github.com/projectdiscovery/mapcidr v1.1.97 h1:7FkxNNVXp+m1rIu5Nv/2SrF9k4+LwP8QuWs2puwy+2w= github.com/projectdiscovery/mapcidr v1.1.97/go.mod h1:9dgTJh1SP02gYZdpzMjm6vtYFkEHQHoTyaVNvaeJ7lA= -github.com/projectdiscovery/networkpolicy v0.1.53 h1:Oduo0GXFm0aSWqkbkyVXRjwT+GitqnqEEV0KiEtR2aM= -github.com/projectdiscovery/networkpolicy v0.1.53/go.mod h1:+4ef+If67QABeDgSEoOxOgs5ZiM7ygaEeYQPkaZkLDQ= +github.com/projectdiscovery/networkpolicy v0.1.54 h1:+v7yyuRBu2S7dzeIJToDENy1i+HjRhNzqnQrDlm00z8= +github.com/projectdiscovery/networkpolicy v0.1.54/go.mod h1:tN+YAFVlJjHv/znPEhkcSAjmBMkMhslC9XpWKTrKEHw= github.com/projectdiscovery/ratelimit v0.0.92 h1:2h2/AU81Lbn/xevb4KtZCMhaQej+u7W3uLXFUD163iU= github.com/projectdiscovery/ratelimit v0.0.92/go.mod h1:Tqpx1QKdFI63L01T2gpSvV5Uro9CnZkZWr80ciSXSBY= github.com/projectdiscovery/rawhttp v0.1.92 h1:38GRsVLNllES/dZcEcA6s6P+rYBp34H9mDphMJMDvvg= @@ -296,8 +296,8 @@ github.com/projectdiscovery/tlsx v1.4.0 h1:OU2mNK6TOB/YiFX3jhmgwOepFWxPoj5rn8npW github.com/projectdiscovery/tlsx v1.4.0/go.mod h1:WCrUXAHjdKbDNjsu5EbVBS7qvzap3fxi/E85v/Flyx8= github.com/projectdiscovery/useragent v0.0.109 h1:b86BPdZvmgbJousa04jAugDRY4Y+rAvEamE0TFCPmeE= github.com/projectdiscovery/useragent v0.0.109/go.mod h1:sRyBqDAcD31RKYrO8fyXGowLd36fNwY1g0MNW9mp7zk= -github.com/projectdiscovery/utils v0.11.8 h1:oBP3eRG/S/Pdid6CkL58IVyq+KZdWdXohDtEKIKvWag= -github.com/projectdiscovery/utils v0.11.8/go.mod h1:AcQ1n3ETcYj4cHiPXsSaAyFnvN4Bn8UgN5tCmh+7KWM= +github.com/projectdiscovery/utils v0.11.9 h1:aKLje3zPcZuOGmopN51PasWxPX/CypPwzP2ke/Qa2bA= +github.com/projectdiscovery/utils v0.11.9/go.mod h1:d3BcbsHhuxW9T3jpvrgzwshvqhaL9qPSkoi+3h/DY8E= github.com/projectdiscovery/wappalyzergo v0.3.4 h1:yO+xel+C0d/vOFWcUKVZRpIDAfcEmsjpVDjQ0jifAUw= github.com/projectdiscovery/wappalyzergo v0.3.4/go.mod h1:6xTRCUQhX8m+L1eqJDQIALJlRv2Bnhrmku80YU9bBXE= github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=