|
| 1 | +package httpguard |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "net" |
| 6 | + "net/http" |
| 7 | + "net/http/httptest" |
| 8 | + "strings" |
| 9 | + "testing" |
| 10 | + "time" |
| 11 | +) |
| 12 | + |
| 13 | +// resolveTo builds a client whose dialer is the guarded one but whose DNS |
| 14 | +// answers are forced to target, simulating a participant who registered a |
| 15 | +// hostname resolving (or rebinding) to an address of their choosing. |
| 16 | +func resolveTo(t *testing.T, target string) *http.Client { |
| 17 | + t.Helper() |
| 18 | + client := NewNoRedirectClient(5 * time.Second) |
| 19 | + transport := client.Transport.(*http.Transport) |
| 20 | + dialer := NewDialer() |
| 21 | + transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { |
| 22 | + _, port, err := net.SplitHostPort(addr) |
| 23 | + if err != nil { |
| 24 | + return nil, err |
| 25 | + } |
| 26 | + return dialer.DialContext(ctx, network, net.JoinHostPort(target, port)) |
| 27 | + } |
| 28 | + return client |
| 29 | +} |
| 30 | + |
| 31 | +func requireBlocked(t *testing.T, err error) { |
| 32 | + t.Helper() |
| 33 | + if err == nil { |
| 34 | + t.Fatal("expected the dial to be blocked, got nil error") |
| 35 | + } |
| 36 | + if !strings.Contains(err.Error(), "ssrf guard") { |
| 37 | + t.Fatalf("expected an ssrf guard error, got %v", err) |
| 38 | + } |
| 39 | +} |
| 40 | + |
| 41 | +func TestDialControlBlocksPrivateTargets(t *testing.T) { |
| 42 | + SetAllowPrivate(false) |
| 43 | + |
| 44 | + // Each case is a hostname that resolves to a private target: the literal |
| 45 | + // forms an attacker can register plus the ones a naive string check misses. |
| 46 | + cases := map[string]string{ |
| 47 | + "loopback": "127.0.0.1", |
| 48 | + "loopback_upper_8": "127.5.6.7", |
| 49 | + "cloud_metadata": "169.254.169.254", |
| 50 | + "rfc1918_10": "10.0.0.1", |
| 51 | + "rfc1918_172": "172.16.0.1", |
| 52 | + "rfc1918_192": "192.168.1.1", |
| 53 | + "unspecified": "0.0.0.0", |
| 54 | + "ipv6_loopback": "::1", |
| 55 | + "ipv6_link_local": "fe80::1", |
| 56 | + "ipv6_ula": "fc00::1", |
| 57 | + "ipv4_mapped_v6": "::ffff:10.0.0.1", |
| 58 | + "ipv4_mapped_meta_v6": "::ffff:169.254.169.254", |
| 59 | + } |
| 60 | + |
| 61 | + for name, target := range cases { |
| 62 | + t.Run(name, func(t *testing.T) { |
| 63 | + client := resolveTo(t, target) |
| 64 | + _, err := client.Get("http://ssrf.attacker.tld/") |
| 65 | + requireBlocked(t, err) |
| 66 | + }) |
| 67 | + } |
| 68 | +} |
| 69 | + |
| 70 | +// A hostname that resolves to loopback is the core of issue #1470: the on-chain |
| 71 | +// registration gate cannot reject it (no DNS in ValidateBasic), so the dial is |
| 72 | +// where it must fail. Uses a real server to prove the request never lands. |
| 73 | +func TestGuardBlocksHostnameResolvingToLoopback(t *testing.T) { |
| 74 | + SetAllowPrivate(false) |
| 75 | + |
| 76 | + var reached bool |
| 77 | + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 78 | + reached = true |
| 79 | + w.WriteHeader(http.StatusOK) |
| 80 | + })) |
| 81 | + t.Cleanup(server.Close) |
| 82 | + |
| 83 | + _, port, err := net.SplitHostPort(strings.TrimPrefix(server.URL, "http://")) |
| 84 | + if err != nil { |
| 85 | + t.Fatal(err) |
| 86 | + } |
| 87 | + |
| 88 | + client := resolveTo(t, "127.0.0.1") |
| 89 | + _, err = client.Get("http://ssrf.attacker.tld:" + port + "/") |
| 90 | + requireBlocked(t, err) |
| 91 | + if reached { |
| 92 | + t.Fatal("guard let the request through to the loopback server") |
| 93 | + } |
| 94 | +} |
| 95 | + |
| 96 | +// Decimal and hex host forms are alternate spellings of 127.0.0.1. The guard |
| 97 | +// checks the resolved IP, so the spelling is irrelevant -- this pins that. |
| 98 | +func TestGuardBlocksNumericLoopbackSpellings(t *testing.T) { |
| 99 | + SetAllowPrivate(false) |
| 100 | + |
| 101 | + for _, raw := range []string{"http://2130706433/", "http://0x7f000001/", "http://127.1/"} { |
| 102 | + t.Run(raw, func(t *testing.T) { |
| 103 | + client := NewNoRedirectClient(5 * time.Second) |
| 104 | + _, err := client.Get(raw) |
| 105 | + requireBlocked(t, err) |
| 106 | + }) |
| 107 | + } |
| 108 | +} |
| 109 | + |
| 110 | +// A public host answering 302 -> 127.0.0.1 must not reach the private target. |
| 111 | +// The redirect is refused outright; had it been followed, the new hop's dial |
| 112 | +// would hit the guard too. |
| 113 | +func TestNoRedirectClientDoesNotFollowRedirectToPrivate(t *testing.T) { |
| 114 | + SetAllowPrivate(true) // let the public-side server on loopback be reachable |
| 115 | + |
| 116 | + var privateReached bool |
| 117 | + private := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 118 | + privateReached = true |
| 119 | + })) |
| 120 | + t.Cleanup(private.Close) |
| 121 | + |
| 122 | + public := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 123 | + http.Redirect(w, r, private.URL, http.StatusFound) |
| 124 | + })) |
| 125 | + t.Cleanup(public.Close) |
| 126 | + |
| 127 | + client := NewNoRedirectClient(5 * time.Second) |
| 128 | + resp, err := client.Get(public.URL) |
| 129 | + if err != nil { |
| 130 | + t.Fatalf("expected the 3xx to surface as a response, got %v", err) |
| 131 | + } |
| 132 | + defer resp.Body.Close() |
| 133 | + |
| 134 | + if resp.StatusCode != http.StatusFound { |
| 135 | + t.Fatalf("expected the caller to observe 302, got %d", resp.StatusCode) |
| 136 | + } |
| 137 | + if privateReached { |
| 138 | + t.Fatal("client followed the redirect into the private target") |
| 139 | + } |
| 140 | +} |
| 141 | + |
| 142 | +// Dev/test environments register docker-internal hostnames that resolve to |
| 143 | +// private IPs, so the opt-out has to actually let them through. |
| 144 | +func TestAllowPrivateLetsPrivateTargetsThrough(t *testing.T) { |
| 145 | + SetAllowPrivate(true) |
| 146 | + t.Cleanup(func() { SetAllowPrivate(false) }) |
| 147 | + |
| 148 | + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 149 | + w.WriteHeader(http.StatusOK) |
| 150 | + })) |
| 151 | + t.Cleanup(server.Close) |
| 152 | + |
| 153 | + client := NewNoRedirectClient(5 * time.Second) |
| 154 | + resp, err := client.Get(server.URL) |
| 155 | + if err != nil { |
| 156 | + t.Fatalf("allowPrivate should permit the loopback dial, got %v", err) |
| 157 | + } |
| 158 | + defer resp.Body.Close() |
| 159 | + if resp.StatusCode != http.StatusOK { |
| 160 | + t.Fatalf("expected 200, got %d", resp.StatusCode) |
| 161 | + } |
| 162 | +} |
| 163 | + |
| 164 | +func TestPublicAddressAllowedInBothModes(t *testing.T) { |
| 165 | + for _, allow := range []bool{false, true} { |
| 166 | + SetAllowPrivate(allow) |
| 167 | + if err := DialControl("tcp", "93.184.216.34:80", nil); err != nil { |
| 168 | + t.Fatalf("public address rejected with allowPrivate=%v: %v", allow, err) |
| 169 | + } |
| 170 | + } |
| 171 | + SetAllowPrivate(false) |
| 172 | +} |
| 173 | + |
| 174 | +func TestDialControlFailsClosedOnMalformedAddress(t *testing.T) { |
| 175 | + SetAllowPrivate(false) |
| 176 | + requireBlocked(t, DialControl("tcp", "not-an-address", nil)) |
| 177 | + requireBlocked(t, DialControl("tcp", "still.a.hostname:80", nil)) |
| 178 | +} |
0 commit comments