diff --git a/weed/command/scaffold/security.toml b/weed/command/scaffold/security.toml index 83c23fc0c..e5019b36c 100644 --- a/weed/command/scaffold/security.toml +++ b/weed/command/scaffold/security.toml @@ -232,6 +232,15 @@ kek = "" # Cannot be used while /etc/s3/sse_kek exists on the filer — delete it first. key = "" +# Trusted reverse proxies allowed to set X-Forwarded-For / X-Real-Ip for +# aws:SourceIp condition evaluation in S3 bucket and IAM policies. When the +# direct TCP peer is in this list, forwarded headers are honored; otherwise +# the direct peer address is used (matching AWS S3 semantics). Leave empty to +# always use the direct peer, which is the safe default. +# Can also be set via env var: WEED_S3_TRUSTED_PROXIES_WHITE_LIST +[s3.trusted_proxies] +white_list = "" # comma separated; bare IPs or CIDRs, e.g. "10.10.10.0/24" + # white list. It's checking request ip address. [guard] white_list = "" diff --git a/weed/s3api/auth_credentials.go b/weed/s3api/auth_credentials.go index 7d020f92e..428f42b1d 100644 --- a/weed/s3api/auth_credentials.go +++ b/weed/s3api/auth_credentials.go @@ -86,6 +86,10 @@ type IdentityAccessManagement struct { // Keyed by policy name, kept in sync by PutPolicy/DeletePolicy. iamPolicyEngine *policy_engine.PolicyEngine + // trustedProxies is applied to every (re)built iamPolicyEngine so that + // aws:SourceIp resolution honors the configured allowlist across rebuilds. + trustedProxies *policy_engine.TrustedProxies + // background polling stopChan chan struct{} shutdownOnce sync.Once @@ -2518,7 +2522,7 @@ func (iam *IdentityAccessManagement) evaluateAttachedIAMPolicies(r *http.Request principal := buildPrincipalARN(identity, r) s3Action := ResolveS3Action(r, string(action), bucket, resourceObject) explicitAllow := false - conditions := policy_engine.ExtractConditionValuesFromRequest(r) + conditions := engine.ExtractConditionValuesFromRequest(r) for k, v := range policy_engine.ExtractPrincipalVariables(principal) { conditions[k] = v } @@ -2610,7 +2614,11 @@ func (iam *IdentityAccessManagement) isActionExplicitlyDeniedByIAM(r *http.Reque if manager == nil { return false } - denied, err := manager.IsPrincipalActionExplicitlyDenied(r.Context(), principal, action, resource, policyNames, sessionToken, extractRequestContext(r)) + var requestContext map[string]interface{} + if s3iam, ok := iam.iamIntegration.(*S3IAMIntegration); ok { + requestContext = s3iam.extractRequestContext(r) + } + denied, err := manager.IsPrincipalActionExplicitlyDenied(r.Context(), principal, action, resource, policyNames, sessionToken, requestContext) if err != nil { glog.Warningf("AssumeRole explicit-deny check failed for %s, denying: %v", identity.Name, err) return true @@ -3135,9 +3143,22 @@ func (iam *IdentityAccessManagement) removeUserGroupLocked(username, groupName s func (iam *IdentityAccessManagement) ensureIAMPolicyEngine() { if iam.iamPolicyEngine == nil { iam.iamPolicyEngine = policy_engine.NewPolicyEngine() + iam.iamPolicyEngine.SetTrustedProxies(iam.trustedProxies) } } +// SetTrustedProxies configures the allowlist used by the IAM policy engine +// when resolving aws:SourceIp from forwarded headers, and applies it to the +// current cached engine if one exists. +func (iam *IdentityAccessManagement) SetTrustedProxies(tp *policy_engine.TrustedProxies) { + iam.m.Lock() + iam.trustedProxies = tp + if iam.iamPolicyEngine != nil { + iam.iamPolicyEngine.SetTrustedProxies(tp) + } + iam.m.Unlock() +} + // rebuildIAMPolicyEngineLocked rebuilds the entire IAM policy engine cache // from the current policies map. Must be called with iam.m held. func (iam *IdentityAccessManagement) rebuildIAMPolicyEngineLocked() { @@ -3146,6 +3167,7 @@ func (iam *IdentityAccessManagement) rebuildIAMPolicyEngineLocked() { return } engine := policy_engine.NewPolicyEngine() + engine.SetTrustedProxies(iam.trustedProxies) for name, p := range iam.policies { if err := engine.SetBucketPolicy(name, p.Content); err != nil { glog.Warningf("IAM policy cache rebuild: skipping invalid policy %q: %v", name, err) diff --git a/weed/s3api/policy_engine/engine.go b/weed/s3api/policy_engine/engine.go index 5b950d237..b3a3048eb 100644 --- a/weed/s3api/policy_engine/engine.go +++ b/weed/s3api/policy_engine/engine.go @@ -2,11 +2,11 @@ package policy_engine import ( "fmt" - "net" "net/http" "regexp" "strings" "sync" + "sync/atomic" "time" "github.com/seaweedfs/seaweedfs/weed/glog" @@ -30,8 +30,9 @@ type PolicyEvaluationContext struct { // PolicyEngine is the main policy evaluation engine type PolicyEngine struct { - contexts map[string]*PolicyEvaluationContext - mutex sync.RWMutex + contexts map[string]*PolicyEvaluationContext + mutex sync.RWMutex + trustedProxies atomic.Pointer[TrustedProxies] } // NewPolicyEngine creates a new policy evaluation engine @@ -41,6 +42,12 @@ func NewPolicyEngine() *PolicyEngine { } } +// SetTrustedProxies configures the allowlist used to decide whether +// forwarded headers are honored when extracting aws:SourceIp. +func (engine *PolicyEngine) SetTrustedProxies(tp *TrustedProxies) { + engine.trustedProxies.Store(tp) +} + // SetBucketPolicy sets the policy for a bucket func (engine *PolicyEngine) SetBucketPolicy(bucketName string, policyJSON string) error { policy, err := ParsePolicy(policyJSON) @@ -405,11 +412,11 @@ func ExtractPrincipalVariables(principal string) map[string][]string { } // ExtractConditionValuesFromRequest extracts condition values from HTTP request -func ExtractConditionValuesFromRequest(r *http.Request) map[string][]string { +func (engine *PolicyEngine) ExtractConditionValuesFromRequest(r *http.Request) map[string][]string { values := make(map[string][]string) // AWS condition keys - values["aws:SourceIp"] = []string{extractSourceIP(r)} + values["aws:SourceIp"] = []string{engine.extractSourceIP(r)} values["aws:SecureTransport"] = []string{fmt.Sprintf("%t", r.TLS != nil)} // Use AWS standard condition key for current time values["aws:CurrentTime"] = []string{time.Now().Format(time.RFC3339)} @@ -519,36 +526,11 @@ func injectSSEForMultipart(conditions map[string][]string, inheritedSSE string) return modified } -// extractSourceIP returns the direct TCP peer address for aws:SourceIp -// condition evaluation. Forwarding headers (X-Forwarded-For, X-Real-Ip) are -// intentionally ignored: without a configurable trusted-proxy allowlist they -// are client-controlled and spoofable, which would let a caller behind a -// private-looking peer bypass any aws:SourceIp restriction. -func extractSourceIP(r *http.Request) string { - if r == nil { - return "" - } - - remoteAddr := strings.TrimSpace(r.RemoteAddr) - if remoteAddr == "" { - return "" - } - - if remoteAddr == "@" { - return remoteAddr - } - - host := remoteAddr - if h, _, err := net.SplitHostPort(remoteAddr); err == nil { - host = h - } - - remoteIP := net.ParseIP(host) - if remoteIP == nil { - return "" - } - - return remoteIP.String() +// extractSourceIP returns the client IP for aws:SourceIp condition +// evaluation, honoring forwarded headers only when the direct TCP peer is in +// the configured trusted-proxy allowlist (see SetTrustedProxies). +func (engine *PolicyEngine) extractSourceIP(r *http.Request) string { + return engine.trustedProxies.Load().ExtractSourceIP(r) } // BuildResourceArn builds an ARN for the given bucket and object @@ -667,7 +649,7 @@ func (engine *PolicyEngine) GetAllBucketsWithPolicies() []string { func (engine *PolicyEngine) EvaluatePolicyForRequest(bucketName, objectName, action, principal string, r *http.Request) PolicyEvaluationResult { resource := BuildResourceArn(bucketName, objectName) actionName := BuildActionName(action) - conditions := ExtractConditionValuesFromRequest(r) + conditions := engine.ExtractConditionValuesFromRequest(r) // Extract principal information for variables principalVars := ExtractPrincipalVariables(principal) diff --git a/weed/s3api/policy_engine/engine_test.go b/weed/s3api/policy_engine/engine_test.go index 0187a8a61..d527e54bb 100644 --- a/weed/s3api/policy_engine/engine_test.go +++ b/weed/s3api/policy_engine/engine_test.go @@ -380,7 +380,7 @@ func TestExtractConditionValuesFromRequest(t *testing.T) { RemoteAddr: "192.168.1.100:12345", } - values := ExtractConditionValuesFromRequest(req) + values := NewPolicyEngine().ExtractConditionValuesFromRequest(req) // Check extracted values if len(values["aws:SourceIp"]) != 1 || values["aws:SourceIp"][0] != "192.168.1.100" { @@ -493,7 +493,7 @@ func TestExtractConditionValuesFromRequestSourceIPPrecedence(t *testing.T) { RemoteAddr: tt.remoteAddr, } - values := ExtractConditionValuesFromRequest(req) + values := NewPolicyEngine().ExtractConditionValuesFromRequest(req) if len(values["aws:SourceIp"]) != 1 || values["aws:SourceIp"][0] != tt.expectedIP { t.Errorf("Expected SourceIp %q, got %v", tt.expectedIP, values["aws:SourceIp"]) } @@ -512,7 +512,7 @@ func TestExtractSourceIP_IgnoresForwardedHeaders(t *testing.T) { RemoteAddr: "10.0.0.5:54321", } - values := ExtractConditionValuesFromRequest(req) + values := NewPolicyEngine().ExtractConditionValuesFromRequest(req) if got := values["aws:SourceIp"]; len(got) != 1 || got[0] != "10.0.0.5" { t.Errorf("Expected SourceIp to be the direct peer 10.0.0.5, got %v", got) } @@ -542,13 +542,27 @@ func TestExtractSourceIP_EnforcesIPRestrictionPolicy(t *testing.T) { Action: "s3:GetObject", Resource: "arn:aws:s3:::secret-bucket/secret-key", Principal: "*", - Conditions: ExtractConditionValuesFromRequest(r), + Conditions: engine.ExtractConditionValuesFromRequest(r), }) if result != PolicyResultDeny { t.Errorf("Expected Deny for peer outside 10.0.0.0/24 despite spoofed X-Forwarded-For, got %v", result) } } +func TestExtractSourceIP_TrustedProxyHonorsForwardedHeader(t *testing.T) { + engine := NewPolicyEngine() + engine.SetTrustedProxies(NewTrustedProxies([]string{"10.0.0.0/24"})) + + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.RemoteAddr = "10.0.0.1:54321" + r.Header.Set("X-Forwarded-For", "203.0.113.99") + + values := engine.ExtractConditionValuesFromRequest(r) + if got := values["aws:SourceIp"]; len(got) != 1 || got[0] != "203.0.113.99" { + t.Errorf("Expected trusted proxy to honor X-Forwarded-For 203.0.113.99, got %v", got) + } +} + func TestPolicyEvaluationWithConditions(t *testing.T) { engine := NewPolicyEngine() diff --git a/weed/s3api/policy_engine/trusted_proxies.go b/weed/s3api/policy_engine/trusted_proxies.go new file mode 100644 index 000000000..9aa35f451 --- /dev/null +++ b/weed/s3api/policy_engine/trusted_proxies.go @@ -0,0 +1,122 @@ +package policy_engine + +import ( + "net" + "net/http" + "strings" + + "github.com/seaweedfs/seaweedfs/weed/glog" +) + +type TrustedProxies struct { + ips map[string]struct{} + cidrs map[string]*net.IPNet +} + +func NewTrustedProxies(whiteList []string) *TrustedProxies { + tp := &TrustedProxies{ + ips: make(map[string]struct{}), + cidrs: make(map[string]*net.IPNet), + } + for _, entry := range whiteList { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + if strings.Contains(entry, "/") { + _, cidrnet, err := net.ParseCIDR(entry) + if err != nil { + glog.Errorf("Parse CIDR %s in s3 trusted_proxies failed: %v", entry, err) + continue + } + tp.cidrs[entry] = cidrnet + } else { + ip := net.ParseIP(entry) + if ip == nil { + glog.Errorf("Parse IP %s in s3 trusted_proxies failed", entry) + continue + } + tp.ips[ip.String()] = struct{}{} + } + } + return tp +} + +func (tp *TrustedProxies) IsTrusted(ipStr string) bool { + if tp == nil { + return false + } + if _, ok := tp.ips[ipStr]; ok { + return true + } + ip := net.ParseIP(ipStr) + if ip == nil { + return false + } + for _, cidrnet := range tp.cidrs { + if cidrnet.Contains(ip) { + return true + } + } + return false +} + +// ExtractSourceIP returns the client IP for aws:SourceIp condition evaluation. +// The direct TCP peer is used unless it is in the trusted proxy allowlist, in +// which case X-Forwarded-For (right-to-left, skipping trusted hops) then +// X-Real-Ip are honored. +func (tp *TrustedProxies) ExtractSourceIP(r *http.Request) string { + if r == nil { + return "" + } + remoteAddr := strings.TrimSpace(r.RemoteAddr) + if remoteAddr == "" { + return "" + } + if remoteAddr == "@" { + return remoteAddr + } + host := remoteAddr + if h, _, err := net.SplitHostPort(remoteAddr); err == nil { + host = h + } + remoteIP := net.ParseIP(host) + if remoteIP == nil { + return "" + } + if tp.IsTrusted(host) { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + entries := strings.Split(xff, ",") + malformed := false + for i := len(entries) - 1; i >= 0; i-- { + candidate := strings.TrimSpace(entries[i]) + if candidate == "" { + continue + } + ip := net.ParseIP(candidate) + if ip == nil { + malformed = true + break + } + if tp.IsTrusted(ip.String()) { + continue + } + return ip.String() + } + if !malformed { + for _, candidate := range entries { + candidate = strings.TrimSpace(candidate) + if ip := net.ParseIP(candidate); ip != nil { + return ip.String() + } + } + } + } + if xRealIP := strings.TrimSpace(r.Header.Get("X-Real-Ip")); xRealIP != "" { + if ip := net.ParseIP(xRealIP); ip != nil { + return ip.String() + } + } + } + return remoteIP.String() +} diff --git a/weed/s3api/policy_engine/trusted_proxies_test.go b/weed/s3api/policy_engine/trusted_proxies_test.go new file mode 100644 index 000000000..7f0cfefb8 --- /dev/null +++ b/weed/s3api/policy_engine/trusted_proxies_test.go @@ -0,0 +1,128 @@ +package policy_engine + +import ( + "net/http" + "testing" +) + +func newReq(remoteAddr string, xff, xRealIP string) *http.Request { + r := &http.Request{RemoteAddr: remoteAddr, Header: http.Header{}} + if xff != "" { + r.Header.Set("X-Forwarded-For", xff) + } + if xRealIP != "" { + r.Header.Set("X-Real-Ip", xRealIP) + } + return r +} + +func TestTrustedProxies_IsTrusted(t *testing.T) { + tp := NewTrustedProxies([]string{"10.0.0.5", "192.168.0.0/16"}) + if !tp.IsTrusted("10.0.0.5") { + t.Error("bare IP should be trusted") + } + if !tp.IsTrusted("192.168.1.100") { + t.Error("IP in CIDR should be trusted") + } + if tp.IsTrusted("8.8.8.8") { + t.Error("public IP should not be trusted") + } + if tp.IsTrusted("not-an-ip") { + t.Error("invalid IP should not be trusted") + } +} + +func TestTrustedProxies_CanonicalizesBareIPv6(t *testing.T) { + tp := NewTrustedProxies([]string{"2001:0db8::1"}) + if !tp.IsTrusted("2001:db8::1") { + t.Error("canonical IPv6 should match non-canonical allowlist entry") + } +} + +func TestTrustedProxies_InvalidBareIPSkipped(t *testing.T) { + tp := NewTrustedProxies([]string{"not-an-ip", "10.0.0.5"}) + if tp.IsTrusted("not-an-ip") { + t.Error("invalid entry should not be stored") + } + if !tp.IsTrusted("10.0.0.5") { + t.Error("valid entry after invalid one should still load") + } +} + +func TestTrustedProxies_NilNotTrusted(t *testing.T) { + var tp *TrustedProxies + if tp.IsTrusted("127.0.0.1") { + t.Error("nil TrustedProxies should not trust any IP") + } +} + +func TestTrustedProxies_ExtractSourceIP_DirectPeerWhenUntrusted(t *testing.T) { + tp := NewTrustedProxies([]string{"10.0.0.0/24"}) + r := newReq("203.0.113.5:1234", "8.8.8.8", "1.1.1.1") + if got := tp.ExtractSourceIP(r); got != "203.0.113.5" { + t.Errorf("untrusted peer: want 203.0.113.5, got %s", got) + } +} + +func TestTrustedProxies_ExtractSourceIP_XForwardedFor(t *testing.T) { + tp := NewTrustedProxies([]string{"10.0.0.0/24"}) + r := newReq("10.0.0.1:1234", "8.8.8.8, 10.0.0.2", "") + if got := tp.ExtractSourceIP(r); got != "8.8.8.8" { + t.Errorf("trusted proxy: want 8.8.8.8, got %s", got) + } +} + +func TestTrustedProxies_ExtractSourceIP_AllTrustedReturnsLeftmost(t *testing.T) { + tp := NewTrustedProxies([]string{"10.0.0.0/24"}) + r := newReq("10.0.0.1:1234", "10.0.0.5, 10.0.0.6", "") + if got := tp.ExtractSourceIP(r); got != "10.0.0.5" { + t.Errorf("all-trusted chain: want leftmost 10.0.0.5, got %s", got) + } +} + +func TestTrustedProxies_ExtractSourceIP_MalformedXFFFallsBackToPeer(t *testing.T) { + tp := NewTrustedProxies([]string{"10.0.0.0/24"}) + r := newReq("10.0.0.1:1234", "8.8.8.8, garbage", "") + if got := tp.ExtractSourceIP(r); got != "10.0.0.1" { + t.Errorf("malformed XFF: want direct peer 10.0.0.1, got %s", got) + } +} + +func TestTrustedProxies_ExtractSourceIP_XRealIP(t *testing.T) { + tp := NewTrustedProxies([]string{"10.0.0.0/24"}) + r := newReq("10.0.0.1:1234", "", "8.8.8.8") + if got := tp.ExtractSourceIP(r); got != "8.8.8.8" { + t.Errorf("X-Real-Ip: want 8.8.8.8, got %s", got) + } +} + +func TestTrustedProxies_ExtractSourceIP_XForwardedForPreferredOverXRealIP(t *testing.T) { + tp := NewTrustedProxies([]string{"10.0.0.0/24"}) + r := newReq("10.0.0.1:1234", "8.8.8.8", "1.1.1.1") + if got := tp.ExtractSourceIP(r); got != "8.8.8.8" { + t.Errorf("XFF should win over X-Real-Ip: want 8.8.8.8, got %s", got) + } +} + +func TestTrustedProxies_ExtractSourceIP_NoTrustedProxiesUsesPeer(t *testing.T) { + tp := NewTrustedProxies(nil) + r := newReq("10.0.0.1:1234", "8.8.8.8", "1.1.1.1") + if got := tp.ExtractSourceIP(r); got != "10.0.0.1" { + t.Errorf("empty allowlist: want 10.0.0.1, got %s", got) + } +} + +func TestTrustedProxies_ExtractSourceIP_NilRequest(t *testing.T) { + tp := NewTrustedProxies([]string{"10.0.0.0/24"}) + if got := tp.ExtractSourceIP(nil); got != "" { + t.Errorf("nil request: want empty, got %s", got) + } +} + +func TestTrustedProxies_ExtractSourceIP_UnixSocket(t *testing.T) { + tp := NewTrustedProxies([]string{"10.0.0.0/24"}) + r := newReq("@", "", "") + if got := tp.ExtractSourceIP(r); got != "@" { + t.Errorf("unix socket: want @, got %s", got) + } +} diff --git a/weed/s3api/s3_iam_middleware.go b/weed/s3api/s3_iam_middleware.go index 8ee81eb19..77b01b7a2 100644 --- a/weed/s3api/s3_iam_middleware.go +++ b/weed/s3api/s3_iam_middleware.go @@ -3,9 +3,9 @@ package s3api import ( "context" "fmt" - "net" "net/http" "strings" + "sync/atomic" "time" "github.com/golang-jwt/jwt/v5" @@ -13,6 +13,7 @@ import ( "github.com/seaweedfs/seaweedfs/weed/iam/integration" "github.com/seaweedfs/seaweedfs/weed/iam/providers" "github.com/seaweedfs/seaweedfs/weed/iam/sts" + "github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine" "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" "github.com/seaweedfs/seaweedfs/weed/security" ) @@ -33,10 +34,11 @@ type IAMManagerProvider interface { // S3IAMIntegration provides IAM integration for S3 API type S3IAMIntegration struct { - iamManager *integration.IAMManager - stsService *sts.STSService - filerAddress string - enabled bool + iamManager *integration.IAMManager + stsService *sts.STSService + filerAddress string + enabled bool + trustedProxies atomic.Pointer[policy_engine.TrustedProxies] } // NewS3IAMIntegration creates a new S3 IAM integration @@ -59,6 +61,12 @@ func (s3iam *S3IAMIntegration) GetIAMManager() *integration.IAMManager { return s3iam.iamManager } +// SetTrustedProxies configures the allowlist used to decide whether +// forwarded headers are honored when extracting aws:SourceIp. +func (s3iam *S3IAMIntegration) SetTrustedProxies(tp *policy_engine.TrustedProxies) { + s3iam.trustedProxies.Store(tp) +} + // AuthenticateJWT authenticates JWT tokens using our STS service func (s3iam *S3IAMIntegration) AuthenticateJWT(ctx context.Context, r *http.Request) (*IAMIdentity, s3err.ErrorCode) { @@ -247,7 +255,7 @@ func (s3iam *S3IAMIntegration) AuthorizeAction(ctx context.Context, identity *IA } // Extract request context for policy conditions - requestContext := extractRequestContext(r) + requestContext := s3iam.extractRequestContext(r) // For list operations, populate the s3:prefix condition key and ensure the // resource ARN stays at bucket level (matching AWS ListBucket semantics). @@ -386,25 +394,20 @@ func buildS3ResourceArn(bucket string, objectKey string) string { } // extractRequestContext extracts request context for policy conditions -func extractRequestContext(r *http.Request) map[string]interface{} { +func (s3iam *S3IAMIntegration) extractRequestContext(r *http.Request) map[string]interface{} { context := make(map[string]interface{}) - // Extract source IP for IP-based conditions - // Use AWS-compatible key name for policy variable substitution - sourceIP := extractSourceIP(r) + sourceIP := s3iam.extractSourceIP(r) if sourceIP != "" { context["aws:SourceIp"] = sourceIP } - // Extract user agent if userAgent := r.Header.Get("User-Agent"); userAgent != "" { context["userAgent"] = userAgent } - // Extract request time context["requestTime"] = r.Context().Value("requestTime") - // Extract additional headers that might be useful for conditions if referer := r.Header.Get("Referer"); referer != "" { context["referer"] = referer } @@ -412,17 +415,11 @@ func extractRequestContext(r *http.Request) map[string]interface{} { return context } -// extractSourceIP returns the direct TCP peer address for aws:SourceIp -// condition evaluation. Forwarding headers (X-Forwarded-For, X-Real-IP) are -// intentionally ignored: without a configurable trusted-proxy allowlist they -// are client-controlled and spoofable, which would let a caller behind a -// private-looking peer bypass any aws:SourceIp restriction. -func extractSourceIP(r *http.Request) string { - remoteIP := r.RemoteAddr - if ip, _, err := net.SplitHostPort(remoteIP); err == nil { - remoteIP = ip - } - return remoteIP +// extractSourceIP returns the client IP for aws:SourceIp condition +// evaluation, honoring forwarded headers only when the direct TCP peer is in +// the configured trusted-proxy allowlist (see SetTrustedProxies). +func (s3iam *S3IAMIntegration) extractSourceIP(r *http.Request) string { + return s3iam.trustedProxies.Load().ExtractSourceIP(r) } // ParseUnverifiedJWTToken parses a JWT token and returns its claims WITHOUT cryptographic verification diff --git a/weed/s3api/s3_jwt_auth_test.go b/weed/s3api/s3_jwt_auth_test.go index 2997f3e81..a3705862c 100644 --- a/weed/s3api/s3_jwt_auth_test.go +++ b/weed/s3api/s3_jwt_auth_test.go @@ -13,6 +13,7 @@ import ( "github.com/seaweedfs/seaweedfs/weed/iam/oidc" "github.com/seaweedfs/seaweedfs/weed/iam/policy" "github.com/seaweedfs/seaweedfs/weed/iam/sts" + "github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine" "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" "github.com/stretchr/testify/assert" @@ -195,8 +196,8 @@ func TestRequestContextExtraction(t *testing.T) { t.Run(tt.name, func(t *testing.T) { req := tt.setupRequest() - // Extract request context - context := extractRequestContext(req) + s3iam := &S3IAMIntegration{} + context := s3iam.extractRequestContext(req) if tt.expectedIP != "" { assert.Equal(t, tt.expectedIP, context["aws:SourceIp"]) @@ -209,6 +210,20 @@ func TestRequestContextExtraction(t *testing.T) { } } +// TestRequestContextExtraction_TrustedProxy verifies that when a trusted +// proxy allowlist is configured, X-Forwarded-For is honored. +func TestRequestContextExtraction_TrustedProxy(t *testing.T) { + s3iam := &S3IAMIntegration{} + s3iam.SetTrustedProxies(policy_engine.NewTrustedProxies([]string{"10.0.0.0/24"})) + + req := httptest.NewRequest("GET", "/test-bucket/test-file.txt", http.NoBody) + req.Header.Set("X-Forwarded-For", "203.0.113.99") + req.RemoteAddr = "10.0.0.1:12345" + + context := s3iam.extractRequestContext(req) + assert.Equal(t, "203.0.113.99", context["aws:SourceIp"]) +} + // TestIPBasedPolicyEnforcement tests IP-based conditional policies func TestIPBasedPolicyEnforcement(t *testing.T) { iamManager := setupTestIAMManager(t) diff --git a/weed/s3api/s3api_bucket_policy_engine.go b/weed/s3api/s3api_bucket_policy_engine.go index 28e4b90e1..cfc5bdf5c 100644 --- a/weed/s3api/s3api_bucket_policy_engine.go +++ b/weed/s3api/s3api_bucket_policy_engine.go @@ -145,7 +145,7 @@ func (bpe *BucketPolicyEngine) EvaluatePolicy(bucket, object, action, principal // Extract conditions and claims from request if available if r != nil { - args.Conditions = policy_engine.ExtractConditionValuesFromRequest(r) + args.Conditions = bpe.engine.ExtractConditionValuesFromRequest(r) // Extract principal-related variables (aws:username, etc.) from principal ARN principalVars := policy_engine.ExtractPrincipalVariables(principal) diff --git a/weed/s3api/s3api_server.go b/weed/s3api/s3api_server.go index e19210561..baefc8218 100644 --- a/weed/s3api/s3api_server.go +++ b/weed/s3api/s3api_server.go @@ -426,6 +426,8 @@ func NewS3ApiServerWithStore(router *mux.Router, option *S3ApiServerOption, expl } } + s3ApiServer.applyTrustedProxies(util.GetViper()) + // Initialize embedded IAM API if enabled if option.EnableIam { s3ApiServer.embeddedIam = NewEmbeddedIamApi(s3ApiServer.credentialManager, iam, option.IamReadOnly) @@ -460,6 +462,7 @@ func NewS3ApiServerWithStore(router *mux.Router, option *S3ApiServerOption, expl v.GetString("jwt.filer_signing.read.key"), v.GetInt("jwt.filer_signing.read.expires_after_seconds"), ) + s3ApiServer.applyTrustedProxies(v) util_http.ReloadJwtSigningReadConfig() }) s3ApiServer.bucketRegistry = NewBucketRegistry(s3ApiServer) @@ -506,6 +509,22 @@ func NewS3ApiServerWithStore(router *mux.Router, option *S3ApiServerOption, expl return s3ApiServer, nil } +// applyTrustedProxies reads [s3.trusted_proxies] from the security config and +// propagates the allowlist to the bucket policy engine, the IAM policy +// engine, and the IAM integration so aws:SourceIp honors forwarded headers +// only from configured trusted proxies. +func (s3a *S3ApiServer) applyTrustedProxies(v util.Configuration) { + whiteList := util.StringSplit(v.GetString("s3.trusted_proxies.white_list"), ",") + tp := policy_engine.NewTrustedProxies(whiteList) + if s3a.policyEngine != nil { + s3a.policyEngine.engine.SetTrustedProxies(tp) + } + s3a.iam.SetTrustedProxies(tp) + if s3a.iamIntegration != nil { + s3a.iamIntegration.SetTrustedProxies(tp) + } +} + func (s3a *S3ApiServer) Shutdown() { if s3a.versionsReconcilerStop != nil { s3a.versionsReconcilerStop()