mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-06 12:07:51 +01:00
feat(iam): STS web-identity AWS-fidelity polish (Phase 1) (#9318)
* feat(iam): STS web-identity AWS-fidelity polish - OIDC discovery via .well-known/openid-configuration; falls back to /.well-known/jwks.json when discovery is absent. Reject discovery docs whose issuer claim does not match the configured issuer to defend against issuer-substitution. - ComputeParentUser derives a stable per-identity hash from (sub, iss). Surface as aws:userid in the request context and as a parent_user claim in the session JWT so per-user state survives token rotation. - Per-role MaxSessionDuration (3600..43200) clamps requested DurationSeconds before the STS service applies its own caps. - Tighten RoleSessionName to the AWS contract: 2..64 chars from [\w+=,.@-]. - Populate PackedPolicySize in AssumeRole / AssumeRoleWithWebIdentity / AssumeRoleWithLDAPIdentity responses as a percentage of the 2048-byte inline session policy budget. * fix(iam): leave omitted DurationSeconds nil so STS default applies capDurationByRole was substituting the role's MaxSessionDuration when the caller omitted DurationSeconds entirely. AWS returns the configured default (typically 1 hour) in that case, not the role's upper bound — a 12h MaxSessionDuration shouldn't silently make every no-duration assume-role mint a 12h session. Return nil when requested is nil; let the downstream calculateSessionDuration in the STS service apply its TokenDuration default. The role-max upper bound still clamps when the request arrives with a concrete value above the cap. Addresses gemini high-priority review on PR #9318. * fix(iam): synchronize OIDCProvider JWKS cache fields jwksCache, jwksFetchedAt, resolvedJWKSUri, and discoveryFailed are mutated lazily on the first token-validate call and refreshed afterwards on TTL expiry. Multiple S3 requests can land here in parallel, so the writes were racing against subsequent reads on every other goroutine. resolvedJWKSUri/discoveryFailed inherited the same un-protected pattern when discovery shipped. Add sync.RWMutex; getPublicKey takes the read lock for the common cache-hit path and promotes to the write lock for misses + refreshes. fetchJWKSLocked / resolveJWKSUriLocked assume the write lock is held by the caller; fetchJWKS keeps the test-friendly entry point that acquires the lock itself. Addresses gemini high-priority review on PR #9318. * fix(iam): trim trailing slash + retry discovery after transient failure Two OIDC discovery edge cases reviewers flagged: 1. Issuer comparison was sensitive to trailing slashes. resolveJWKSUri trims them when building the discovery URL, but the doc.Issuer ↔ p.config.Issuer check did not, so an IDP whose issuer claim drops or adds the slash relative to the configured value would be falsely rejected. Trim a single trailing slash on each side before comparing. 2. discoveryFailed flipped to true on any error and stayed there for the process lifetime. A transient 5xx at startup permanently locked the provider into the /.well-known/jwks.json fallback. Reset the flag at the top of fetchJWKSLocked when no URI has been cached yet, so each JWKS refresh (typically once per TTL = 1h) reattempts discovery. Successful discovery remains cached via resolvedJWKSUri so we don't pay the discovery RTT on every refresh. Addresses gemini security-medium + medium reviews on PR #9318. * fix(iam): require non-empty issuer in OIDC discovery doc The previous "doc.Issuer != "" && ..." guard let a discovery document that omitted the issuer field bypass the issuer-mismatch check entirely, letting the doc steer fetchJWKS at any URL it provided. OIDC Discovery 1.0 §3 mandates the issuer field; treat missing as a hard failure same as mismatched. Trailing-slash equivalence still applies. Adds TestDiscoveryRejectsMissingIssuer alongside the existing TestDiscoveryRejectsIssuerMismatch via a new omitDiscoveryIssuer toggle on fakeIDP.
This commit is contained in:
1 parent
2417ba0354
commit
d951a8df5a
11 files changed
+698
-39
No files matched your search
+69
-17
@@ -53,6 +53,34 @@ const (
|
||||
// federationNameRegex validates the Name parameter for GetFederationToken per AWS spec
|
||||
var federationNameRegex = regexp.MustCompile(`^[\w+=,.@-]+$`)
|
||||
|
||||
// roleSessionNameRegex validates RoleSessionName per AWS spec.
|
||||
// Same character class as federation Name, but the length bounds differ
|
||||
// (RoleSessionName is 2..64).
|
||||
var roleSessionNameRegex = regexp.MustCompile(`^[\w+=,.@-]+$`)
|
||||
|
||||
const (
|
||||
minRoleSessionNameLen = 2
|
||||
maxRoleSessionNameLen = 64
|
||||
)
|
||||
|
||||
// validateRoleSessionName enforces the AWS RoleSessionName contract:
|
||||
// length 2..64, characters [\w+=,.@-]+. Returns the STS error code and a
|
||||
// descriptive error suitable for callers to surface to the caller.
|
||||
func validateRoleSessionName(name string) (STSErrorCode, error) {
|
||||
if name == "" {
|
||||
return STSErrMissingParameter, fmt.Errorf("RoleSessionName is required")
|
||||
}
|
||||
if len(name) < minRoleSessionNameLen || len(name) > maxRoleSessionNameLen {
|
||||
return STSErrInvalidParameterValue,
|
||||
fmt.Errorf("RoleSessionName must be between %d and %d characters", minRoleSessionNameLen, maxRoleSessionNameLen)
|
||||
}
|
||||
if !roleSessionNameRegex.MatchString(name) {
|
||||
return STSErrInvalidParameterValue,
|
||||
fmt.Errorf(`RoleSessionName contains invalid characters; allowed: [\w+=,.@-]`)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// STS duration constants (AWS specification)
|
||||
const (
|
||||
minDurationSeconds = int64(900) // 15 minutes
|
||||
@@ -61,6 +89,27 @@ const (
|
||||
maxFederationDurationSeconds = int64(129600) // 36 hours (GetFederationToken max)
|
||||
)
|
||||
|
||||
// AWS limits inline session policies to 2048 characters for AssumeRole,
|
||||
// AssumeRoleWithWebIdentity, and AssumeRoleWithSAML. PackedPolicySize is
|
||||
// returned as a percentage of that budget so callers can detect how close
|
||||
// they are to the limit.
|
||||
const sessionPolicyBudgetBytes = 2048
|
||||
|
||||
// computePackedPolicySize returns the inline session policy size as a
|
||||
// percentage of the per-action budget, or nil when no session policy was
|
||||
// provided. Output is bounded to [0, 100] for AWS-compat reporting; the
|
||||
// actual policy size validation happens upstream in NormalizeSessionPolicy.
|
||||
func computePackedPolicySize(policyJSON string) *int64 {
|
||||
if policyJSON == "" {
|
||||
return nil
|
||||
}
|
||||
pct := int64(len(policyJSON)) * 100 / sessionPolicyBudgetBytes
|
||||
if pct > 100 {
|
||||
pct = 100
|
||||
}
|
||||
return &pct
|
||||
}
|
||||
|
||||
// parseDurationSecondsWithBounds parses and validates the DurationSeconds parameter
|
||||
// against the given min and max bounds. Returns nil if the parameter is not provided.
|
||||
func parseDurationSecondsWithBounds(r *http.Request, minSec, maxSec int64) (*int64, STSErrorCode, error) {
|
||||
@@ -170,9 +219,8 @@ func (h *STSHandlers) handleAssumeRoleWithWebIdentity(w http.ResponseWriter, r *
|
||||
return
|
||||
}
|
||||
|
||||
if roleSessionName == "" {
|
||||
h.writeSTSErrorResponse(w, r, STSErrMissingParameter,
|
||||
fmt.Errorf("RoleSessionName is required"))
|
||||
if errCode, err := validateRoleSessionName(roleSessionName); err != nil {
|
||||
h.writeSTSErrorResponse(w, r, errCode, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -245,6 +293,7 @@ func (h *STSHandlers) handleAssumeRoleWithWebIdentity(w http.ResponseWriter, r *
|
||||
Expiration: response.Credentials.Expiration.Format(time.RFC3339),
|
||||
},
|
||||
SubjectFromWebIdentityToken: response.AssumedRoleUser.Subject,
|
||||
PackedPolicySize: computePackedPolicySize(sessionPolicyJSON),
|
||||
},
|
||||
}
|
||||
xmlResponse.ResponseMetadata.RequestId = request_id.GetFromRequest(r)
|
||||
@@ -264,9 +313,8 @@ func (h *STSHandlers) handleAssumeRole(w http.ResponseWriter, r *http.Request) {
|
||||
// Validate required parameters
|
||||
// RoleArn is optional to support S3-compatible clients that omit it
|
||||
|
||||
if roleSessionName == "" {
|
||||
h.writeSTSErrorResponse(w, r, STSErrMissingParameter,
|
||||
fmt.Errorf("RoleSessionName is required"))
|
||||
if errCode, err := validateRoleSessionName(roleSessionName); err != nil {
|
||||
h.writeSTSErrorResponse(w, r, errCode, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -373,8 +421,9 @@ func (h *STSHandlers) handleAssumeRole(w http.ResponseWriter, r *http.Request) {
|
||||
// Build and return response
|
||||
xmlResponse := &AssumeRoleResponse{
|
||||
Result: AssumeRoleResult{
|
||||
Credentials: stsCreds,
|
||||
AssumedRoleUser: assumedUser,
|
||||
Credentials: stsCreds,
|
||||
AssumedRoleUser: assumedUser,
|
||||
PackedPolicySize: computePackedPolicySize(sessionPolicyJSON),
|
||||
},
|
||||
}
|
||||
xmlResponse.ResponseMetadata.RequestId = request_id.GetFromRequest(r)
|
||||
@@ -397,9 +446,8 @@ func (h *STSHandlers) handleAssumeRoleWithLDAPIdentity(w http.ResponseWriter, r
|
||||
return
|
||||
}
|
||||
|
||||
if roleSessionName == "" {
|
||||
h.writeSTSErrorResponse(w, r, STSErrMissingParameter,
|
||||
fmt.Errorf("RoleSessionName is required"))
|
||||
if errCode, err := validateRoleSessionName(roleSessionName); err != nil {
|
||||
h.writeSTSErrorResponse(w, r, errCode, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -514,8 +562,9 @@ func (h *STSHandlers) handleAssumeRoleWithLDAPIdentity(w http.ResponseWriter, r
|
||||
// Build and return response
|
||||
xmlResponse := &AssumeRoleWithLDAPIdentityResponse{
|
||||
Result: LDAPIdentityResult{
|
||||
Credentials: stsCreds,
|
||||
AssumedRoleUser: assumedUser,
|
||||
Credentials: stsCreds,
|
||||
AssumedRoleUser: assumedUser,
|
||||
PackedPolicySize: computePackedPolicySize(sessionPolicyJSON),
|
||||
},
|
||||
}
|
||||
xmlResponse.ResponseMetadata.RequestId = request_id.GetFromRequest(r)
|
||||
@@ -906,6 +955,7 @@ type WebIdentityResult struct {
|
||||
Credentials STSCredentials `xml:"Credentials"`
|
||||
SubjectFromWebIdentityToken string `xml:"SubjectFromWebIdentityToken,omitempty"`
|
||||
AssumedRoleUser *AssumedRoleUser `xml:"AssumedRoleUser,omitempty"`
|
||||
PackedPolicySize *int64 `xml:"PackedPolicySize,omitempty"`
|
||||
}
|
||||
|
||||
// STSCredentials represents temporary security credentials
|
||||
@@ -933,8 +983,9 @@ type AssumeRoleResponse struct {
|
||||
|
||||
// AssumeRoleResult contains the result of AssumeRole
|
||||
type AssumeRoleResult struct {
|
||||
Credentials STSCredentials `xml:"Credentials"`
|
||||
AssumedRoleUser *AssumedRoleUser `xml:"AssumedRoleUser,omitempty"`
|
||||
Credentials STSCredentials `xml:"Credentials"`
|
||||
AssumedRoleUser *AssumedRoleUser `xml:"AssumedRoleUser,omitempty"`
|
||||
PackedPolicySize *int64 `xml:"PackedPolicySize,omitempty"`
|
||||
}
|
||||
|
||||
// AssumeRoleWithLDAPIdentityResponse is the response for AssumeRoleWithLDAPIdentity
|
||||
@@ -948,8 +999,9 @@ type AssumeRoleWithLDAPIdentityResponse struct {
|
||||
|
||||
// LDAPIdentityResult contains the result of AssumeRoleWithLDAPIdentity
|
||||
type LDAPIdentityResult struct {
|
||||
Credentials STSCredentials `xml:"Credentials"`
|
||||
AssumedRoleUser *AssumedRoleUser `xml:"AssumedRoleUser,omitempty"`
|
||||
Credentials STSCredentials `xml:"Credentials"`
|
||||
AssumedRoleUser *AssumedRoleUser `xml:"AssumedRoleUser,omitempty"`
|
||||
PackedPolicySize *int64 `xml:"PackedPolicySize,omitempty"`
|
||||
}
|
||||
|
||||
// GetCallerIdentityResponse is the response for GetCallerIdentity
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package s3api
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestComputePackedPolicySize(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
policyLen int
|
||||
empty bool
|
||||
want int64
|
||||
}{
|
||||
{"empty -> nil", 0, true, 0},
|
||||
{"tiny policy -> 0%", 10, false, 0},
|
||||
{"half budget -> 50%", sessionPolicyBudgetBytes / 2, false, 50},
|
||||
{"full budget -> 100%", sessionPolicyBudgetBytes, false, 100},
|
||||
{"oversized -> capped at 100", sessionPolicyBudgetBytes * 3, false, 100},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
policy := repeat('a', tc.policyLen)
|
||||
got := computePackedPolicySize(policy)
|
||||
switch {
|
||||
case tc.empty:
|
||||
if got != nil {
|
||||
t.Fatalf("expected nil for empty input, got %d", *got)
|
||||
}
|
||||
case got == nil:
|
||||
t.Fatalf("expected non-nil result, got nil")
|
||||
case *got != tc.want:
|
||||
t.Fatalf("got=%d want=%d", *got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package s3api
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateRoleSessionName(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
// wantCode is checked only when wantErr is true
|
||||
wantCode STSErrorCode
|
||||
}{
|
||||
{"empty rejected", "", true, STSErrMissingParameter},
|
||||
{"single char rejected (below min len 2)", "a", true, STSErrInvalidParameterValue},
|
||||
{"min length 2 accepted", "ab", false, ""},
|
||||
{"plain ascii accepted", "session-name_1", false, ""},
|
||||
{"all special chars allowed", "+=,.@-", false, ""},
|
||||
{"email-style accepted", "alice@example.com", false, ""},
|
||||
{"max length 64 accepted", string(make([]byte, 64)), true, STSErrInvalidParameterValue}, // zero bytes -> invalid charset
|
||||
{"max length 64 valid charset accepted", repeat('a', 64), false, ""},
|
||||
{"length 65 rejected", repeat('a', 65), true, STSErrInvalidParameterValue},
|
||||
{"space rejected", "alice bob", true, STSErrInvalidParameterValue},
|
||||
{"slash rejected", "alice/bob", true, STSErrInvalidParameterValue},
|
||||
{"colon rejected", "alice:bob", true, STSErrInvalidParameterValue},
|
||||
{"unicode rejected", "alicé", true, STSErrInvalidParameterValue},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
code, err := validateRoleSessionName(tc.input)
|
||||
gotErr := err != nil
|
||||
if gotErr != tc.wantErr {
|
||||
t.Fatalf("err mismatch: got=%v want=%v (err=%v)", gotErr, tc.wantErr, err)
|
||||
}
|
||||
if tc.wantErr && code != tc.wantCode {
|
||||
t.Fatalf("code mismatch: got=%s want=%s", code, tc.wantCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func repeat(b byte, n int) string {
|
||||
out := make([]byte, n)
|
||||
for i := range out {
|
||||
out[i] = b
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
Reference in new issue
Block a user