Files
seaweedfs/weed/s3api/s3api_bucket_policy_engine.go
T
Chris Lu 02749c1192 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.
2026-09-14 13:54:26 -07:00

193 lines
7.1 KiB
Go

package s3api
import (
"encoding/json"
"fmt"
"net/http"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
)
// BucketPolicyEngine wraps the policy_engine to provide bucket policy evaluation
type BucketPolicyEngine struct {
engine *policy_engine.PolicyEngine
// MultipartSSELookup retrieves the canonical SSE algorithm ("AES256" or
// "aws:kms") that was stored when a multipart upload was initiated.
// Returns "" when the upload had no SSE or when the entry cannot be found.
// Set by S3ApiServer after construction to give the engine filer access.
MultipartSSELookup func(bucket, uploadID string) string
}
// NewBucketPolicyEngine creates a new bucket policy engine
func NewBucketPolicyEngine() *BucketPolicyEngine {
return &BucketPolicyEngine{
engine: policy_engine.NewPolicyEngine(),
}
}
// LoadBucketPolicy loads a bucket policy into the engine from the filer entry
func (bpe *BucketPolicyEngine) LoadBucketPolicy(bucket string, entry *filer_pb.Entry) error {
if entry == nil || entry.Extended == nil {
return nil
}
policyJSON, exists := entry.Extended[BUCKET_POLICY_METADATA_KEY]
if !exists || len(policyJSON) == 0 {
// No policy for this bucket - remove it if it exists
bpe.engine.DeleteBucketPolicy(bucket)
return nil
}
// Set the policy in the engine
if err := bpe.engine.SetBucketPolicy(bucket, string(policyJSON)); err != nil {
glog.Errorf("Failed to load bucket policy for %s: %v", bucket, err)
return err
}
glog.V(3).Infof("Loaded bucket policy for %s into policy engine", bucket)
return nil
}
// LoadBucketPolicyFromCache loads a bucket policy from a cached BucketConfig
//
// This function loads the policy directly into the engine
func (bpe *BucketPolicyEngine) LoadBucketPolicyFromCache(bucket string, policyDoc *policy_engine.PolicyDocument) error {
if policyDoc == nil {
// No policy for this bucket - remove it if it exists
bpe.engine.DeleteBucketPolicy(bucket)
return nil
}
// Policy is already in correct format, just load it
// We need to re-marshal to string because SetBucketPolicy expects JSON string
policyJSON, err := json.Marshal(policyDoc)
if err != nil {
glog.Errorf("Failed to marshal bucket policy for %s: %v", bucket, err)
return err
}
// Set the policy in the engine
if err := bpe.engine.SetBucketPolicy(bucket, string(policyJSON)); err != nil {
glog.Errorf("Failed to load bucket policy for %s: %v", bucket, err)
return err
}
glog.V(4).Infof("Loaded bucket policy for %s into policy engine from cache", bucket)
return nil
}
// DeleteBucketPolicy removes a bucket policy from the engine
func (bpe *BucketPolicyEngine) DeleteBucketPolicy(bucket string) error {
return bpe.engine.DeleteBucketPolicy(bucket)
}
// HasPolicyForBucket checks if a bucket has a policy configured
func (bpe *BucketPolicyEngine) HasPolicyForBucket(bucket string) bool {
return bpe.engine.HasPolicyForBucket(bucket)
}
// GetBucketPolicy gets the policy for a bucket
func (bpe *BucketPolicyEngine) GetBucketPolicy(bucket string) (*policy_engine.PolicyDocument, error) {
return bpe.engine.GetBucketPolicy(bucket)
}
// ListBucketPolicies returns all buckets that have policies
func (bpe *BucketPolicyEngine) ListBucketPolicies() []string {
return bpe.engine.GetAllBucketsWithPolicies()
}
// EvaluatePolicy evaluates whether an action is allowed by bucket policy
//
// Parameters:
// - bucket: the bucket name
// - object: the object key (can be empty for bucket-level operations)
// - action: the action being performed (e.g., "Read", "Write")
// - principal: the principal ARN or identifier
// - r: the HTTP request (optional, used for condition evaluation and action resolution)
// - objectEntry: the object's metadata from entry.Extended (can be nil at auth time,
// should be passed when available for tag-based conditions like s3:ExistingObjectTag)
//
// Returns:
// - allowed: whether the policy allows the action
// - evaluated: whether a policy was found and evaluated (false = no policy exists)
// - error: any error during evaluation
func (bpe *BucketPolicyEngine) EvaluatePolicy(bucket, object, action, principal string, r *http.Request, claims map[string]interface{}, objectEntry map[string][]byte) (allowed bool, evaluated bool, err error) {
// Validate required parameters
if bucket == "" {
return false, false, fmt.Errorf("bucket cannot be empty")
}
if action == "" {
return false, false, fmt.Errorf("action cannot be empty")
}
// Convert action to S3 action format
// ResolveS3Action handles nil request internally (falls back to mapBaseActionToS3Format)
s3Action := ResolveS3Action(r, action, bucket, object)
// Build resource ARN
resource := buildResourceARN(bucket, object)
glog.V(4).Infof("EvaluatePolicy: bucket=%s, resource=%s, action=%s, principal=%s",
bucket, resource, s3Action, principal)
// Evaluate using the policy engine
args := &policy_engine.PolicyEvaluationArgs{
Action: s3Action,
Resource: resource,
Principal: principal,
ObjectEntry: objectEntry,
}
// glog.V(4).Infof("EvaluatePolicy [Wrapper]: bucket=%s, resource=%s, action=%s, principal=%s",
// bucket, resource, s3Action, principal)
// Extract conditions and claims from request if available
if r != nil {
args.Conditions = bpe.engine.ExtractConditionValuesFromRequest(r)
// Extract principal-related variables (aws:username, etc.) from principal ARN
principalVars := policy_engine.ExtractPrincipalVariables(principal)
for k, v := range principalVars {
args.Conditions[k] = v
}
// Extract JWT claims if authenticated via JWT or STS
if claims != nil {
args.Claims = claims
} else {
// If claims were not provided directly, try to get them from context Identity?
// But the caller is responsible for passing them.
// Falling back to empty claims if not provided.
}
// For multipart continuation actions look up the SSE algorithm that was
// set at CreateMultipartUpload time. UploadPart/UploadPartCopy do not
// re-send the SSE header, so we inject the stored value so that bucket
// policy conditions on s3:x-amz-server-side-encryption evaluate correctly.
if policy_engine.IsMultipartContinuationAction(s3Action) && bpe.MultipartSSELookup != nil {
if uploadID := r.URL.Query().Get("uploadId"); uploadID != "" {
args.InheritedSSEAlgorithm = bpe.MultipartSSELookup(bucket, uploadID)
}
}
}
result := bpe.engine.EvaluatePolicy(bucket, args)
switch result {
case policy_engine.PolicyResultAllow:
// glog.V(4).Infof("EvaluatePolicy [Wrapper]: ALLOW - bucket=%s, action=%s, principal=%s", bucket, s3Action, principal)
return true, true, nil
case policy_engine.PolicyResultDeny:
// glog.V(4).Infof("EvaluatePolicy [Wrapper]: DENY - bucket=%s, action=%s, principal=%s", bucket, s3Action, principal)
return false, true, nil
case policy_engine.PolicyResultIndeterminate:
// No policy exists for this bucket
// glog.V(4).Infof("EvaluatePolicy [Wrapper]: INDETERMINATE (no policy) - bucket=%s", bucket)
return false, false, nil
default:
return false, false, fmt.Errorf("unknown policy result: %v", result)
}
}