mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-15 02:28:31 +02:00
The TestPresignedURLIAMValidation was failing because the presigned URL validation was hardcoding the principal ARN as 'PresignedUser' instead of extracting the actual role from the JWT session token. ### Problem: - Test used session token from S3ReadOnlyRole - ValidatePresignedURLWithIAM hardcoded principal as PresignedUser - Authorization checked wrong role permissions - PUT operation incorrectly succeeded instead of being denied ### Solution: - Extract role and session information from JWT token claims - Use parseJWTToken() to get 'role' and 'snam' claims - Build correct principal ARN from token data - Use 'principal' claim directly if available, fallback to constructed ARN ### Test Results: ✅ TestPresignedURLIAMValidation: All 4 test cases now pass ✅ GET with read permissions: ALLOWED (correct) ✅ PUT with read-only permissions: DENIED (correct - was failing before) ✅ GET without session token: Falls back to standard auth ✅ Invalid session token: Correctly rejected ### Technical Details: - Principal now correctly shows: arn:seaweed:sts::assumed-role/S3ReadOnlyRole/presigned-test-session - Authorization logic now validates against actual assumed role - Maintains compatibility with existing presigned URL generation tests - All 20+ presigned URL tests continue to pass This ensures presigned URLs respect the actual IAM role permissions from the session token, providing proper security enforcement.
384 lines
13 KiB
Go
384 lines
13 KiB
Go
package s3api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
|
)
|
|
|
|
// S3PresignedURLManager handles IAM integration for presigned URLs
|
|
type S3PresignedURLManager struct {
|
|
s3iam *S3IAMIntegration
|
|
}
|
|
|
|
// NewS3PresignedURLManager creates a new presigned URL manager with IAM integration
|
|
func NewS3PresignedURLManager(s3iam *S3IAMIntegration) *S3PresignedURLManager {
|
|
return &S3PresignedURLManager{
|
|
s3iam: s3iam,
|
|
}
|
|
}
|
|
|
|
// PresignedURLRequest represents a request to generate a presigned URL
|
|
type PresignedURLRequest struct {
|
|
Method string `json:"method"` // HTTP method (GET, PUT, POST, DELETE)
|
|
Bucket string `json:"bucket"` // S3 bucket name
|
|
ObjectKey string `json:"object_key"` // S3 object key
|
|
Expiration time.Duration `json:"expiration"` // URL expiration duration
|
|
SessionToken string `json:"session_token"` // JWT session token for IAM
|
|
Headers map[string]string `json:"headers"` // Additional headers to sign
|
|
QueryParams map[string]string `json:"query_params"` // Additional query parameters
|
|
}
|
|
|
|
// PresignedURLResponse represents the generated presigned URL
|
|
type PresignedURLResponse struct {
|
|
URL string `json:"url"` // The presigned URL
|
|
Method string `json:"method"` // HTTP method
|
|
Headers map[string]string `json:"headers"` // Required headers
|
|
ExpiresAt time.Time `json:"expires_at"` // URL expiration time
|
|
SignedHeaders []string `json:"signed_headers"` // List of signed headers
|
|
CanonicalQuery string `json:"canonical_query"` // Canonical query string
|
|
}
|
|
|
|
// ValidatePresignedURLWithIAM validates a presigned URL request using IAM policies
|
|
func (iam *IdentityAccessManagement) ValidatePresignedURLWithIAM(r *http.Request, identity *Identity) s3err.ErrorCode {
|
|
if iam.iamIntegration == nil {
|
|
// Fall back to standard validation
|
|
return s3err.ErrNone
|
|
}
|
|
|
|
// Extract bucket and object from request
|
|
bucket, object := s3_constants.GetBucketAndObject(r)
|
|
|
|
// Determine the S3 action from HTTP method and path
|
|
action := determineS3ActionFromRequest(r, bucket, object)
|
|
|
|
// Check if the user has permission for this action
|
|
ctx := r.Context()
|
|
sessionToken := extractSessionTokenFromPresignedURL(r)
|
|
if sessionToken == "" {
|
|
// No session token in presigned URL - use standard auth
|
|
return s3err.ErrNone
|
|
}
|
|
|
|
// Parse JWT token to extract role and session information
|
|
tokenClaims, err := parseJWTToken(sessionToken)
|
|
if err != nil {
|
|
glog.V(3).Infof("Failed to parse JWT token in presigned URL: %v", err)
|
|
return s3err.ErrAccessDenied
|
|
}
|
|
|
|
// Extract role information from token claims
|
|
roleName, ok := tokenClaims["role"].(string)
|
|
if !ok || roleName == "" {
|
|
glog.V(3).Info("No role found in JWT token for presigned URL")
|
|
return s3err.ErrAccessDenied
|
|
}
|
|
|
|
sessionName, ok := tokenClaims["snam"].(string)
|
|
if !ok || sessionName == "" {
|
|
sessionName = "presigned-session" // Default fallback
|
|
}
|
|
|
|
// Use the principal ARN directly from token claims, or build it if not available
|
|
principalArn, ok := tokenClaims["principal"].(string)
|
|
if !ok || principalArn == "" {
|
|
// Fallback: extract role name from role ARN and build principal ARN
|
|
roleNameOnly := roleName
|
|
if strings.Contains(roleName, "/") {
|
|
parts := strings.Split(roleName, "/")
|
|
roleNameOnly = parts[len(parts)-1]
|
|
}
|
|
principalArn = fmt.Sprintf("arn:seaweed:sts::assumed-role/%s/%s", roleNameOnly, sessionName)
|
|
}
|
|
|
|
// Create IAM identity for authorization using extracted information
|
|
iamIdentity := &IAMIdentity{
|
|
Name: identity.Name,
|
|
Principal: principalArn,
|
|
SessionToken: sessionToken,
|
|
Account: identity.Account,
|
|
}
|
|
|
|
// Authorize using IAM
|
|
errCode := iam.iamIntegration.AuthorizeAction(ctx, iamIdentity, action, bucket, object, r)
|
|
if errCode != s3err.ErrNone {
|
|
glog.V(3).Infof("IAM authorization failed for presigned URL: principal=%s action=%s bucket=%s object=%s",
|
|
iamIdentity.Principal, action, bucket, object)
|
|
return errCode
|
|
}
|
|
|
|
glog.V(3).Infof("IAM authorization succeeded for presigned URL: principal=%s action=%s bucket=%s object=%s",
|
|
iamIdentity.Principal, action, bucket, object)
|
|
return s3err.ErrNone
|
|
}
|
|
|
|
// GeneratePresignedURLWithIAM generates a presigned URL with IAM policy validation
|
|
func (pm *S3PresignedURLManager) GeneratePresignedURLWithIAM(ctx context.Context, req *PresignedURLRequest, baseURL string) (*PresignedURLResponse, error) {
|
|
if pm.s3iam == nil || !pm.s3iam.enabled {
|
|
return nil, fmt.Errorf("IAM integration not enabled")
|
|
}
|
|
|
|
// Validate session token and get identity
|
|
// Use a proper ARN format for the principal
|
|
principalArn := fmt.Sprintf("arn:seaweed:sts::assumed-role/PresignedUser/presigned-session")
|
|
iamIdentity := &IAMIdentity{
|
|
SessionToken: req.SessionToken,
|
|
Principal: principalArn,
|
|
Name: "presigned-user",
|
|
Account: &AccountAdmin,
|
|
}
|
|
|
|
// Determine S3 action from method
|
|
action := determineS3ActionFromMethodAndPath(req.Method, req.Bucket, req.ObjectKey)
|
|
|
|
// Check IAM permissions before generating URL
|
|
authRequest := &http.Request{
|
|
Method: req.Method,
|
|
URL: &url.URL{Path: "/" + req.Bucket + "/" + req.ObjectKey},
|
|
Header: make(http.Header),
|
|
}
|
|
authRequest.Header.Set("Authorization", "Bearer "+req.SessionToken)
|
|
authRequest = authRequest.WithContext(ctx)
|
|
|
|
errCode := pm.s3iam.AuthorizeAction(ctx, iamIdentity, action, req.Bucket, req.ObjectKey, authRequest)
|
|
if errCode != s3err.ErrNone {
|
|
return nil, fmt.Errorf("IAM authorization failed: user does not have permission for action %s on resource %s/%s", action, req.Bucket, req.ObjectKey)
|
|
}
|
|
|
|
// Generate presigned URL with validated permissions
|
|
return pm.generatePresignedURL(req, baseURL, iamIdentity)
|
|
}
|
|
|
|
// generatePresignedURL creates the actual presigned URL
|
|
func (pm *S3PresignedURLManager) generatePresignedURL(req *PresignedURLRequest, baseURL string, identity *IAMIdentity) (*PresignedURLResponse, error) {
|
|
// Calculate expiration time
|
|
expiresAt := time.Now().Add(req.Expiration)
|
|
|
|
// Build the base URL
|
|
urlPath := "/" + req.Bucket
|
|
if req.ObjectKey != "" {
|
|
urlPath += "/" + req.ObjectKey
|
|
}
|
|
|
|
// Create query parameters for AWS signature v4
|
|
queryParams := make(map[string]string)
|
|
for k, v := range req.QueryParams {
|
|
queryParams[k] = v
|
|
}
|
|
|
|
// Add AWS signature v4 parameters
|
|
queryParams["X-Amz-Algorithm"] = "AWS4-HMAC-SHA256"
|
|
queryParams["X-Amz-Credential"] = fmt.Sprintf("seaweedfs/%s/us-east-1/s3/aws4_request", expiresAt.Format("20060102"))
|
|
queryParams["X-Amz-Date"] = expiresAt.Format("20060102T150405Z")
|
|
queryParams["X-Amz-Expires"] = strconv.Itoa(int(req.Expiration.Seconds()))
|
|
queryParams["X-Amz-SignedHeaders"] = "host"
|
|
|
|
// Add session token if available
|
|
if identity.SessionToken != "" {
|
|
queryParams["X-Amz-Security-Token"] = identity.SessionToken
|
|
}
|
|
|
|
// Build canonical query string
|
|
canonicalQuery := buildCanonicalQuery(queryParams)
|
|
|
|
// For now, we'll create a mock signature
|
|
// In production, this would use proper AWS signature v4 signing
|
|
mockSignature := generateMockSignature(req.Method, urlPath, canonicalQuery, identity.SessionToken)
|
|
queryParams["X-Amz-Signature"] = mockSignature
|
|
|
|
// Build final URL
|
|
finalQuery := buildCanonicalQuery(queryParams)
|
|
fullURL := baseURL + urlPath + "?" + finalQuery
|
|
|
|
// Prepare response
|
|
headers := make(map[string]string)
|
|
for k, v := range req.Headers {
|
|
headers[k] = v
|
|
}
|
|
|
|
return &PresignedURLResponse{
|
|
URL: fullURL,
|
|
Method: req.Method,
|
|
Headers: headers,
|
|
ExpiresAt: expiresAt,
|
|
SignedHeaders: []string{"host"},
|
|
CanonicalQuery: canonicalQuery,
|
|
}, nil
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
// determineS3ActionFromRequest determines the S3 action based on HTTP request
|
|
func determineS3ActionFromRequest(r *http.Request, bucket, object string) Action {
|
|
return determineS3ActionFromMethodAndPath(r.Method, bucket, object)
|
|
}
|
|
|
|
// determineS3ActionFromMethodAndPath determines the S3 action based on method and path
|
|
func determineS3ActionFromMethodAndPath(method, bucket, object string) Action {
|
|
switch method {
|
|
case "GET":
|
|
if object == "" {
|
|
return s3_constants.ACTION_LIST // ListBucket
|
|
} else {
|
|
return s3_constants.ACTION_READ // GetObject
|
|
}
|
|
case "PUT", "POST":
|
|
return s3_constants.ACTION_WRITE // PutObject
|
|
case "DELETE":
|
|
if object == "" {
|
|
return s3_constants.ACTION_DELETE_BUCKET // DeleteBucket
|
|
} else {
|
|
return s3_constants.ACTION_WRITE // DeleteObject (uses WRITE action)
|
|
}
|
|
case "HEAD":
|
|
if object == "" {
|
|
return s3_constants.ACTION_LIST // HeadBucket
|
|
} else {
|
|
return s3_constants.ACTION_READ // HeadObject
|
|
}
|
|
default:
|
|
return s3_constants.ACTION_READ // Default to read
|
|
}
|
|
}
|
|
|
|
// extractSessionTokenFromPresignedURL extracts session token from presigned URL query parameters
|
|
func extractSessionTokenFromPresignedURL(r *http.Request) string {
|
|
// Check for X-Amz-Security-Token in query parameters
|
|
if token := r.URL.Query().Get("X-Amz-Security-Token"); token != "" {
|
|
return token
|
|
}
|
|
|
|
// Check for session token in other possible locations
|
|
if token := r.URL.Query().Get("SessionToken"); token != "" {
|
|
return token
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// buildCanonicalQuery builds a canonical query string for AWS signature
|
|
func buildCanonicalQuery(params map[string]string) string {
|
|
var keys []string
|
|
for k := range params {
|
|
keys = append(keys, k)
|
|
}
|
|
|
|
// Sort keys for canonical order
|
|
for i := 0; i < len(keys); i++ {
|
|
for j := i + 1; j < len(keys); j++ {
|
|
if keys[i] > keys[j] {
|
|
keys[i], keys[j] = keys[j], keys[i]
|
|
}
|
|
}
|
|
}
|
|
|
|
var parts []string
|
|
for _, k := range keys {
|
|
parts = append(parts, fmt.Sprintf("%s=%s", url.QueryEscape(k), url.QueryEscape(params[k])))
|
|
}
|
|
|
|
return strings.Join(parts, "&")
|
|
}
|
|
|
|
// generateMockSignature generates a mock signature for testing purposes
|
|
func generateMockSignature(method, path, query, sessionToken string) string {
|
|
// This is a simplified signature for demonstration
|
|
// In production, use proper AWS signature v4 calculation
|
|
data := fmt.Sprintf("%s\n%s\n%s\n%s", method, path, query, sessionToken)
|
|
hash := sha256.Sum256([]byte(data))
|
|
return hex.EncodeToString(hash[:])[:16] // Truncate for readability
|
|
}
|
|
|
|
// ValidatePresignedURLExpiration validates that a presigned URL hasn't expired
|
|
func ValidatePresignedURLExpiration(r *http.Request) error {
|
|
query := r.URL.Query()
|
|
|
|
// Get X-Amz-Date and X-Amz-Expires
|
|
dateStr := query.Get("X-Amz-Date")
|
|
expiresStr := query.Get("X-Amz-Expires")
|
|
|
|
if dateStr == "" || expiresStr == "" {
|
|
return fmt.Errorf("missing required presigned URL parameters")
|
|
}
|
|
|
|
// Parse date (always in UTC)
|
|
signedDate, err := time.Parse("20060102T150405Z", dateStr)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid X-Amz-Date format: %v", err)
|
|
}
|
|
|
|
// Parse expires
|
|
expires, err := strconv.Atoi(expiresStr)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid X-Amz-Expires format: %v", err)
|
|
}
|
|
|
|
// Check expiration - compare in UTC
|
|
expirationTime := signedDate.Add(time.Duration(expires) * time.Second)
|
|
now := time.Now().UTC()
|
|
if now.After(expirationTime) {
|
|
return fmt.Errorf("presigned URL has expired")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// PresignedURLSecurityPolicy represents security constraints for presigned URL generation
|
|
type PresignedURLSecurityPolicy struct {
|
|
MaxExpirationDuration time.Duration `json:"max_expiration_duration"` // Maximum allowed expiration
|
|
AllowedMethods []string `json:"allowed_methods"` // Allowed HTTP methods
|
|
RequiredHeaders []string `json:"required_headers"` // Headers that must be present
|
|
IPWhitelist []string `json:"ip_whitelist"` // Allowed IP addresses/ranges
|
|
MaxFileSize int64 `json:"max_file_size"` // Maximum file size for uploads
|
|
}
|
|
|
|
// DefaultPresignedURLSecurityPolicy returns a default security policy
|
|
func DefaultPresignedURLSecurityPolicy() *PresignedURLSecurityPolicy {
|
|
return &PresignedURLSecurityPolicy{
|
|
MaxExpirationDuration: 7 * 24 * time.Hour, // 7 days max
|
|
AllowedMethods: []string{"GET", "PUT", "POST", "HEAD"},
|
|
RequiredHeaders: []string{},
|
|
IPWhitelist: []string{}, // Empty means no IP restrictions
|
|
MaxFileSize: 5 * 1024 * 1024 * 1024, // 5GB default
|
|
}
|
|
}
|
|
|
|
// ValidatePresignedURLRequest validates a presigned URL request against security policy
|
|
func (policy *PresignedURLSecurityPolicy) ValidatePresignedURLRequest(req *PresignedURLRequest) error {
|
|
// Check expiration duration
|
|
if req.Expiration > policy.MaxExpirationDuration {
|
|
return fmt.Errorf("expiration duration %v exceeds maximum allowed %v", req.Expiration, policy.MaxExpirationDuration)
|
|
}
|
|
|
|
// Check HTTP method
|
|
methodAllowed := false
|
|
for _, allowedMethod := range policy.AllowedMethods {
|
|
if req.Method == allowedMethod {
|
|
methodAllowed = true
|
|
break
|
|
}
|
|
}
|
|
if !methodAllowed {
|
|
return fmt.Errorf("HTTP method %s is not allowed", req.Method)
|
|
}
|
|
|
|
// Check required headers
|
|
for _, requiredHeader := range policy.RequiredHeaders {
|
|
if _, exists := req.Headers[requiredHeader]; !exists {
|
|
return fmt.Errorf("required header %s is missing", requiredHeader)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|