Files
seaweedfs/weed/s3api/auth_security_test.go
Chris Lu 685571d93f fix(s3): allow anonymous unsigned-streaming PutObject (#9727)
Modern botocore attaches a CRC32 trailer to plain PutObject, turning the
payload into STREAMING-UNSIGNED-PAYLOAD-TRAILER. An anonymous upload then
carries that header but no Authorization, so it was classified as
authTypeStreamingUnsigned and sent straight to SigV4 verification, which
rejected it as AccessDenied while explicit credentials kept working.

Fall back to the anonymous identity when an unsigned-streaming request
carries no signature, mirroring the plain anonymous path. The request
stays classified as unsigned-streaming so the chunked body is still
decoded.
2026-05-28 17:00:41 -07:00

546 lines
20 KiB
Go

package s3api
import (
"context"
"crypto/sha256"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func signRawHTTPRequest(ctx context.Context, req *http.Request, accessKey, secretKey, region string) error {
creds := aws.Credentials{
AccessKeyID: accessKey,
SecretAccessKey: secretKey,
}
signer := v4.NewSigner()
payloadHash := fmt.Sprintf("%x", sha256.Sum256([]byte{}))
return signer.SignHTTP(ctx, creds, req, payloadHash, "s3", region, time.Now())
}
func TestReproIssue7912(t *testing.T) {
// This test asserts behavior for a config with no anonymous identity; reset
// the shared in-memory store so a leaked anonymous identity from another test
// does not satisfy the unsigned-streaming auth path.
resetMemoryStore()
// Create a temporary s3.json
configContent := `{
"identities": [
{
"name": "xx",
"credentials": [
{
"accessKey": "xx_access_key",
"secretKey": "xx_secret_key"
}
],
"actions": ["Admin", "Read", "Write", "List", "Tagging"]
},
{
"name": "read_only_user",
"credentials": [
{
"accessKey": "readonly_access_key",
"secretKey": "readonly_secret_key"
}
],
"actions": ["Read", "List"]
}
]
}`
tmpFile, err := os.CreateTemp("", "s3-config-*.json")
assert.NoError(t, err)
defer os.Remove(tmpFile.Name())
_, err = tmpFile.Write([]byte(configContent))
assert.NoError(t, err)
tmpFile.Close()
// Initialize Identities Access Management
option := &S3ApiServerOption{
Config: tmpFile.Name(),
}
iam := NewIdentityAccessManagementWithStore(option, nil, "memory")
assert.True(t, iam.isEnabled(), "Auth should be enabled")
// Test case 1: Unknown access key should be rejected
t.Run("Unknown access key", func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "http://localhost:8333/", nil)
r.Host = "localhost:8333"
err := signRawHTTPRequest(context.Background(), r, "unknown_key", "any_secret", "us-east-1")
require.NoError(t, err)
identity, errCode := iam.authRequest(r, s3_constants.ACTION_LIST)
assert.Equal(t, s3err.ErrInvalidAccessKeyID, errCode, "Should be denied with unknown access key")
assert.Nil(t, identity)
})
t.Run("Positive test case: properly signed credentials", func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "http://localhost:8333/", nil)
r.Host = "localhost:8333"
err := signRawHTTPRequest(context.Background(), r, "readonly_access_key", "readonly_secret_key", "us-east-1")
require.NoError(t, err)
identity, errCode := iam.authRequest(r, s3_constants.ACTION_LIST)
assert.Equal(t, s3err.ErrNone, errCode)
require.NotNil(t, identity)
assert.Equal(t, "read_only_user", identity.Name)
})
t.Run("Nil identity tests for guards", func(t *testing.T) {
var nilIdentity *Identity
// Test isAdmin guard
assert.False(t, nilIdentity.isAdmin())
// Test CanDo guard
assert.False(t, nilIdentity.CanDo(s3_constants.ACTION_LIST, "bucket", "object"))
})
t.Run("AuthSignatureOnly path", func(t *testing.T) {
// Valid request
r := httptest.NewRequest(http.MethodGet, "http://localhost:8333/", nil)
r.Host = "localhost:8333"
err := signRawHTTPRequest(context.Background(), r, "xx_access_key", "xx_secret_key", "us-east-1")
require.NoError(t, err)
identity, errCode := iam.AuthSignatureOnly(r)
assert.Equal(t, s3err.ErrNone, errCode)
require.NotNil(t, identity)
assert.Equal(t, "xx", identity.Name)
// Invalid request (wrong signature)
r2 := httptest.NewRequest(http.MethodGet, "http://localhost:8333/", nil)
r2.Host = "localhost:8333"
err = signRawHTTPRequest(context.Background(), r2, "xx_access_key", "this_is_a_wrong_secret", "us-east-1")
require.NoError(t, err)
_, errCode2 := iam.AuthSignatureOnly(r2)
assert.Equal(t, s3err.ErrSignatureDoesNotMatch, errCode2)
// Verify fix: Streaming unsigned payload should be denied without auth header in AuthSignatureOnly
r3 := httptest.NewRequest(http.MethodPut, "http://localhost:8333/somebucket/someobject", nil)
r3.Header.Set("x-amz-content-sha256", "STREAMING-UNSIGNED-PAYLOAD-TRAILER")
// No Authorization header
_, errCode3 := iam.AuthSignatureOnly(r3)
assert.Equal(t, s3err.ErrAccessDenied, errCode3, "AuthSignatureOnly should be denied with unsigned streaming if no auth header")
// Verify fix: client-supplied X-SeaweedFS-Principal / X-SeaweedFS-Session-Token
// headers must be stripped, otherwise a signed low-privilege caller could
// spoof the principal that downstream IAM authorization (e.g. S3Tables
// CreateTableBucket) evaluates against.
r4 := httptest.NewRequest(http.MethodGet, "http://localhost:8333/", nil)
r4.Host = "localhost:8333"
err = signRawHTTPRequest(context.Background(), r4, "readonly_access_key", "readonly_secret_key", "us-east-1")
require.NoError(t, err)
// Attacker injects these headers after signing; they are not part of
// SignedHeaders, so SigV4 verification still passes.
r4.Header.Set(s3_constants.SeaweedFSPrincipalHeader, "arn:aws:iam::123456789012:root")
r4.Header.Set(s3_constants.SeaweedFSSessionTokenHeader, "spoofed-session-token")
_, errCode4 := iam.AuthSignatureOnly(r4)
assert.Equal(t, s3err.ErrNone, errCode4)
assert.Empty(t, r4.Header.Get(s3_constants.SeaweedFSPrincipalHeader), "client-supplied X-SeaweedFS-Principal must be stripped")
assert.Empty(t, r4.Header.Get(s3_constants.SeaweedFSSessionTokenHeader), "client-supplied X-SeaweedFS-Session-Token must be stripped")
})
t.Run("Wrong secret key", func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "http://localhost:8333/", nil)
r.Host = "localhost:8333"
err := signRawHTTPRequest(context.Background(), r, "readonly_access_key", "this_is_a_wrong_secret", "us-east-1")
require.NoError(t, err)
identity, errCode := iam.authRequest(r, s3_constants.ACTION_LIST)
assert.Equal(t, s3err.ErrSignatureDoesNotMatch, errCode, "Should NOT be allowed with wrong signature")
assert.Nil(t, identity)
})
t.Run("Anonymous request to protected bucket", func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "http://localhost:8333/somebucket/", nil)
// No Authorization header
identity, errCode := iam.authRequest(r, s3_constants.ACTION_LIST)
assert.Equal(t, s3err.ErrAccessDenied, errCode, "Should be denied for anonymous")
assert.Nil(t, identity)
})
t.Run("Non-S3 request should be denied", func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "http://localhost:8333/", nil)
// No headers at all
identity, errCode := iam.authRequest(r, s3_constants.ACTION_LIST)
assert.Equal(t, s3err.ErrAccessDenied, errCode)
assert.Nil(t, identity)
})
t.Run("Any other credentials", func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "http://localhost:8333/", nil)
r.Host = "localhost:8333"
err := signRawHTTPRequest(context.Background(), r, "some_other_key", "some_secret", "us-east-1")
require.NoError(t, err)
identity, errCode := iam.authRequest(r, s3_constants.ACTION_LIST)
assert.Equal(t, s3err.ErrInvalidAccessKeyID, errCode, "Should NOT be allowed with ANY other credentials")
assert.Nil(t, identity)
})
t.Run("Streaming unsigned payload bypass attempt", func(t *testing.T) {
r := httptest.NewRequest(http.MethodPut, "http://localhost:8333/somebucket/someobject", nil)
r.Header.Set("x-amz-content-sha256", "STREAMING-UNSIGNED-PAYLOAD-TRAILER")
// No Authorization header
identity, errCode := iam.authRequest(r, s3_constants.ACTION_WRITE)
assert.Equal(t, s3err.ErrAccessDenied, errCode, "Should be denied with unsigned streaming if no auth header")
assert.Nil(t, identity)
})
}
// TestAnonymousStreamingUnsignedUpload is a regression test for issue #9725.
// Modern botocore/aiobotocore attaches a CRC32 trailer to plain PutObject calls,
// turning the payload into STREAMING-UNSIGNED-PAYLOAD-TRAILER. An anonymous
// (anon=True) upload then carries that header but no Authorization, which used to
// be routed straight to SigV4 verification and rejected as AccessDenied. It must
// instead fall back to the configured anonymous identity, just like a plain
// anonymous PUT does.
func TestAnonymousStreamingUnsignedUpload(t *testing.T) {
// This test loads an anonymous identity into the shared in-memory credential
// store; reset before and after so it neither inherits nor leaks state.
resetMemoryStore()
defer resetMemoryStore()
configContent := `{
"identities": [
{
"name": "anonymous",
"actions": ["Read", "Write", "List"]
}
]
}`
tmpFile, err := os.CreateTemp("", "s3-config-*.json")
require.NoError(t, err)
defer os.Remove(tmpFile.Name())
_, err = tmpFile.Write([]byte(configContent))
require.NoError(t, err)
require.NoError(t, tmpFile.Close())
iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{Config: tmpFile.Name()}, nil, "memory")
require.True(t, iam.isEnabled(), "Auth should be enabled")
r := httptest.NewRequest(http.MethodPut, "http://localhost:8333/somebucket/someobject", nil)
r.Header.Set("x-amz-content-sha256", "STREAMING-UNSIGNED-PAYLOAD-TRAILER")
r.Header.Set("x-amz-trailer", "x-amz-checksum-crc32")
// No Authorization header: anonymous upload with a checksum trailer.
// The request must stay classified as unsigned-streaming so getRequestDataReader
// still decodes the chunked body; only the auth resolution falls back to anonymous.
assert.Equal(t, authTypeStreamingUnsigned, getRequestAuthType(r))
identity, errCode := iam.authRequest(r, s3_constants.ACTION_WRITE)
assert.Equal(t, s3err.ErrNone, errCode, "anonymous unsigned-streaming PUT should resolve to the anonymous identity")
require.NotNil(t, identity)
assert.Equal(t, s3_constants.AccountAnonymousId, identity.Name)
}
// TestExternalUrlSignatureVerification tests that S3 signature verification works
// correctly when s3.externalUrl is configured. It uses the real AWS SDK v2 signer
// to prove correctness against actual S3 clients behind a reverse proxy.
func TestExternalUrlSignatureVerification(t *testing.T) {
configContent := `{
"identities": [
{
"name": "test_user",
"credentials": [
{
"accessKey": "AKIAIOSFODNN7EXAMPLE",
"secretKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
}
],
"actions": ["Admin", "Read", "Write", "List", "Tagging"]
}
]
}`
tmpFile, err := os.CreateTemp("", "s3-config-*.json")
require.NoError(t, err)
defer os.Remove(tmpFile.Name())
_, err = tmpFile.Write([]byte(configContent))
require.NoError(t, err)
tmpFile.Close()
accessKey := "AKIAIOSFODNN7EXAMPLE"
secretKey := "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
tests := []struct {
name string
clientUrl string // URL the client signs against
backendHost string // Host header SeaweedFS sees (from proxy)
externalUrl string // s3.externalUrl config
expectSuccess bool
}{
{
name: "non-standard port with externalUrl",
clientUrl: "https://api.example.com:9000/test-bucket/object",
backendHost: "backend:8333",
externalUrl: "https://api.example.com:9000",
expectSuccess: true,
},
{
name: "HTTPS default port 443 with externalUrl (port stripped by SDK and parseExternalUrlToHost)",
clientUrl: "https://api.example.com/test-bucket/object",
backendHost: "backend:8333",
externalUrl: "https://api.example.com:443",
expectSuccess: true,
},
{
name: "HTTPS without explicit port with externalUrl",
clientUrl: "https://api.example.com/test-bucket/object",
backendHost: "backend:8333",
externalUrl: "https://api.example.com",
expectSuccess: true,
},
{
name: "HTTP default port 80 with externalUrl",
clientUrl: "http://api.example.com/test-bucket/object",
backendHost: "backend:8333",
externalUrl: "http://api.example.com:80",
expectSuccess: true,
},
{
name: "without externalUrl, internal host causes mismatch",
clientUrl: "https://api.example.com:9000/test-bucket/object",
backendHost: "backend:8333",
externalUrl: "",
expectSuccess: false,
},
{
name: "without externalUrl, matching host works",
clientUrl: "http://localhost:8333/test-bucket/object",
backendHost: "localhost:8333",
externalUrl: "",
expectSuccess: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create IAM with the externalUrl
option := &S3ApiServerOption{
Config: tmpFile.Name(),
ExternalUrl: tt.externalUrl,
}
iam := NewIdentityAccessManagementWithStore(option, nil, "memory")
require.True(t, iam.isEnabled())
// Step 1: Sign a request targeting the client-facing URL using real AWS SDK signer
signReq, err := http.NewRequest(http.MethodGet, tt.clientUrl, nil)
require.NoError(t, err)
signReq.Host = signReq.URL.Host
err = signRawHTTPRequest(context.Background(), signReq, accessKey, secretKey, "us-east-1")
require.NoError(t, err)
// Step 2: Create a separate request as the proxy would deliver it
proxyReq := httptest.NewRequest(http.MethodGet, tt.clientUrl, nil)
proxyReq.Host = tt.backendHost
proxyReq.URL.Host = tt.backendHost
// Copy the auth headers from the signed request
proxyReq.Header.Set("Authorization", signReq.Header.Get("Authorization"))
proxyReq.Header.Set("X-Amz-Date", signReq.Header.Get("X-Amz-Date"))
proxyReq.Header.Set("X-Amz-Content-Sha256", signReq.Header.Get("X-Amz-Content-Sha256"))
// Step 3: Verify
identity, errCode := iam.authRequest(proxyReq, s3_constants.ACTION_LIST)
if tt.expectSuccess {
assert.Equal(t, s3err.ErrNone, errCode, "Expected successful signature verification")
require.NotNil(t, identity)
assert.Equal(t, "test_user", identity.Name)
} else {
assert.NotEqual(t, s3err.ErrNone, errCode, "Expected signature verification to fail")
}
})
}
}
// TestRealSDKSignerWithForwardedHeaders proves that extractHostHeader produces
// values that match the real AWS SDK v2 signer's SanitizeHostForHeader behavior.
//
// Unlike the self-referential tests in auto_signature_v4_test.go (which sign and
// verify with the same extractHostHeader function), this test uses the real AWS
// SDK v2 signer to sign the request. The SDK has its own host sanitization:
// - strips :80 for http
// - strips :443 for https
// - preserves all other ports
//
// If extractHostHeader disagrees with the SDK, the signature will not match.
func TestRealSDKSignerWithForwardedHeaders(t *testing.T) {
configContent := `{
"identities": [
{
"name": "test_user",
"credentials": [
{
"accessKey": "AKIAIOSFODNN7EXAMPLE",
"secretKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
}
],
"actions": ["Admin", "Read", "Write", "List", "Tagging"]
}
]
}`
tmpFile, err := os.CreateTemp("", "s3-config-*.json")
require.NoError(t, err)
defer os.Remove(tmpFile.Name())
_, err = tmpFile.Write([]byte(configContent))
require.NoError(t, err)
tmpFile.Close()
accessKey := "AKIAIOSFODNN7EXAMPLE"
secretKey := "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
// Each test case simulates:
// 1. A client connecting to clientUrl (SDK signs with the host from this URL)
// 2. A proxy forwarding to SeaweedFS with X-Forwarded-* headers
// 3. SeaweedFS extracting the host and verifying the signature
tests := []struct {
name string
clientUrl string // URL the client/SDK signs against
backendHost string // Host header SeaweedFS receives (proxy rewrites this)
forwardedHost string // X-Forwarded-Host set by proxy
forwardedPort string // X-Forwarded-Port set by proxy
forwardedProto string // X-Forwarded-Proto set by proxy
expectedHost string // what extractHostHeader should return (must match SDK)
}{
{
name: "HTTPS non-standard port",
clientUrl: "https://api.example.com:9000/bucket/key",
backendHost: "seaweedfs:8333",
forwardedHost: "api.example.com",
forwardedPort: "9000",
forwardedProto: "https",
expectedHost: "api.example.com:9000",
},
{
name: "HTTPS standard port 443 (SDK strips, we must too)",
clientUrl: "https://api.example.com/bucket/key",
backendHost: "seaweedfs:8333",
forwardedHost: "api.example.com",
forwardedPort: "443",
forwardedProto: "https",
expectedHost: "api.example.com",
},
{
name: "HTTP standard port 80 (SDK strips, we must too)",
clientUrl: "http://api.example.com/bucket/key",
backendHost: "seaweedfs:8333",
forwardedHost: "api.example.com",
forwardedPort: "80",
forwardedProto: "http",
expectedHost: "api.example.com",
},
{
name: "HTTP non-standard port 8080",
clientUrl: "http://api.example.com:8080/bucket/key",
backendHost: "seaweedfs:8333",
forwardedHost: "api.example.com",
forwardedPort: "8080",
forwardedProto: "http",
expectedHost: "api.example.com:8080",
},
{
name: "X-Forwarded-Host with port (Traefik style), HTTPS 443",
clientUrl: "https://api.example.com/bucket/key",
backendHost: "seaweedfs:8333",
forwardedHost: "api.example.com:443",
forwardedPort: "443",
forwardedProto: "https",
expectedHost: "api.example.com",
},
{
name: "X-Forwarded-Host with port (Traefik style), HTTP 80",
clientUrl: "http://api.example.com/bucket/key",
backendHost: "seaweedfs:8333",
forwardedHost: "api.example.com:80",
forwardedPort: "80",
forwardedProto: "http",
expectedHost: "api.example.com",
},
{
name: "no forwarded headers, direct access",
clientUrl: "http://localhost:8333/bucket/key",
backendHost: "localhost:8333",
forwardedHost: "",
forwardedPort: "",
forwardedProto: "",
expectedHost: "localhost:8333",
},
{
name: "empty proto with port 80 (defaults to http, strip)",
clientUrl: "http://api.example.com/bucket/key",
backendHost: "seaweedfs:8333",
forwardedHost: "api.example.com",
forwardedPort: "80",
forwardedProto: "",
expectedHost: "api.example.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
option := &S3ApiServerOption{Config: tmpFile.Name()}
iam := NewIdentityAccessManagementWithStore(option, nil, "memory")
require.True(t, iam.isEnabled())
// Step 1: Sign a request using the real AWS SDK v2 signer.
// The SDK applies its own SanitizeHostForHeader logic.
signReq, err := http.NewRequest(http.MethodGet, tt.clientUrl, nil)
require.NoError(t, err)
signReq.Host = signReq.URL.Host
err = signRawHTTPRequest(context.Background(), signReq, accessKey, secretKey, "us-east-1")
require.NoError(t, err)
// Step 2: Build the request as the proxy would deliver it to SeaweedFS.
proxyReq := httptest.NewRequest(http.MethodGet, tt.clientUrl, nil)
proxyReq.Host = tt.backendHost
// Copy signed auth headers
proxyReq.Header.Set("Authorization", signReq.Header.Get("Authorization"))
proxyReq.Header.Set("X-Amz-Date", signReq.Header.Get("X-Amz-Date"))
proxyReq.Header.Set("X-Amz-Content-Sha256", signReq.Header.Get("X-Amz-Content-Sha256"))
// Set forwarded headers
if tt.forwardedHost != "" {
proxyReq.Header.Set("X-Forwarded-Host", tt.forwardedHost)
}
if tt.forwardedPort != "" {
proxyReq.Header.Set("X-Forwarded-Port", tt.forwardedPort)
}
if tt.forwardedProto != "" {
proxyReq.Header.Set("X-Forwarded-Proto", tt.forwardedProto)
}
// Step 3: Verify extractHostHeader returns the expected value.
// This is the critical correctness check: our extracted host must match
// what the SDK signed with, otherwise the signature will not match.
extractedHost := extractHostHeader(proxyReq, iam.externalHost)
assert.Equal(t, tt.expectedHost, extractedHost,
"extractHostHeader must return a value matching what AWS SDK signed with")
// Step 4: Verify the full signature matches.
identity, errCode := iam.authRequest(proxyReq, s3_constants.ACTION_LIST)
assert.Equal(t, s3err.ErrNone, errCode,
"Signature from real AWS SDK signer must verify successfully")
require.NotNil(t, identity)
assert.Equal(t, "test_user", identity.Name)
})
}
}