S3: support for the X-Forwarded-Prefix header (#7068)

* support for the X-Forwarded-Prefix header

* remove comments

* refactoring

* refactoring

* path.Clean
This commit is contained in:
Chris Lu
2025-08-01 13:07:54 -07:00
committed by GitHub
parent 52d87f1d29
commit f1eb4dd427
2 changed files with 328 additions and 23 deletions

View File

@@ -16,6 +16,7 @@ import (
"time"
"unicode/utf8"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
@@ -254,6 +255,260 @@ func preSignV4(iam *IdentityAccessManagement, req *http.Request, accessKey, secr
return nil
}
// newTestIAM creates a test IAM with a standard test user
func newTestIAM() *IdentityAccessManagement {
iam := &IdentityAccessManagement{}
iam.identities = []*Identity{
{
Name: "testuser",
Credentials: []*Credential{{AccessKey: "AKIAIOSFODNN7EXAMPLE", SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"}},
Actions: []Action{s3_constants.ACTION_ADMIN, s3_constants.ACTION_READ, s3_constants.ACTION_WRITE},
},
}
// Initialize the access key map for lookup
iam.accessKeyIdent = make(map[string]*Identity)
iam.accessKeyIdent["AKIAIOSFODNN7EXAMPLE"] = iam.identities[0]
return iam
}
// Test X-Forwarded-Prefix support for reverse proxy scenarios
func TestSignatureV4WithForwardedPrefix(t *testing.T) {
tests := []struct {
name string
forwardedPrefix string
expectedPath string
}{
{
name: "prefix without trailing slash",
forwardedPrefix: "/s3",
expectedPath: "/s3/test-bucket/test-object",
},
{
name: "prefix with trailing slash",
forwardedPrefix: "/s3/",
expectedPath: "/s3/test-bucket/test-object",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
iam := newTestIAM()
// Create a request with X-Forwarded-Prefix header
r, err := newTestRequest("GET", "https://example.com/test-bucket/test-object", 0, nil)
if err != nil {
t.Fatalf("Failed to create test request: %v", err)
}
// Set the mux variables manually since we're not going through the actual router
r = mux.SetURLVars(r, map[string]string{
"bucket": "test-bucket",
"object": "test-object",
})
r.Header.Set("X-Forwarded-Prefix", tt.forwardedPrefix)
r.Header.Set("Host", "example.com")
r.Header.Set("X-Forwarded-Host", "example.com")
// Sign the request with the expected normalized path
signV4WithPath(r, "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", tt.expectedPath)
// Test signature verification
_, errCode := iam.doesSignatureMatch(getContentSha256Cksum(r), r)
if errCode != s3err.ErrNone {
t.Errorf("Expected successful signature validation with X-Forwarded-Prefix %q, got error: %v (code: %d)", tt.forwardedPrefix, errCode, int(errCode))
}
})
}
}
// Test basic presigned URL functionality without prefix
func TestPresignedSignatureV4Basic(t *testing.T) {
iam := newTestIAM()
// Create a presigned request without X-Forwarded-Prefix header
r, err := newTestRequest("GET", "https://example.com/test-bucket/test-object", 0, nil)
if err != nil {
t.Fatalf("Failed to create test request: %v", err)
}
// Set the mux variables manually since we're not going through the actual router
r = mux.SetURLVars(r, map[string]string{
"bucket": "test-bucket",
"object": "test-object",
})
r.Header.Set("Host", "example.com")
// Create presigned URL with the normal path (no prefix)
err = preSignV4WithPath(iam, r, "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 3600, r.URL.Path)
if err != nil {
t.Errorf("Failed to presign request: %v", err)
}
// Test presigned signature verification
_, errCode := iam.doesPresignedSignatureMatch(getContentSha256Cksum(r), r)
if errCode != s3err.ErrNone {
t.Errorf("Expected successful presigned signature validation, got error: %v (code: %d)", errCode, int(errCode))
}
}
// Test X-Forwarded-Prefix support for presigned URLs
func TestPresignedSignatureV4WithForwardedPrefix(t *testing.T) {
tests := []struct {
name string
forwardedPrefix string
originalPath string
expectedPath string
}{
{
name: "prefix without trailing slash",
forwardedPrefix: "/s3",
originalPath: "/s3/test-bucket/test-object",
expectedPath: "/s3/test-bucket/test-object",
},
{
name: "prefix with trailing slash",
forwardedPrefix: "/s3/",
originalPath: "/s3/test-bucket/test-object",
expectedPath: "/s3/test-bucket/test-object",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
iam := newTestIAM()
// Create a presigned request that simulates reverse proxy scenario:
// 1. Client generates presigned URL with prefixed path
// 2. Proxy strips prefix and forwards to SeaweedFS with X-Forwarded-Prefix header
// Start with the original request URL (what client sees)
r, err := newTestRequest("GET", "https://example.com"+tt.originalPath, 0, nil)
if err != nil {
t.Fatalf("Failed to create test request: %v", err)
}
// Generate presigned URL with the original prefixed path
err = preSignV4WithPath(iam, r, "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 3600, tt.originalPath)
if err != nil {
t.Errorf("Failed to presign request: %v", err)
return
}
// Now simulate what the reverse proxy does:
// 1. Strip the prefix from the URL path
r.URL.Path = "/test-bucket/test-object"
// 2. Set the mux variables for the stripped path
r = mux.SetURLVars(r, map[string]string{
"bucket": "test-bucket",
"object": "test-object",
})
// 3. Add the forwarded headers
r.Header.Set("X-Forwarded-Prefix", tt.forwardedPrefix)
r.Header.Set("Host", "example.com")
r.Header.Set("X-Forwarded-Host", "example.com")
// Test presigned signature verification
_, errCode := iam.doesPresignedSignatureMatch(getContentSha256Cksum(r), r)
if errCode != s3err.ErrNone {
t.Errorf("Expected successful presigned signature validation with X-Forwarded-Prefix %q, got error: %v (code: %d)", tt.forwardedPrefix, errCode, int(errCode))
}
})
}
}
// preSignV4WithPath adds presigned URL parameters to the request with a custom path
func preSignV4WithPath(iam *IdentityAccessManagement, req *http.Request, accessKey, secretKey string, expires int64, urlPath string) error {
// Create credential scope
now := time.Now().UTC()
dateStr := now.Format(iso8601Format)
// Create credential header
scope := fmt.Sprintf("%s/%s/%s/%s", now.Format(yyyymmdd), "us-east-1", "s3", "aws4_request")
credential := fmt.Sprintf("%s/%s", accessKey, scope)
// Get the query parameters
query := req.URL.Query()
query.Set("X-Amz-Algorithm", signV4Algorithm)
query.Set("X-Amz-Credential", credential)
query.Set("X-Amz-Date", dateStr)
query.Set("X-Amz-Expires", fmt.Sprintf("%d", expires))
query.Set("X-Amz-SignedHeaders", "host")
// Set the query on the URL (without signature yet)
req.URL.RawQuery = query.Encode()
// Get the payload hash
hashedPayload := getContentSha256Cksum(req)
// Extract signed headers
extractedSignedHeaders := make(http.Header)
extractedSignedHeaders["host"] = []string{extractHostHeader(req)}
// Get canonical request with custom path
canonicalRequest := getCanonicalRequest(extractedSignedHeaders, hashedPayload, req.URL.RawQuery, urlPath, req.Method)
// Get string to sign
stringToSign := getStringToSign(canonicalRequest, now, scope)
// Get signing key
signingKey := getSigningKey(secretKey, now.Format(yyyymmdd), "us-east-1", "s3")
// Calculate signature
signature := getSignature(signingKey, stringToSign)
// Add signature to query
query.Set("X-Amz-Signature", signature)
req.URL.RawQuery = query.Encode()
return nil
}
// signV4WithPath signs a request with a custom path
func signV4WithPath(req *http.Request, accessKey, secretKey, urlPath string) {
// Create credential scope
now := time.Now().UTC()
dateStr := now.Format(iso8601Format)
// Set required headers
req.Header.Set("X-Amz-Date", dateStr)
// Create credential header
scope := fmt.Sprintf("%s/%s/%s/%s", now.Format(yyyymmdd), "us-east-1", "s3", "aws4_request")
credential := fmt.Sprintf("%s/%s", accessKey, scope)
// Get signed headers
signedHeaders := "host;x-amz-date"
// Extract signed headers
extractedSignedHeaders := make(http.Header)
extractedSignedHeaders["host"] = []string{extractHostHeader(req)}
extractedSignedHeaders["x-amz-date"] = []string{dateStr}
// Get the payload hash
hashedPayload := getContentSha256Cksum(req)
// Get canonical request with custom path
canonicalRequest := getCanonicalRequest(extractedSignedHeaders, hashedPayload, req.URL.RawQuery, urlPath, req.Method)
// Get string to sign
stringToSign := getStringToSign(canonicalRequest, now, scope)
// Get signing key
signingKey := getSigningKey(secretKey, now.Format(yyyymmdd), "us-east-1", "s3")
// Calculate signature
signature := getSignature(signingKey, stringToSign)
// Set Authorization header
authorization := fmt.Sprintf("%s Credential=%s, SignedHeaders=%s, Signature=%s",
signV4Algorithm, credential, signedHeaders, signature)
req.Header.Set("Authorization", authorization)
}
// Returns new HTTP request object.
func newTestRequest(method, urlStr string, contentLength int64, body io.ReadSeeker) (*http.Request, error) {
if method == "" {