* request_id: add shared request middleware
* s3err: preserve request ids in responses and logs
* iam: reuse request ids in XML responses
* sts: reuse request ids in XML responses
* request_id: drop legacy header fallback
* request_id: use AWS-style request id format
* iam: fix AWS-compatible XML format for ErrorResponse and field ordering
- ErrorResponse uses bare <RequestId> at root level instead of
<ResponseMetadata> wrapper, matching the AWS IAM error response spec
- Move CommonResponse to last field in success response structs so
<ResponseMetadata> serializes after result elements
- Add randomness to request ID generation to avoid collisions
- Add tests for XML ordering and ErrorResponse format
* iam: remove duplicate error_response_test.go
Test is already covered by responses_test.go.
* address PR review comments
- Guard against typed nil pointers in SetResponseRequestID before
interface assertion (CodeRabbit)
- Use regexp instead of strings.Index in test helpers for extracting
request IDs (Gemini)
* request_id: prevent spoofing, fix nil-error branch, thread reqID to error writers
- Ensure() now always generates a server-side ID, ignoring client-sent
x-amz-request-id headers to prevent request ID spoofing. Uses a
private context key (contextKey{}) instead of the header string.
- writeIamErrorResponse in both iamapi and embedded IAM now accepts
reqID as a parameter instead of calling Ensure() internally, ensuring
a single request ID per request lifecycle.
- The nil-iamError branch in writeIamErrorResponse now writes a 500
Internal Server Error response instead of returning silently.
- Updated tests to set request IDs via context (not headers) and added
tests for spoofing prevention and context reuse.
* sts: add request-id consistency assertions to ActionInBody tests
* test: update admin test to expect server-generated request IDs
The test previously sent a client x-amz-request-id header and expected
it echoed back. Since Ensure() now ignores client headers to prevent
spoofing, update the test to verify the server returns a non-empty
server-generated request ID instead.
* iam: add generic WithRequestID helper alongside reflection-based fallback
Add WithRequestID[T] that uses generics to take the address of a value
type, satisfying the pointer receiver on SetRequestId without reflection.
The existing SetResponseRequestID is kept for the two call sites that
operate on interface{} (from large action switches where the concrete
type varies at runtime). Generics cannot replace reflection there since
Go cannot infer type parameters from interface{}.
* Remove reflection and generics from request ID setting
Call SetRequestId directly on concrete response types in each switch
branch before boxing into interface{}, eliminating the need for
WithRequestID (generics) and SetResponseRequestID (reflection).
* iam: return pointer responses in action dispatch
* Fix IAM error handling consistency and ensure request IDs on all responses
- UpdateUser/CreatePolicy error branches: use writeIamErrorResponse instead
of s3err.WriteErrorResponse to preserve IAM formatting and request ID
- ExecuteAction: accept reqID parameter and generate one if empty, ensuring
every response carries a RequestId regardless of caller
* Clean up inline policies on DeleteUser and UpdateUser rename
DeleteUser: remove InlinePolicies[userName] from policy storage before
removing the identity, so policies are not orphaned.
UpdateUser: move InlinePolicies[userName] to InlinePolicies[newUserName]
when renaming, so GetUserPolicy/DeleteUserPolicy work under the new name.
Both operations persist the updated policies and return an error if
the storage write fails, preventing partial state.
218 lines
8.5 KiB
Go
218 lines
8.5 KiB
Go
package s3api
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/gorilla/mux"
|
|
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
|
"github.com/seaweedfs/seaweedfs/weed/util/request_id"
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
// Minimal mock implementation of AuthenticateJWT needed for testing
|
|
type mockIAMIntegration struct{}
|
|
|
|
func (m *mockIAMIntegration) AuthenticateJWT(ctx context.Context, r *http.Request) (*IAMIdentity, s3err.ErrorCode) {
|
|
return &IAMIdentity{
|
|
Name: "test-user",
|
|
Account: &Account{
|
|
Id: "test-account",
|
|
DisplayName: "test-account",
|
|
EmailAddress: "test@example.com",
|
|
},
|
|
Principal: "arn:aws:iam::test-account:user/test-user",
|
|
SessionToken: "mock-session-token",
|
|
}, s3err.ErrNone
|
|
}
|
|
func (m *mockIAMIntegration) AuthorizeAction(ctx context.Context, identity *IAMIdentity, action Action, bucket, object string, r *http.Request) s3err.ErrorCode {
|
|
return s3err.ErrNone
|
|
}
|
|
func (m *mockIAMIntegration) ValidateTrustPolicyForPrincipal(ctx context.Context, roleArn, principalArn string) error {
|
|
return nil
|
|
}
|
|
func (m *mockIAMIntegration) ValidateSessionToken(ctx context.Context, token string) (*sts.SessionInfo, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockIAMIntegration) DefaultAllow() bool {
|
|
return true
|
|
}
|
|
|
|
func TestSTSAssumeRolePostBody(t *testing.T) {
|
|
// Setup S3ApiServer with IAM enabled
|
|
option := &S3ApiServerOption{
|
|
DomainName: "localhost",
|
|
EnableIam: true,
|
|
Filers: []pb.ServerAddress{"localhost:8888"},
|
|
}
|
|
|
|
// Create IAM instance that we can control
|
|
// We need to bypass the file/store loading logic in NewIdentityAccessManagement
|
|
// So we construct it manually similarly to how it's done for tests
|
|
iam := &IdentityAccessManagement{
|
|
identities: []*Identity{{Name: "test-user"}},
|
|
isAuthEnabled: true,
|
|
accessKeyIdent: make(map[string]*Identity),
|
|
nameToIdentity: make(map[string]*Identity),
|
|
iamIntegration: &mockIAMIntegration{},
|
|
}
|
|
|
|
// Pre-populate an identity for testing
|
|
ident := &Identity{
|
|
Name: "test-user",
|
|
Credentials: []*Credential{
|
|
{AccessKey: "test", SecretKey: "test", Status: "Active"},
|
|
},
|
|
Actions: nil, // Admin
|
|
IsStatic: true,
|
|
}
|
|
iam.identities[0] = ident
|
|
iam.accessKeyIdent["test"] = ident
|
|
iam.nameToIdentity["test-user"] = ident
|
|
|
|
s3a := &S3ApiServer{
|
|
option: option,
|
|
iam: iam,
|
|
embeddedIam: &EmbeddedIamApi{iam: iam, getS3ApiConfigurationFunc: func(cfg *iam_pb.S3ApiConfiguration) error { return nil }},
|
|
stsHandlers: NewSTSHandlers(nil, iam), // STS service nil -> will return STSErrSTSNotReady (503)
|
|
credentialManager: nil, // Not needed for this test as we pre-populated IAM
|
|
cb: &CircuitBreaker{
|
|
counters: make(map[string]*int64),
|
|
limitations: make(map[string]int64),
|
|
},
|
|
}
|
|
s3a.cb.s3a = s3a
|
|
s3a.inFlightDataLimitCond = sync.NewCond(&sync.Mutex{})
|
|
|
|
// Create router and register routes
|
|
router := mux.NewRouter()
|
|
s3a.registerRouter(router)
|
|
|
|
// Test Case 1: STS Action in Query String (Should work - routed to STS)
|
|
t.Run("ActionInQuery", func(t *testing.T) {
|
|
req := httptest.NewRequest("POST", "/?Action=AssumeRole", nil)
|
|
// We aren't signing requests, so we expect STSErrAccessDenied (403) from STS handler
|
|
// due to invalid signature, OR STSErrSTSNotReady (503) if it gets past auth.
|
|
// The key is it should NOT be 501 Not Implemented (which comes from IAM handler)
|
|
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
// If routed to STS, we expect 400 (Bad Request) - MissingParameter
|
|
// because we didn't provide RoleArn/RoleSessionName etc.
|
|
// Or 503 if it checks STS service readiness first.
|
|
|
|
// Let's see what we get. The STS handler checks parameters first.
|
|
// "RoleArn is required" -> 400 Bad Request
|
|
|
|
assert.NotEqual(t, http.StatusNotImplemented, rr.Code, "Should not return 501 (IAM handler)")
|
|
assert.Equal(t, http.StatusBadRequest, rr.Code, "Should return 400 (STS handler) for missing params")
|
|
assert.Equal(t, rr.Header().Get(request_id.AmzRequestIDHeader), extractSTSRequestID(rr.Body.String()))
|
|
})
|
|
|
|
// Test Case 2: STS Action in Body (Should FAIL current implementation - routed to IAM)
|
|
t.Run("ActionInBody", func(t *testing.T) {
|
|
form := url.Values{}
|
|
form.Add("Action", "AssumeRole")
|
|
form.Add("RoleArn", "arn:aws:iam::123:role/test")
|
|
form.Add("RoleSessionName", "session")
|
|
|
|
req := httptest.NewRequest("POST", "/", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
// We need an Authorization header to trigger the IAM matcher
|
|
// The matcher checks: getRequestAuthType(r) != authTypeAnonymous
|
|
// So we provide a dummy auth header
|
|
|
|
req.Header.Set("Authorization", "Bearer test-token")
|
|
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
// CURRENT BEHAVIOR:
|
|
// The Router does not match "/" for STS because Action is not in query.
|
|
// The Router matches "/" for IAM because it has Authorization header.
|
|
// IAM handler (AuthIam) calls DoActions.
|
|
// DoActions switches on "AssumeRole" -> default -> Not Implemented (501).
|
|
|
|
// DESIRED BEHAVIOR (after fix):
|
|
// Should be routed to UnifiedPostHandler (or similar), detected as STS action,
|
|
// and routed to STS handler.
|
|
// STS handler should return 403 Forbidden (Access Denied) or 400 Bad Request
|
|
// because of signature mismatch (since we provided dummy auth).
|
|
// It should NOT be 501.
|
|
|
|
// For verification of fix, we assert it IS 503 (STS Service Not Initialized).
|
|
// This confirms it was routed to STS handler.
|
|
if rr.Code != http.StatusServiceUnavailable {
|
|
t.Logf("Unexpected status code: %d", rr.Code)
|
|
t.Logf("Response body: %s", rr.Body.String())
|
|
}
|
|
// Confirm it routed to STS
|
|
assert.Equal(t, http.StatusServiceUnavailable, rr.Code, "Fixed behavior: Should return 503 from STS handler (service not ready)")
|
|
assert.Equal(t, rr.Header().Get(request_id.AmzRequestIDHeader), extractSTSRequestID(rr.Body.String()))
|
|
})
|
|
|
|
// Test Case 3: STS Action in Body with SigV4-style Authorization (Real-world scenario)
|
|
// This test validates that requests with AWS SigV4 Authorization headers and POST body
|
|
// parameters are correctly routed to the STS handler.
|
|
t.Run("ActionInBodyWithSigV4Style", func(t *testing.T) {
|
|
form := url.Values{}
|
|
form.Add("Action", "AssumeRole")
|
|
form.Add("RoleArn", "arn:aws:iam::123:role/test")
|
|
form.Add("RoleSessionName", "session")
|
|
|
|
bodyContent := form.Encode()
|
|
req := httptest.NewRequest("POST", "/", strings.NewReader(bodyContent))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
// Set AWS SigV4-style Authorization header
|
|
// This simulates a real SigV4-signed request without needing perfect signature
|
|
// The key is to validate that UnifiedPostHandler correctly routes based on Action
|
|
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=test/20260212/us-east-1/sts/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=dummy")
|
|
req.Header.Set("x-amz-date", "20260212T000000Z")
|
|
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
// With SigV4-style Authorization header, the request should:
|
|
// 1. Be recognized as authenticated (not anonymous)
|
|
// 2. Be routed to UnifiedPostHandler
|
|
// 3. UnifiedPostHandler should parse Action=AssumeRole from body
|
|
// 4. Route to STS handler (which returns 503 because stsService is nil)
|
|
// OR return 403 if signature validation fails (which is acceptable)
|
|
|
|
// The key validation is that it should NOT return 501 (IAM handler's "Not Implemented")
|
|
// This confirms the routing fix works for SigV4-signed requests with POST body params
|
|
|
|
if rr.Code != http.StatusServiceUnavailable && rr.Code != http.StatusForbidden {
|
|
t.Logf("Unexpected status code: %d", rr.Code)
|
|
t.Logf("Response body: %s", rr.Body.String())
|
|
}
|
|
|
|
// Accept either 503 (routed to STS, service unavailable) or 403 (signature failed)
|
|
// Both indicate correct routing to STS handler, not IAM handler
|
|
assert.NotEqual(t, http.StatusNotImplemented, rr.Code, "Should not return 501 (IAM handler)")
|
|
assert.Contains(t, []int{http.StatusServiceUnavailable, http.StatusForbidden}, rr.Code,
|
|
"Should return 503 (STS unavailable) or 403 (auth failed), confirming STS routing")
|
|
assert.Equal(t, rr.Header().Get(request_id.AmzRequestIDHeader), extractSTSRequestID(rr.Body.String()))
|
|
})
|
|
}
|
|
|
|
func extractSTSRequestID(body string) string {
|
|
re := regexp.MustCompile(`<RequestId>([^<]+)</RequestId>`)
|
|
matches := re.FindStringSubmatch(body)
|
|
if len(matches) < 2 {
|
|
return ""
|
|
}
|
|
return matches[1]
|
|
}
|