Files
seaweedFS/weed/s3api/auth_credentials_test.go
blitt001 3d81d5bef7 Fix S3 signature verification behind reverse proxies (#8444)
* Fix S3 signature verification behind reverse proxies

When SeaweedFS is deployed behind a reverse proxy (e.g. nginx, Kong,
Traefik), AWS S3 Signature V4 verification fails because the Host header
the client signed with (e.g. "localhost:9000") differs from the Host
header SeaweedFS receives on the backend (e.g. "seaweedfs:8333").

This commit adds a new -s3.externalUrl parameter (and S3_EXTERNAL_URL
environment variable) that tells SeaweedFS what public-facing URL clients
use to connect. When set, SeaweedFS uses this host value for signature
verification instead of the Host header from the incoming request.

New parameter:
  -s3.externalUrl  (flag) or S3_EXTERNAL_URL (environment variable)
  Example: -s3.externalUrl=http://localhost:9000
  Example: S3_EXTERNAL_URL=https://s3.example.com

The environment variable is particularly useful in Docker/Kubernetes
deployments where the external URL is injected via container config.
The flag takes precedence over the environment variable when both are set.

At startup, the URL is parsed and default ports are stripped to match
AWS SDK behavior (port 80 for HTTP, port 443 for HTTPS), so
"http://s3.example.com:80" and "http://s3.example.com" are equivalent.

Bugs fixed:
- Default port stripping was removed by a prior PR, causing signature
  mismatches when clients connect on standard ports (80/443)
- X-Forwarded-Port was ignored when X-Forwarded-Host was not present
- Scheme detection now uses proper precedence: X-Forwarded-Proto >
  TLS connection > URL scheme > "http"
- Test expectations for standard port stripping were incorrect
- expectedHost field in TestSignatureV4WithForwardedPort was declared
  but never actually checked (self-referential test)

* Add Docker integration test for S3 proxy signature verification

Docker Compose setup with nginx reverse proxy to validate that the
-s3.externalUrl parameter (or S3_EXTERNAL_URL env var) correctly
resolves S3 signature verification when SeaweedFS runs behind a proxy.

The test uses nginx proxying port 9000 to SeaweedFS on port 8333,
with X-Forwarded-Host/Port/Proto headers set. SeaweedFS is configured
with -s3.externalUrl=http://localhost:9000 so it uses "localhost:9000"
for signature verification, matching what the AWS CLI signs with.

The test can be run with aws CLI on the host or without it by using
the amazon/aws-cli Docker image with --network host.

Test covers: create-bucket, list-buckets, put-object, head-object,
list-objects-v2, get-object, content round-trip integrity,
delete-object, and delete-bucket — all through the reverse proxy.

* Create s3-proxy-signature-tests.yml

* fix CLI

* fix CI

* Update s3-proxy-signature-tests.yml

* address comments

* Update Dockerfile

* add user

* no need for fuse

* Update s3-proxy-signature-tests.yml

* debug

* weed mini

* fix health check

* health check

* fix health checking

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Chris Lu <chris.lu@gmail.com>
2026-02-26 14:20:42 -08:00

890 lines
26 KiB
Go

package s3api
import (
"fmt"
"os"
"reflect"
"sync"
"testing"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
. "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/stretchr/testify/assert"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
jsonpb "google.golang.org/protobuf/encoding/protojson"
_ "github.com/seaweedfs/seaweedfs/weed/credential/filer_etc"
_ "github.com/seaweedfs/seaweedfs/weed/credential/memory"
)
func TestIdentityListFileFormat(t *testing.T) {
s3ApiConfiguration := &iam_pb.S3ApiConfiguration{}
identity1 := &iam_pb.Identity{
Name: "some_name",
Credentials: []*iam_pb.Credential{
{
AccessKey: "some_access_key1",
SecretKey: "some_secret_key2",
},
},
Actions: []string{
ACTION_ADMIN,
ACTION_READ,
ACTION_WRITE,
},
}
identity2 := &iam_pb.Identity{
Name: "some_read_only_user",
Credentials: []*iam_pb.Credential{
{
AccessKey: "some_access_key1",
SecretKey: "some_secret_key1",
},
},
Actions: []string{
ACTION_READ,
},
}
identity3 := &iam_pb.Identity{
Name: "some_normal_user",
Credentials: []*iam_pb.Credential{
{
AccessKey: "some_access_key2",
SecretKey: "some_secret_key2",
},
},
Actions: []string{
ACTION_READ,
ACTION_WRITE,
},
}
s3ApiConfiguration.Identities = append(s3ApiConfiguration.Identities, identity1)
s3ApiConfiguration.Identities = append(s3ApiConfiguration.Identities, identity2)
s3ApiConfiguration.Identities = append(s3ApiConfiguration.Identities, identity3)
m := jsonpb.MarshalOptions{
EmitUnpopulated: true,
Indent: " ",
}
text, _ := m.Marshal(s3ApiConfiguration)
println(string(text))
}
func TestCanDo(t *testing.T) {
ident1 := &Identity{
Name: "anything",
Actions: []Action{
"Write:bucket1/a/b/c/*",
"Write:bucket1/a/b/other",
},
}
// object specific
assert.Equal(t, true, ident1.CanDo(ACTION_WRITE, "bucket1", "/a/b/c/d.txt"))
assert.Equal(t, true, ident1.CanDo(ACTION_WRITE, "bucket1", "/a/b/c/d/e.txt"))
assert.Equal(t, false, ident1.CanDo(ACTION_DELETE_BUCKET, "bucket1", ""))
assert.Equal(t, false, ident1.CanDo(ACTION_WRITE, "bucket1", "/a/b/other/some"), "action without *")
assert.Equal(t, false, ident1.CanDo(ACTION_WRITE, "bucket1", "/a/b/*"), "action on parent directory")
// bucket specific
ident2 := &Identity{
Name: "anything",
Actions: []Action{
"Read:bucket1",
"Write:bucket1/*",
"WriteAcp:bucket1",
},
}
assert.Equal(t, true, ident2.CanDo(ACTION_READ, "bucket1", "/a/b/c/d.txt"))
assert.Equal(t, true, ident2.CanDo(ACTION_WRITE, "bucket1", "/a/b/c/d.txt"))
assert.Equal(t, true, ident2.CanDo(ACTION_WRITE_ACP, "bucket1", ""))
assert.Equal(t, false, ident2.CanDo(ACTION_READ_ACP, "bucket1", ""))
assert.Equal(t, false, ident2.CanDo(ACTION_LIST, "bucket1", "/a/b/c/d.txt"))
// across buckets
ident3 := &Identity{
Name: "anything",
Actions: []Action{
"Read",
"Write",
},
}
assert.Equal(t, true, ident3.CanDo(ACTION_READ, "bucket1", "/a/b/c/d.txt"))
assert.Equal(t, true, ident3.CanDo(ACTION_WRITE, "bucket1", "/a/b/c/d.txt"))
assert.Equal(t, false, ident3.CanDo(ACTION_LIST, "bucket1", "/a/b/other/some"))
assert.Equal(t, false, ident3.CanDo(ACTION_WRITE_ACP, "bucket1", ""))
// partial buckets
ident4 := &Identity{
Name: "anything",
Actions: []Action{
"Read:special_*",
"ReadAcp:special_*",
},
}
assert.Equal(t, true, ident4.CanDo(ACTION_READ, "special_bucket", "/a/b/c/d.txt"))
assert.Equal(t, true, ident4.CanDo(ACTION_READ_ACP, "special_bucket", ""))
assert.Equal(t, false, ident4.CanDo(ACTION_READ, "bucket1", "/a/b/c/d.txt"))
// admin buckets
ident5 := &Identity{
Name: "anything",
Actions: []Action{
"Admin:special_*",
},
}
assert.Equal(t, true, ident5.CanDo(ACTION_READ, "special_bucket", "/a/b/c/d.txt"))
assert.Equal(t, true, ident5.CanDo(ACTION_READ_ACP, "special_bucket", ""))
assert.Equal(t, true, ident5.CanDo(ACTION_WRITE, "special_bucket", "/a/b/c/d.txt"))
assert.Equal(t, true, ident5.CanDo(ACTION_WRITE_ACP, "special_bucket", ""))
// anonymous buckets
ident6 := &Identity{
Name: "anonymous",
Actions: []Action{
"Read",
},
}
assert.Equal(t, true, ident6.CanDo(ACTION_READ, "anything_bucket", "/a/b/c/d.txt"))
//test deleteBucket operation
ident7 := &Identity{
Name: "anything",
Actions: []Action{
"DeleteBucket:bucket1",
},
}
assert.Equal(t, true, ident7.CanDo(ACTION_DELETE_BUCKET, "bucket1", ""))
}
func TestMatchWildcardPattern(t *testing.T) {
tests := []struct {
pattern string
target string
match bool
}{
// Basic * wildcard tests
{"Bucket/*", "Bucket/a/b", true},
{"Bucket/*", "x/Bucket/a", false},
{"Bucket/*/admin", "Bucket/x/admin", true},
{"Bucket/*/admin", "Bucket/x/y/admin", true},
{"Bucket/*/admin", "Bucket////x////uwu////y////admin", true},
{"abc*def", "abcXYZdef", true},
{"abc*def", "abcXYZdefZZ", false},
{"syr/*", "syr/a/b", true},
// ? wildcard tests (matches exactly one character)
{"ab?d", "abcd", true},
{"ab?d", "abXd", true},
{"ab?d", "abd", false}, // ? must match exactly one character
{"ab?d", "abcXd", false}, // ? matches only one character
{"a?c", "abc", true},
{"a?c", "aXc", true},
{"a?c", "ac", false},
{"???", "abc", true},
{"???", "ab", false},
{"???", "abcd", false},
// Combined * and ? wildcards
{"a*?", "ab", true}, // * matches empty, ? matches 'b'
{"a*?", "abc", true}, // * matches 'b', ? matches 'c'
{"a*?", "a", false}, // ? must match something
{"a?*", "ab", true}, // ? matches 'b', * matches empty
{"a?*", "abc", true}, // ? matches 'b', * matches 'c'
{"a?*b", "aXb", true}, // ? matches 'X', * matches empty
{"a?*b", "aXYZb", true},
{"*?*", "a", true},
{"*?*", "", false}, // ? requires at least one character
// Edge cases: * matches empty string
{"a*b", "ab", true}, // * matches empty string
{"a**b", "ab", true}, // multiple stars match empty
{"a**b", "axb", true}, // multiple stars match 'x'
{"a**b", "axyb", true},
{"*", "", true},
{"*", "anything", true},
{"**", "", true},
{"**", "anything", true},
// Edge cases: empty strings
{"", "", true},
{"a", "", false},
{"", "a", false},
// Trailing * matches empty
{"a*", "a", true},
{"a*", "abc", true},
{"abc*", "abc", true},
{"abc*", "abcdef", true},
// Leading * matches empty
{"*a", "a", true},
{"*a", "XXXa", true},
{"*abc", "abc", true},
{"*abc", "XXXabc", true},
// Multiple wildcards
{"*a*", "a", true},
{"*a*", "Xa", true},
{"*a*", "aX", true},
{"*a*", "XaX", true},
{"*a*b*", "ab", true},
{"*a*b*", "XaYbZ", true},
// Exact match (no wildcards)
{"exact", "exact", true},
{"exact", "notexact", false},
{"exact", "exactnot", false},
// S3-style action patterns
{"Read:bucket*", "Read:bucket-test", true},
{"Read:bucket*", "Read:bucket", true},
{"Write:bucket/path/*", "Write:bucket/path/file.txt", true},
{"Admin:*", "Admin:anything", true},
}
for _, tt := range tests {
t.Run(tt.pattern+"_"+tt.target, func(t *testing.T) {
result := policy_engine.MatchesWildcard(tt.pattern, tt.target)
if result != tt.match {
t.Errorf("policy_engine.MatchesWildcard(%q, %q) = %v, want %v", tt.pattern, tt.target, result, tt.match)
}
})
}
}
type LoadS3ApiConfigurationTestCase struct {
pbAccount *iam_pb.Account
pbIdent *iam_pb.Identity
expectIdent *Identity
}
func TestLoadS3ApiConfiguration(t *testing.T) {
specifiedAccount := Account{
Id: "specifiedAccountID",
DisplayName: "specifiedAccountName",
EmailAddress: "specifiedAccounEmail@example.com",
}
pbSpecifiedAccount := iam_pb.Account{
Id: "specifiedAccountID",
DisplayName: "specifiedAccountName",
EmailAddress: "specifiedAccounEmail@example.com",
}
testCases := map[string]*LoadS3ApiConfigurationTestCase{
"notSpecifyAccountId": {
pbIdent: &iam_pb.Identity{
Name: "notSpecifyAccountId",
Actions: []string{
"Read",
"Write",
},
Credentials: []*iam_pb.Credential{
{
AccessKey: "some_access_key1",
SecretKey: "some_secret_key2",
},
},
},
expectIdent: &Identity{
Name: "notSpecifyAccountId",
Account: &AccountAdmin,
PrincipalArn: fmt.Sprintf("arn:aws:iam::%s:user/notSpecifyAccountId", defaultAccountID),
Actions: []Action{
"Read",
"Write",
},
Credentials: []*Credential{
{
AccessKey: "some_access_key1",
SecretKey: "some_secret_key2",
},
},
},
},
"specifiedAccountID": {
pbAccount: &pbSpecifiedAccount,
pbIdent: &iam_pb.Identity{
Name: "specifiedAccountID",
Account: &pbSpecifiedAccount,
Actions: []string{
"Read",
"Write",
},
},
expectIdent: &Identity{
Name: "specifiedAccountID",
Account: &specifiedAccount,
PrincipalArn: fmt.Sprintf("arn:aws:iam::%s:user/specifiedAccountID", defaultAccountID),
Actions: []Action{
"Read",
"Write",
},
},
},
"anonymous": {
pbIdent: &iam_pb.Identity{
Name: "anonymous",
Actions: []string{
"Read",
"Write",
},
},
expectIdent: &Identity{
Name: "anonymous",
Account: &AccountAnonymous,
PrincipalArn: "*",
Actions: []Action{
"Read",
"Write",
},
},
},
}
config := &iam_pb.S3ApiConfiguration{
Identities: make([]*iam_pb.Identity, 0),
}
for _, v := range testCases {
config.Identities = append(config.Identities, v.pbIdent)
if v.pbAccount != nil {
config.Accounts = append(config.Accounts, v.pbAccount)
}
}
iam := IdentityAccessManagement{}
err := iam.loadS3ApiConfiguration(config)
if err != nil {
return
}
for _, ident := range iam.identities {
tc := testCases[ident.Name]
if !reflect.DeepEqual(ident, tc.expectIdent) {
t.Errorf("not expect for ident name %s", ident.Name)
}
}
}
func TestNewIdentityAccessManagementWithStoreEnvVars(t *testing.T) {
// Save original environment
originalAccessKeyId := os.Getenv("AWS_ACCESS_KEY_ID")
originalSecretAccessKey := os.Getenv("AWS_SECRET_ACCESS_KEY")
// Clean up after test
defer func() {
if originalAccessKeyId != "" {
os.Setenv("AWS_ACCESS_KEY_ID", originalAccessKeyId)
} else {
os.Unsetenv("AWS_ACCESS_KEY_ID")
}
if originalSecretAccessKey != "" {
os.Setenv("AWS_SECRET_ACCESS_KEY", originalSecretAccessKey)
} else {
os.Unsetenv("AWS_SECRET_ACCESS_KEY")
}
}()
tests := []struct {
name string
accessKeyId string
secretAccessKey string
expectEnvIdentity bool
expectedName string
description string
}{
{
name: "Environment variables used as fallback",
accessKeyId: "AKIA1234567890ABCDEF",
secretAccessKey: "secret123456789012345678901234567890abcdef12",
expectEnvIdentity: true,
expectedName: "admin-AKIA1234",
description: "When no config file and no filer config, environment variables should be used",
},
{
name: "Short access key fallback",
accessKeyId: "SHORT",
secretAccessKey: "secret123456789012345678901234567890abcdef12",
expectEnvIdentity: true,
expectedName: "admin-SHORT",
description: "Short access keys should work correctly as fallback",
},
{
name: "No env vars means no identities",
accessKeyId: "",
secretAccessKey: "",
expectEnvIdentity: false,
expectedName: "",
description: "When no env vars and no config, should have no identities",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Reset the memory store to avoid test pollution
if store := credential.Stores[0]; store.GetName() == credential.StoreTypeMemory {
if memStore, ok := store.(interface{ Reset() }); ok {
memStore.Reset()
}
}
// Set up environment variables
if tt.accessKeyId != "" {
os.Setenv("AWS_ACCESS_KEY_ID", tt.accessKeyId)
} else {
os.Unsetenv("AWS_ACCESS_KEY_ID")
}
if tt.secretAccessKey != "" {
os.Setenv("AWS_SECRET_ACCESS_KEY", tt.secretAccessKey)
} else {
os.Unsetenv("AWS_SECRET_ACCESS_KEY")
}
// Create IAM instance with memory store for testing (no config file)
option := &S3ApiServerOption{
Config: "", // No config file - this should trigger environment variable fallback
}
iam := NewIdentityAccessManagementWithStore(option, nil, string(credential.StoreTypeMemory))
if tt.expectEnvIdentity {
// Should have exactly one identity from environment variables
assert.Len(t, iam.identities, 1, "Should have exactly one identity from environment variables")
identity := iam.identities[0]
assert.Equal(t, tt.expectedName, identity.Name, "Identity name should match expected")
assert.Len(t, identity.Credentials, 1, "Should have one credential")
assert.Equal(t, tt.accessKeyId, identity.Credentials[0].AccessKey, "Access key should match environment variable")
assert.Equal(t, tt.secretAccessKey, identity.Credentials[0].SecretKey, "Secret key should match environment variable")
assert.Contains(t, identity.Actions, Action(ACTION_ADMIN), "Should have admin action")
} else {
// When no env vars, should have no identities (since no config file)
assert.Len(t, iam.identities, 0, "Should have no identities when no env vars and no config file")
}
})
}
}
// TestConfigFileWithNoIdentitiesAllowsEnvVars tests that when a config file exists
// but contains no identities (e.g., only KMS settings), environment variables should still work.
// This test validates the fix for issue #7311.
func TestConfigFileWithNoIdentitiesAllowsEnvVars(t *testing.T) {
// Reset the memory store to avoid test pollution
if store := credential.Stores[0]; store.GetName() == credential.StoreTypeMemory {
if memStore, ok := store.(interface{ Reset() }); ok {
memStore.Reset()
}
}
// Set environment variables
testAccessKey := "AKIATEST1234567890AB"
testSecretKey := "testSecret1234567890123456789012345678901234"
t.Setenv("AWS_ACCESS_KEY_ID", testAccessKey)
t.Setenv("AWS_SECRET_ACCESS_KEY", testSecretKey)
// Create a temporary config file with only KMS settings (no identities)
configContent := `{
"kms": {
"default": {
"provider": "local",
"config": {
"keyPath": "/tmp/test-key"
}
}
}
}`
tmpFile, err := os.CreateTemp("", "s3-config-*.json")
assert.NoError(t, err, "Should create temp config file")
defer os.Remove(tmpFile.Name())
_, err = tmpFile.Write([]byte(configContent))
assert.NoError(t, err, "Should write config content")
tmpFile.Close()
// Create IAM instance with config file that has no identities
option := &S3ApiServerOption{
Config: tmpFile.Name(),
}
iam := NewIdentityAccessManagementWithStore(option, nil, string(credential.StoreTypeMemory))
// Should have exactly one identity from environment variables
assert.Len(t, iam.identities, 1, "Should have exactly one identity from environment variables even when config file exists with no identities")
identity := iam.identities[0]
assert.Equal(t, "admin-AKIATEST", identity.Name, "Identity name should be based on access key")
assert.Len(t, identity.Credentials, 1, "Should have one credential")
assert.Equal(t, testAccessKey, identity.Credentials[0].AccessKey, "Access key should match environment variable")
assert.Equal(t, testSecretKey, identity.Credentials[0].SecretKey, "Secret key should match environment variable")
assert.Contains(t, identity.Actions, Action(ACTION_ADMIN), "Should have admin action")
}
// TestBucketLevelListPermissions tests that bucket-level List permissions work correctly
// This test validates the fix for issue #7066
func TestBucketLevelListPermissions(t *testing.T) {
// Test the functionality that was broken in issue #7066
t.Run("Bucket Wildcard Permissions", func(t *testing.T) {
// Create identity with bucket-level List permission using wildcards
identity := &Identity{
Name: "bucket-user",
Actions: []Action{
"List:mybucket*",
"Read:mybucket*",
"ReadAcp:mybucket*",
"Write:mybucket*",
"WriteAcp:mybucket*",
"Tagging:mybucket*",
},
}
// Test cases for bucket-level wildcard permissions
testCases := []struct {
name string
action Action
bucket string
object string
shouldAllow bool
description string
}{
{
name: "exact bucket match",
action: "List",
bucket: "mybucket",
object: "",
shouldAllow: true,
description: "Should allow access to exact bucket name",
},
{
name: "bucket with suffix",
action: "List",
bucket: "mybucket-prod",
object: "",
shouldAllow: true,
description: "Should allow access to bucket with matching prefix",
},
{
name: "bucket with numbers",
action: "List",
bucket: "mybucket123",
object: "",
shouldAllow: true,
description: "Should allow access to bucket with numbers",
},
{
name: "different bucket",
action: "List",
bucket: "otherbucket",
object: "",
shouldAllow: false,
description: "Should deny access to bucket with different prefix",
},
{
name: "partial match",
action: "List",
bucket: "notmybucket",
object: "",
shouldAllow: false,
description: "Should deny access to bucket that contains but doesn't start with the prefix",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := identity.CanDo(tc.action, tc.bucket, tc.object)
assert.Equal(t, tc.shouldAllow, result, tc.description)
})
}
})
t.Run("Global List Permission", func(t *testing.T) {
// Create identity with global List permission
identity := &Identity{
Name: "global-user",
Actions: []Action{
"List",
},
}
// Should allow access to any bucket
testCases := []string{"anybucket", "mybucket", "test-bucket", "prod-data"}
for _, bucket := range testCases {
result := identity.CanDo("List", bucket, "")
assert.True(t, result, "Global List permission should allow access to bucket %s", bucket)
}
})
t.Run("No Wildcard Exact Match", func(t *testing.T) {
// Create identity with exact bucket permission (no wildcard)
identity := &Identity{
Name: "exact-user",
Actions: []Action{
"List:specificbucket",
},
}
// Should only allow access to the exact bucket
assert.True(t, identity.CanDo("List", "specificbucket", ""), "Should allow access to exact bucket")
assert.False(t, identity.CanDo("List", "specificbucket-test", ""), "Should deny access to bucket with suffix")
assert.False(t, identity.CanDo("List", "otherbucket", ""), "Should deny access to different bucket")
})
t.Log("This test validates the fix for issue #7066")
t.Log("Bucket-level List permissions like 'List:bucket*' work correctly")
t.Log("ListBucketsHandler now uses consistent authentication flow")
}
// TestListBucketsAuthRequest tests that authRequest works correctly for ListBuckets operations
// This test validates that the fix for the regression identified in PR #7067 works correctly
func TestListBucketsAuthRequest(t *testing.T) {
t.Run("ListBuckets special case handling", func(t *testing.T) {
// Create identity with bucket-specific permissions (no global List permission)
identity := &Identity{
Name: "bucket-user",
Account: &AccountAdmin,
Actions: []Action{
Action("List:mybucket*"),
Action("Read:mybucket*"),
},
}
// Test 1: ListBuckets operation should succeed (bucket = "")
// This would have failed before the fix because CanDo("List", "", "") would return false
// After the fix, it bypasses the CanDo check for ListBuckets operations
// Simulate what happens in authRequest for ListBuckets:
// action = ACTION_LIST, bucket = "", object = ""
// Before fix: identity.CanDo(ACTION_LIST, "", "") would fail
// After fix: the CanDo check should be bypassed
// Test the individual CanDo method to show it would fail without the special case
result := identity.CanDo(Action(ACTION_LIST), "", "")
assert.False(t, result, "CanDo should return false for empty bucket with bucket-specific permissions")
// Test with a specific bucket that matches the permission
result2 := identity.CanDo(Action(ACTION_LIST), "mybucket", "")
assert.True(t, result2, "CanDo should return true for matching bucket")
// Test with a specific bucket that doesn't match
result3 := identity.CanDo(Action(ACTION_LIST), "otherbucket", "")
assert.False(t, result3, "CanDo should return false for non-matching bucket")
})
t.Run("Object listing maintains permission enforcement", func(t *testing.T) {
// Create identity with bucket-specific permissions
identity := &Identity{
Name: "bucket-user",
Account: &AccountAdmin,
Actions: []Action{
Action("List:mybucket*"),
},
}
// For object listing operations, the normal permission checks should still apply
// These operations have a specific bucket in the URL
// Should succeed for allowed bucket
result1 := identity.CanDo(Action(ACTION_LIST), "mybucket", "prefix/")
assert.True(t, result1, "Should allow listing objects in permitted bucket")
result2 := identity.CanDo(Action(ACTION_LIST), "mybucket-prod", "")
assert.True(t, result2, "Should allow listing objects in wildcard-matched bucket")
// Should fail for disallowed bucket
result3 := identity.CanDo(Action(ACTION_LIST), "otherbucket", "")
assert.False(t, result3, "Should deny listing objects in non-permitted bucket")
})
t.Log("This test validates the fix for the regression identified in PR #7067")
t.Log("ListBuckets operation bypasses global permission check when bucket is empty")
t.Log("Object listing still properly enforces bucket-level permissions")
}
// TestSignatureVerificationDoesNotCheckPermissions tests that signature verification
// only validates the signature and identity, not permissions. Permissions should be
// checked later in authRequest based on the actual operation.
// This test validates the fix for issue #7334
func TestSignatureVerificationDoesNotCheckPermissions(t *testing.T) {
t.Run("List-only user can authenticate via signature", func(t *testing.T) {
// Create IAM with a user that only has List permissions on specific buckets
iam := &IdentityAccessManagement{
hashes: make(map[string]*sync.Pool),
hashCounters: make(map[string]*int32),
}
err := iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{
{
Name: "list-only-user",
Credentials: []*iam_pb.Credential{
{
AccessKey: "list_access_key",
SecretKey: "list_secret_key",
},
},
Actions: []string{
"List:bucket-123",
"Read:bucket-123",
},
},
},
})
assert.NoError(t, err)
// Before the fix, signature verification would fail because it checked for Write permission
// After the fix, signature verification should succeed (only checking signature validity)
// The actual permission check happens later in authRequest with the correct action
// The user should be able to authenticate (signature verification passes)
// But authorization for specific actions is checked separately
identity, cred, found := iam.lookupByAccessKey("list_access_key")
assert.True(t, found, "Should find the user by access key")
assert.Equal(t, "list-only-user", identity.Name)
assert.Equal(t, "list_secret_key", cred.SecretKey)
// User should have the correct permissions
assert.True(t, identity.CanDo(Action(ACTION_LIST), "bucket-123", ""))
assert.True(t, identity.CanDo(Action(ACTION_READ), "bucket-123", ""))
// User should NOT have write permissions
assert.False(t, identity.CanDo(Action(ACTION_WRITE), "bucket-123", ""))
})
t.Log("This test validates the fix for issue #7334")
t.Log("Signature verification no longer checks for Write permission")
t.Log("This allows list-only and read-only users to authenticate via AWS Signature V4")
}
func TestStaticIdentityProtection(t *testing.T) {
iam := NewIdentityAccessManagement(&S3ApiServerOption{}, nil)
// Add a static identity
staticIdent := &Identity{
Name: "static-user",
IsStatic: true,
}
iam.m.Lock()
if iam.nameToIdentity == nil {
iam.nameToIdentity = make(map[string]*Identity)
}
iam.identities = append(iam.identities, staticIdent)
iam.nameToIdentity[staticIdent.Name] = staticIdent
iam.m.Unlock()
// Add a dynamic identity
dynamicIdent := &Identity{
Name: "dynamic-user",
IsStatic: false,
}
iam.m.Lock()
iam.identities = append(iam.identities, dynamicIdent)
iam.nameToIdentity[dynamicIdent.Name] = dynamicIdent
iam.m.Unlock()
// Try to remove static identity
iam.RemoveIdentity("static-user")
// Verify static identity still exists
iam.m.RLock()
_, ok := iam.nameToIdentity["static-user"]
iam.m.RUnlock()
assert.True(t, ok, "Static identity should not be removed")
// Try to remove dynamic identity
iam.RemoveIdentity("dynamic-user")
// Verify dynamic identity is removed
iam.m.RLock()
_, ok = iam.nameToIdentity["dynamic-user"]
iam.m.RUnlock()
assert.False(t, ok, "Dynamic identity should have been removed")
}
func TestParseExternalUrlToHost(t *testing.T) {
tests := []struct {
name string
input string
expected string
expectErr bool
}{
{
name: "empty string",
input: "",
expected: "",
},
{
name: "HTTPS with default port stripped",
input: "https://api.example.com:443",
expected: "api.example.com",
},
{
name: "HTTP with default port stripped",
input: "http://api.example.com:80",
expected: "api.example.com",
},
{
name: "HTTPS with non-standard port preserved",
input: "https://api.example.com:9000",
expected: "api.example.com:9000",
},
{
name: "HTTP with non-standard port preserved",
input: "http://api.example.com:8080",
expected: "api.example.com:8080",
},
{
name: "HTTPS without port",
input: "https://api.example.com",
expected: "api.example.com",
},
{
name: "HTTP without port",
input: "http://api.example.com",
expected: "api.example.com",
},
{
name: "IPv6 with non-standard port",
input: "https://[::1]:9000",
expected: "[::1]:9000",
},
{
name: "IPv6 with default HTTPS port stripped",
input: "https://[::1]:443",
expected: "::1",
},
{
name: "IPv6 without port",
input: "https://[::1]",
expected: "::1",
},
{
name: "invalid URL",
input: "://not-a-url",
expectErr: true,
},
{
name: "missing host",
input: "https://",
expectErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := parseExternalUrlToHost(tt.input)
if tt.expectErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.expected, result)
})
}
}