From 2d30ab3ada5c771f67048884d3afa916bddca53a Mon Sep 17 00:00:00 2001 From: Sanaei Date: Mon, 24 Aug 2026 13:27:40 +0200 Subject: [PATCH] fix(panel): stop one poisoned DNS answer from blocking outbound tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SanitizePublicHTTPURL rejected a hostname as soon as any single resolved address was blocked, so a resolver returning a bogon AAAA for the test URL host (e.g. 2001::1 for www.google.com, inside the Teredo range blocked since b51f0976) failed the outbound Check button outright — including TCP mode, which never uses the test URL. Mirror SSRFGuardedDialContext instead: one usable address is enough, because the guarded dialer skips blocked answers at connect time; a hostname with nothing usable is still refused. Closes #6290 --- internal/web/service/url_safety.go | 12 ++++++--- internal/web/service/url_safety_test.go | 36 +++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 internal/web/service/url_safety_test.go diff --git a/internal/web/service/url_safety.go b/internal/web/service/url_safety.go index b53e605b8..950d25d1b 100644 --- a/internal/web/service/url_safety.go +++ b/internal/web/service/url_safety.go @@ -72,15 +72,21 @@ func rejectPrivateHost(ctx context.Context, hostname string) error { if err != nil { return fmt.Errorf("cannot resolve host %s: %w", hostname, err) } + return rejectAllBlockedIPs(hostname, ips) +} + +// One usable address is enough — SSRFGuardedDialContext skips blocked ones at +// dial time, so a poisoned AAAA answer must not veto a healthy hostname. +func rejectAllBlockedIPs(hostname string, ips []net.IPAddr) error { if len(ips) == 0 { return fmt.Errorf("host %s has no IP addresses", hostname) } for _, ipAddr := range ips { - if isBlockedIP(ipAddr.IP) { - return fmt.Errorf("host %s resolves to blocked private/internal address %s", hostname, ipAddr.IP.String()) + if !isBlockedIP(ipAddr.IP) { + return nil } } - return nil + return fmt.Errorf("host %s resolves to blocked private/internal address %s", hostname, ips[0].IP.String()) } func isBlockedIP(ip net.IP) bool { diff --git a/internal/web/service/url_safety_test.go b/internal/web/service/url_safety_test.go new file mode 100644 index 000000000..74a8d2781 --- /dev/null +++ b/internal/web/service/url_safety_test.go @@ -0,0 +1,36 @@ +package service + +import ( + "net" + "testing" +) + +func TestRejectAllBlockedIPsNeedsOnlyOneUsableAddress(t *testing.T) { + teredo := net.IPAddr{IP: net.ParseIP("2001::1")} + public4 := net.IPAddr{IP: net.ParseIP("142.250.74.36")} + private4 := net.IPAddr{IP: net.ParseIP("10.0.0.1")} + + cases := []struct { + name string + ips []net.IPAddr + wantErr string + }{ + {"poisoned AAAA next to healthy A", []net.IPAddr{teredo, public4}, ""}, + {"all blocked", []net.IPAddr{teredo, private4}, "host h.example resolves to blocked private/internal address 2001::1"}, + {"no addresses", nil, "host h.example has no IP addresses"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := rejectAllBlockedIPs("h.example", tc.ips) + if tc.wantErr == "" { + if err != nil { + t.Fatalf("rejectAllBlockedIPs() = %v, want nil", err) + } + return + } + if err == nil || err.Error() != tc.wantErr { + t.Fatalf("rejectAllBlockedIPs() = %v, want %q", err, tc.wantErr) + } + }) + } +}