s3api: configurable trusted-proxy allowlist for aws:SourceIp (#11302) (#11315)

* s3api: add TrustedProxies allowlist helper for aws:SourceIp extraction

Introduces a policy_engine.TrustedProxies type that parses a
comma-separated list of bare IPs and CIDRs (mirroring Guard.UpdateWhiteList)
and extracts the client IP for aws:SourceIp condition evaluation.

When the direct TCP peer is in the allowlist, X-Forwarded-For is walked
right-to-left skipping trusted hops (then X-Real-Ip); otherwise the direct
peer address is returned. This is the building block for restoring
configurable forwarded-header trust removed in b88156f (#11231), as
proposed in #11302.

* s3api: honor trusted-proxy allowlist in bucket/IAM policy engine

Make ExtractConditionValuesFromRequest a method on *PolicyEngine so it
can use the engine TrustedProxies when resolving aws:SourceIp. With no
allowlist configured the behavior is unchanged from b88156f: the direct
TCP peer is used and forwarded headers are ignored. When an allowlist is
configured via SetTrustedProxies, requests from a trusted peer honor
X-Forwarded-For (right-to-left) then X-Real-Ip.

Update the two call sites (auth_credentials.go, s3api_bucket_policy_engine.go)
and the engine tests to the method form, and add a regression test for the
trusted-proxy path.

* s3api: honor trusted-proxy allowlist in IAM role/session policies

Make extractRequestContext and extractSourceIP methods on
*S3IAMIntegration so they can use the integration TrustedProxies when
resolving aws:SourceIp. With no allowlist configured the behavior is
unchanged from b88156f: the direct TCP peer is used and forwarded
headers are ignored. When an allowlist is configured via
SetTrustedProxies, requests from a trusted peer honor X-Forwarded-For
(right-to-left) then X-Real-Ip.

Update the call site in isActionExplicitlyDeniedByIAM to type-assert
the integration and use the method, and add a regression test for the
trusted-proxy path.

* s3api: load [s3.trusted_proxies] from security.toml and wire to engines

Read s3.trusted_proxies.white_list (comma-separated IPs/CIDRs) from
security.toml and propagate the allowlist to the bucket policy engine,
the IAM policy engine (persisted across rebuilds via
IdentityAccessManagement.SetTrustedProxies), and the IAM integration.
Reloaded on SIGHUP alongside the JWT signing keys. Document the new
section in the scaffold security.toml.

Closes #11302.

* s3api: harden TrustedProxies parsing and X-Forwarded-For traversal

Canonicalize bare IP entries (via net.ParseIP + String) so non-canonical
IPv6 allowlist entries such as 2001:0db8::1 match peers rendered as
2001:db8::1, and log+skip unparseable bare entries instead of storing
them inertly.

When walking X-Forwarded-For right-to-left, stop at the first malformed
(non-empty, unparseable) entry instead of skipping it, and only fall
back to the leftmost valid IP when the chain was well-formed. This
prevents a malformed hop from masking a forged IP to its left.

Addresses review feedback on #11315.

* s3api: make TrustedProxies reload race-free via atomic.Pointer

Store the trusted-proxy allowlist behind sync/atomic.Pointer in
PolicyEngine and S3IAMIntegration so SIGHUP reloads (which swap the
allowlist) cannot race with concurrent request handlers reading it.
This mirrors the existing Guard guardState pattern. The
IdentityAccessManagement copy is already protected by iam.m.

Addresses review feedback on #11315.
This commit is contained in:
Chris Lu
2026-09-14 13:54:26 -07:00
committed by GitHub
parent ac03d3fd78
commit 02749c1192
10 changed files with 377 additions and 69 deletions
+9
View File
@@ -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 = ""
+24 -2
View File
@@ -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)
+18 -36
View File
@@ -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)
+18 -4
View File
@@ -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()
+122
View File
@@ -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()
}
@@ -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)
}
}
+21 -24
View File
@@ -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
+17 -2
View File
@@ -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)
+1 -1
View File
@@ -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)
+19
View File
@@ -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()