mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-16 19:40:43 +02:00
* filer: add guarded remote-storage client builder hook for lazy fetch The lazy-remote fetch path (maybeLazyFetchFromRemote) resolved its remote-storage client through the unguarded shared cache, bypassing the SSRF chokepoint (BuildGuardedRemoteStorageClient) that the CVE-2026-73080 remediation wired into the volume, filer stream and s3 stream dial paths. Add a RemoteStorageClientBuilder hook on Filer plus conf-only lookups on FilerRemoteStorage, and route the lazy fetch through the builder when set (endpoint deny-list + DNS-rebinding-safe dialer), falling back to the shared cache otherwise. The filer server wires the builder in a follow-up. * filer: route lazy directory listing through the guarded remote client maybeLazyListFromRemote shared the unguarded client resolution of the fetch path, so a caller-supplied remote endpoint was dialed without the SSRF deny-list or rebinding-safe dialer. Resolve the conf and build the client through buildRemoteStorageClient so the same guard covers listing. * filer: route lazy remote delete through the guarded remote client maybeDeleteFromRemote issued outbound DELETE/RemoveDirectory requests through the unguarded client, giving a write-side SSRF to a caller-chosen endpoint. Resolve the conf and build the client through buildRemoteStorageClient so the endpoint deny-list and rebinding-safe dialer apply to the delete path as well. * filer server: wire the guarded remote client builder into the filer Set Filer.BuildGuardedRemoteClient to BuildGuardedRemoteStorageClient and forward AllowUntrustedRemoteEndpoints so the lazy-remote fetch, list and delete paths apply the same SSRF endpoint checks as the volume and streaming read paths. * filer: test lazy fetch honors the guarded remote client builder Add a regression test that sets BuildGuardedRemoteClient to a rejecting builder and asserts maybeLazyFetchFromRemote returns no entry without reaching the remote, covering the SSRF guard wired in the prior commits. * filer: skip remote client for local-only lazy deletes maybeDeleteFromRemote resolved and validated the mount's remote client before checking entry.Remote, so a local-only file (no Remote entry) under a mount whose endpoint the guard rejects failed to delete: the guard error aborted the metadata deletion, leaving a file that needs no remote operation undeletable. Move the local-only check ahead of client construction so only remote-backed files and directories pay the guard. * filer: build the guarded remote client inside the lazy singleflight The lazy fetch and list paths built the guarded client before their singleflight blocks, so concurrent requests for the same key each allocated a fresh SDK client and HTTP transport even though only one remote operation ran. Move client construction inside the singleflight so the deduplicated operation builds it once, matching the per-request guard semantics of the sibling streaming paths without the duplicate transport churn. * filer: test guarded rejection for the lazy list and delete paths Add regression tests that set BuildGuardedRemoteClient to a rejecting builder and assert the lazy list does not reach the remote, a remote-backed file delete is blocked, and a local-only file under a rejected mount still deletes (covering the local-only fix). * filer: decouple lazy guarded-client build from the first caller's context Building the guarded client inside the singleflight made concurrent fetches share the first caller's context. If that caller canceled while endpoint DNS validation was running, the builder returned an error and published a not-found result to other callers whose contexts were still valid. Build with context.WithoutCancel so the guard's DNS validation is not tied to any single caller's cancellation, matching the list path's existing decoupling for the remote operation itself. * filer: reject remote-storage confs that dial blocked endpoints at load The filer's lazy-fetch / lazy-list / remote-delete paths resolve remote storage clients by name from FilerRemoteStorage.storageNameToConf and dial them via remote_storage.GetRemoteStorage, which bypasses the SSRF deny-list the volume server (BuildGuardedRemoteStorageClient) and the filer's own direct-read path apply. A RemoteConf planted under /etc/remote with a loopback / private / IMDS S3 endpoint is reloaded into storageNameToConf on the next metadata-change event and then dialed on the next cache miss — server-side request forgery from the filer. Apply the volume server's SSRF deny-list at conf load time, the single chokepoint that populates storageNameToConf: - Add RemoteStorageConfValidator, injected into FilerRemoteStorage by the filer server (the filer package cannot import the server package). A conf that fails validation is dropped from storageNameToConf, so the name-based client resolution on the lazy paths returns "not found" instead of dialing the blocked endpoint. - Add ValidateRemoteConfForLoad in weed_server, which mirrors BuildGuardedRemoteStorageClient's gcs credential + endpoint checks (validateRemoteEndpoint via guardedRemoteClient) without building a client. allowUntrusted skips the check, mirroring the volume server opt-out (-filer.allowUntrustedRemoteEndpoints). - The filer server injects the validator at construction. A conf whose type dials a fixed provider host (no caller-supplied endpoint) passes; only caller-influenced endpoints are denied. * filer: skip DNS resolution in the load-time SSRF validator ValidateRemoteConfForLoad resolved hostnames during /etc/remote reload, so a transient DNS failure (2s timeout) dropped the conf from the fresh map that replaces the live map, disabling a working mount until the next metadata event. The build-time guard (BuildGuardedRemoteStorageClient) already re-resolves and re-validates the endpoint at dial time with the rebinding-safe dialer, so DNS at load is redundant for security. Split the static checks (scheme, IMDS hostnames, IP-literal blocked addresses, gcs credentials) into validateRemoteEndpointForLoad, which does no DNS. Hostname endpoints pass at load and are caught at dial if they resolve to a blocked address. This preserves fail-fast for statically-blocked confs (loopback IPs, IMDS hostnames) without letting transient DNS failures disable mounts. * filer: accept empty S3 endpoints in the guarded remote client builder guardedRemoteClient returned ok=true with an empty endpoint for a standard AWS S3 config (no custom S3Endpoint), so BuildGuardedRemoteStorageClient and ValidateRemoteConfForLoad rejected it with "remote endpoint is empty" — breaking standard AWS S3 mounts on the lazy paths and the sibling streaming read paths that already use the guarded builder. An empty endpoint is not caller-supplied: the AWS SDK derives the regional endpoint from the region, so there is nothing for the SSRF guard to validate. Return ok=false for empty S3-compatible endpoints so the builder falls through to the shared unguarded cache, matching the historical behavior for standard AWS S3.
782 lines
28 KiB
Go
782 lines
28 KiB
Go
package weed_server
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRemoteEndpointGuardCoversAzure confirms the SSRF guard reaches the azure
|
|
// backend, which dials a caller-supplied AzureEndpoint. It replays the two
|
|
// steps FetchAndWriteNeedle performs before building the client: resolve the
|
|
// endpoint the type would dial via guardedRemoteClient, then validate it. An
|
|
// azure conf pointed at an internal address must be rejected.
|
|
func TestRemoteEndpointGuardCoversAzure(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
conf *remote_pb.RemoteConf
|
|
wantSub string
|
|
}{
|
|
{"imds", &remote_pb.RemoteConf{Type: "azure", AzureEndpoint: "https://169.254.169.254/"}, "metadata"},
|
|
{"loopback", &remote_pb.RemoteConf{Type: "azure", AzureEndpoint: "https://127.0.0.1/"}, "loopback"},
|
|
{"private", &remote_pb.RemoteConf{Type: "azure", AzureEndpoint: "https://10.0.0.5/"}, "private"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
endpoint, _, ok := guardedRemoteClient(tc.conf)
|
|
if !ok {
|
|
t.Fatalf("azure endpoint %q not guarded, the SSRF check would be skipped", tc.conf.AzureEndpoint)
|
|
}
|
|
err := validateRemoteEndpoint(context.Background(), endpoint)
|
|
if err == nil {
|
|
t.Fatalf("expected endpoint %q to be rejected", endpoint)
|
|
}
|
|
if !strings.Contains(err.Error(), tc.wantSub) {
|
|
t.Fatalf("error %q missing %q", err, tc.wantSub)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestValidateReplicaTarget covers the replica upload leg of
|
|
// FetchAndWriteNeedle. Replica targets are peer volume servers, so unlike the
|
|
// remote endpoint they may sit on a private network; the guard still rejects
|
|
// loopback / link-local / unspecified hosts and any target that is not a bare
|
|
// host:port, since a scheme, path or query would move the upload to a different
|
|
// URL through the format string.
|
|
func TestValidateReplicaTarget(t *testing.T) {
|
|
originalLookup := lookupIPAddrFunc
|
|
t.Cleanup(func() { lookupIPAddrFunc = originalLookup })
|
|
|
|
lookupIPAddrFunc = stubLookup(t, map[string][]net.IP{
|
|
"peer.example.com": {net.ParseIP("10.0.0.7")},
|
|
"loop.example.com": {net.ParseIP("127.0.0.1")},
|
|
"linklocal.example.com": {net.ParseIP("169.254.169.254")},
|
|
})
|
|
|
|
cases := []struct {
|
|
name string
|
|
target string
|
|
wantErr bool
|
|
wantSub string
|
|
}{
|
|
// A path plus a trailing "?a=" would otherwise swallow ?type=replicate.
|
|
{"embedded path and query", "127.0.0.1:7000/status/x/?a=", true, "bare host:port"},
|
|
{"loopback literal", "127.0.0.1:8080", true, "loopback"},
|
|
{"ipv6 loopback", "[::1]:8080", true, "loopback"},
|
|
{"metadata literal", "169.254.169.254:80", true, "metadata"},
|
|
{"unspecified", "0.0.0.0:8080", true, "unspecified"},
|
|
{"metadata hostname", "metadata:80", true, "metadata"},
|
|
{"scheme rejected", "http://10.0.0.7:8080", true, "bare host:port"},
|
|
{"path rejected", "10.0.0.7:8080/x", true, "bare host:port"},
|
|
{"query rejected", "10.0.0.7:8080?a=b", true, "bare host:port"},
|
|
{"userinfo rejected", "user@10.0.0.7:8080", true, "bare host:port"},
|
|
{"missing port literal", "10.0.0.7", true, "bare host:port"},
|
|
{"missing port hostname", "peer.example.com", true, "bare host:port"},
|
|
{"empty", "", true, "empty"},
|
|
{"resolves to loopback", "loop.example.com:8080", true, "loopback"},
|
|
{"resolves to link-local", "linklocal.example.com:8080", true, "metadata"},
|
|
// Legitimate peer volume servers on private networks must pass.
|
|
{"private peer literal", "10.0.0.7:8080", false, ""},
|
|
{"private 192 peer", "192.168.1.5:8080", false, ""},
|
|
{"private peer hostname", "peer.example.com:8080", false, ""},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
err := validateReplicaTarget(context.Background(), tc.target)
|
|
if tc.wantErr {
|
|
if err == nil {
|
|
t.Fatalf("expected error for %q, got nil", tc.target)
|
|
}
|
|
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.target, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGuardedRemoteClientSkipsFixedHostBackends confirms backends that only
|
|
// reach a fixed provider host bypass the endpoint guard: azure with no explicit
|
|
// endpoint (public cloud, host derived from the account) and unrelated types.
|
|
func TestGuardedRemoteClientSkipsFixedHostBackends(t *testing.T) {
|
|
for _, conf := range []*remote_pb.RemoteConf{
|
|
{Type: "azure", AzureAccountName: "acct"},
|
|
{Type: "gcs"},
|
|
nil,
|
|
} {
|
|
if _, _, ok := guardedRemoteClient(conf); ok {
|
|
t.Errorf("conf %+v should not be guarded", conf)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestGuardedRemoteClientAzureBuildsGuardedClient exercises the whole azure
|
|
// path: a public endpoint passes validation and the constructor builds a client
|
|
// through the guarded HTTP transport.
|
|
func TestGuardedRemoteClientAzureBuildsGuardedClient(t *testing.T) {
|
|
conf := &remote_pb.RemoteConf{
|
|
Type: "azure",
|
|
AzureAccountName: "testaccount",
|
|
AzureAccountKey: "aW52YWxpZGtleQ==",
|
|
AzureEndpoint: "https://testaccount.blob.core.usgovcloudapi.net/",
|
|
}
|
|
endpoint, makeClient, ok := guardedRemoteClient(conf)
|
|
if !ok {
|
|
t.Fatal("azure with an endpoint should be guarded")
|
|
}
|
|
client, err := makeClient(newGuardedHTTPClient(endpoint))
|
|
if err != nil {
|
|
t.Fatalf("build guarded azure client: %v", err)
|
|
}
|
|
if client == nil {
|
|
t.Fatal("expected a client")
|
|
}
|
|
}
|
|
|
|
// TestGcsCredentialsArePath confirms a caller-supplied gcs credentials value is
|
|
// only accepted as inline JSON. A filesystem path would otherwise be read from
|
|
// disk by the SDK when handling the request.
|
|
func TestGcsCredentialsArePath(t *testing.T) {
|
|
paths := []string{
|
|
"/etc/hostname",
|
|
"/etc/shadow",
|
|
"/nope/nothere",
|
|
"~/creds.json",
|
|
"relative/creds.json",
|
|
}
|
|
for _, p := range paths {
|
|
if !gcsCredentialsArePath(p) {
|
|
t.Errorf("expected %q to be treated as a path", p)
|
|
}
|
|
}
|
|
inlineOrEmpty := []string{
|
|
"",
|
|
`{"type":"service_account"}`,
|
|
`{}`,
|
|
}
|
|
for _, c := range inlineOrEmpty {
|
|
if gcsCredentialsArePath(c) {
|
|
t.Errorf("expected %q to be accepted (inline or empty)", c)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestGuardedRemoteClientGuardsGcsTokenURL confirms the token endpoint named by
|
|
// inline gcs credentials is the endpoint the guard validates, so a loopback
|
|
// token_uri is refused while the Google default passes.
|
|
func TestGuardedRemoteClientGuardsGcsTokenURL(t *testing.T) {
|
|
originalLookup := lookupIPAddrFunc
|
|
t.Cleanup(func() { lookupIPAddrFunc = originalLookup })
|
|
lookupIPAddrFunc = stubLookup(t, map[string][]net.IP{
|
|
"oauth2.googleapis.com": {net.ParseIP("142.250.72.10")},
|
|
})
|
|
|
|
endpoint, makeClient, ok := guardedRemoteClient(&remote_pb.RemoteConf{
|
|
Type: "gcs",
|
|
GcsGoogleApplicationCredentials: `{"type":"service_account","token_uri":"http://127.0.0.1:9/token"}`,
|
|
})
|
|
if !ok {
|
|
t.Fatal("gcs conf with inline credentials should be guarded")
|
|
}
|
|
if endpoint != "http://127.0.0.1:9/token" {
|
|
t.Errorf("endpoint = %q, want the credential token_uri", endpoint)
|
|
}
|
|
if err := validateRemoteEndpoint(context.Background(), endpoint); err == nil {
|
|
t.Error("expected the loopback token endpoint to be rejected")
|
|
}
|
|
if makeClient == nil {
|
|
t.Error("expected a constructor")
|
|
}
|
|
|
|
endpoint, _, ok = guardedRemoteClient(&remote_pb.RemoteConf{
|
|
Type: "gcs",
|
|
GcsGoogleApplicationCredentials: `{"type":"service_account"}`,
|
|
})
|
|
if !ok {
|
|
t.Fatal("gcs conf with inline credentials should be guarded")
|
|
}
|
|
if err := validateRemoteEndpoint(context.Background(), endpoint); err != nil {
|
|
t.Errorf("default token endpoint %q should pass: %v", endpoint, err)
|
|
}
|
|
}
|
|
|
|
// TestCheckGcsCredentials confirms only inline credentials that carry their own
|
|
// key material are accepted. The federated types name a url, file or executable
|
|
// that the SDK reads the token from, none of which the endpoint guard sees.
|
|
func TestCheckGcsCredentials(t *testing.T) {
|
|
rejected := []string{
|
|
"/etc/hostname",
|
|
"~/creds.json",
|
|
`{`,
|
|
`{}`,
|
|
`{"type":"external_account","token_url":"http://127.0.0.1:9/v1/token","credential_source":{"url":"http://169.254.169.254/latest/meta-data/"}}`,
|
|
`{"type":"external_account","token_url":"http://127.0.0.1:9/v1/token","credential_source":{"file":"/etc/shadow"}}`,
|
|
`{"type":"external_account","credential_source":{"executable":{"command":"/bin/sh"}}}`,
|
|
`{"type":"external_account_authorized_user","token_url":"http://127.0.0.1:9/v1/token"}`,
|
|
`{"type":"impersonated_service_account","service_account_impersonation_url":"http://127.0.0.1:9/x"}`,
|
|
}
|
|
for _, creds := range rejected {
|
|
if err := checkGcsCredentials(creds); err == nil {
|
|
t.Errorf("expected %q to be rejected", creds)
|
|
}
|
|
}
|
|
accepted := []string{
|
|
"",
|
|
`{"type":"service_account","client_email":"a@b.com","private_key":"k"}`,
|
|
`{"type":"service_account","token_uri":"https://oauth2.googleapis.com/token"}`,
|
|
`{"type":"authorized_user","refresh_token":"r"}`,
|
|
}
|
|
for _, creds := range accepted {
|
|
if err := checkGcsCredentials(creds); err != nil {
|
|
t.Errorf("expected %q to be accepted, got %v", creds, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// TestGuardedReplicaDialerRebind confirms the replica upload's dial-time guard
|
|
// refuses a hostname that rebinds to loopback after validateReplicaTarget, yet
|
|
// keeps letting private peers through (allowPrivate).
|
|
func TestGuardedReplicaDialerRebind(t *testing.T) {
|
|
originalLookup := lookupIPAddrFunc
|
|
t.Cleanup(func() { lookupIPAddrFunc = originalLookup })
|
|
|
|
const host = "replica.example.com"
|
|
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
|
|
}
|
|
|
|
// Up-front validation sees the public answer and accepts the target.
|
|
if err := validateReplicaTarget(context.Background(), host+":8080"); err != nil {
|
|
t.Fatalf("public replica target should validate, got %v", err)
|
|
}
|
|
// The dial then re-resolves to loopback and must refuse it.
|
|
dial := guardedDialerPolicy(host+":8080", true)
|
|
conn, err := dial(context.Background(), "tcp", host+":8080")
|
|
if conn != nil {
|
|
conn.Close()
|
|
t.Fatalf("guarded replica dialer must refuse loopback rebind, got conn")
|
|
}
|
|
if err == nil || !strings.Contains(err.Error(), "loopback") {
|
|
t.Fatalf("expected loopback refusal, got %v", err)
|
|
}
|
|
|
|
// A private literal peer is allowed through: the dial is attempted (and here
|
|
// fails on the already-cancelled context) rather than blocked as private.
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
if _, perr := guardedDialerPolicy("10.0.0.5:80", true)(ctx, "tcp", "10.0.0.5:80"); perr != nil && strings.Contains(perr.Error(), "private") {
|
|
t.Fatalf("private peer must be allowed by the replica dialer, got %v", perr)
|
|
}
|
|
}
|
|
|
|
// TestBuildGuardedRemoteStorageClient confirms the shared builder refuses a
|
|
// caller-influenced endpoint that resolves to a blocked address, and a gcs
|
|
// credentials path, while allowUntrusted falls back to the plain builder.
|
|
func TestBuildGuardedRemoteStorageClient(t *testing.T) {
|
|
loopbackS3 := &remote_pb.RemoteConf{
|
|
Name: "poc",
|
|
Type: "s3",
|
|
S3Endpoint: "http://127.0.0.1:8000",
|
|
S3AccessKey: "k",
|
|
S3SecretKey: "s",
|
|
S3Region: "us-east-1",
|
|
}
|
|
if _, err := BuildGuardedRemoteStorageClient(context.Background(), loopbackS3, false); err == nil {
|
|
t.Error("expected a loopback s3 endpoint to be rejected")
|
|
} else if !strings.Contains(err.Error(), "reject remote endpoint") {
|
|
t.Errorf("error = %v, want reject remote endpoint", err)
|
|
}
|
|
if _, err := BuildGuardedRemoteStorageClient(context.Background(), loopbackS3, true); err != nil {
|
|
t.Errorf("allowUntrusted should build the client: %v", err)
|
|
}
|
|
|
|
gcsPathCreds := &remote_pb.RemoteConf{
|
|
Name: "poc",
|
|
Type: "gcs",
|
|
GcsGoogleApplicationCredentials: "/etc/hostname",
|
|
}
|
|
if _, err := BuildGuardedRemoteStorageClient(context.Background(), gcsPathCreds, false); err == nil {
|
|
t.Error("expected a non-credentials file path to be rejected")
|
|
} else if !strings.Contains(err.Error(), "reject remote credentials") {
|
|
t.Errorf("error = %v, want reject remote credentials", err)
|
|
} else if strings.Contains(err.Error(), "/etc/hostname") {
|
|
t.Errorf("error must not leak the file path: %v", err)
|
|
}
|
|
|
|
// A file path that points to valid GCS credentials should be accepted.
|
|
credsFile := filepath.Join(t.TempDir(), "service-account.json")
|
|
validCreds := `{"type":"service_account","token_uri":"https://oauth2.googleapis.com/token","client_email":"sa@example.iam.gserviceaccount.com","private_key":"-----BEGIN PRIVATE KEY-----\nMIIBVwIBADANBgkqhkiG9w0BAQEFAASCAUEwggE9AgEAAkEAxY\n-----END PRIVATE KEY-----\n","private_key_id":"key1"}`
|
|
if err := os.WriteFile(credsFile, []byte(validCreds), 0600); err != nil {
|
|
t.Fatalf("write creds file: %v", err)
|
|
}
|
|
gcsFileCreds := &remote_pb.RemoteConf{
|
|
Name: "good",
|
|
Type: "gcs",
|
|
GcsGoogleApplicationCredentials: credsFile,
|
|
}
|
|
if err := checkGcsCredentials(credsFile); err != nil {
|
|
t.Errorf("valid gcs credentials file should pass: %v", err)
|
|
}
|
|
if _, err := BuildGuardedRemoteStorageClient(context.Background(), gcsFileCreds, false); err != nil {
|
|
t.Errorf("valid gcs credentials file should build: %v", err)
|
|
}
|
|
|
|
// A nonexistent path must be rejected without leaking the path in the error.
|
|
gcsMissingCreds := &remote_pb.RemoteConf{
|
|
Name: "missing",
|
|
Type: "gcs",
|
|
GcsGoogleApplicationCredentials: filepath.Join(t.TempDir(), "does-not-exist.json"),
|
|
}
|
|
if _, err := BuildGuardedRemoteStorageClient(context.Background(), gcsMissingCreds, false); err == nil {
|
|
t.Error("expected a nonexistent credentials file to be rejected")
|
|
} else if strings.Contains(err.Error(), "does-not-exist") {
|
|
t.Errorf("error must not leak the file path: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestValidateRemoteConfForLoad confirms the load-time validator (injected into
|
|
// the filer's FilerRemoteStorage) rejects a RemoteConf whose endpoint resolves
|
|
// to a blocked address, while allowUntrusted skips the check. A conf whose type
|
|
// dials a fixed provider host (no caller-supplied endpoint) passes.
|
|
func TestValidateRemoteConfForLoad(t *testing.T) {
|
|
loopbackS3 := &remote_pb.RemoteConf{
|
|
Name: "poc",
|
|
Type: "s3",
|
|
S3Endpoint: "http://127.0.0.1:8000",
|
|
S3Region: "us-east-1",
|
|
}
|
|
if err := ValidateRemoteConfForLoad(context.Background(), loopbackS3, false); err == nil {
|
|
t.Error("expected a loopback s3 endpoint to be rejected at load")
|
|
} else if !strings.Contains(err.Error(), "reject remote endpoint") {
|
|
t.Errorf("error = %v, want reject remote endpoint", err)
|
|
}
|
|
// allowUntrusted mirrors the volume server opt-out.
|
|
if err := ValidateRemoteConfForLoad(context.Background(), loopbackS3, true); err != nil {
|
|
t.Errorf("allowUntrusted should accept the conf: %v", err)
|
|
}
|
|
// A non-S3-compatible type with no caller-supplied endpoint dials a fixed
|
|
// provider host, so there is nothing caller-influenced to deny.
|
|
fixedHost := &remote_pb.RemoteConf{Name: "fixed", Type: "gcs"}
|
|
if err := ValidateRemoteConfForLoad(context.Background(), fixedHost, false); err != nil {
|
|
t.Errorf("fixed-host provider should pass: %v", err)
|
|
}
|
|
// nil conf is a no-op.
|
|
if err := ValidateRemoteConfForLoad(context.Background(), nil, false); err != nil {
|
|
t.Errorf("nil conf should be a no-op: %v", err)
|
|
}
|
|
// A hostname endpoint is not resolved at load time (DNS is left to the
|
|
// build-time guard at dial), so it must pass even if it would resolve to a
|
|
// blocked address. This prevents transient DNS failures from disabling
|
|
// working mounts during /etc/remote reload.
|
|
hostnameS3 := &remote_pb.RemoteConf{
|
|
Name: "host",
|
|
Type: "s3",
|
|
S3Endpoint: "http://internal.example.com",
|
|
S3Region: "us-east-1",
|
|
}
|
|
if err := ValidateRemoteConfForLoad(context.Background(), hostnameS3, false); err != nil {
|
|
t.Errorf("hostname endpoint should pass at load (DNS deferred to dial): %v", err)
|
|
}
|
|
// A standard AWS S3 config with no custom endpoint (empty S3Endpoint) has
|
|
// no caller-supplied endpoint to guard — the AWS SDK derives the regional
|
|
// endpoint. Both the load-time validator and the build-time guard must
|
|
// accept it so standard AWS S3 mounts keep working.
|
|
standardS3 := &remote_pb.RemoteConf{
|
|
Name: "aws",
|
|
Type: "s3",
|
|
S3Region: "us-east-1",
|
|
}
|
|
if err := ValidateRemoteConfForLoad(context.Background(), standardS3, false); err != nil {
|
|
t.Errorf("standard AWS S3 (empty endpoint) should pass: %v", err)
|
|
}
|
|
if _, err := BuildGuardedRemoteStorageClient(context.Background(), standardS3, false); err != nil {
|
|
t.Errorf("standard AWS S3 (empty endpoint) should build: %v", err)
|
|
}
|
|
// A non-s3 S3-compatible type with an empty endpoint is a misconfiguration
|
|
// (the AWS SDK would derive an AWS endpoint). The guard must reject it
|
|
// rather than fall through to the unguarded cache.
|
|
aliyunNoEndpoint := &remote_pb.RemoteConf{
|
|
Name: "aliyun",
|
|
Type: "aliyun",
|
|
AliyunRegion: "cn-hangzhou",
|
|
}
|
|
if err := ValidateRemoteConfForLoad(context.Background(), aliyunNoEndpoint, false); err == nil {
|
|
t.Error("aliyun with empty endpoint should be rejected at load")
|
|
}
|
|
if _, err := BuildGuardedRemoteStorageClient(context.Background(), aliyunNoEndpoint, false); err == nil {
|
|
t.Error("aliyun with empty endpoint should be rejected by the guard")
|
|
}
|
|
}
|