s3: tighten STS session token handling (#11383)

* s3api: test that a session token must not reveal its credential

* sts: derive secret access key with HMAC keyed on the signing key

* s3api: stop accepting STS session tokens as bearer credentials

* security: reject STS session tokens on filer and admin gRPC auth

* test: sign s3/iam framework requests with the session credential

* s3api: exercise the real auth pipeline in the end-to-end harness
This commit is contained in:
Chris Lu authored and GitHub committed 2026-09-17 23:50:16 -07:00
1 parent 15520f601f
commit f40687b34e
19 files changed
+278 -165

No files matched your search

+121
View File
@@ -0,0 +1,121 @@
package s3api
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
// sessionIdFromToken reads the session id out of a session token (or a
// presigned URL's X-Amz-Security-Token) without any key.
func sessionIdFromToken(t *testing.T, token string) string {
t.Helper()
parts := strings.Split(token, ".")
require.Len(t, parts, 3)
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
require.NoError(t, err)
var claims map[string]interface{}
require.NoError(t, json.Unmarshal(payload, &claims))
sid, ok := claims["sid"].(string)
require.True(t, ok, "session token should carry a sid claim")
return sid
}
// issueTestSession mints an STS session the way AssumeRoleWithWebIdentity does.
func issueTestSession(t *testing.T, stsService *sts.STSService, config *sts.STSConfig) (*sts.SessionInfo, string) {
t.Helper()
sessionId, err := sts.GenerateSessionId()
require.NoError(t, err)
claims := sts.NewSTSSessionClaims(sessionId, config.Issuer, time.Now().Add(time.Hour)).
WithSessionName("alice-session").
WithRoleInfo("arn:aws:iam::role/AppRole",
"arn:aws:sts::assumed-role/AppRole/alice-session",
"arn:aws:sts::assumed-role/AppRole/alice-session")
token, err := sts.NewTokenGenerator(config.SigningKey, config.Issuer).GenerateJWTWithClaims(claims)
require.NoError(t, err)
sessionInfo, err := stsService.ValidateSessionToken(context.Background(), token)
require.NoError(t, err)
return sessionInfo, token
}
// testIdentityFromSessionToken builds the identity for a validated STS session
// token. Session tokens are not bearer credentials; tests use this to reach
// the authorization layer the way a verified SigV4 request would.
func testIdentityFromSessionToken(t *testing.T, s3iam *S3IAMIntegration, sessionToken string) *IAMIdentity {
t.Helper()
sessionInfo, err := s3iam.stsService.ValidateSessionToken(context.Background(), sessionToken)
require.NoError(t, err)
claims := make(map[string]interface{}, len(sessionInfo.RequestContext)+4)
for k, v := range sessionInfo.RequestContext {
claims[k] = v
}
claims["sub"] = sessionInfo.Subject
claims["role"] = sessionInfo.RoleArn
claims["principal"] = sessionInfo.Principal
claims["snam"] = sessionInfo.SessionName
return &IAMIdentity{
Name: sessionInfo.Subject,
Principal: sessionInfo.Principal,
SessionToken: sessionToken,
Account: &Account{
DisplayName: sessionInfo.SessionName,
EmailAddress: sessionInfo.Subject + "@seaweedfs.local",
Id: sessionInfo.Subject,
},
Claims: claims,
}
}
// A presigned URL discloses the session token in X-Amz-Security-Token. Whoever
// holds it must not be able to reconstruct the temporary credential or turn it
// into a standalone credential.
func TestSTSSessionTokenDoesNotRevealCredential(t *testing.T) {
stsService, config := setupTestSTSService(t)
iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{}, nil, "memory")
s3iam := &S3IAMIntegration{stsService: stsService, enabled: true}
iam.SetIAMIntegration(s3iam)
sessionInfo, sessionToken := issueTestSession(t, stsService, config)
// control: the issued credential verifies
req, err := newTestRequest(http.MethodGet, "https://example.com/reports/data.csv", 0, nil)
require.NoError(t, err)
req.Header.Set("X-Amz-Security-Token", sessionToken)
require.NoError(t, signRequestV4(req, sessionInfo.Credentials.AccessKeyId, sessionInfo.Credentials.SecretAccessKey))
_, errCode := iam.reqSignatureV4Verify(req)
require.Equal(t, s3err.ErrNone, errCode)
// attack: recompute the credential from the token's public claims
sid := sessionIdFromToken(t, sessionToken)
akHash := sha256.Sum256([]byte("access-key:" + sid))
accessKey := "ASIA" + hex.EncodeToString(akHash[:8])
skHash := sha256.Sum256([]byte("secret-key:" + sid))
secretKey := base64.StdEncoding.EncodeToString(skHash[:])
forged, err := newTestRequest(http.MethodDelete, "https://example.com/reports/payroll.csv", 0, nil)
require.NoError(t, err)
forged.Header.Set("X-Amz-Security-Token", sessionToken)
require.NoError(t, signRequestV4(forged, accessKey, secretKey))
_, errCode = iam.reqSignatureV4Verify(forged)
require.NotEqual(t, s3err.ErrNone, errCode,
"a request signed with a credential derived from the session token must not verify")
// attack: replay the token itself as a bearer credential
bearerReq := httptest.NewRequest(http.MethodDelete, "/reports/payroll.csv", http.NoBody)
bearerReq.Header.Set("Authorization", "Bearer "+sessionToken)
_, errCode = s3iam.AuthenticateJWT(context.Background(), bearerReq)
require.NotEqual(t, s3err.ErrNone, errCode,
"an STS session token must not authenticate as a bearer token")
}
+1 -1
View File
@@ -98,7 +98,7 @@ func TestAuthorizeWithIAMSessionTokenExtraction(t *testing.T) {
// preserved when converting to credentials for authorization.
func TestSTSSessionTokenIntoCredentials(t *testing.T) {
// Create a credential generator and session claims
credGen := sts.NewCredentialGenerator()
credGen := sts.NewCredentialGenerator([]byte("test-signing-key"))
sessionId := "test-session-123"
expiresAt := time.Now().Add(time.Hour)
+18 -24
View File
@@ -114,13 +114,12 @@ func TestS3EndToEndWithJWT(t *testing.T) {
})
require.NoError(t, err, "Failed to assume role %s", tt.roleArn)
jwtToken := response.Credentials.SessionToken
require.NotEmpty(t, jwtToken, "JWT token should not be empty")
require.NotEmpty(t, response.Credentials.SessionToken, "session token should not be empty")
// Execute S3 operations
for i, operation := range tt.s3Operations {
t.Run(fmt.Sprintf("%s_%s", tt.name, operation.Operation), func(t *testing.T) {
allowed := executeS3OperationWithJWT(t, s3Server, operation, jwtToken)
allowed := executeS3OperationWithJWT(t, s3Server, operation, response.Credentials)
expected := tt.expectedResults[i]
if expected {
@@ -153,8 +152,6 @@ func TestS3MultipartUploadWithJWT(t *testing.T) {
})
require.NoError(t, err)
jwtToken := response.Credentials.SessionToken
// Test multipart upload workflow
tests := []struct {
name string
@@ -205,7 +202,7 @@ func TestS3MultipartUploadWithJWT(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
allowed := executeS3OperationWithJWT(t, s3Server, tt.operation, jwtToken)
allowed := executeS3OperationWithJWT(t, s3Server, tt.operation, response.Credentials)
if tt.expected {
assert.True(t, allowed, "Multipart operation %s should be allowed", tt.operation.Operation)
} else {
@@ -304,10 +301,7 @@ func TestS3ListObjectsV2PrefixCondition(t *testing.T) {
require.NotEmpty(t, sessionToken)
// Authenticate to get IAM identity
authReq := httptest.NewRequest("GET", "/examples", http.NoBody)
authReq.Header.Set("Authorization", "Bearer "+sessionToken)
identity, errCode := s3IAMIntegration.AuthenticateJWT(ctx, authReq)
require.Equal(t, s3err.ErrNone, errCode, "Authentication should succeed")
identity := testIdentityFromSessionToken(t, s3IAMIntegration, sessionToken)
tests := []struct {
name string
@@ -434,10 +428,7 @@ func TestS3CreateBucketWithAttachedPolicy(t *testing.T) {
})
require.NoError(t, err)
authReq := httptest.NewRequest("PUT", "/ml-models", http.NoBody)
authReq.Header.Set("Authorization", "Bearer "+response.Credentials.SessionToken)
identity, errCode := s3IAMIntegration.AuthenticateJWT(ctx, authReq)
require.Equal(t, s3err.ErrNone, errCode)
identity := testIdentityFromSessionToken(t, s3IAMIntegration, response.Credentials.SessionToken)
tests := []struct {
name string
@@ -538,8 +529,6 @@ func TestS3PerformanceWithIAM(t *testing.T) {
})
require.NoError(t, err)
jwtToken := response.Credentials.SessionToken
// Benchmark multiple GET requests
numRequests := 100
start := time.Now()
@@ -552,7 +541,7 @@ func TestS3PerformanceWithIAM(t *testing.T) {
Operation: "GetObject",
}
executeS3OperationWithJWT(t, s3Server, operation, jwtToken)
executeS3OperationWithJWT(t, s3Server, operation, response.Credentials)
}
duration := time.Since(start)
@@ -629,11 +618,13 @@ func setupCompleteS3IAMSystem(t *testing.T) (http.Handler, *integration.IAMManag
t.Skip("Could not create S3 IAM integration")
}
iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{}, nil, "memory")
iam.SetIAMIntegration(s3IAMIntegration)
// Add a simple test endpoint that we can use to verify IAM functionality
router.HandleFunc("/test-auth", func(w http.ResponseWriter, r *http.Request) {
// Test JWT authentication
identity, errCode := s3IAMIntegration.AuthenticateJWT(r.Context(), r)
if errCode != s3err.ErrNone {
identity, errCode, _ := iam.authenticateRequestInternal(r)
if errCode != s3err.ErrNone || identity == nil {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("Authentication failed"))
return
@@ -655,7 +646,7 @@ func setupCompleteS3IAMSystem(t *testing.T) (http.Handler, *integration.IAMManag
}
// Test authorization with appropriate action
authErrCode := s3IAMIntegration.AuthorizeAction(r.Context(), identity, action, "test-bucket", "test-object", r)
authErrCode := iam.authorizeWithIAM(r, identity, action, "test-bucket", "test-object")
if authErrCode != s3err.ErrNone {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("Authorization failed"))
@@ -900,10 +891,11 @@ func setupS3IPRestrictedRole(ctx context.Context, manager *integration.IAMManage
})
}
func executeS3OperationWithJWT(t *testing.T, s3Server http.Handler, operation S3Operation, jwtToken string) bool {
func executeS3OperationWithJWT(t *testing.T, s3Server http.Handler, operation S3Operation, creds *sts.Credentials) bool {
// Use our simplified test endpoint for IAM validation with the correct HTTP method
req := httptest.NewRequest(operation.Method, "/test-auth", nil)
req.Header.Set("Authorization", "Bearer "+jwtToken)
req, err := newTestRequest(operation.Method, "http://example.com/test-auth", 0, nil)
require.NoError(t, err)
req.Header.Set("X-Amz-Security-Token", creds.SessionToken)
req.Header.Set("Content-Type", "application/octet-stream")
// Set source IP if specified
@@ -912,6 +904,8 @@ func executeS3OperationWithJWT(t *testing.T, s3Server http.Handler, operation S3
req.RemoteAddr = operation.SourceIP + ":12345"
}
require.NoError(t, signRequestV4(req, creds.AccessKeyId, creds.SecretAccessKey))
// Execute request
recorder := httptest.NewRecorder()
s3Server.ServeHTTP(recorder, req)
+1 -3
View File
@@ -220,9 +220,7 @@ func groupsRolesJWT(t *testing.T, subject string, groups, roles []string) string
}
func authenticateSessionToken(t *testing.T, s3iam *S3IAMIntegration, sessionToken string) (*IAMIdentity, s3err.ErrorCode) {
req := httptest.NewRequest("GET", "/", http.NoBody)
req.Header.Set("Authorization", "Bearer "+sessionToken)
return s3iam.AuthenticateJWT(context.Background(), req)
return testIdentityFromSessionToken(t, s3iam, sessionToken), s3err.ErrNone
}
func authorizeGet(ctx context.Context, s3iam *S3IAMIntegration, identity *IAMIdentity, bucket string) bool {
+6 -45
View File
@@ -189,51 +189,12 @@ func (s3iam *S3IAMIntegration) AuthenticateJWT(ctx context.Context, r *http.Requ
}, 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
// STS session tokens authenticate SigV4 requests via proof of possession
// (signature with the derived secret). As bearer tokens they would turn
// every presigned URL, which carries the token in X-Amz-Security-Token,
// into a standalone credential.
glog.V(3).Infof("Rejected STS session token presented as bearer token")
return nil, s3err.ErrAccessDenied
}
// ValidateSessionToken checks the validity of an STS session token
+13 -3
View File
@@ -100,9 +100,9 @@ func TestJWTAuthenticationFlow(t *testing.T) {
// 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")
// Test session authentication via SigV4 proof of possession
identity, errCode := testSessionAuthentication(t, iamServer, response.Credentials)
require.Equal(t, s3err.ErrNone, errCode, "Session authentication should succeed")
require.NotNil(t, identity)
// Test authorization with appropriate role based on test case
@@ -537,6 +537,16 @@ func setupTestIPRestrictedRole(ctx context.Context, manager *integration.IAMMana
})
}
// testSessionAuthentication authenticates STS temporary credentials the way a
// real client does: a SigV4-signed request carrying the session token.
func testSessionAuthentication(t *testing.T, iam *IdentityAccessManagement, creds *sts.Credentials) (*Identity, s3err.ErrorCode) {
req, err := newTestRequest("GET", "https://example.com/test-bucket/test-object", 0, nil)
require.NoError(t, err)
req.Header.Set("X-Amz-Security-Token", creds.SessionToken)
require.NoError(t, signRequestV4(req, creds.AccessKeyId, creds.SecretAccessKey))
return iam.reqSignatureV4Verify(req)
}
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)
+3
View File
@@ -46,6 +46,9 @@ func (s3a *S3ApiServer) checkAdminAuth(ctx context.Context) error {
if err != nil || parsed == nil || !parsed.Valid {
return status.Error(codes.Unauthenticated, "invalid admin token")
}
if claims, ok := parsed.Claims.(*security.SeaweedFilerAdminClaims); !ok || claims.SessionId != "" {
return status.Error(codes.Unauthenticated, "invalid admin token")
}
return nil
}
+2 -2
View File
@@ -802,7 +802,7 @@ func (h *STSHandlers) handleGetFederationToken(w http.ResponseWriter, r *http.Re
}
// Generate temporary credentials
stsCredGen := sts.NewCredentialGenerator()
stsCredGen := h.stsService.GetCredentialGenerator()
stsCredsDet, err := stsCredGen.GenerateTemporaryCredentials(sessionId, expiration)
if err != nil {
h.writeSTSErrorResponse(w, r, STSErrInternalError,
@@ -921,7 +921,7 @@ func (h *STSHandlers) prepareSTSCredentials(ctx context.Context, roleArn, roleSe
}
// Generate temporary credentials (deterministic based on sessionId)
stsCredGen := sts.NewCredentialGenerator()
stsCredGen := h.stsService.GetCredentialGenerator()
stsCredsDet, err := stsCredGen.GenerateTemporaryCredentials(sessionId, expiration)
if err != nil {
return STSCredentials{}, nil, fmt.Errorf("failed to generate temporary credentials: %w", err)