mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-12 23:09:33 +01:00
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:
1 parent
15520f601f
commit
f40687b34e
19 files changed
+278
-165
No files matched your search
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user