mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-11 16:57:45 +02:00
s3api: tolerate session tokens on statically configured credentials (#11694)
* s3api: consolidate session token extraction into extractSessionToken Three call sites duplicated the same header/header/query lookup; share one helper named after the existing s3tables equivalent. * s3api: tolerate session tokens on statically configured credentials Credential vendors like Unity Catalog emit a session token with every vended credential, including static ones (UC's StaticAwsCredentialGenerator only engages when s3.sessionToken is set). Requests signed by a configured access key were routed to STS validation and rejected, so static credential vending never worked against SeaweedFS. Resolve the access key first: when it maps to a configured credential the signature alone authenticates the request, and the attached token is marked ignored so authorization does not route it into the STS session-policy path. STS-issued access keys are never in the static map, so temporary credentials still validate their token exactly as before. * s3api: cover static credentials carrying a session token * test: exercise UC static credential vending against SeaweedFS s3.sessionToken.0 selects UC's StaticAwsCredentialGenerator, which vends the configured keys verbatim. The vended session token is foreign to SeaweedFS and previously failed SigV4; now it round-trips through temporary-table-credentials into real S3 I/O. * s3api: reject temporary credentials in GetFederationToken after auth Token presence alone cannot distinguish a temporary credential from a statically configured one carrying a vended token. Move the check behind verifyV4Signature and key it on the operative session token so tolerated tokens keep the caller eligible. * s3api: test GetFederationToken with authenticated temporary credentials Sign the rejection cases with real session credentials so they reach the post-auth check, and cover a vended static credential being accepted. * test: check vended-credential delete error in UC integration test
This commit is contained in:
1 parent
924a683397
commit
c023493cf0
9 files changed
+297
-109
No files matched your search
@@ -26,6 +26,10 @@ const (
|
||||
ucAPIBase = "/api/2.1/unity-catalog"
|
||||
ucWarehouse = "lakehouse"
|
||||
ucWarehouseKey = "warehouse"
|
||||
// A non-empty s3.sessionToken.N puts UC's credential vendor on its
|
||||
// StaticAwsCredentialGenerator path: it vends the configured access key,
|
||||
// secret key and this token verbatim instead of calling AWS STS.
|
||||
ucVendedSessionToken = "unity-catalog-vended-session-token"
|
||||
|
||||
// Role used by the master-role STS-vended variant of the test. The trust
|
||||
// policy is wide open so any caller can assume it; in production UC
|
||||
@@ -65,6 +69,9 @@ type ucServerOpts struct {
|
||||
// MasterRoleArn populates aws.masterRoleArn. Empty means UC falls back to
|
||||
// static aws.accessKey / aws.secretKey for storage operations.
|
||||
MasterRoleArn string
|
||||
// SessionToken populates s3.sessionToken.0, selecting UC's static
|
||||
// credential generator for the per-bucket config.
|
||||
SessionToken string
|
||||
// ExtraEnv adds environment variables to the UC container, useful for
|
||||
// AWS_ENDPOINT_URL_STS-style overrides.
|
||||
ExtraEnv map[string]string
|
||||
@@ -220,6 +227,7 @@ func (env *testEnv) startUnityCatalog(t *testing.T, ctx context.Context, opts uc
|
||||
fmt.Sprintf("s3.awsRoleArn.0=%s", opts.MasterRoleArn),
|
||||
fmt.Sprintf("s3.accessKey.0=%s", env.accessKey),
|
||||
fmt.Sprintf("s3.secretKey.0=%s", env.secretKey),
|
||||
fmt.Sprintf("s3.sessionToken.0=%s", opts.SessionToken),
|
||||
fmt.Sprintf("s3.endpoint.0=%s", s3EndpointForContainer),
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
@@ -8,6 +8,7 @@ package unity_catalog
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -46,7 +47,7 @@ func TestUnityCatalogDeltaIntegration(t *testing.T) {
|
||||
}
|
||||
|
||||
t.Log(">>> starting Unity Catalog server (static keys)...")
|
||||
env.startUnityCatalog(t, ctx, ucServerOpts{})
|
||||
env.startUnityCatalog(t, ctx, ucServerOpts{SessionToken: ucVendedSessionToken})
|
||||
t.Log(">>> Unity Catalog ready")
|
||||
|
||||
uc := newUCClient(fmt.Sprintf("http://127.0.0.1:%d", env.ucHostPort))
|
||||
@@ -145,28 +146,41 @@ func TestUnityCatalogDeltaIntegration(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("TemporaryTableCredentialsRejected", func(t *testing.T) {
|
||||
// With aws.masterRoleArn empty AND no s3.sessionToken.0 set, UC OSS
|
||||
// always tries to AssumeRole via its internal StsClient (see
|
||||
// AwsCredentialVendor.createPerBucketCredentialGenerator). Against a
|
||||
// non-AWS endpoint, that call doesn't reach a real STS, so UC returns
|
||||
// "S3 bucket configuration not found." or an STS-side error. This is
|
||||
// the gap users hit at <https://github.com/data-engineering-helpers/mds-in-a-box/blob/main/unitycatalog-playground/etc/conf/server.properties#L45>:
|
||||
// "with simple S3 access and secret keys, Unity Catalog does not seem
|
||||
// to work."
|
||||
//
|
||||
// The assertion is therefore inverted: we expect a non-nil error from
|
||||
// /temporary-table-credentials with this configuration. A future
|
||||
// variant can pin s3.sessionToken.0 (UC's StaticAwsCredentialGenerator
|
||||
// path) once SeaweedFS' SigV4 path tolerates the vended session token.
|
||||
t.Run("TemporaryTableCredentials", func(t *testing.T) {
|
||||
// s3.sessionToken.0 is set, so UC's AwsCredentialVendor takes the
|
||||
// StaticAwsCredentialGenerator path and vends the configured keys
|
||||
// verbatim, token included — no STS call at all. SeaweedFS authenticates
|
||||
// the request on the static access key and tolerates the foreign token.
|
||||
if createdTable.TableID == "" {
|
||||
t.Fatalf("created table has empty table_id; cannot request temporary credentials")
|
||||
}
|
||||
_, err := uc.generateTemporaryTableCredentials(ctx, createdTable.TableID, "READ_WRITE")
|
||||
if err == nil {
|
||||
t.Fatalf("expected /temporary-table-credentials to fail with the static-key playground configuration; it succeeded unexpectedly")
|
||||
creds, err := uc.generateTemporaryTableCredentials(ctx, createdTable.TableID, "READ_WRITE")
|
||||
if err != nil {
|
||||
t.Fatalf("temporary-table-credentials: %v", err)
|
||||
}
|
||||
awsCreds := creds.AwsTempCredentials
|
||||
if awsCreds == nil || awsCreds.AccessKeyID == "" || awsCreds.SessionToken == "" {
|
||||
t.Fatalf("expected aws_temp_credentials with a session_token, got %+v", creds)
|
||||
}
|
||||
if awsCreds.AccessKeyID != env.accessKey || awsCreds.SessionToken != ucVendedSessionToken {
|
||||
t.Fatalf("expected UC to vend the configured static credential, got %+v", awsCreds)
|
||||
}
|
||||
|
||||
s3v := env.newHostS3ClientWithCreds(t, ctx, awsCreds.AccessKeyID, awsCreds.SecretAccessKey, awsCreds.SessionToken)
|
||||
probeKey := fmt.Sprintf("%s/%s/%s/vended.txt", ucWarehouseKey, schemaName, tableName)
|
||||
if _, err := s3v.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(ucWarehouse),
|
||||
Key: aws.String(probeKey),
|
||||
Body: strings.NewReader("ok"),
|
||||
}); err != nil {
|
||||
t.Fatalf("PutObject with UC-vended credentials: %v", err)
|
||||
}
|
||||
if _, err := s3v.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: aws.String(ucWarehouse),
|
||||
Key: aws.String(probeKey),
|
||||
}); err != nil {
|
||||
t.Fatalf("DeleteObject with UC-vended credentials: %v", err)
|
||||
}
|
||||
t.Logf("expected failure (UC static-key path requires AWS STS): %v", err)
|
||||
})
|
||||
|
||||
t.Run("DeleteTableSchemaCatalog", func(t *testing.T) {
|
||||
|
||||
@@ -2674,13 +2674,7 @@ func (iam *IdentityAccessManagement) isActionExplicitlyDeniedByIAM(r *http.Reque
|
||||
|
||||
// A chained caller authenticates with an STS session token whose inline
|
||||
// session policy can also carry an explicit deny.
|
||||
sessionToken := r.Header.Get(s3_constants.SeaweedFSSessionTokenHeader)
|
||||
if sessionToken == "" {
|
||||
sessionToken = r.Header.Get("X-Amz-Security-Token")
|
||||
if sessionToken == "" {
|
||||
sessionToken = r.URL.Query().Get("X-Amz-Security-Token")
|
||||
}
|
||||
}
|
||||
sessionToken := extractSessionToken(r)
|
||||
|
||||
if len(policyNames) == 0 && sessionToken == "" {
|
||||
return false
|
||||
@@ -2748,12 +2742,26 @@ func (iam *IdentityAccessManagement) attachedPolicyNames(identity *Identity) []s
|
||||
return names
|
||||
}
|
||||
|
||||
// extractSessionToken returns the request's session token, whichever transport
|
||||
// carried it: the internal header set after JWT authentication, the
|
||||
// X-Amz-Security-Token header, or the presigned-URL query parameter.
|
||||
func extractSessionToken(r *http.Request) string {
|
||||
if s3_constants.IsSessionTokenIgnored(r.Context()) {
|
||||
return ""
|
||||
}
|
||||
if token := r.Header.Get(s3_constants.SeaweedFSSessionTokenHeader); token != "" {
|
||||
return token
|
||||
}
|
||||
if token := r.Header.Get("X-Amz-Security-Token"); token != "" {
|
||||
return token
|
||||
}
|
||||
return r.URL.Query().Get("X-Amz-Security-Token")
|
||||
}
|
||||
|
||||
// hasSessionToken reports whether the request carries an STS session token,
|
||||
// whose session policies are known only to the IAM integration.
|
||||
func hasSessionToken(r *http.Request) bool {
|
||||
return r.Header.Get(s3_constants.SeaweedFSSessionTokenHeader) != "" ||
|
||||
r.Header.Get("X-Amz-Security-Token") != "" ||
|
||||
r.URL.Query().Get("X-Amz-Security-Token") != ""
|
||||
return extractSessionToken(r) != ""
|
||||
}
|
||||
|
||||
// authorizationRoute picks the mechanism, so every caller routes identically.
|
||||
@@ -3021,22 +3029,11 @@ func (iam *IdentityAccessManagement) authorizeWithIAM(r *http.Request, identity
|
||||
iam.primeBucketForIAM(bucket)
|
||||
}
|
||||
|
||||
// Get session info from request headers
|
||||
// First check for JWT-based authentication headers (SeaweedFSSessionTokenHeader)
|
||||
sessionToken := r.Header.Get(s3_constants.SeaweedFSSessionTokenHeader)
|
||||
// JWT authentication records its token in SeaweedFSSessionTokenHeader;
|
||||
// SigV4 requests carry it as X-Amz-Security-Token.
|
||||
sessionToken := extractSessionToken(r)
|
||||
principal := r.Header.Get(s3_constants.SeaweedFSPrincipalHeader)
|
||||
|
||||
// Fallback to AWS Signature V4 STS token if JWT token not present
|
||||
// This handles the case where STS AssumeRoleWithWebIdentity generates temporary credentials
|
||||
// that include an X-Amz-Security-Token header (in addition to the access key and secret)
|
||||
if sessionToken == "" {
|
||||
sessionToken = r.Header.Get("X-Amz-Security-Token")
|
||||
if sessionToken == "" {
|
||||
// Also check query parameters for presigned URLs with STS tokens
|
||||
sessionToken = r.URL.Query().Get("X-Amz-Security-Token")
|
||||
}
|
||||
}
|
||||
|
||||
policyNames := iam.attachedPolicyNames(identity)
|
||||
|
||||
iamIdentity := &IAMIdentity{
|
||||
|
||||
@@ -265,38 +265,40 @@ func (iam *IdentityAccessManagement) verifyV4Signature(r *http.Request, shouldCh
|
||||
|
||||
var cred *Credential
|
||||
|
||||
// 2. Check for STS session token
|
||||
sessionToken := r.Header.Get("X-Amz-Security-Token")
|
||||
if sessionToken == "" {
|
||||
sessionToken = r.URL.Query().Get("X-Amz-Security-Token")
|
||||
}
|
||||
if sessionToken != "" {
|
||||
// Validate STS session token
|
||||
identity, cred, errCode = iam.validateSTSSessionToken(r, sessionToken, authInfo.AccessKey)
|
||||
if errCode != s3err.ErrNone {
|
||||
return nil, nil, "", nil, errCode
|
||||
}
|
||||
} else {
|
||||
// 3. Lookup user and credentials
|
||||
var found bool
|
||||
identity, cred, found = iam.lookupByAccessKey(authInfo.AccessKey)
|
||||
if !found {
|
||||
// Log detailed error information for InvalidAccessKeyId (avoid slice allocation for performance)
|
||||
iam.m.RLock()
|
||||
keyCount := len(iam.accessKeyIdent)
|
||||
iam.m.RUnlock()
|
||||
|
||||
glog.Warningf("InvalidAccessKeyId: attempted key '%s' not found. Available keys: %d, Auth enabled: %v",
|
||||
authInfo.AccessKey, keyCount, iam.isAuthEnabled)
|
||||
return nil, nil, "", nil, s3err.ErrInvalidAccessKeyID
|
||||
}
|
||||
|
||||
// 2. Resolve the credential. A configured access key wins over a session
|
||||
// token: credential vendors like Unity Catalog emit a token with static
|
||||
// credentials too, and it adds nothing the signature does not prove.
|
||||
// STS-issued access keys are never registered in the static map, so they
|
||||
// still land on session-token validation.
|
||||
sessionToken := extractSessionToken(r)
|
||||
var found bool
|
||||
identity, cred, found = iam.lookupByAccessKey(authInfo.AccessKey)
|
||||
switch {
|
||||
case found:
|
||||
// Check service account expiration
|
||||
if cred.isCredentialExpired() {
|
||||
glog.V(2).Infof("Service account credential %s has expired (expiration: %d, now: %d)",
|
||||
authInfo.AccessKey, cred.Expiration, time.Now().Unix())
|
||||
return nil, nil, "", nil, s3err.ErrAccessDenied
|
||||
}
|
||||
if sessionToken != "" {
|
||||
*r = *r.WithContext(s3_constants.IgnoreSessionTokenInContext(r.Context()))
|
||||
}
|
||||
case sessionToken != "":
|
||||
// Validate STS session token
|
||||
identity, cred, errCode = iam.validateSTSSessionToken(r, sessionToken, authInfo.AccessKey)
|
||||
if errCode != s3err.ErrNone {
|
||||
return nil, nil, "", nil, errCode
|
||||
}
|
||||
default:
|
||||
// Log detailed error information for InvalidAccessKeyId (avoid slice allocation for performance)
|
||||
iam.m.RLock()
|
||||
keyCount := len(iam.accessKeyIdent)
|
||||
iam.m.RUnlock()
|
||||
|
||||
glog.Warningf("InvalidAccessKeyId: attempted key '%s' not found. Available keys: %d, Auth enabled: %v",
|
||||
authInfo.AccessKey, keyCount, iam.isAuthEnabled)
|
||||
return nil, nil, "", nil, s3err.ErrInvalidAccessKeyID
|
||||
}
|
||||
|
||||
// 3. Perform permission check
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
)
|
||||
|
||||
// Credential vendors like Unity Catalog emit a session token even for static
|
||||
// credentials. A request signed by a configured access key must authenticate
|
||||
// as that identity, tolerating the foreign token.
|
||||
func TestStaticCredentialWithSessionToken(t *testing.T) {
|
||||
const token = "vended-static-session-token"
|
||||
|
||||
newSigned := func(t *testing.T) *http.Request {
|
||||
req := mustNewRequest(http.MethodGet, "http://127.0.0.1:9000/bucket/object", 0, nil, t)
|
||||
req.Header.Set("X-Amz-Security-Token", token)
|
||||
require.NoError(t, signRequestV4(req, "access_key_1", "secret_key_1"))
|
||||
return mux.SetURLVars(req, map[string]string{"bucket": "bucket", "object": "object"})
|
||||
}
|
||||
|
||||
t.Run("authenticates as the static identity", func(t *testing.T) {
|
||||
iam := newTestIAMWithCreds()
|
||||
r := newSigned(t)
|
||||
|
||||
identity, errCode := iam.reqSignatureV4Verify(r)
|
||||
require.Equal(t, s3err.ErrNone, errCode)
|
||||
assert.Equal(t, "someone", identity.Name)
|
||||
assert.True(t, s3_constants.IsSessionTokenIgnored(r.Context()))
|
||||
assert.False(t, hasSessionToken(r), "tolerated token must be inert for authorization")
|
||||
})
|
||||
|
||||
t.Run("authorized through the static action path", func(t *testing.T) {
|
||||
iam := newTestIAMWithCreds()
|
||||
r := newSigned(t)
|
||||
|
||||
_, errCode := iam.authRequest(r, s3_constants.ACTION_READ)
|
||||
assert.Equal(t, s3err.ErrNone, errCode)
|
||||
})
|
||||
|
||||
t.Run("repeat verification stays idempotent", func(t *testing.T) {
|
||||
iam := newTestIAMWithCreds()
|
||||
r := newSigned(t)
|
||||
|
||||
_, errCode := iam.reqSignatureV4Verify(r)
|
||||
require.Equal(t, s3err.ErrNone, errCode)
|
||||
// Handlers re-authenticate the same request (e.g. PutObjectAcl).
|
||||
_, errCode = iam.reqSignatureV4Verify(r)
|
||||
assert.Equal(t, s3err.ErrNone, errCode)
|
||||
})
|
||||
|
||||
t.Run("unsigned token is still rejected", func(t *testing.T) {
|
||||
iam := newTestIAMWithCreds()
|
||||
r := mustNewSignedRequest(http.MethodGet, "http://127.0.0.1:9000/bucket/object", 0, nil, t)
|
||||
r.Header.Set("X-Amz-Security-Token", token)
|
||||
|
||||
_, errCode := iam.reqSignatureV4Verify(r)
|
||||
assert.Equal(t, s3err.ErrSignatureDoesNotMatch, errCode)
|
||||
})
|
||||
|
||||
t.Run("unknown access key with token still rejected", func(t *testing.T) {
|
||||
iam := newTestIAMWithCreds()
|
||||
req := mustNewRequest(http.MethodGet, "http://127.0.0.1:9000/bucket/object", 0, nil, t)
|
||||
req.Header.Set("X-Amz-Security-Token", token)
|
||||
require.NoError(t, signRequestV4(req, "no_such_key", "secret_key_1"))
|
||||
|
||||
_, errCode := iam.reqSignatureV4Verify(req)
|
||||
assert.Equal(t, s3err.ErrInvalidAccessKeyID, errCode)
|
||||
})
|
||||
|
||||
t.Run("wrong secret with token still rejected", func(t *testing.T) {
|
||||
iam := newTestIAMWithCreds()
|
||||
req := mustNewRequest(http.MethodGet, "http://127.0.0.1:9000/bucket/object", 0, nil, t)
|
||||
req.Header.Set("X-Amz-Security-Token", token)
|
||||
require.NoError(t, signRequestV4(req, "access_key_1", "wrong_secret"))
|
||||
|
||||
_, errCode := iam.reqSignatureV4Verify(req)
|
||||
assert.Equal(t, s3err.ErrSignatureDoesNotMatch, errCode)
|
||||
})
|
||||
|
||||
t.Run("presigned URL with token works", func(t *testing.T) {
|
||||
iam := newTestIAMWithCreds()
|
||||
req := mustNewRequest(http.MethodGet, "http://127.0.0.1:9000/bucket/object", 0, nil, t)
|
||||
q := req.URL.Query()
|
||||
q.Set("X-Amz-Security-Token", token)
|
||||
req.URL.RawQuery = q.Encode()
|
||||
require.NoError(t, preSignV4(iam, req, "access_key_1", "secret_key_1", int64((10*time.Minute).Seconds())))
|
||||
|
||||
_, _, errCode := iam.doesPresignedSignatureMatch(req)
|
||||
require.Equal(t, s3err.ErrNone, errCode)
|
||||
assert.True(t, s3_constants.IsSessionTokenIgnored(req.Context()))
|
||||
})
|
||||
}
|
||||
|
||||
func newTestIAMWithCreds() *IdentityAccessManagement {
|
||||
iam := &IdentityAccessManagement{
|
||||
hashes: make(map[string]*sync.Pool),
|
||||
hashCounters: make(map[string]*int32),
|
||||
}
|
||||
_ = iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{
|
||||
Identities: []*iam_pb.Identity{
|
||||
{
|
||||
Name: "someone",
|
||||
Credentials: []*iam_pb.Credential{
|
||||
{AccessKey: "access_key_1", SecretKey: "secret_key_1"},
|
||||
},
|
||||
Actions: []string{"Admin", "Read", "Write"},
|
||||
},
|
||||
},
|
||||
})
|
||||
return iam
|
||||
}
|
||||
@@ -322,11 +322,12 @@ func IsSeaweedFSInternalHeader(headerKey string) bool {
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
contextKeyIdentityName contextKey = "s3-identity-name"
|
||||
contextKeyIdentityObject contextKey = "s3-identity-object"
|
||||
contextKeyIdentityHolder contextKey = "s3-identity-holder"
|
||||
contextKeyPrincipalArn contextKey = "s3-principal-arn"
|
||||
contextKeyIdentityClaim contextKey = "s3-identity-claim"
|
||||
contextKeyIdentityName contextKey = "s3-identity-name"
|
||||
contextKeyIdentityObject contextKey = "s3-identity-object"
|
||||
contextKeyIdentityHolder contextKey = "s3-identity-holder"
|
||||
contextKeyPrincipalArn contextKey = "s3-principal-arn"
|
||||
contextKeyIdentityClaim contextKey = "s3-identity-claim"
|
||||
contextKeyIgnoredSessionToken contextKey = "s3-ignored-session-token"
|
||||
)
|
||||
|
||||
// identityHolder is a mutable container for the authenticated identity name,
|
||||
@@ -449,6 +450,22 @@ func GetIdentityClaimFromContext(r *http.Request) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// IgnoreSessionTokenInContext marks the request's session token as tolerated
|
||||
// rather than authenticated: a statically configured access key signed the
|
||||
// request, so the token (which credential vendors like Unity Catalog emit on
|
||||
// static credentials too) must not steer authorization into the STS session
|
||||
// path. A context value because headers can be spoofed; this cannot.
|
||||
func IgnoreSessionTokenInContext(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, contextKeyIgnoredSessionToken, true)
|
||||
}
|
||||
|
||||
// IsSessionTokenIgnored reports whether the request carries a session token
|
||||
// that was tolerated for a statically configured access key.
|
||||
func IsSessionTokenIgnored(ctx context.Context) bool {
|
||||
ignored, _ := ctx.Value(contextKeyIgnoredSessionToken).(bool)
|
||||
return ignored
|
||||
}
|
||||
|
||||
// SetIdentityInContext stores the full authenticated identity object in the request context
|
||||
// This is used to pass the full identity (including for JWT users) to handlers
|
||||
func SetIdentityInContext(ctx context.Context, identity interface{}) context.Context {
|
||||
|
||||
+7
-13
@@ -671,19 +671,6 @@ func (h *STSHandlers) handleGetFederationToken(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
|
||||
// Reject calls from temporary credentials (session tokens) early,
|
||||
// before SigV4 verification — no need to authenticate first.
|
||||
// GetFederationToken can only be called by long-term IAM users.
|
||||
securityToken := r.Header.Get("X-Amz-Security-Token")
|
||||
if securityToken == "" {
|
||||
securityToken = r.URL.Query().Get("X-Amz-Security-Token")
|
||||
}
|
||||
if securityToken != "" {
|
||||
h.writeSTSErrorResponse(w, r, STSErrAccessDenied,
|
||||
fmt.Errorf("GetFederationToken cannot be called with temporary credentials"))
|
||||
return
|
||||
}
|
||||
|
||||
// Check if STS service is initialized
|
||||
if h.stsService == nil || !h.stsService.IsInitialized() {
|
||||
h.writeSTSErrorResponse(w, r, STSErrSTSNotReady,
|
||||
@@ -713,6 +700,13 @@ func (h *STSHandlers) handleGetFederationToken(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
|
||||
// GetFederationToken can only be called by long-term IAM users.
|
||||
if extractSessionToken(r) != "" {
|
||||
h.writeSTSErrorResponse(w, r, STSErrAccessDenied,
|
||||
fmt.Errorf("GetFederationToken cannot be called with temporary credentials"))
|
||||
return
|
||||
}
|
||||
|
||||
r = r.WithContext(recordIdentityInContext(r, identity))
|
||||
|
||||
glog.V(2).Infof("GetFederationToken: caller identity=%s, name=%s", identity.Name, name)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -129,34 +130,46 @@ func TestGetFederationToken_WithSessionPolicy(t *testing.T) {
|
||||
assert.Equal(t, []string{"S3FullAccess"}, sessionInfo.Policies)
|
||||
}
|
||||
|
||||
// TestGetFederationToken_RejectTemporaryCredentials tests that requests with
|
||||
// session tokens are rejected.
|
||||
// TestGetFederationToken_RejectTemporaryCredentials tests that requests
|
||||
// authenticated with temporary credentials are rejected.
|
||||
func TestGetFederationToken_RejectTemporaryCredentials(t *testing.T) {
|
||||
stsService, _ := setupTestSTSService(t)
|
||||
stsHandlers := NewSTSHandlers(stsService, &IdentityAccessManagement{
|
||||
iamIntegration: &MockIAMIntegration{},
|
||||
})
|
||||
iam := &IdentityAccessManagement{
|
||||
iamIntegration: &MockIAMIntegration{
|
||||
validateSessionFunc: func(context.Context, string) (*sts.SessionInfo, error) {
|
||||
return &sts.SessionInfo{
|
||||
AssumedRoleUser: "role/user",
|
||||
Principal: "arn:aws:sts:::assumed-role/role/user",
|
||||
Credentials: &sts.Credentials{
|
||||
AccessKeyId: "STSACCESSKEY",
|
||||
SecretAccessKey: "sts-secret-key",
|
||||
},
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
hashes: make(map[string]*sync.Pool),
|
||||
hashCounters: make(map[string]*int32),
|
||||
}
|
||||
stsHandlers := NewSTSHandlers(stsService, iam)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setToken func(r *http.Request)
|
||||
description string
|
||||
name string
|
||||
setToken func(r *http.Request)
|
||||
}{
|
||||
{
|
||||
name: "SessionTokenInHeader",
|
||||
setToken: func(r *http.Request) {
|
||||
r.Header.Set("X-Amz-Security-Token", "some-session-token")
|
||||
r.Header.Set("X-Amz-Security-Token", "session-token")
|
||||
},
|
||||
description: "Session token in X-Amz-Security-Token header should be rejected",
|
||||
},
|
||||
{
|
||||
name: "SessionTokenInQuery",
|
||||
setToken: func(r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
q.Set("X-Amz-Security-Token", "some-session-token")
|
||||
q.Set("X-Amz-Security-Token", "session-token")
|
||||
r.URL.RawQuery = q.Encode()
|
||||
},
|
||||
description: "Session token in query string should be rejected",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -167,28 +180,47 @@ func TestGetFederationToken_RejectTemporaryCredentials(t *testing.T) {
|
||||
form.Set("Name", "TestUser")
|
||||
form.Set("Version", "2011-06-15")
|
||||
|
||||
req := httptest.NewRequest("POST", "/", strings.NewReader(form.Encode()))
|
||||
req := mustNewRequest(http.MethodPost, "http://sts.amazonaws.com/",
|
||||
int64(len(form.Encode())), strings.NewReader(form.Encode()), t)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
tt.setToken(req)
|
||||
|
||||
// Parse form so the handler can read it
|
||||
require.NoError(t, req.ParseForm())
|
||||
// Re-set values after parse
|
||||
req.Form.Set("Action", "GetFederationToken")
|
||||
req.Form.Set("Name", "TestUser")
|
||||
req.Form.Set("Version", "2011-06-15")
|
||||
require.NoError(t, signRequestV4(req, "STSACCESSKEY", "sts-secret-key"))
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
stsHandlers.HandleSTSRequest(rr, req)
|
||||
|
||||
// The handler rejects temporary credentials before SigV4 verification
|
||||
assert.Equal(t, http.StatusForbidden, rr.Code, tt.description)
|
||||
assert.Equal(t, http.StatusForbidden, rr.Code)
|
||||
assert.Contains(t, rr.Body.String(), "AccessDenied")
|
||||
assert.Contains(t, rr.Body.String(), "cannot be called with temporary credentials")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A session token attached to a statically configured credential (as credential
|
||||
// vendors emit) must not disqualify the caller as temporary.
|
||||
func TestGetFederationToken_VendedStaticCredential(t *testing.T) {
|
||||
stsService, _ := setupTestSTSService(t)
|
||||
iam := newTestIAMWithCreds()
|
||||
stsHandlers := NewSTSHandlers(stsService, iam)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("Action", "GetFederationToken")
|
||||
form.Set("Name", "VendedApp")
|
||||
form.Set("Version", "2011-06-15")
|
||||
|
||||
req := mustNewRequest(http.MethodPost, "http://sts.amazonaws.com/",
|
||||
int64(len(form.Encode())), strings.NewReader(form.Encode()), t)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("X-Amz-Security-Token", "vendor-token")
|
||||
require.NoError(t, signRequestV4(req, "access_key_1", "secret_key_1"))
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
stsHandlers.HandleSTSRequest(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
|
||||
assert.Contains(t, rr.Body.String(), "GetFederationTokenResponse")
|
||||
}
|
||||
|
||||
// TestGetFederationToken_MissingName tests that a missing Name parameter returns an error
|
||||
func TestGetFederationToken_MissingName(t *testing.T) {
|
||||
stsService, _ := setupTestSTSService(t)
|
||||
|
||||
@@ -97,6 +97,9 @@ func hasSessionToken(r *http.Request) bool {
|
||||
}
|
||||
|
||||
func extractSessionToken(r *http.Request) string {
|
||||
if s3_constants.IsSessionTokenIgnored(r.Context()) {
|
||||
return ""
|
||||
}
|
||||
if token := r.Header.Get(s3_constants.SeaweedFSSessionTokenHeader); token != "" {
|
||||
return token
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user