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:
Chris Lu
2026-09-11 22:23:26 -07:00
committed by GitHub
parent 210afacd12
commit 5a0e017457
2 changed files with 169 additions and 3 deletions
+45 -3
View File
@@ -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
+124
View File
@@ -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))
}
}