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 == "" {