mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-20 23:14:21 +00:00
* 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 inb88156f(#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 fromb88156f: 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 fromb88156f: 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:
@@ -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 = ""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user