fix(s3): verify SigV4 signature with decoded path behind X-Forwarded-Prefix (#12299)

* fix(s3): verify SigV4 signature with decoded path behind X-Forwarded-Prefix

When SeaweedFS is hosted behind a reverse proxy that strips a URL prefix (X-Forwarded-Prefix) and forwards the percent-encoded request path, signature verification fails for object keys containing non-ASCII characters. The client signs the canonical URI as the single percent-encoded path including the prefix, but the server verifies against the already-encoded forwarded path joined to the prefix, producing a mismatch (SignatureDoesNotMatch).

Decode the forwarded path and rejoin the prefix before verifying, mirroring the existing un-prefixed decoded retry. ASCII keys are unaffected because the decoded path equals the original, so the retry is skipped.

Signed-off-by: Mohd Quamar Tyagi <104281681+Tyagiquamar@users.noreply.github.com>

* s3: build the SigV4 candidate paths once and try them per host

The forwarded-prefix and plain branches each repeated the decode-and-verify
step for every host candidate. List the escaped and decoded paths once, the
prefixed forms first, and verify them in the same order as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Signed-off-by: Mohd Quamar Tyagi <104281681+Tyagiquamar@users.noreply.github.com>
Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-11-01 16:27:45 -08:00
1 parent d6ec6bc4e7
commit ab204a03e6
2 files changed
+50 -23

No files matched your search

+17 -23
View File
@@ -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
}
}
}
+33
View File
@@ -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))
}
}