diff --git a/weed/s3api/auth_signature_v4.go b/weed/s3api/auth_signature_v4.go index 5f96669554..661bd7002f 100644 --- a/weed/s3api/auth_signature_v4.go +++ b/weed/s3api/auth_signature_v4.go @@ -374,35 +374,29 @@ func (iam *IdentityAccessManagement) verifyV4Signature(r *http.Request, shouldCh if trustedPeer { forwardedPrefix = r.Header.Get("X-Forwarded-Prefix") } + // 9. Candidate paths: the escaped path as sent, then the decoded path that + // an S3 client encodes once when signing; each with the forwarded prefix first. + signaturePaths := []string{pathForSignature} + if decodedPath, decodeErr := url.PathUnescape(pathForSignature); decodeErr == nil && decodedPath != pathForSignature { + signaturePaths = append(signaturePaths, decodedPath) + } + if forwardedPrefix != "" { + prefixedPaths := make([]string, 0, 2*len(signaturePaths)) + for _, p := range signaturePaths { + prefixedPaths = append(prefixedPaths, buildPathWithForwardedPrefix(forwardedPrefix, p)) + } + signaturePaths = append(prefixedPaths, signaturePaths...) + } var matchedHost string +hostCandidates: for i, hostCandidate := range extractHostHeaderCandidates(r, iam.externalHost) { if i > 0 && !replaceSignedHostHeader(extractedSignedHeaders, hostCandidate) { break } - - // 9. Verify with the X-Forwarded-Prefix path first - if forwardedPrefix != "" { - cleanedPath := buildPathWithForwardedPrefix(forwardedPrefix, pathForSignature) - calculatedSignature, errCode = verify(cleanedPath) - if errCode == s3err.ErrNone { + for _, p := range signaturePaths { + if calculatedSignature, errCode = verify(p); errCode == s3err.ErrNone { matchedHost = hostCandidate - break - } - } - - // 10. Verify with the original path - calculatedSignature, errCode = verify(pathForSignature) - if errCode == 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 { - matchedHost = hostCandidate - break + break hostCandidates } } } diff --git a/weed/s3api/auth_signature_v4_test.go b/weed/s3api/auth_signature_v4_test.go index a426357b02..e5abf46606 100644 --- a/weed/s3api/auth_signature_v4_test.go +++ b/weed/s3api/auth_signature_v4_test.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "fmt" "net/http" + "net/url" "slices" "sync" "testing" @@ -735,3 +736,35 @@ func TestVerifyV4SignatureUsesOriginalRequestPath(t *testing.T) { t.Fatalf("signed request rejected after path rewrite: %d", errCode) } } + +// TestSignatureV4WithForwardedPrefixUnicodeKey guards against a signature +// mismatch when an object key contains non-ASCII characters and the request is +// received through a trusted reverse proxy that strips a path prefix. The +// client signs the single percent-encoded path including the prefix, so the +// server must decode the forwarded (already encoded) path before re-encoding it +// together with the prefix. +func TestSignatureV4WithForwardedPrefixUnicodeKey(t *testing.T) { + iam := newTestIAMWithTrustedProxy() + + bucket := "test-bucket" + key := "たあいまd·.jpg" + + // The proxy strips the X-Forwarded-Prefix and forwards the percent-encoded + // object path (the server sees the encoded form, not the raw Unicode key). + r, err := newTestRequest("GET", "https://example.com/"+bucket+"/"+url.PathEscape(key), 0, nil) + if err != nil { + t.Fatalf("failed to create test request: %v", err) + } + + r.Header.Set("X-Forwarded-Prefix", "/s3") + r.Header.Set("Host", "example.com") + r.RemoteAddr = "10.0.0.1:1234" + + // The client signs the full URL path including the prefix, using the single + // percent-encoded form of the Unicode key. + signV4WithPath(r, "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", "/s3/"+bucket+"/"+key) + + if _, _, errCode := iam.doesSignatureMatch(r); errCode != s3err.ErrNone { + t.Fatalf("signature verification failed for unicode key behind forwarded prefix: code %d", int(errCode)) + } +}