aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChris Lu <chrislusf@users.noreply.github.com>2025-08-01 13:07:54 -0700
committerGitHub <noreply@github.com>2025-08-01 13:07:54 -0700
commitf1eb4dd42710769fa8cb37be7a66b2e976e7f4ef (patch)
treeca5cca4aeef58a35c3a7d9b3b852d3277dc261f8
parent52d87f1d29501004dddb69a0a6e42eae3a1075ef (diff)
downloadseaweedfs-f1eb4dd42710769fa8cb37be7a66b2e976e7f4ef.tar.xz
seaweedfs-f1eb4dd42710769fa8cb37be7a66b2e976e7f4ef.zip
S3: support for the X-Forwarded-Prefix header (#7068)
* support for the X-Forwarded-Prefix header * remove comments * refactoring * refactoring * path.Clean
-rw-r--r--weed/s3api/auth_signature_v4.go94
-rw-r--r--weed/s3api/auto_signature_v4_test.go255
2 files changed, 327 insertions, 22 deletions
diff --git a/weed/s3api/auth_signature_v4.go b/weed/s3api/auth_signature_v4.go
index 8d9011f0d..b42547de7 100644
--- a/weed/s3api/auth_signature_v4.go
+++ b/weed/s3api/auth_signature_v4.go
@@ -24,6 +24,7 @@ import (
"crypto/subtle"
"encoding/hex"
"net/http"
+ "path"
"regexp"
"sort"
"strconv"
@@ -154,13 +155,14 @@ func (iam *IdentityAccessManagement) doesSignatureMatch(hashedPayload string, r
}
bucket, object := s3_constants.GetBucketAndObject(r)
- if !identity.canDo(s3_constants.ACTION_WRITE, bucket, object) {
+ canDoResult := identity.canDo(s3_constants.ACTION_WRITE, bucket, object)
+ if !canDoResult {
return nil, s3err.ErrAccessDenied
}
// Extract date, if not present throw error.
var dateStr string
- if dateStr = req.Header.Get(http.CanonicalHeaderKey("x-amz-date")); dateStr == "" {
+ if dateStr = req.Header.Get("x-amz-date"); dateStr == "" {
if dateStr = r.Header.Get("Date"); dateStr == "" {
return nil, s3err.ErrMissingDateHeader
}
@@ -174,25 +176,67 @@ func (iam *IdentityAccessManagement) doesSignatureMatch(hashedPayload string, r
// Query string.
queryStr := req.URL.Query().Encode()
+ // Check if reverse proxy is forwarding with prefix
+ if forwardedPrefix := r.Header.Get("X-Forwarded-Prefix"); forwardedPrefix != "" {
+ // Try signature verification with the forwarded prefix first.
+ // This handles cases where reverse proxies strip URL prefixes and add the X-Forwarded-Prefix header.
+ errCode = iam.verifySignatureWithPath(extractedSignedHeaders, hashedPayload, queryStr, path.Clean(forwardedPrefix+req.URL.Path), req.Method, foundCred.SecretKey, t, signV4Values)
+ if errCode == s3err.ErrNone {
+ return identity, errCode
+ }
+ }
+
+ // Try normal signature verification (without prefix)
+ errCode = iam.verifySignatureWithPath(extractedSignedHeaders, hashedPayload, queryStr, req.URL.Path, req.Method, foundCred.SecretKey, t, signV4Values)
+ if errCode == s3err.ErrNone {
+ return identity, errCode
+ }
+
+ return nil, errCode
+}
+
+// verifySignatureWithPath verifies signature with a given path (used for both normal and prefixed paths).
+func (iam *IdentityAccessManagement) verifySignatureWithPath(extractedSignedHeaders http.Header, hashedPayload, queryStr, urlPath, method, secretKey string, t time.Time, signV4Values signValues) s3err.ErrorCode {
// Get canonical request.
- canonicalRequest := getCanonicalRequest(extractedSignedHeaders, hashedPayload, queryStr, req.URL.Path, req.Method)
+ canonicalRequest := getCanonicalRequest(extractedSignedHeaders, hashedPayload, queryStr, urlPath, method)
// Get string to sign from canonical request.
stringToSign := getStringToSign(canonicalRequest, t, signV4Values.Credential.getScope())
// Get hmac signing key.
- signingKey := getSigningKey(foundCred.SecretKey, signV4Values.Credential.scope.date.Format(yyyymmdd), signV4Values.Credential.scope.region, "s3")
+ signingKey := getSigningKey(secretKey, signV4Values.Credential.scope.date.Format(yyyymmdd), signV4Values.Credential.scope.region, "s3")
// Calculate signature.
newSignature := getSignature(signingKey, stringToSign)
// Verify if signature match.
if !compareSignatureV4(newSignature, signV4Values.Signature) {
- return nil, s3err.ErrSignatureDoesNotMatch
+ return s3err.ErrSignatureDoesNotMatch
+ }
+
+ return s3err.ErrNone
+}
+
+// verifyPresignedSignatureWithPath verifies presigned signature with a given path (used for both normal and prefixed paths).
+func (iam *IdentityAccessManagement) verifyPresignedSignatureWithPath(extractedSignedHeaders http.Header, hashedPayload, queryStr, urlPath, method, secretKey string, t time.Time, credHeader credentialHeader, signature string) s3err.ErrorCode {
+ // Get canonical request.
+ canonicalRequest := getCanonicalRequest(extractedSignedHeaders, hashedPayload, queryStr, urlPath, method)
+
+ // Get string to sign from canonical request.
+ stringToSign := getStringToSign(canonicalRequest, t, credHeader.getScope())
+
+ // Get hmac signing key.
+ signingKey := getSigningKey(secretKey, credHeader.scope.date.Format(yyyymmdd), credHeader.scope.region, "s3")
+
+ // Calculate expected signature.
+ expectedSignature := getSignature(signingKey, stringToSign)
+
+ // Verify if signature match.
+ if !compareSignatureV4(expectedSignature, signature) {
+ return s3err.ErrSignatureDoesNotMatch
}
- // Return error none.
- return identity, s3err.ErrNone
+ return s3err.ErrNone
}
// Simple implementation for presigned signature verification
@@ -284,24 +328,24 @@ func (iam *IdentityAccessManagement) doesPresignedSignatureMatch(hashedPayload s
queryForCanonical.Del("X-Amz-Signature")
queryStr := strings.Replace(queryForCanonical.Encode(), "+", "%20", -1)
- // Get canonical request
- canonicalRequest := getCanonicalRequest(extractedSignedHeaders, hashedPayload, queryStr, r.URL.Path, r.Method)
-
- // Get string to sign
- stringToSign := getStringToSign(canonicalRequest, t, credHeader.getScope())
-
- // Get signing key
- signingKey := getSigningKey(foundCred.SecretKey, credHeader.scope.date.Format(yyyymmdd), credHeader.scope.region, "s3")
-
- // Calculate expected signature
- expectedSignature := getSignature(signingKey, stringToSign)
+ var errCode s3err.ErrorCode
+ // Check if reverse proxy is forwarding with prefix for presigned URLs
+ if forwardedPrefix := r.Header.Get("X-Forwarded-Prefix"); forwardedPrefix != "" {
+ // Try signature verification with the forwarded prefix first.
+ // This handles cases where reverse proxies strip URL prefixes and add the X-Forwarded-Prefix header.
+ errCode = iam.verifyPresignedSignatureWithPath(extractedSignedHeaders, hashedPayload, queryStr, path.Clean(forwardedPrefix+r.URL.Path), r.Method, foundCred.SecretKey, t, credHeader, signature)
+ if errCode == s3err.ErrNone {
+ return identity, errCode
+ }
+ }
- // Verify signature
- if !compareSignatureV4(expectedSignature, signature) {
- return nil, s3err.ErrSignatureDoesNotMatch
+ // Try normal signature verification (without prefix)
+ errCode = iam.verifyPresignedSignatureWithPath(extractedSignedHeaders, hashedPayload, queryStr, r.URL.Path, r.Method, foundCred.SecretKey, t, credHeader, signature)
+ if errCode == s3err.ErrNone {
+ return identity, errCode
}
- return identity, s3err.ErrNone
+ return nil, errCode
}
// credentialHeader data type represents structured form of Credential
@@ -444,6 +488,12 @@ func extractSignedHeaders(signedHeaders []string, r *http.Request) (http.Header,
// extractHostHeader returns the value of host header if available.
func extractHostHeader(r *http.Request) string {
+ // Check for X-Forwarded-Host header first, which is set by reverse proxies
+ if forwardedHost := r.Header.Get("X-Forwarded-Host"); forwardedHost != "" {
+ // Using reverse proxy with X-Forwarded-Host.
+ return forwardedHost
+ }
+
hostHeaderValue := r.Host
// For standard requests, this should be fine.
if r.Host != "" {
diff --git a/weed/s3api/auto_signature_v4_test.go b/weed/s3api/auto_signature_v4_test.go
index b8b817ab8..61da40aff 100644
--- a/weed/s3api/auto_signature_v4_test.go
+++ b/weed/s3api/auto_signature_v4_test.go
@@ -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 == "" {