Files
seaweedfs/weed/s3api/s3_iam_middleware.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

559 lines
19 KiB
Go

package s3api
import (
"context"
"fmt"
"net/http"
"strings"
"sync/atomic"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/seaweedfs/seaweedfs/weed/glog"
"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"
)
// IAMIntegration defines the interface for IAM integration
type IAMIntegration interface {
AuthenticateJWT(ctx context.Context, r *http.Request) (*IAMIdentity, s3err.ErrorCode)
AuthorizeAction(ctx context.Context, identity *IAMIdentity, action Action, bucket string, objectKey string, r *http.Request) s3err.ErrorCode
ValidateSessionToken(ctx context.Context, token string) (*sts.SessionInfo, error)
ValidateTrustPolicyForPrincipal(ctx context.Context, roleArn, principalArn string) error
DefaultAllow() bool
}
// IAMManagerProvider exposes the IAMManager backing an IAM integration.
type IAMManagerProvider interface {
GetIAMManager() *integration.IAMManager
}
// S3IAMIntegration provides IAM integration for S3 API
type S3IAMIntegration struct {
iamManager *integration.IAMManager
stsService *sts.STSService
filerAddress string
enabled bool
trustedProxies atomic.Pointer[policy_engine.TrustedProxies]
}
// NewS3IAMIntegration creates a new S3 IAM integration
func NewS3IAMIntegration(iamManager *integration.IAMManager, filerAddress string) *S3IAMIntegration {
var stsService *sts.STSService
if iamManager != nil {
stsService = iamManager.GetSTSService()
}
return &S3IAMIntegration{
iamManager: iamManager,
stsService: stsService,
filerAddress: filerAddress,
enabled: iamManager != nil,
}
}
// GetIAMManager returns the IAMManager backing this integration.
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) {
if !s3iam.enabled {
return nil, s3err.ErrNotImplemented
}
// Extract bearer token from Authorization header
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, security.BearerPrefix) {
return nil, s3err.ErrAccessDenied
}
sessionToken := strings.TrimPrefix(authHeader, security.BearerPrefix)
if sessionToken == "" {
return nil, s3err.ErrAccessDenied
}
// Basic token format validation - reject obviously invalid tokens
if sessionToken == "invalid-token" || len(sessionToken) < 10 {
glog.V(3).Info("Session token format is invalid")
return nil, s3err.ErrAccessDenied
}
// SECURITY NOTE: ParseJWTToken parses without cryptographic verification
// This is SAFE because we only use the unverified claims to route to the correct
// verification method. All code paths below perform full cryptographic verification:
// - OIDC tokens: validated via validateExternalOIDCToken (line 98)
// - STS tokens: validated via ValidateSessionToken (line 156)
// The unverified issuer claim is only used for routing, never for authorization.
tokenClaims, err := ParseUnverifiedJWTToken(sessionToken)
if err != nil {
glog.V(3).Infof("Failed to parse JWT token: %v", err)
return nil, s3err.ErrAccessDenied
}
// Determine token type by issuer claim (more robust than checking role claim)
// We use the unverified claims ONLY for routing to the correct verification method.
// We DO NOT use these claims for building the identity.
issuer, issuerOk := tokenClaims["iss"].(string)
if !issuerOk {
glog.V(3).Infof("Token missing issuer claim - invalid JWT")
return nil, s3err.ErrAccessDenied
}
// Check if this is an STS-issued token by examining the issuer
if !s3iam.isSTSIssuer(issuer) {
// Not an STS session token, try to validate as OIDC token with timeout
// Create a context with a reasonable timeout to prevent hanging
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
identity, err := s3iam.validateExternalOIDCToken(ctx, sessionToken)
if err != nil {
return nil, s3err.ErrAccessDenied
}
// Extract role from OIDC identity
if identity.RoleArn == "" {
return nil, s3err.ErrAccessDenied
}
// Same trust-policy gate as AssumeRoleWithWebIdentity: the role mapping
// alone must not grant a role that STS would refuse for this token.
if err := s3iam.iamManager.ValidateTrustPolicyForWebIdentity(ctx, identity.RoleArn, sessionToken, nil); err != nil {
glog.V(3).Infof("OIDC bearer token rejected by trust policy of role %s: %v", identity.RoleArn, err)
return nil, s3err.ErrAccessDenied
}
// Create claims map and populate with standard claims and attributes
claims := make(map[string]interface{}, len(identity.Attributes)+5)
// Add all attributes from the identity to the claims
// This makes attributes like "preferred_username" available for policy substitution
for k, v := range identity.Attributes {
claims[k] = v
}
// Add standard OIDC fields to claims so they are available as variables
// This ensures ${jwt:email}, ${jwt:name}, etc. work as documented in the wiki
if identity.Email != "" {
claims["email"] = identity.Email
}
if identity.DisplayName != "" {
claims["name"] = identity.DisplayName
}
if len(identity.Groups) > 0 {
claims["groups"] = identity.Groups
}
// Set critical claims explicitly, overwriting any from attributes to ensure correctness.
claims["sub"] = identity.UserID
claims["role"] = identity.RoleArn
// Use real email address if available
emailAddress := identity.UserID + "@oidc.local"
if identity.Email != "" {
emailAddress = identity.Email
}
displayName := identity.UserID
if identity.DisplayName != "" {
displayName = identity.DisplayName
}
// Return IAM identity for OIDC token
return &IAMIdentity{
Name: identity.UserID,
Principal: identity.RoleArn,
SessionToken: sessionToken,
Account: &Account{
DisplayName: displayName,
EmailAddress: emailAddress,
Id: identity.UserID,
},
Claims: claims,
IdentityClaim: sts.ResolveIdentityClaim(claims),
}, s3err.ErrNone
}
// This is an STS-issued token - validate with STS service
// ValidateSessionToken performs cryptographic verification and extraction of trusted claims
sessionInfo, err := s3iam.stsService.ValidateSessionToken(ctx, sessionToken)
if err != nil {
glog.V(3).Infof("STS session validation failed: %v", err)
return nil, s3err.ErrAccessDenied
}
// Create claims map starting with request context (which holds custom claims)
claims := make(map[string]interface{})
if sessionInfo.RequestContext != nil {
for k, v := range sessionInfo.RequestContext {
claims[k] = v
}
}
// Add standard claims
claims["sub"] = sessionInfo.Subject
claims["role"] = sessionInfo.RoleArn
claims["principal"] = sessionInfo.Principal
claims["snam"] = sessionInfo.SessionName
// Create IAM identity from VALIDATED session info
// We use the trusted data returned by the STS service, not the unverified token claims
identity := &IAMIdentity{
Name: sessionInfo.Subject,
Principal: sessionInfo.Principal,
SessionToken: sessionToken,
Account: &Account{
DisplayName: sessionInfo.SessionName,
EmailAddress: sessionInfo.Subject + "@seaweedfs.local",
Id: sessionInfo.Subject,
},
Claims: claims,
}
// ParentUser is set only for OIDC-federated sessions. Resolve the audit
// identity claim from the original request context (not the local claims
// map, whose sub was overwritten with the opaque session subject above) so
// the bearer path surfaces the same authoritative OIDC identity as SigV4.
if sessionInfo.ParentUser != "" {
identity.IdentityClaim = sts.ResolveIdentityClaim(sessionInfo.RequestContext)
}
glog.V(3).Infof("JWT authentication successful for principal: %s", identity.Principal)
return identity, s3err.ErrNone
}
// ValidateSessionToken checks the validity of an STS session token
func (s3iam *S3IAMIntegration) ValidateSessionToken(ctx context.Context, token string) (*sts.SessionInfo, error) {
if s3iam.stsService == nil {
return nil, fmt.Errorf("STS service not available")
}
return s3iam.stsService.ValidateSessionToken(ctx, token)
}
// AuthorizeAction authorizes actions using our policy engine
func (s3iam *S3IAMIntegration) AuthorizeAction(ctx context.Context, identity *IAMIdentity, action Action, bucket string, objectKey string, r *http.Request) s3err.ErrorCode {
if !s3iam.enabled {
return s3err.ErrNone // Fallback to existing authorization
}
if identity == nil || identity.Principal == "" {
return s3err.ErrAccessDenied
}
// Extract request context for policy conditions
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).
// See https://github.com/seaweedfs/seaweedfs/issues/8969
resourceObjectKey := objectKey
if action == "List" {
listPrefix := r.URL.Query().Get("prefix")
if listPrefix != "" {
requestContext["s3:prefix"] = listPrefix
} else if objectKey != "" && objectKey != "/" {
requestContext["s3:prefix"] = objectKey
} else {
requestContext["s3:prefix"] = ""
}
resourceObjectKey = ""
}
// Build resource ARN for the S3 operation
resourceArn := buildS3ResourceArn(bucket, resourceObjectKey)
// Add identity claims to request context for policy variables
// Only add claim keys if they don't already exist (to avoid overwriting request-derived context)
if identity.Claims != nil {
for k, v := range identity.Claims {
// Only add the claim if this key doesn't already exist in request context
if _, exists := requestContext[k]; !exists {
requestContext[k] = v
}
// If the claim doesn't have a namespace prefix (e.g. "email"), add "jwt:" prefix
// This allows ${jwt:email} or ${jwt:preferred_username} to work
// Only add namespaced version if it doesn't already exist
if !strings.Contains(k, ":") {
jwtKey := "jwt:" + k
if _, exists := requestContext[jwtKey]; !exists {
requestContext[jwtKey] = v
}
}
}
}
// Determine the specific S3 action based on the HTTP request details
specificAction := ResolveS3Action(r, string(action), bucket, objectKey)
// Create action request
actionRequest := &integration.ActionRequest{
Principal: identity.Principal,
Action: specificAction,
Resource: resourceArn,
// Static SigV4 IAM users do not carry a session token. IAMManager
// evaluates their attached policies directly and only validates STS/OIDC
// session state when a token is actually present.
SessionToken: identity.SessionToken,
RequestContext: requestContext,
PolicyNames: identity.PolicyNames,
}
// Check if action is allowed using our policy engine
allowed, err := s3iam.iamManager.IsActionAllowed(ctx, actionRequest)
if err != nil {
return s3err.ErrAccessDenied
}
if !allowed {
return s3err.ErrAccessDenied
}
return s3err.ErrNone
}
// ValidateTrustPolicyForPrincipal delegates to IAMManager to validate trust policy
func (s3iam *S3IAMIntegration) ValidateTrustPolicyForPrincipal(ctx context.Context, roleArn, principalArn string) error {
if s3iam.iamManager == nil {
return fmt.Errorf("IAM manager not available")
}
return s3iam.iamManager.ValidateTrustPolicyForPrincipal(ctx, roleArn, principalArn)
}
// DefaultAllow returns whether access is allowed by default when no policy is found
func (s3iam *S3IAMIntegration) DefaultAllow() bool {
if s3iam.iamManager == nil {
return true // Default to true if IAM is not enabled
}
return s3iam.iamManager.DefaultAllow()
}
// IAMIdentity represents an authenticated identity with session information
type IAMIdentity struct {
Name string
Principal string
SessionToken string
Account *Account
PolicyNames []string
Claims map[string]interface{}
IdentityClaim string // Authoritative OIDC identity claim for audit logging; empty for non-federated sessions
}
// IsAdmin checks if the identity has admin privileges
func (identity *IAMIdentity) IsAdmin() bool {
// In our IAM system, admin status is determined by policies, not identity
// This is handled by the policy engine during authorization
return false
}
// Mock session structures for validation
type MockSessionInfo struct {
AssumedRoleUser MockAssumedRoleUser
}
type MockAssumedRoleUser struct {
AssumedRoleId string
Arn string
}
// Helper functions
// buildS3ResourceArn builds an S3 resource ARN from bucket and object
func buildS3ResourceArn(bucket string, objectKey string) string {
// If bucket is already an ARN, return it as-is
if strings.HasPrefix(bucket, "arn:") {
return bucket
}
if bucket == "" {
return "arn:aws:s3:::*"
}
if objectKey == "" || objectKey == "/" {
return "arn:aws:s3:::" + bucket
}
// Remove leading slash from object key if present
objectKey = strings.TrimPrefix(objectKey, "/")
return "arn:aws:s3:::" + bucket + "/" + objectKey
}
// extractRequestContext extracts request context for policy conditions
func (s3iam *S3IAMIntegration) extractRequestContext(r *http.Request) map[string]interface{} {
context := make(map[string]interface{})
sourceIP := s3iam.extractSourceIP(r)
if sourceIP != "" {
context["aws:SourceIp"] = sourceIP
}
if userAgent := r.Header.Get("User-Agent"); userAgent != "" {
context["userAgent"] = userAgent
}
context["requestTime"] = r.Context().Value("requestTime")
if referer := r.Header.Get("Referer"); referer != "" {
context["referer"] = referer
}
return context
}
// 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
//
// SECURITY WARNING: This function does NOT validate the token signature!
// It should ONLY be used for:
// 1. Routing tokens to the appropriate verification method (e.g., checking issuer to determine STS vs OIDC)
// 2. Extracting claims for logging/debugging AFTER the token has been cryptographically verified
//
// NEVER use the returned claims for authorization decisions without first calling a proper
// verification function like ValidateSessionToken() or validateExternalOIDCToken().
func ParseUnverifiedJWTToken(tokenString string) (jwt.MapClaims, error) {
// Parse token without verification to get claims
// This token IS NOT VERIFIED at this stage.
// It is only used to peek at claims (like issuer) to determine which verification key/strategy to use.
token, _, err := new(jwt.Parser).ParseUnverified(tokenString, jwt.MapClaims{})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(jwt.MapClaims); ok {
return claims, nil
}
return nil, fmt.Errorf("invalid token claims")
}
// SetIAMIntegration adds advanced IAM integration to the S3ApiServer
func (s3a *S3ApiServer) SetIAMIntegration(iamManager *integration.IAMManager) {
if s3a.iam != nil {
s3a.iam.iamIntegration = NewS3IAMIntegration(iamManager, "localhost:8888")
glog.V(1).Infof("IAM integration successfully set on S3ApiServer")
} else {
glog.Errorf("Cannot set IAM integration: s3a.iam is nil")
}
}
// OIDCIdentity represents an identity validated through OIDC
type OIDCIdentity struct {
UserID string
RoleArn string
Provider string
Email string
DisplayName string
Groups []string
Attributes map[string]string
}
// validateExternalOIDCToken validates an external OIDC token using the STS service's secure issuer-based lookup
// This method delegates to the STS service's validateWebIdentityToken for better security and efficiency
func (s3iam *S3IAMIntegration) validateExternalOIDCToken(ctx context.Context, token string) (*OIDCIdentity, error) {
if s3iam.iamManager == nil {
return nil, fmt.Errorf("IAM manager not available")
}
// Get STS service for secure token validation
stsService := s3iam.iamManager.GetSTSService()
if stsService == nil {
return nil, fmt.Errorf("STS service not available")
}
// Use the STS service's secure validateWebIdentityToken method
// This method uses issuer-based lookup to select the correct provider, which is more secure and efficient
externalIdentity, provider, err := stsService.ValidateWebIdentityToken(ctx, token)
if err != nil {
return nil, fmt.Errorf("token validation failed: %w", err)
}
if externalIdentity == nil {
return nil, fmt.Errorf("authentication succeeded but no identity returned")
}
// Extract role from external identity attributes
rolesAttr, exists := externalIdentity.Attributes["roles"]
if !exists || rolesAttr == "" {
glog.V(3).Infof("No roles found in external identity")
return nil, fmt.Errorf("no roles found in external identity")
}
// Parse roles (stored as comma-separated string)
rolesStr := strings.TrimSpace(rolesAttr)
roles := strings.Split(rolesStr, ",")
// Clean up role names
var cleanRoles []string
for _, role := range roles {
cleanRole := strings.TrimSpace(role)
if cleanRole != "" {
cleanRoles = append(cleanRoles, cleanRole)
}
}
if len(cleanRoles) == 0 {
glog.V(3).Infof("Empty roles list after parsing")
return nil, fmt.Errorf("no valid roles found in token")
}
// Determine the primary role using intelligent selection
roleArn := s3iam.selectPrimaryRole(cleanRoles, externalIdentity)
return &OIDCIdentity{
UserID: externalIdentity.UserID,
RoleArn: roleArn,
Provider: fmt.Sprintf("%T", provider), // Use provider type as identifier
Email: externalIdentity.Email,
DisplayName: externalIdentity.DisplayName,
Groups: externalIdentity.Groups,
Attributes: externalIdentity.Attributes,
}, nil
}
// selectPrimaryRole simply picks the first role from the list
// The OIDC provider should return roles in priority order (most important first)
func (s3iam *S3IAMIntegration) selectPrimaryRole(roles []string, externalIdentity *providers.ExternalIdentity) string {
if len(roles) == 0 {
return ""
}
// Just pick the first one - keep it simple
selectedRole := roles[0]
return selectedRole
}
// isSTSIssuer determines if an issuer belongs to the STS service
// Uses exact match against configured STS issuer for security and correctness
func (s3iam *S3IAMIntegration) isSTSIssuer(issuer string) bool {
if s3iam.stsService == nil || s3iam.stsService.Config == nil {
return false
}
// Directly compare with the configured STS issuer for exact match
// This prevents false positives from external OIDC providers that might
// contain STS-related keywords in their issuer URLs
return issuer == s3iam.stsService.Config.Issuer
}