package weed_server import ( "context" "errors" "net" "strings" "sync/atomic" "testing" "github.com/seaweedfs/seaweedfs/weed/pb/remote_pb" s3remote "github.com/seaweedfs/seaweedfs/weed/remote_storage/s3" ) // stubLookup returns a resolver func that maps the supplied hostnames to // the supplied IP addresses, and errors for any host that is not in the map. func stubLookup(t *testing.T, mapping map[string][]net.IP) func(ctx context.Context, host string) ([]net.IPAddr, error) { t.Helper() return func(_ context.Context, host string) ([]net.IPAddr, error) { ips, ok := mapping[host] if !ok { return nil, &net.DNSError{Err: "no such host", Name: host, IsNotFound: true} } out := make([]net.IPAddr, 0, len(ips)) for _, ip := range ips { out = append(out, net.IPAddr{IP: ip}) } return out, nil } } func TestValidateRemoteEndpoint(t *testing.T) { originalLookup := lookupIPAddrFunc t.Cleanup(func() { lookupIPAddrFunc = originalLookup }) lookupIPAddrFunc = stubLookup(t, map[string][]net.IP{ "s3.us-east-1.amazonaws.com": {net.ParseIP("52.216.10.10")}, "internal.example.com": {net.ParseIP("127.0.0.1")}, "linklocal.example.com": {net.ParseIP("169.254.10.20")}, "private.example.com": {net.ParseIP("10.1.2.3")}, "private172.example.com": {net.ParseIP("172.20.0.5")}, "private192.example.com": {net.ParseIP("192.168.1.1")}, "cgnat.example.com": {net.ParseIP("100.64.0.42")}, }) cases := []struct { name string endpoint string wantErr bool wantSub string }{ { name: "empty", endpoint: "", wantErr: true, wantSub: "empty", }, { name: "loopback literal", endpoint: "http://127.0.0.1:8080", wantErr: true, wantSub: "loopback", }, { name: "ipv6 loopback", endpoint: "http://[::1]:8080", wantErr: true, wantSub: "loopback", }, { name: "imds ipv4", endpoint: "http://169.254.169.254/", wantErr: true, wantSub: "metadata", }, { name: "unspecified ipv4", endpoint: "http://0.0.0.0/", wantErr: true, wantSub: "unspecified", }, { name: "link-local ipv6", endpoint: "http://[fe80::1]/", wantErr: true, wantSub: "link-local", }, { name: "ftp scheme", endpoint: "ftp://example.com/", wantErr: true, wantSub: "http or https", }, { name: "missing scheme", endpoint: "example.com/", wantErr: true, wantSub: "http or https", }, { name: "imds hostname", endpoint: "http://metadata.google.internal/", wantErr: true, wantSub: "metadata service", }, { name: "imds short hostname", endpoint: "http://metadata/", wantErr: true, wantSub: "metadata service", }, { name: "host resolves to loopback", endpoint: "https://internal.example.com/", wantErr: true, wantSub: "loopback", }, { name: "host resolves to link-local", endpoint: "https://linklocal.example.com/", wantErr: true, wantSub: "link-local", }, { name: "rfc1918 10/8 literal", endpoint: "http://10.0.0.1/", wantErr: true, wantSub: "private", }, { name: "rfc1918 172.16/12 literal", endpoint: "http://172.16.5.5/", wantErr: true, wantSub: "private", }, { name: "rfc1918 192.168/16 literal", endpoint: "http://192.168.0.1/", wantErr: true, wantSub: "private", }, { name: "cgnat literal", endpoint: "http://100.64.0.1/", wantErr: true, wantSub: "CGNAT", }, { name: "host resolves to rfc1918 10/8", endpoint: "https://private.example.com/", wantErr: true, wantSub: "private", }, { name: "host resolves to rfc1918 172/12", endpoint: "https://private172.example.com/", wantErr: true, wantSub: "private", }, { name: "host resolves to rfc1918 192.168/16", endpoint: "https://private192.example.com/", wantErr: true, wantSub: "private", }, { name: "host resolves to cgnat", endpoint: "https://cgnat.example.com/", wantErr: true, wantSub: "CGNAT", }, { name: "nat64 imds", endpoint: "http://[64:ff9b::a9fe:a9fe]/", wantErr: true, wantSub: "metadata", }, { name: "nat64 loopback", endpoint: "http://[64:ff9b::7f00:1]/", wantErr: true, wantSub: "loopback", }, { name: "6to4 private", endpoint: "http://[2002:a00:1::]/", wantErr: true, wantSub: "private", }, { name: "teredo loopback", endpoint: "http://[2001:0:4136:e378:8000:63bf:80ff:fffe]/", wantErr: true, wantSub: "loopback", }, { name: "ipv4-compatible loopback", endpoint: "http://[::7f00:1]/", wantErr: true, wantSub: "loopback", }, { name: "nat64 public passes", endpoint: "http://[64:ff9b::808:808]/", wantErr: false, }, { name: "6to4 public passes", endpoint: "http://[2002:808:808::]/", wantErr: false, }, { name: "teredo public passes", endpoint: "http://[2001::f7f7:f7f7]/", wantErr: false, }, { name: "ipv4-compatible public passes", endpoint: "http://[::808:808]/", wantErr: false, }, { name: "nat64 non-wellknown-prefix not decoded", endpoint: "http://[64:ff9b:1::a9fe:a9fe]/", wantErr: false, }, { name: "public s3", endpoint: "https://s3.us-east-1.amazonaws.com/", wantErr: false, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { err := validateRemoteEndpoint(context.Background(), tc.endpoint) if tc.wantErr { if err == nil { t.Fatalf("expected error for %q, got nil", tc.endpoint) } if tc.wantSub != "" && !strings.Contains(err.Error(), tc.wantSub) { t.Fatalf("expected error to contain %q, got %v", tc.wantSub, err) } return } if err != nil { t.Fatalf("unexpected error for %q: %v", tc.endpoint, err) } }) } } func TestValidateRemoteEndpointResolverFailure(t *testing.T) { originalLookup := lookupIPAddrFunc t.Cleanup(func() { lookupIPAddrFunc = originalLookup }) resolveErr := errors.New("simulated DNS failure") lookupIPAddrFunc = func(_ context.Context, _ string) ([]net.IPAddr, error) { return nil, resolveErr } err := validateRemoteEndpoint(context.Background(), "https://does-not-resolve.example.com/") if err == nil { t.Fatal("expected error when resolver fails") } if !strings.Contains(err.Error(), "resolve remote endpoint host") { t.Fatalf("expected resolver error wrapping, got %v", err) } } // TestGuardedDialerRebind simulates a DNS rebinding attack: the host first // resolves to a public address (passing validateRemoteEndpoint) and then // flips to 127.0.0.1 on the very next lookup (what the AWS SDK would do at // dial time). The dial path must refuse the loopback answer instead of // connecting to it. func TestGuardedDialerRebind(t *testing.T) { originalLookup := lookupIPAddrFunc t.Cleanup(func() { lookupIPAddrFunc = originalLookup }) const host = "rebind.example.com" endpoint := "https://" + host + "/" var calls atomic.Int32 lookupIPAddrFunc = func(_ context.Context, name string) ([]net.IPAddr, error) { if name != host { return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true} } if calls.Add(1) == 1 { return []net.IPAddr{{IP: net.ParseIP("52.216.10.10")}}, nil } return []net.IPAddr{{IP: net.ParseIP("127.0.0.1")}}, nil } if err := validateRemoteEndpoint(context.Background(), endpoint); err != nil { t.Fatalf("first-pass validation should accept public IP, got %v", err) } dial := guardedDialer(endpoint) conn, err := dial(context.Background(), "tcp", host+":443") if conn != nil { conn.Close() t.Fatalf("guarded dialer must refuse loopback rebind, got conn") } if err == nil || !strings.Contains(err.Error(), "loopback") { t.Fatalf("guarded dialer should fail with loopback error, got %v", err) } } // TestRemoteEndpointGuardCoversS3CompatibleSiblings confirms the SSRF guard // reaches every S3-SDK-backed provider, not just type "s3". It replays the two // steps FetchAndWriteNeedle performs before building the client: resolve the // endpoint the type would dial, then validate it against the deny-list. A // sibling type pointed at an internal address must be rejected. func TestRemoteEndpointGuardCoversS3CompatibleSiblings(t *testing.T) { cases := []struct { conf *remote_pb.RemoteConf wantSub string }{ {&remote_pb.RemoteConf{Type: "wasabi", WasabiEndpoint: "http://169.254.169.254/"}, "metadata"}, {&remote_pb.RemoteConf{Type: "b2", BackblazeEndpoint: "http://127.0.0.1/"}, "loopback"}, {&remote_pb.RemoteConf{Type: "aliyun", AliyunEndpoint: "http://192.168.0.1/"}, "private"}, {&remote_pb.RemoteConf{Type: "tencent", TencentEndpoint: "http://100.64.0.1/"}, "CGNAT"}, {&remote_pb.RemoteConf{Type: "baidu", BaiduEndpoint: "http://169.254.169.254/"}, "metadata"}, {&remote_pb.RemoteConf{Type: "filebase", FilebaseEndpoint: "http://172.16.0.1/"}, "private"}, {&remote_pb.RemoteConf{Type: "storj", StorjEndpoint: "http://10.0.0.5/"}, "private"}, {&remote_pb.RemoteConf{Type: "contabo", ContaboEndpoint: "http://[::1]/"}, "loopback"}, } for _, tc := range cases { endpoint, ok := s3remote.S3CompatibleEndpoint(tc.conf) if !ok { t.Errorf("type %q: not recognized as S3-compatible, guard would be skipped", tc.conf.Type) continue } err := validateRemoteEndpoint(context.Background(), endpoint) if err == nil { t.Errorf("type %q: expected endpoint %q to be rejected", tc.conf.Type, endpoint) continue } if !strings.Contains(err.Error(), tc.wantSub) { t.Errorf("type %q: error %q missing %q", tc.conf.Type, err, tc.wantSub) } } } // TestGuardedDialerLiteralBlocked confirms that a literal blocked IP target // is refused without any DNS lookup. func TestGuardedDialerLiteralBlocked(t *testing.T) { originalLookup := lookupIPAddrFunc t.Cleanup(func() { lookupIPAddrFunc = originalLookup }) lookupIPAddrFunc = func(_ context.Context, name string) ([]net.IPAddr, error) { t.Fatalf("resolver should not be called for IP literal target, got lookup of %q", name) return nil, nil } dial := guardedDialer("http://10.0.0.5:80") conn, err := dial(context.Background(), "tcp", "10.0.0.5:80") if conn != nil { conn.Close() t.Fatalf("guarded dialer must refuse rfc1918 literal, got conn") } if err == nil || !strings.Contains(err.Error(), "private") { t.Fatalf("guarded dialer should fail with private-address error, got %v", err) } }