package main import ( "context" "errors" "net" "net/netip" "testing" ) func testPolicy(answer func(host string) []netip.Addr) *Policy { policy := NewPolicy() policy.lookup = func(ctx context.Context, host string) ([]netip.Addr, error) { return answer(host), nil } return policy } func publicAnswer(string) []netip.Addr { return []netip.Addr{netip.MustParseAddr("93.184.216.34")} } func codeOf(err error) string { var policyErr *PolicyError if errors.As(err, &policyErr) { return policyErr.Code } if err == nil { return "" } return "other:" + err.Error() } func TestCheckTable(t *testing.T) { policy := testPolicy(publicAnswer) cases := []struct { name string url string method string mode string want string }{ {"apex", "https://reddit.com/r/go", "GET", "fetch", ""}, {"subdomain", "https://old.reddit.com/r/go.json", "get", "fetch", ""}, {"short domain", "https://redd.it/abc", "GET", "any", ""}, {"explicit 443", "https://www.reddit.com:443/", "GET", "fetch", ""}, {"plain http 80", "http://reddit.com/", "GET", "fetch", ""}, {"trailing dot", "https://reddit.com./", "GET", "fetch", ""}, {"upper case host", "https://WWW.REDDIT.COM/", "GET", "fetch", ""}, {"prefix attack", "https://evilreddit.com/", "GET", "fetch", RefuseHost}, {"suffix attack", "https://reddit.com.evil.net/", "GET", "fetch", RefuseHost}, {"hyphen attack", "https://not-reddit.com/", "GET", "fetch", RefuseHost}, {"other host", "https://example.com/", "GET", "fetch", RefuseHost}, {"port 8080", "https://reddit.com:8080/", "GET", "fetch", RefusePort}, {"port 22", "http://reddit.com:22/", "GET", "fetch", RefusePort}, {"port 0", "https://reddit.com:0/", "GET", "fetch", RefusePort}, {"ipv4 literal", "http://127.0.0.1/", "GET", "fetch", RefuseHost}, {"ipv4 literal 80", "http://93.184.216.34/", "GET", "fetch", RefuseHost}, {"ipv6 literal", "http://[::1]/", "GET", "fetch", RefuseHost}, {"credentials", "https://user:pw@reddit.com/", "GET", "fetch", RefuseBadURL}, {"scheme ftp", "ftp://reddit.com/", "GET", "fetch", RefuseBadURL}, {"scheme file", "file:///etc/passwd", "GET", "fetch", RefuseBadURL}, {"empty", "", "GET", "fetch", RefuseBadURL}, {"post", "https://reddit.com/", "POST", "fetch", RefuseMethod}, {"delete", "https://reddit.com/", "DELETE", "fetch", RefuseMethod}, {"bad port text", "https://reddit.com:abc/", "GET", "fetch", RefuseBadURL}, } for _, item := range cases { _, err := policy.Check(item.url, item.method, item.mode) if got := codeOf(err); got != item.want { t.Errorf("%s: got %q want %q", item.name, got, item.want) } } long := "https://reddit.com/" + string(make([]byte, MaxURLChars)) if _, err := policy.Check(long, "GET", "fetch"); codeOf(err) != RefuseBadURL { t.Errorf("long url not refused: %v", err) } } func TestCheckHostPortModes(t *testing.T) { policy := testPolicy(publicAnswer) if _, err := policy.CheckHostPort("reddit.com", 443, "tunnel"); err != nil { t.Fatal(err) } if _, err := policy.CheckHostPort("reddit.com", 25, "tunnel"); codeOf(err) != RefusePort { t.Fatalf("port: %v", err) } if _, err := policy.CheckHostPort("evilreddit.com", 443, "tunnel"); codeOf(err) != RefuseHost { t.Fatalf("host: %v", err) } } func TestPublicAddressTable(t *testing.T) { cases := map[string]bool{ "8.8.8.8": true, "93.184.216.34": true, "2606:4700:4700::1111": true, "10.0.0.1": false, "10.255.255.255": false, "127.0.0.1": false, "127.1.2.3": false, "169.254.169.254": false, "172.16.0.1": false, "172.31.255.255": false, "172.32.0.1": true, "192.168.1.1": false, "100.64.0.1": false, "100.127.255.255": false, "100.128.0.1": true, "0.0.0.0": false, "0.1.2.3": false, "224.0.0.1": false, "239.255.255.250": false, "255.255.255.255": false, "240.0.0.1": false, "198.18.0.1": false, "192.0.2.1": false, "::": false, "::1": false, "fe80::1": false, "fc00::1": false, "fd12:3456::1": false, "ff02::1": false, "::ffff:127.0.0.1": false, "::ffff:10.0.0.1": false, "::ffff:8.8.8.8": true, "2001:db8::1": false, "64:ff9b::7f00:1": false, "2002:7f00:1::1": false, "2001::1": false, } for text, want := range cases { if got := IsPublicAddr(netip.MustParseAddr(text)); got != want { t.Errorf("%s: got %v want %v", text, got, want) } } if IsPublicAddr(netip.Addr{}) { t.Error("invalid address counted as public") } if IsPublicAddr(netip.MustParseAddr("fe80::1%eth0")) { t.Error("zoned address counted as public") } } func TestResolveRefusesAnyPrivateAnswer(t *testing.T) { cases := map[string][]string{ "loopback": {"127.0.0.1"}, "private": {"10.1.2.3"}, "mixed": {"93.184.216.34", "192.168.0.1"}, "v6 loopback": {"::1"}, "v6 mapped": {"::ffff:10.0.0.1"}, "cgnat": {"100.64.5.5"}, } for name, list := range cases { addrs := []netip.Addr{} for _, item := range list { addrs = append(addrs, netip.MustParseAddr(item)) } policy := testPolicy(func(string) []netip.Addr { return addrs }) if _, err := policy.Resolve(context.Background(), "reddit.com"); codeOf(err) != RefuseAddress { t.Errorf("%s: %v", name, err) } } empty := testPolicy(func(string) []netip.Addr { return nil }) if _, err := empty.Resolve(context.Background(), "reddit.com"); codeOf(err) != FailDNS { t.Errorf("empty answer: %v", err) } } func TestDialConnectsToValidatedIPAndDefeatsRebinding(t *testing.T) { calls := 0 policy := testPolicy(func(string) []netip.Addr { calls++ if calls == 1 { return []netip.Addr{netip.MustParseAddr("93.184.216.34")} } return []netip.Addr{netip.MustParseAddr("127.0.0.1")} }) var dialed []string policy.dial = func(ctx context.Context, network, address string) (net.Conn, error) { dialed = append(dialed, address) client, server := net.Pipe() server.Close() return client, nil } if _, err := policy.DialContext(context.Background(), "tcp", "www.reddit.com:443"); err != nil { t.Fatal(err) } if len(dialed) != 1 || dialed[0] != "93.184.216.34:443" { t.Fatalf("dialed %v", dialed) } if _, err := policy.DialContext(context.Background(), "tcp", "www.reddit.com:443"); codeOf(err) != RefuseAddress { t.Fatalf("rebinding not refused: %v", err) } if len(dialed) != 1 { t.Fatalf("private address was dialed: %v", dialed) } if _, err := policy.DialContext(context.Background(), "tcp", "evil.com:443"); codeOf(err) != RefuseHost { t.Fatalf("host: %v", err) } if _, err := policy.DialContext(context.Background(), "tcp", "reddit.com:22"); codeOf(err) != RefusePort { t.Fatalf("port: %v", err) } } func TestRedirectLeavingAllowlistRefused(t *testing.T) { policy := testPolicy(publicAnswer) cases := map[string]string{ "https://old.reddit.com/x": "", "https://redd.it/y": "", "https://evil.com/": RefuseRedirect, "https://evilreddit.com/": RefuseRedirect, "http://reddit.com:8080/": RefuseRedirect, "http://127.0.0.1/": RefuseRedirect, "ftp://reddit.com/": RefuseRedirect, "https://reddit.com.evil.io": RefuseRedirect, } for location, want := range cases { _, err := policy.CheckRedirect(location, "GET") if got := codeOf(err); got != want { t.Errorf("%s: got %q want %q", location, got, want) } } if _, err := policy.CheckRedirect("https://reddit.com/", "POST"); codeOf(err) != RefuseRedirect { t.Error("method change through redirect allowed") } } func TestRefineOnlyNarrows(t *testing.T) { policy := testPolicy(publicAnswer) rejected := policy.Refine([]Destination{ {Suffix: "old.reddit.com", Methods: []string{"GET", "POST"}, Ports: []int{80, 443, 8080}, Modes: []string{"fetch", "any"}}, {Suffix: "redd.it", Methods: []string{"GET"}, Ports: []int{443}, Modes: []string{"tunnel", "fetch"}}, {Suffix: "evil.com", Methods: []string{"GET"}, Ports: []int{443}, Modes: []string{"fetch"}}, {Suffix: "evilreddit.com", Methods: []string{"GET"}, Ports: []int{443}, Modes: []string{"fetch"}}, {Suffix: "old.reddit.com", Methods: []string{"GET"}, Ports: []int{443}, Modes: []string{"fetch"}}, }) if len(rejected) != 3 { t.Fatalf("rejected %v", rejected) } if _, err := policy.Check("https://old.reddit.com/", "GET", "fetch"); err != nil { t.Fatal(err) } if _, err := policy.Check("https://old.reddit.com/", "POST", "fetch"); codeOf(err) != RefuseMethod { t.Errorf("post widened: %v", err) } if _, err := policy.Check("http://old.reddit.com:8080/", "GET", "fetch"); codeOf(err) != RefusePort { t.Errorf("port widened: %v", err) } if _, err := policy.Check("https://www.reddit.com/", "GET", "fetch"); codeOf(err) != RefuseHost { t.Errorf("dropped destination still allowed: %v", err) } if _, err := policy.Check("http://redd.it/a", "GET", "fetch"); codeOf(err) != RefusePort { t.Errorf("redd.it port 80 should be gone: %v", err) } policy.Refine(nil) if _, err := policy.Check("https://redd.it/a", "GET", "fetch"); codeOf(err) != RefuseHost { t.Errorf("empty refinement must allow nothing: %v", err) } }