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

571 lines
16 KiB
Go

package s3api
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/seaweedfs/seaweedfs/weed/iam/integration"
"github.com/seaweedfs/seaweedfs/weed/iam/ldap"
"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"
"github.com/stretchr/testify/require"
)
// createTestJWTAuth creates a test JWT token with the specified issuer, subject and signing key
func createTestJWTAuth(t *testing.T, issuer, subject, signingKey string) string {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"iss": issuer,
"sub": subject,
"aud": "test-client-id",
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
// Add claims that trust policy validation expects
"idp": "test-oidc", // Identity provider claim for trust policy matching
})
tokenString, err := token.SignedString([]byte(signingKey))
require.NoError(t, err)
return tokenString
}
// TestJWTAuthenticationFlow tests the JWT authentication flow without full S3 server
func TestJWTAuthenticationFlow(t *testing.T) {
// Set up IAM system
iamManager := setupTestIAMManager(t)
// Create IAM integration
s3iam := NewS3IAMIntegration(iamManager, "localhost:8888")
// Create IAM server with integration
iamServer := setupIAMWithIntegration(t, iamManager, s3iam)
// Test scenarios
tests := []struct {
name string
roleArn string
setupRole func(ctx context.Context, mgr *integration.IAMManager)
testOperations []JWTTestOperation
}{
{
name: "Read-Only JWT Authentication",
roleArn: "arn:aws:iam::role/S3ReadOnlyRole",
setupRole: setupTestReadOnlyRole,
testOperations: []JWTTestOperation{
{Action: s3_constants.ACTION_READ, Bucket: "test-bucket", Object: "test-file.txt", ExpectedAllow: true},
{Action: s3_constants.ACTION_WRITE, Bucket: "test-bucket", Object: "new-file.txt", ExpectedAllow: false},
{Action: s3_constants.ACTION_LIST, Bucket: "test-bucket", Object: "", ExpectedAllow: true},
},
},
{
name: "Admin JWT Authentication",
roleArn: "arn:aws:iam::role/S3AdminRole",
setupRole: setupTestAdminRole,
testOperations: []JWTTestOperation{
{Action: s3_constants.ACTION_READ, Bucket: "admin-bucket", Object: "admin-file.txt", ExpectedAllow: true},
{Action: s3_constants.ACTION_WRITE, Bucket: "admin-bucket", Object: "new-admin-file.txt", ExpectedAllow: true},
{Action: s3_constants.ACTION_DELETE_BUCKET, Bucket: "admin-bucket", Object: "", ExpectedAllow: true},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
// Set up role
tt.setupRole(ctx, iamManager)
// Create a valid JWT token for testing
validJWTToken := createTestJWTAuth(t, "https://test-issuer.com", "test-user-123", "test-signing-key")
// Assume role to get JWT
response, err := iamManager.AssumeRoleWithWebIdentity(ctx, &sts.AssumeRoleWithWebIdentityRequest{
RoleArn: tt.roleArn,
WebIdentityToken: validJWTToken,
RoleSessionName: "jwt-auth-test",
})
require.NoError(t, err)
jwtToken := response.Credentials.SessionToken
// Test each operation
for _, op := range tt.testOperations {
t.Run(string(op.Action), func(t *testing.T) {
// Test JWT authentication
identity, errCode := testJWTAuthentication(t, iamServer, jwtToken)
require.Equal(t, s3err.ErrNone, errCode, "JWT authentication should succeed")
require.NotNil(t, identity)
// Test authorization with appropriate role based on test case
var testRoleName string
if tt.name == "Read-Only JWT Authentication" {
testRoleName = "TestReadRole"
} else {
testRoleName = "TestAdminRole"
}
allowed := testJWTAuthorizationWithRole(t, iamServer, identity, op.Action, op.Bucket, op.Object, jwtToken, testRoleName)
assert.Equal(t, op.ExpectedAllow, allowed, "Operation %s should have expected result", op.Action)
})
}
})
}
}
// TestJWTTokenValidation tests JWT token validation edge cases
func TestJWTTokenValidation(t *testing.T) {
iamManager := setupTestIAMManager(t)
s3iam := NewS3IAMIntegration(iamManager, "localhost:8888")
iamServer := setupIAMWithIntegration(t, iamManager, s3iam)
tests := []struct {
name string
token string
expectedErr s3err.ErrorCode
}{
{
name: "Empty token",
token: "",
expectedErr: s3err.ErrAccessDenied,
},
{
name: "Invalid token format",
token: "invalid-token",
expectedErr: s3err.ErrAccessDenied,
},
{
name: "Expired token",
token: "expired-session-token",
expectedErr: s3err.ErrAccessDenied,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
identity, errCode := testJWTAuthentication(t, iamServer, tt.token)
assert.Equal(t, tt.expectedErr, errCode)
assert.Nil(t, identity)
})
}
}
// TestRequestContextExtraction tests context extraction for policy conditions
func TestRequestContextExtraction(t *testing.T) {
tests := []struct {
name string
setupRequest func() *http.Request
expectedIP string
expectedUA string
}{
{
name: "Standard request with IP",
setupRequest: func() *http.Request {
req := httptest.NewRequest("GET", "/test-bucket/test-file.txt", http.NoBody)
req.Header.Set("X-Forwarded-For", "192.168.1.100")
req.Header.Set("User-Agent", "aws-sdk-go/1.0")
req.RemoteAddr = "127.0.0.1:12345"
return req
},
expectedIP: "127.0.0.1",
expectedUA: "aws-sdk-go/1.0",
},
{
name: "Request with X-Real-IP",
setupRequest: func() *http.Request {
req := httptest.NewRequest("GET", "/test-bucket/test-file.txt", http.NoBody)
req.Header.Set("X-Real-IP", "10.0.0.1")
req.Header.Set("User-Agent", "boto3/1.0")
req.RemoteAddr = "127.0.0.1:12345"
return req
},
expectedIP: "127.0.0.1",
expectedUA: "boto3/1.0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := tt.setupRequest()
s3iam := &S3IAMIntegration{}
context := s3iam.extractRequestContext(req)
if tt.expectedIP != "" {
assert.Equal(t, tt.expectedIP, context["aws:SourceIp"])
}
if tt.expectedUA != "" {
assert.Equal(t, tt.expectedUA, context["userAgent"])
}
})
}
}
// 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)
s3iam := NewS3IAMIntegration(iamManager, "localhost:8888")
ctx := context.Background()
// Set up IP-restricted role
setupTestIPRestrictedRole(ctx, iamManager)
// Create a valid JWT token for testing
validJWTToken := createTestJWTAuth(t, "https://test-issuer.com", "test-user-123", "test-signing-key")
// Assume role
response, err := iamManager.AssumeRoleWithWebIdentity(ctx, &sts.AssumeRoleWithWebIdentityRequest{
RoleArn: "arn:aws:iam::role/S3IPRestrictedRole",
WebIdentityToken: validJWTToken,
RoleSessionName: "ip-test-session",
})
require.NoError(t, err)
tests := []struct {
name string
sourceIP string
shouldAllow bool
}{
{
name: "Allow from office IP",
sourceIP: "192.168.1.100",
shouldAllow: true,
},
{
name: "Block from external IP",
sourceIP: "8.8.8.8",
shouldAllow: false,
},
{
name: "Allow from internal range",
sourceIP: "10.0.0.1",
shouldAllow: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create request with specific IP
req := httptest.NewRequest("GET", "/restricted-bucket/file.txt", http.NoBody)
req.Header.Set("Authorization", "Bearer "+response.Credentials.SessionToken)
req.RemoteAddr = tt.sourceIP + ":12345"
// Create IAM identity for testing
identity := &IAMIdentity{
Name: "test-user",
Principal: response.AssumedRoleUser.Arn,
SessionToken: response.Credentials.SessionToken,
}
// Test authorization with IP condition
errCode := s3iam.AuthorizeAction(ctx, identity, s3_constants.ACTION_READ, "restricted-bucket", "file.txt", req)
if tt.shouldAllow {
assert.Equal(t, s3err.ErrNone, errCode, "Should allow access from IP %s", tt.sourceIP)
} else {
assert.Equal(t, s3err.ErrAccessDenied, errCode, "Should deny access from IP %s", tt.sourceIP)
}
})
}
}
// JWTTestOperation represents a test operation for JWT testing
type JWTTestOperation struct {
Action Action
Bucket string
Object string
ExpectedAllow bool
}
// Helper functions
func setupTestIAMManager(t *testing.T) *integration.IAMManager {
// Create IAM manager
manager := integration.NewIAMManager()
// Initialize with test configuration
config := &integration.IAMConfig{
STS: &sts.STSConfig{
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
MaxSessionLength: sts.FlexibleDuration{Duration: time.Hour * 12},
Issuer: "test-sts",
SigningKey: []byte("test-signing-key-32-characters-long"),
},
Policy: &policy.PolicyEngineConfig{
DefaultEffect: "Deny",
StoreType: "memory",
},
Roles: &integration.RoleStoreConfig{
StoreType: "memory",
},
}
err := manager.Initialize(config, func() string {
return "localhost:8888" // Mock filer address for testing
})
require.NoError(t, err)
// Set up test identity providers
setupTestIdentityProviders(t, manager)
return manager
}
func setupTestIdentityProviders(t *testing.T, manager *integration.IAMManager) {
// Set up OIDC provider
oidcProvider := oidc.NewMockOIDCProvider("test-oidc")
oidcConfig := &oidc.OIDCConfig{
Issuer: "https://test-issuer.com",
ClientID: "test-client-id",
}
err := oidcProvider.Initialize(oidcConfig)
require.NoError(t, err)
oidcProvider.SetupDefaultTestData()
// Set up LDAP provider
ldapProvider := ldap.NewMockLDAPProvider("test-ldap")
err = ldapProvider.Initialize(nil) // Mock doesn't need real config
require.NoError(t, err)
ldapProvider.SetupDefaultTestData()
// Register providers
err = manager.RegisterIdentityProvider(oidcProvider)
require.NoError(t, err)
err = manager.RegisterIdentityProvider(ldapProvider)
require.NoError(t, err)
}
func setupIAMWithIntegration(t *testing.T, iamManager *integration.IAMManager, s3iam *S3IAMIntegration) *IdentityAccessManagement {
// Create a minimal IdentityAccessManagement for testing
iam := &IdentityAccessManagement{
isAuthEnabled: true,
}
// Set IAM integration
iam.SetIAMIntegration(s3iam)
return iam
}
func setupTestReadOnlyRole(ctx context.Context, manager *integration.IAMManager) {
// Create read-only policy
readPolicy := &policy.PolicyDocument{
Version: "2012-10-17",
Statement: []policy.Statement{
{
Sid: "AllowS3Read",
Effect: "Allow",
Action: []string{"s3:GetObject", "s3:ListBucket"},
Resource: []string{
"arn:aws:s3:::*",
"arn:aws:s3:::*/*",
},
},
{
Sid: "AllowSTSSessionValidation",
Effect: "Allow",
Action: []string{"sts:ValidateSession"},
Resource: []string{"*"},
},
},
}
manager.CreatePolicy(ctx, "", "S3ReadOnlyPolicy", readPolicy)
// Create role
manager.CreateRole(ctx, "", "S3ReadOnlyRole", &integration.RoleDefinition{
RoleName: "S3ReadOnlyRole",
TrustPolicy: &policy.PolicyDocument{
Version: "2012-10-17",
Statement: []policy.Statement{
{
Effect: "Allow",
Principal: map[string]interface{}{
"Federated": "test-oidc",
},
Action: []string{"sts:AssumeRoleWithWebIdentity"},
},
},
},
AttachedPolicies: []string{"S3ReadOnlyPolicy"},
})
// Also create a TestReadRole for read-only authorization testing
manager.CreateRole(ctx, "", "TestReadRole", &integration.RoleDefinition{
RoleName: "TestReadRole",
TrustPolicy: &policy.PolicyDocument{
Version: "2012-10-17",
Statement: []policy.Statement{
{
Effect: "Allow",
Principal: map[string]interface{}{
"Federated": "test-oidc",
},
Action: []string{"sts:AssumeRoleWithWebIdentity"},
},
},
},
AttachedPolicies: []string{"S3ReadOnlyPolicy"},
})
}
func setupTestAdminRole(ctx context.Context, manager *integration.IAMManager) {
// Create admin policy
adminPolicy := &policy.PolicyDocument{
Version: "2012-10-17",
Statement: []policy.Statement{
{
Sid: "AllowAllS3",
Effect: "Allow",
Action: []string{"s3:*"},
Resource: []string{
"arn:aws:s3:::*",
"arn:aws:s3:::*/*",
},
},
{
Sid: "AllowSTSSessionValidation",
Effect: "Allow",
Action: []string{"sts:ValidateSession"},
Resource: []string{"*"},
},
},
}
manager.CreatePolicy(ctx, "", "S3AdminPolicy", adminPolicy)
// Create role
manager.CreateRole(ctx, "", "S3AdminRole", &integration.RoleDefinition{
RoleName: "S3AdminRole",
TrustPolicy: &policy.PolicyDocument{
Version: "2012-10-17",
Statement: []policy.Statement{
{
Effect: "Allow",
Principal: map[string]interface{}{
"Federated": "test-oidc",
},
Action: []string{"sts:AssumeRoleWithWebIdentity"},
},
},
},
AttachedPolicies: []string{"S3AdminPolicy"},
})
// Also create a TestAdminRole with admin policy for authorization testing
manager.CreateRole(ctx, "", "TestAdminRole", &integration.RoleDefinition{
RoleName: "TestAdminRole",
TrustPolicy: &policy.PolicyDocument{
Version: "2012-10-17",
Statement: []policy.Statement{
{
Effect: "Allow",
Principal: map[string]interface{}{
"Federated": "test-oidc",
},
Action: []string{"sts:AssumeRoleWithWebIdentity"},
},
},
},
AttachedPolicies: []string{"S3AdminPolicy"}, // Admin gets full access
})
}
func setupTestIPRestrictedRole(ctx context.Context, manager *integration.IAMManager) {
// Create IP-restricted policy
restrictedPolicy := &policy.PolicyDocument{
Version: "2012-10-17",
Statement: []policy.Statement{
{
Sid: "AllowFromOffice",
Effect: "Allow",
Action: []string{"s3:GetObject", "s3:ListBucket"},
Resource: []string{
"arn:aws:s3:::*",
"arn:aws:s3:::*/*",
},
Condition: map[string]map[string]interface{}{
"IpAddress": {
"aws:SourceIp": []string{"192.168.1.0/24", "10.0.0.0/8"},
},
},
},
},
}
manager.CreatePolicy(ctx, "", "S3IPRestrictedPolicy", restrictedPolicy)
// Create role
manager.CreateRole(ctx, "", "S3IPRestrictedRole", &integration.RoleDefinition{
RoleName: "S3IPRestrictedRole",
TrustPolicy: &policy.PolicyDocument{
Version: "2012-10-17",
Statement: []policy.Statement{
{
Effect: "Allow",
Principal: map[string]interface{}{
"Federated": "test-oidc",
},
Action: []string{"sts:AssumeRoleWithWebIdentity"},
},
},
},
AttachedPolicies: []string{"S3IPRestrictedPolicy"},
})
}
func testJWTAuthentication(t *testing.T, iam *IdentityAccessManagement, token string) (*Identity, s3err.ErrorCode) {
// Create test request with JWT
req := httptest.NewRequest("GET", "/test-bucket/test-object", http.NoBody)
req.Header.Set("Authorization", "Bearer "+token)
// Test authentication
if iam.iamIntegration == nil {
return nil, s3err.ErrNotImplemented
}
return iam.authenticateJWTWithIAM(req)
}
func testJWTAuthorizationWithRole(t *testing.T, iam *IdentityAccessManagement, identity *Identity, action Action, bucket, object, token, roleName string) bool {
// Create test request
req := httptest.NewRequest("GET", "/"+bucket+"/"+object, http.NoBody)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set(s3_constants.SeaweedFSSessionTokenHeader, token)
// Use a proper principal ARN format that matches what STS would generate
principalArn := "arn:aws:sts::assumed-role/" + roleName + "/test-session"
req.Header.Set(s3_constants.SeaweedFSPrincipalHeader, principalArn)
// Test authorization
if iam.iamIntegration == nil {
return false
}
errCode := iam.authorizeWithIAM(req, identity, action, bucket, object)
return errCode == s3err.ErrNone
}