Files
seaweedfs/weed/s3api/auth_sts_v4_test.go
Chris Lu cce98fcecf fix(s3): strip client-supplied X-SeaweedFS-Principal/Session-Token in AuthSignatureOnly (#9120)
* fix(s3): strip client-supplied X-SeaweedFS-Principal/Session-Token in AuthSignatureOnly

AuthSignatureOnly is the only auth gate in front of S3Tables routes
(incl. CreateTableBucket) and UnifiedPostHandler, but unlike
authenticateRequestInternal it did not clear the internal IAM
trust headers before running signature verification. S3Tables
authorizeIAMAction reads X-SeaweedFS-Principal directly from the
request and prefers it over the authenticated identity's PrincipalArn,
so a signed low-privilege caller could append that header after signing
(unsigned header, SigV4 still verifies) and have IAM policy evaluated
against a spoofed principal, bypassing authorization.

Clear both X-SeaweedFS-Principal and X-SeaweedFS-Session-Token at the
top of AuthSignatureOnly, mirroring the existing guard in
authenticateRequestInternal. Add a regression test covering the
header-injection path.

* refactor(s3): route AuthSignatureOnly through authenticateRequestInternal

Addresses review feedback: both entry points were independently
maintaining the internal-IAM-header stripping and the auth-type dispatch
switch. Collapse AuthSignatureOnly into a thin wrapper around
authenticateRequestInternal so the security-critical header scrub and
the signature-verify switch live in one place. Post-auth behavior
unique to AuthSignatureOnly (AmzAccountId header) stays inline.

No functional change beyond two harmless telemetry tweaks that now
match authenticateRequestInternal: the per-branch glog verbosity shifts
from V(3) to V(4), and the anonymous-found path now sets AmzAuthType.

* refactor(s3): centralize X-SeaweedFS-Principal/Session-Token header names

Introduce SeaweedFSPrincipalHeader and SeaweedFSSessionTokenHeader in
weed/s3api/s3_constants so the trust-header literals are defined once and
referenced consistently by the auth scrub, JWT auth path, bucket policy
principal resolution, IAM authorization, and S3Tables IAM evaluation.
Replace every remaining usage in weed/s3api and weed/s3api/s3tables.
This removes the drift risk the reviewer called out: adding another call
site with a typo can no longer silently bypass the scrub.

Pure rename, no behavior change. No-op integration-test helper in
test/s3/iam/s3_iam_framework.go left untouched (separate module, and the
server now strips the client-supplied value regardless).
2026-04-17 12:23:21 -07:00

150 lines
5.9 KiB
Go

package s3api
import (
"net/http"
"net/url"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
)
// TestAuthorizeWithIAMSessionTokenExtraction tests that the authorizeWithIAM function
// correctly extracts session tokens from multiple sources and prioritizes them appropriately.
// This is a regression test for the bug where X-Amz-Security-Token was not being checked
// for V4 signature authentication with STS credentials.
func TestAuthorizeWithIAMSessionTokenExtraction(t *testing.T) {
t.Run("Extracts X-SeaweedFS-Session-Token from JWT auth", func(t *testing.T) {
req := &http.Request{
Header: http.Header{
"X-Seaweedfs-Session-Token": {"jwt-token-123"},
"X-Seaweedfs-Principal": {"arn:aws:iam::user/test"},
},
URL: &url.URL{},
}
// Extract tokens the same way authorizeWithIAM does
sessionToken := req.Header.Get(s3_constants.SeaweedFSSessionTokenHeader)
principal := req.Header.Get(s3_constants.SeaweedFSPrincipalHeader)
assert.Equal(t, "jwt-token-123", sessionToken, "Should extract JWT session token from header")
assert.Equal(t, "arn:aws:iam::user/test", principal, "Should extract principal from header")
})
t.Run("Extracts X-Amz-Security-Token from V4 STS auth header", func(t *testing.T) {
req := &http.Request{
Header: http.Header{
"X-Amz-Security-Token": {"sts-token-header-456"},
},
URL: &url.URL{},
}
// Extract tokens the same way authorizeWithIAM does
sessionToken := req.Header.Get(s3_constants.SeaweedFSSessionTokenHeader)
principal := req.Header.Get(s3_constants.SeaweedFSPrincipalHeader)
// If JWT token is empty, should fallback to X-Amz-Security-Token
if sessionToken == "" {
sessionToken = req.Header.Get("X-Amz-Security-Token")
}
assert.Equal(t, "sts-token-header-456", sessionToken, "Should fallback to X-Amz-Security-Token when JWT token is empty")
assert.Empty(t, principal, "JWT principal should be empty for V4 auth")
})
t.Run("Extracts X-Amz-Security-Token from query parameter (presigned URL)", func(t *testing.T) {
req := &http.Request{
Header: http.Header{},
URL: &url.URL{RawQuery: "X-Amz-Security-Token=sts-token-query-789"},
}
// Extract tokens the same way authorizeWithIAM does
sessionToken := req.Header.Get(s3_constants.SeaweedFSSessionTokenHeader)
if sessionToken == "" {
sessionToken = req.Header.Get("X-Amz-Security-Token")
if sessionToken == "" {
sessionToken = req.URL.Query().Get("X-Amz-Security-Token")
}
}
assert.Equal(t, "sts-token-query-789", sessionToken, "Should extract token from query parameter")
})
t.Run("JWT token takes precedence over X-Amz-Security-Token", func(t *testing.T) {
req := &http.Request{
Header: http.Header{
"X-Seaweedfs-Session-Token": {"jwt-preferred"},
"X-Seaweedfs-Principal": {"arn:aws:iam::user/jwt-user"},
"X-Amz-Security-Token": {"sts-fallback"},
},
URL: &url.URL{},
}
// Extract tokens the same way authorizeWithIAM does
sessionToken := req.Header.Get(s3_constants.SeaweedFSSessionTokenHeader)
if sessionToken == "" {
sessionToken = req.Header.Get("X-Amz-Security-Token")
}
assert.Equal(t, "jwt-preferred", sessionToken, "JWT token should take precedence")
})
}
// TestSTSSessionTokenIntoCredentials verifies that STS session tokens are properly
// preserved when converting to credentials for authorization.
func TestSTSSessionTokenIntoCredentials(t *testing.T) {
// Create a credential generator and session claims
credGen := sts.NewCredentialGenerator()
sessionId := "test-session-123"
expiresAt := time.Now().Add(time.Hour)
// Generate temporary credentials
creds, err := credGen.GenerateTemporaryCredentials(sessionId, expiresAt)
require.NoError(t, err, "Should generate credentials successfully")
require.NotNil(t, creds, "Credentials should not be nil")
// Verify all credential fields are present
assert.NotEmpty(t, creds.AccessKeyId, "AccessKeyId should be present")
assert.NotEmpty(t, creds.SecretAccessKey, "SecretAccessKey should be present")
assert.NotEmpty(t, creds.SessionToken, "SessionToken should be present for STS")
// Verify deterministic generation (same session ID produces same credentials)
creds2, err := credGen.GenerateTemporaryCredentials(sessionId, expiresAt)
require.NoError(t, err)
assert.Equal(t, creds.AccessKeyId, creds2.AccessKeyId, "AccessKeyId should be deterministic")
assert.Equal(t, creds.SecretAccessKey, creds2.SecretAccessKey, "SecretAccessKey should be deterministic")
assert.Equal(t, creds.SessionToken, creds2.SessionToken, "SessionToken should be deterministic for same sessionId")
// Verify different session produces different credentials
creds3, err := credGen.GenerateTemporaryCredentials("different-session", expiresAt)
require.NoError(t, err)
assert.NotEqual(t, creds.AccessKeyId, creds3.AccessKeyId, "Different sessions should produce different access key IDs")
assert.NotEqual(t, creds.SecretAccessKey, creds3.SecretAccessKey, "Different sessions should produce different secret keys")
assert.NotEqual(t, creds.SessionToken, creds3.SessionToken, "Different sessions should produce different session tokens")
}
// TestActionConstantsForV4Auth verifies that action constants are properly available
// for use in authorization checks with V4 signature authentication.
func TestActionConstantsForV4Auth(t *testing.T) {
// Verify that S3 action constants are available
actions := map[string]string{
"READ": s3_constants.ACTION_READ,
"WRITE": s3_constants.ACTION_WRITE,
"READ_ACP": s3_constants.ACTION_READ_ACP,
"WRITE_ACP": s3_constants.ACTION_WRITE_ACP,
"LIST": s3_constants.ACTION_LIST,
"TAGGING": s3_constants.ACTION_TAGGING,
"ADMIN": s3_constants.ACTION_ADMIN,
}
for name, action := range actions {
assert.NotEmpty(t, action, "Action %s should not be empty", name)
}
}