Files
seaweedfs/weed/server/volume_grpc_remote_test.go
Chris Lu 38c14d3c13 filer: apply SSRF guard to the lazy-remote fetch/list/delete paths (#11294)
* 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.
2026-09-13 14:43:55 -07:00

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")
}
}