diff --git a/weed/s3api/auth_signature_v4.go b/weed/s3api/auth_signature_v4.go index 26c4646e7..7a9f55768 100644 --- a/weed/s3api/auth_signature_v4.go +++ b/weed/s3api/auth_signature_v4.go @@ -324,6 +324,7 @@ func (iam *IdentityAccessManagement) verifyV4Signature(r *http.Request, shouldCh pathForSignature = r.URL.Path } forwardedPrefix := r.Header.Get("X-Forwarded-Prefix") + var matchedHost string for i, hostCandidate := range extractHostHeaderCandidates(r, iam.externalHost) { if i > 0 && !replaceSignedHostHeader(extractedSignedHeaders, hostCandidate) { break @@ -334,25 +335,39 @@ func (iam *IdentityAccessManagement) verifyV4Signature(r *http.Request, shouldCh cleanedPath := buildPathWithForwardedPrefix(forwardedPrefix, pathForSignature) calculatedSignature, errCode = verify(cleanedPath) if errCode == s3err.ErrNone { - return identity, cred, calculatedSignature, authInfo, s3err.ErrNone + matchedHost = hostCandidate + break } } // 10. Verify with the original path calculatedSignature, errCode = verify(pathForSignature) if errCode == s3err.ErrNone { - return identity, cred, calculatedSignature, authInfo, s3err.ErrNone + matchedHost = hostCandidate + break } // 11. Retry with decoded path if signature used raw path encoding if decodedPath, decodeErr := url.PathUnescape(pathForSignature); decodeErr == nil && decodedPath != pathForSignature { calculatedSignature, errCode = verify(decodedPath) if errCode == s3err.ErrNone { - return identity, cred, calculatedSignature, authInfo, s3err.ErrNone + matchedHost = hostCandidate + break } } } + if matchedHost != "" { + if signedBucket, ok := bucketFromVirtualHost(matchedHost, iam.domain); ok { + if routedBucket, _ := s3_constants.GetBucketAndObject(r); routedBucket != "" && routedBucket != signedBucket { + glog.V(2).Infof("reject %s %s: signed host %q implies bucket %q but routed to %q", + r.Method, r.URL.Path, matchedHost, signedBucket, routedBucket) + return nil, nil, "", nil, s3err.ErrAccessDenied + } + } + return identity, cred, calculatedSignature, authInfo, s3err.ErrNone + } + return nil, nil, "", nil, errCode } @@ -988,6 +1003,33 @@ func extractHostHeaderCandidates(r *http.Request, externalHost string) []string return candidates } +func bucketFromVirtualHost(host, domainConfig string) (string, bool) { + if domainConfig == "" { + return "", false + } + h := host + if hh, _, err := net.SplitHostPort(host); err == nil { + h = hh + } + h = strings.ToLower(h) + pathStyleDomains, virtualHostDomains := classifyDomainNames(strings.Split(domainConfig, ",")) + for _, domain := range pathStyleDomains { + if h == strings.ToLower(strings.TrimSpace(domain)) { + return "", false + } + } + for _, domain := range virtualHostDomains { + suffix := "." + strings.ToLower(strings.TrimSpace(domain)) + if strings.HasSuffix(h, suffix) { + bucket := h[:len(h)-len(suffix)] + if bucket != "" { + return bucket, true + } + } + } + return "", false +} + // joinSignedHost renders host:port the way AWS SDKs sign it: default ports are stripped // to match SanitizeHostForHeader, and bare IPv6 addresses lose their brackets. // Reference: https://github.com/aws/aws-sdk-go-v2/blob/main/aws/signer/internal/v4/host.go diff --git a/weed/s3api/auto_signature_v4_test.go b/weed/s3api/auto_signature_v4_test.go index 2b34b9dc6..849befe4c 100644 --- a/weed/s3api/auto_signature_v4_test.go +++ b/weed/s3api/auto_signature_v4_test.go @@ -1911,3 +1911,127 @@ func BenchmarkStreamingVsNonStreaming(b *testing.B) { } }) } + +func TestBucketFromVirtualHost(t *testing.T) { + tests := []struct { + host string + domain string + wantBucket string + wantOk bool + }{ + {"alpha-bkt.s3.test", "s3.test", "alpha-bkt", true}, + {"alpha-bkt.s3.test:8333", "s3.test", "alpha-bkt", true}, + {"alpha-bkt.S3.TEST", "s3.test", "alpha-bkt", true}, + {"s3.test", "s3.test", "", false}, + {"example.com", "s3.test", "", false}, + {"alpha-bkt.s3.test", "", "", false}, + {"alpha-bkt.s3.test", "s3.test,develop.s3.test", "alpha-bkt", true}, + {"bucket.develop.s3.test", "s3.test,develop.s3.test", "bucket.develop", true}, + {"develop.s3.test", "s3.test,develop.s3.test", "", false}, + } + for _, tt := range tests { + t.Run(tt.host, func(t *testing.T) { + bucket, ok := bucketFromVirtualHost(tt.host, tt.domain) + if bucket != tt.wantBucket || ok != tt.wantOk { + t.Errorf("bucketFromVirtualHost(%q, %q) = (%q, %v), want (%q, %v)", + tt.host, tt.domain, bucket, ok, tt.wantBucket, tt.wantOk) + } + }) + } +} + +func newVirtualHostTestIAM(domain string) *IdentityAccessManagement { + iam := &IdentityAccessManagement{ + domain: domain, + hashes: make(map[string]*sync.Pool), + hashCounters: make(map[string]*int32), + } + _ = iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{ + Identities: []*iam_pb.Identity{ + { + Name: "someone", + Credentials: []*iam_pb.Credential{ + {AccessKey: "access_key_1", SecretKey: "secret_key_1"}, + }, + Actions: []string{"Read", "Write"}, + }, + }, + }) + return iam +} + +func TestPresignedVirtualHostRetargetRejected(t *testing.T) { + iam := newVirtualHostTestIAM("s3.test") + + r, err := newTestRequest("GET", "http://alpha-bkt.s3.test/shared.txt", 0, nil) + if err != nil { + t.Fatalf("Failed to create test request: %v", err) + } + r.Header.Set("Host", "alpha-bkt.s3.test") + + if err := preSignV4WithPath(iam, r, "access_key_1", "secret_key_1", 3600, r.URL.Path); err != nil { + t.Fatalf("Failed to presign request: %v", err) + } + + r.Host = "beta-bkt.s3.test" + r.Header.Set("X-Forwarded-Host", "alpha-bkt.s3.test") + + r = mux.SetURLVars(r, map[string]string{ + "bucket": "beta-bkt", + "object": "shared.txt", + }) + + _, _, errCode := iam.doesPresignedSignatureMatch(r) + if errCode != s3err.ErrAccessDenied { + t.Errorf("Expected ErrAccessDenied for retargeted presigned URL, got: %v (code: %d)", errCode, int(errCode)) + } +} + +func TestSignedVirtualHostRetargetRejected(t *testing.T) { + iam := newVirtualHostTestIAM("s3.test") + + r, err := newTestRequest("GET", "http://alpha-bkt.s3.test/shared.txt", 0, nil) + if err != nil { + t.Fatalf("Failed to create test request: %v", err) + } + r.Header.Set("Host", "alpha-bkt.s3.test") + + signV4WithPath(r, "access_key_1", "secret_key_1", r.URL.Path) + + r.Host = "beta-bkt.s3.test" + r.Header.Set("X-Forwarded-Host", "alpha-bkt.s3.test") + + r = mux.SetURLVars(r, map[string]string{ + "bucket": "beta-bkt", + "object": "shared.txt", + }) + + _, _, errCode := iam.doesSignatureMatch(r) + if errCode != s3err.ErrAccessDenied { + t.Errorf("Expected ErrAccessDenied for retargeted signed URL, got: %v (code: %d)", errCode, int(errCode)) + } +} + +func TestPresignedVirtualHostNoRetarget(t *testing.T) { + iam := newVirtualHostTestIAM("s3.test") + + r, err := newTestRequest("GET", "http://alpha-bkt.s3.test/shared.txt", 0, nil) + if err != nil { + t.Fatalf("Failed to create test request: %v", err) + } + r.Header.Set("Host", "alpha-bkt.s3.test") + + if err := preSignV4WithPath(iam, r, "access_key_1", "secret_key_1", 3600, r.URL.Path); err != nil { + t.Fatalf("Failed to presign request: %v", err) + } + + r = mux.SetURLVars(r, map[string]string{ + "bucket": "alpha-bkt", + "object": "shared.txt", + }) + + _, _, errCode := iam.doesPresignedSignatureMatch(r) + if errCode != s3err.ErrNone { + t.Errorf("Expected successful presigned signature validation, got: %v (code: %d)", errCode, int(errCode)) + } +}