mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-14 02:20:41 +02:00
s3: reject virtual-host bucket retargeting via X-Forwarded-Host (#11281)
* s3: reject virtual-host bucket retargeting via X-Forwarded-Host SigV4 verification tries the client-supplied X-Forwarded-Host as a signed host candidate, while routing and IAM select the bucket from the actual Host header. A presigned URL for one virtual-host bucket could therefore be retargeted to another bucket accessible to the same signing identity by changing Host and adding X-Forwarded-Host. After the signature matches a host candidate, extract the bucket that the candidate implies (via the configured virtual-host domains) and compare it with the bucket the router selected. Reject when they differ, before returning success. * test(s3api): cover virtual-host presigned URL retargeting Add unit tests for bucketFromVirtualHost and end-to-end tests that reproduce the X-Forwarded-Host retargeting attack for both presigned and signed requests, plus a negative test confirming the legitimate same-bucket case still verifies. * s3: harden bucketFromVirtualHost for case and overlapping domains Compare host and domain suffixes case-insensitively so a mixed-case X-Forwarded-Host cannot bypass the consistency check. Only treat the exact path-style domain as non-virtual-host; subdomains of a path-style domain still match the virtual-host router pattern and must be checked.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user