package weed_server import ( "context" "fmt" "net" "net/http" "net/url" "slices" "strings" "sync" "time" "github.com/seaweedfs/seaweedfs/weed/operation" "github.com/seaweedfs/seaweedfs/weed/pb/remote_pb" "github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb" "github.com/seaweedfs/seaweedfs/weed/remote_storage" azureremote "github.com/seaweedfs/seaweedfs/weed/remote_storage/azure" gcsremote "github.com/seaweedfs/seaweedfs/weed/remote_storage/gcs" s3remote "github.com/seaweedfs/seaweedfs/weed/remote_storage/s3" "github.com/seaweedfs/seaweedfs/weed/security" "github.com/seaweedfs/seaweedfs/weed/storage/needle" "github.com/seaweedfs/seaweedfs/weed/storage/types" ) // lookupIPAddrFunc resolves a host to one or more IP addresses. It is a // package-level variable so tests can substitute a deterministic resolver. var lookupIPAddrFunc = net.DefaultResolver.LookupIPAddr // blockedIMDSHosts lists hostnames that target cloud instance metadata // services (IMDS). These are blocked regardless of how they happen to // resolve, because some environments alias the IMDS address under a name. var blockedIMDSHosts = map[string]struct{}{ "metadata.google.internal": {}, "metadata": {}, } // validateRemoteEndpoint returns an error if the supplied S3 endpoint is not // safe to dial from a server that has network access to cluster-internal // hosts. It rejects empty/non-http(s) schemes, loopback/link-local/ // unspecified addresses, RFC 1918 + CGNAT ranges, and well-known IMDS // hostnames. Operators that legitimately fetch from private hosts can opt // out with -volume.allowUntrustedRemoteEndpoints. func validateRemoteEndpoint(ctx context.Context, endpoint string) error { if strings.TrimSpace(endpoint) == "" { return fmt.Errorf("remote endpoint is empty") } u, parseErr := url.Parse(endpoint) if parseErr != nil { return fmt.Errorf("parse remote endpoint %q: %w", endpoint, parseErr) } scheme := strings.ToLower(u.Scheme) if scheme != "http" && scheme != "https" { return fmt.Errorf("remote endpoint %q must use http or https, got %q", endpoint, u.Scheme) } host := u.Hostname() if host == "" { return fmt.Errorf("remote endpoint %q has no host", endpoint) } lowerHost := strings.ToLower(host) if _, ok := blockedIMDSHosts[lowerHost]; ok { return fmt.Errorf("remote endpoint %q targets instance metadata service", endpoint) } if ip := net.ParseIP(host); ip != nil { if err := checkBlockedIP(endpoint, ip); err != nil { return err } return nil } resolveCtx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() addrs, lookupErr := lookupIPAddrFunc(resolveCtx, host) if lookupErr != nil { return fmt.Errorf("resolve remote endpoint host %q: %w", host, lookupErr) } for _, addr := range addrs { if err := checkBlockedIP(endpoint, addr.IP); err != nil { return err } } return nil } // imdsIPv4 is the AWS/Azure/GCP IPv4 IMDS address. It is link-local and is // already covered by IsLinkLocalUnicast, but is named explicitly so the // error message is unambiguous in logs. var imdsIPv4 = net.ParseIP("169.254.169.254") // cgnatNet is the RFC 6598 carrier-grade NAT range (100.64.0.0/10). The // stdlib's IsPrivate covers RFC 1918 but not CGNAT, so check it explicitly. var cgnatNet = &net.IPNet{IP: net.IPv4(100, 64, 0, 0), Mask: net.CIDRMask(10, 32)} func checkBlockedIP(endpoint string, ip net.IP) error { return checkBlockedIPPolicy(endpoint, ip, false) } // checkBlockedIPPolicy rejects addresses that must never be dialed from a // server with cluster-internal reach. allowPrivate keeps RFC 1918 / CGNAT // reachable for callers whose target legitimately sits on an internal network // (peer volume servers), while still blocking loopback, link-local (IMDS) and // unspecified. func checkBlockedIPPolicy(endpoint string, ip net.IP, allowPrivate bool) error { if ip == nil { return nil } if ip.Equal(imdsIPv4) { return fmt.Errorf("remote endpoint %q targets instance metadata service %s", endpoint, ip) } switch { case ip.IsLoopback(): return fmt.Errorf("remote endpoint %q resolves to loopback address %s", endpoint, ip) case ip.IsUnspecified(): return fmt.Errorf("remote endpoint %q resolves to unspecified address %s", endpoint, ip) case ip.IsLinkLocalUnicast(), ip.IsLinkLocalMulticast(): return fmt.Errorf("remote endpoint %q resolves to link-local address %s", endpoint, ip) case ip.IsInterfaceLocalMulticast(): return fmt.Errorf("remote endpoint %q resolves to interface-local address %s", endpoint, ip) } if !allowPrivate { switch { case ip.IsPrivate(): return fmt.Errorf("remote endpoint %q resolves to private address %s", endpoint, ip) case cgnatNet.Contains(ip): return fmt.Errorf("remote endpoint %q resolves to CGNAT address %s", endpoint, ip) } } // IPv6 transition addresses embed an IPv4 destination that routes to the // same host wherever the matching relay exists (common in IPv6-only cloud). // net.IP only normalizes ::ffff: mapped addresses, so pull the embedded // IPv4 out of the other forms and re-check it against the deny list. if embedded := embeddedTransitionIPv4(ip); embedded != nil { return checkBlockedIPPolicy(endpoint, embedded, allowPrivate) } return nil } // validateReplicaTarget rejects a replica upload target that could redirect the // forwarded write away from a peer volume server. The target must be a bare // host:port -- a scheme, userinfo, path, query or fragment can smuggle a // different destination through fmt.Sprintf -- whose host is not loopback, // link-local (IMDS) or unspecified. Cluster peers legitimately sit on private // networks, so RFC 1918 / CGNAT are allowed. func validateReplicaTarget(ctx context.Context, target string) error { if strings.TrimSpace(target) == "" { return fmt.Errorf("replica target is empty") } if strings.Contains(target, "://") || strings.ContainsAny(target, "/?#@\\") { return fmt.Errorf("replica target %q must be a bare host:port", target) } host, _, splitErr := net.SplitHostPort(target) if splitErr != nil { return fmt.Errorf("replica target %q must be a bare host:port: %w", target, splitErr) } if host == "" { return fmt.Errorf("replica target %q has no host", target) } if _, ok := blockedIMDSHosts[strings.ToLower(host)]; ok { return fmt.Errorf("replica target %q targets instance metadata service", target) } if ip := net.ParseIP(host); ip != nil { return checkBlockedIPPolicy(target, ip, true) } resolveCtx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() addrs, lookupErr := lookupIPAddrFunc(resolveCtx, host) if lookupErr != nil { return fmt.Errorf("resolve replica target host %q: %w", host, lookupErr) } for _, addr := range addrs { if err := checkBlockedIPPolicy(target, addr.IP, true); err != nil { return err } } return nil } // embeddedTransitionIPv4 returns the IPv4 address carried by an IPv6 transition // address -- NAT64 64:ff9b::/96 (RFC 6052), 6to4 2002::/16 (RFC 3056), Teredo // 2001:0000::/32 (RFC 4380), and the deprecated IPv4-compatible ::/96 (RFC // 4291) -- or nil when ip is not one of those. IPv4-mapped ::ffff:0:0/96 is // excluded because net.IP already normalizes it via To4. func embeddedTransitionIPv4(ip net.IP) net.IP { v6 := ip.To16() if v6 == nil || ip.To4() != nil { return nil } switch { case v6[0] == 0x00 && v6[1] == 0x64 && v6[2] == 0xff && v6[3] == 0x9b && allZero(v6[4:12]): return net.IPv4(v6[12], v6[13], v6[14], v6[15]) case v6[0] == 0x20 && v6[1] == 0x02: return net.IPv4(v6[2], v6[3], v6[4], v6[5]) case v6[0] == 0x20 && v6[1] == 0x01 && v6[2] == 0x00 && v6[3] == 0x00: // Teredo obfuscates the client IPv4 as its ones' complement. return net.IPv4(v6[12]^0xff, v6[13]^0xff, v6[14]^0xff, v6[15]^0xff) case allZero(v6[:12]): // IPv4-compatible ::a.b.c.d; :: and ::1 are already handled above. return net.IPv4(v6[12], v6[13], v6[14], v6[15]) } return nil } func allZero(b []byte) bool { for _, c := range b { if c != 0 { return false } } return true } // guardedDialer returns a DialContext that resolves the host itself and // re-applies checkBlockedIP to every resolved address immediately before // dialing. This closes the DNS-rebinding window between // validateRemoteEndpoint and the actual TCP connect performed by the AWS S3 // client: even if the attacker's DNS flips to 127.0.0.1 (or any other // blocked range) after the up-front check, the dial is refused. func guardedDialer(endpoint string) func(ctx context.Context, network, addr string) (net.Conn, error) { return guardedDialerPolicy(endpoint, false) } // guardedDialerPolicy is guardedDialer with the same allowPrivate knob as // checkBlockedIPPolicy, so the replica upload path can keep dialing private // peers while still refusing loopback / link-local / unspecified at connect // time (closing the rebinding window for replica hostnames too). func guardedDialerPolicy(endpoint string, allowPrivate bool) func(ctx context.Context, network, addr string) (net.Conn, error) { dialer := &net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second} return func(ctx context.Context, network, addr string) (net.Conn, error) { host, port, splitErr := net.SplitHostPort(addr) if splitErr != nil { return nil, splitErr } // If the host is already a literal IP just validate and dial it. if ip := net.ParseIP(host); ip != nil { if err := checkBlockedIPPolicy(endpoint, ip, allowPrivate); err != nil { return nil, err } return dialer.DialContext(ctx, network, addr) } // Otherwise resolve, validate every answer, and dial the first IP // that passes the deny list. Using a literal-IP target prevents the // kernel resolver in net.Dialer from looking the name up a second // time inside Dial and getting a different answer. addrs, lookupErr := lookupIPAddrFunc(ctx, host) if lookupErr != nil { return nil, fmt.Errorf("resolve remote endpoint host %q: %w", host, lookupErr) } var firstBlockErr error for _, a := range addrs { if err := checkBlockedIPPolicy(endpoint, a.IP, allowPrivate); err != nil { if firstBlockErr == nil { firstBlockErr = err } continue } return dialer.DialContext(ctx, network, net.JoinHostPort(a.IP.String(), port)) } if firstBlockErr != nil { return nil, firstBlockErr } return nil, fmt.Errorf("resolve remote endpoint host %q: no addresses", host) } } // newGuardedHTTPClient returns an *http.Client whose transport refuses to // dial addresses that fail checkBlockedIP at connect time. It is meant for // per-request use; do not share across remote configs. func newGuardedHTTPClient(endpoint string) *http.Client { return newGuardedHTTPClientPolicy(endpoint, false) } // newGuardedHTTPClientPolicy is newGuardedHTTPClient with the allowPrivate knob // for the replica upload path, whose targets are cluster peers on private // networks. func newGuardedHTTPClientPolicy(endpoint string, allowPrivate bool) *http.Client { return &http.Client{ Transport: &http.Transport{ // No proxy: guardedDialer must see the real target address. Through // a proxy it would only validate the proxy's IP while the proxy // re-resolves the endpoint host, reopening the rebinding window the // dialer exists to close. Operators that need a proxy can opt out // with -volume.allowUntrustedRemoteEndpoints. Proxy: nil, DialContext: guardedDialerPolicy(endpoint, allowPrivate), ForceAttemptHTTP2: true, MaxIdleConns: 16, IdleConnTimeout: 60 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, }, } } // guardedRemoteClient reports the caller-supplied endpoint a backend dials // directly and a constructor that routes through the given HTTP client, or // ok=false when nothing in the conf steers a destination. The S3-SDK family, // azure (once AzureEndpoint is set) and the gcs token exchange all honor a // caller-supplied endpoint, so each must pass the SSRF deny-list and the // rebinding-safe dialer. func guardedRemoteClient(remoteConf *remote_pb.RemoteConf) (endpoint string, makeClient func(*http.Client) (remote_storage.RemoteStorageClient, error), ok bool) { if remoteConf == nil { return "", nil, false } if ep, isS3 := s3remote.S3CompatibleEndpoint(remoteConf); isS3 { return ep, func(httpClient *http.Client) (remote_storage.RemoteStorageClient, error) { return s3remote.MakeWithHTTPClient(remoteConf, httpClient) }, true } if remoteConf.Type == "azure" && remoteConf.AzureEndpoint != "" { return remoteConf.AzureEndpoint, func(httpClient *http.Client) (remote_storage.RemoteStorageClient, error) { return azureremote.MakeWithHTTPClient(remoteConf, httpClient) }, true } // gcs reaches a fixed object host, but the token exchange goes wherever the // supplied credentials say, so guard that endpoint instead. if remoteConf.Type == "gcs" && remoteConf.GcsGoogleApplicationCredentials != "" { if _, tokenURL, err := gcsremote.ParseInlineCredentials(remoteConf.GcsGoogleApplicationCredentials); err == nil { return tokenURL, func(httpClient *http.Client) (remote_storage.RemoteStorageClient, error) { return gcsremote.MakeWithHTTPClient(remoteConf, httpClient, gcsremote.StaticKeyCredentialTypes...) }, true } } return "", nil, false } // gcsCredentialsArePath reports whether a gcs credentials value is a filesystem // path rather than inline JSON, matching the gcs client's own inline detection. func gcsCredentialsArePath(creds string) bool { return creds != "" && !strings.HasPrefix(creds, "{") } // checkGcsCredentials rejects a caller-supplied gcs credentials value that // would make the SDK read from somewhere other than the credentials themselves, // so the request fails before any client is built. func checkGcsCredentials(creds string) error { if creds == "" { return nil } // A filesystem path is read from disk by the SDK. Accept only inline JSON // on the request; the server env var still supplies a path. if gcsCredentialsArePath(creds) { return fmt.Errorf("gcs credentials must be inline JSON") } credType, _, parseErr := gcsremote.ParseInlineCredentials(creds) if parseErr != nil { return parseErr } if !slices.Contains(gcsremote.StaticKeyCredentialTypes, credType) { return fmt.Errorf("gcs credential type %q is not accepted here", credType) } return nil } // BuildGuardedRemoteStorageClient builds a remote storage client whose dial // path is validated against the SSRF deny-list and pinned against DNS // rebinding, unless allowUntrusted is set. It is the single builder for every // caller that dials a caller-influenced RemoteConf endpoint: the volume // FetchAndWriteNeedle write path and the filer and S3 gateway remote-mount read // paths, which otherwise reach the same s3manager sink unguarded. func BuildGuardedRemoteStorageClient(ctx context.Context, remoteConf *remote_pb.RemoteConf, allowUntrusted bool) (remote_storage.RemoteStorageClient, error) { if !allowUntrusted { if remoteConf.GetType() == "gcs" { if credsErr := checkGcsCredentials(remoteConf.GetGcsGoogleApplicationCredentials()); credsErr != nil { return nil, fmt.Errorf("reject remote credentials: %w", credsErr) } } if endpoint, makeClient, ok := guardedRemoteClient(remoteConf); ok { if validateErr := validateRemoteEndpoint(ctx, endpoint); validateErr != nil { return nil, fmt.Errorf("reject remote endpoint: %w", validateErr) } // Build a one-shot client whose dial path re-validates the resolved // IP every time. This pins the validated endpoint against DNS // rebinding (a hostname that resolves to a public IP for // validateRemoteEndpoint and then flips to 127.0.0.1 / 169.254.x.x // when the SDK dials). client, err := makeClient(newGuardedHTTPClient(endpoint)) if err != nil { return nil, fmt.Errorf("get remote client: %w", err) } return client, nil } } client, err := remote_storage.GetRemoteStorage(remoteConf) if err != nil { return nil, fmt.Errorf("get remote client: %w", err) } return client, nil } func (vs *VolumeServer) FetchAndWriteNeedle(ctx context.Context, req *volume_server_pb.FetchAndWriteNeedleRequest) (resp *volume_server_pb.FetchAndWriteNeedleResponse, err error) { if err := vs.checkGrpcAdminAuth(ctx); err != nil { return nil, err } if err := vs.CheckMaintenanceMode(); err != nil { return nil, err } resp = &volume_server_pb.FetchAndWriteNeedleResponse{} v := vs.store.GetVolume(needle.VolumeId(req.VolumeId)) if v == nil { return nil, fmt.Errorf("not found volume id %d", req.VolumeId) } remoteConf := req.RemoteConf client, getClientErr := BuildGuardedRemoteStorageClient(ctx, remoteConf, vs.AllowUntrustedRemoteEndpoints) if getClientErr != nil { return nil, getClientErr } remoteStorageLocation := req.RemoteLocation var data []byte var readRemoteErr error if cr, ok := client.(remote_storage.RemoteStorageConcurrentReader); ok { concurrency := int(req.DownloadConcurrency) if concurrency <= 0 { concurrency = 0 // let the implementation choose its default } else if concurrency > 64 { concurrency = 64 } data, readRemoteErr = cr.ReadFileWithConcurrency(remoteStorageLocation, req.Offset, req.Size, concurrency) } else { data, readRemoteErr = client.ReadFile(remoteStorageLocation, req.Offset, req.Size) } if readRemoteErr != nil { return nil, fmt.Errorf("read from remote %+v: %w", remoteStorageLocation, readRemoteErr) } // The chunk is recorded with the requested size, so a short read would be // cached as a full-size chunk with a zero-padded or truncated tail. Fail // loudly instead of persisting silently corrupt content. if int64(len(data)) != req.Size { return nil, fmt.Errorf("read from remote %+v: got %d bytes, want %d", remoteStorageLocation, len(data), req.Size) } // Validate every replica target before writing anything, so a malformed or // internal target fails the request instead of leaving a local write behind. if !vs.AllowUntrustedRemoteEndpoints { for _, replica := range req.Replicas { if validateErr := validateReplicaTarget(ctx, replica.Url); validateErr != nil { return nil, fmt.Errorf("reject replica target: %w", validateErr) } } } var wg sync.WaitGroup var localErr error replicaErrs := make([]error, len(req.Replicas)) wg.Add(1) go func() { defer wg.Done() n := new(needle.Needle) n.Id = types.NeedleId(req.NeedleId) n.Cookie = types.Cookie(req.Cookie) n.Data, n.DataSize = data, uint32(len(data)) // copied from *Needle.prepareWriteBuffer() n.Size = 4 + types.Size(n.DataSize) + 1 n.Checksum = needle.NewCRC(n.Data) n.LastModified = uint64(time.Now().Unix()) n.SetHasLastModifiedDate() if _, localWriteErr := vs.store.WriteVolumeNeedle(v.Id, n, true, false); localWriteErr != nil { localErr = fmt.Errorf("local write needle %d size %d: %v", req.NeedleId, req.Size, localWriteErr) } else { resp.ETag = n.Etag() } }() if len(req.Replicas) > 0 { fileId := needle.NewFileId(v.Id, req.NeedleId, req.Cookie) for i, replica := range req.Replicas { wg.Add(1) go func(idx int, targetVolumeServer string) { defer wg.Done() uploadOption := &operation.UploadOption{ UploadUrl: fmt.Sprintf("http://%s/%s?type=replicate", targetVolumeServer, fileId.String()), Filename: "", Cipher: false, IsInputCompressed: false, IsReplication: true, MimeType: "", PairMap: nil, Jwt: security.EncodedJwt(req.Auth), } // Upload through a client that re-checks the target at connect // time, so a replica hostname cannot rebind to a blocked address // after validateReplicaTarget. Peers may be private, so allow // private here; the opt-out uses the shared global client. var uploader *operation.Uploader if vs.AllowUntrustedRemoteEndpoints { var uploaderErr error uploader, uploaderErr = operation.NewUploader() if uploaderErr != nil { replicaErrs[idx] = fmt.Errorf("remote write needle %d size %d: %v", req.NeedleId, req.Size, uploaderErr) return } } else { uploader = operation.NewUploaderWithHttpClient(newGuardedHTTPClientPolicy(targetVolumeServer, true)) } if _, replicaWriteErr := uploader.UploadData(ctx, data, uploadOption); replicaWriteErr != nil { replicaErrs[idx] = fmt.Errorf("remote write needle %d size %d: %v", req.NeedleId, req.Size, replicaWriteErr) } }(i, replica.Url) } } wg.Wait() // local write error wins; otherwise surface the first replica failure err = localErr for _, replicaErr := range replicaErrs { if err == nil { err = replicaErr } } return resp, err }