Files
seaweedfs/weed/s3api/s3api_server_grpc_test.go
T
0742d316aa fix(s3): accept fresh admin tokens when context carries stale authorization (#12390) (#12397)
* fix(s3): accept fresh admin tokens when context carries stale authorization (#12390)

Signed-off-by: Mohd Quamar Tyagi <104281681+Tyagiquamar@users.noreply.github.com>

* security: share the admin Bearer check between S3 and admin-plane peers

Move the authorization scan into security.CheckGrpcAdminToken, built on
IsFilerAdminToken, and call it from CheckGrpcAdminPeer and the S3 IAM cache
check. Both now accept a valid admin token in any authorization value
instead of S3 re-implementing the decode and claim checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* filer: accept a fresh admin token after a stale one on IAM gRPC

The S3 IAM cache check is meant to mirror the filer's, so route the filer's
checkAdminAuth through security.CheckGrpcAdminToken too. IamGrpcStore appends
its token to the caller's context, so a forwarded older token would otherwise
still shadow it here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* security: parse at most four authorization values per admin call

Scanning every value let one request repeat a junk Bearer token (cheap over
HPACK) tens of thousands of times and burn about half a second of JWT parsing
on an unauthenticated port. Real callers send their own token plus at most one
forwarded from their context.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* credential: match the other WithS3InternalAdminAuth replace fix

The plugin worker fix makes the same set-instead-of-append change to
WithS3InternalAdminAuth with different code and a different test at the same
spot, so whichever lands second would conflict. Use the identical code and
test so the two merge cleanly in either order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* security: test an expired admin token ahead of a fresh one

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* security: test that a same-key session token is not an admin token

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* filer: test IAM gRPC with only invalid authorization values

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Signed-off-by: Mohd Quamar Tyagi <104281681+Tyagiquamar@users.noreply.github.com>
Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 09:42:25 -08:00

310 lines
13 KiB
Go

package s3api
import (
"context"
"net"
"testing"
"time"
jwt "github.com/golang-jwt/jwt/v5"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/security"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/peer"
"google.golang.org/grpc/status"
)
const testS3IamCacheSigningKey = "s3-iam-cache-test-key-do-not-use-in-prod"
func newTestS3IamCacheServer(t *testing.T, signingKey string) *S3ApiServer {
t.Helper()
s3a := newTestS3ApiServerWithMemoryIAM(t, nil)
s3a.filerGuard = security.NewGuard(nil, signingKey, 10, "", 60)
return s3a
}
func s3IamCacheBearerCtx(token string) context.Context {
md := metadata.New(map[string]string{"authorization": "Bearer " + token})
return metadata.NewIncomingContext(context.Background(), md)
}
func s3LocalPeerCtx() context.Context {
return peer.NewContext(context.Background(),
&peer.Peer{Addr: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 1}})
}
func TestS3IamCache_NoMetadata_Unauthenticated(t *testing.T) {
s := newTestS3IamCacheServer(t, testS3IamCacheSigningKey)
_, err := s.PutIdentity(context.Background(), &iam_pb.PutIdentityRequest{
Identity: &iam_pb.Identity{Name: "pwn", Actions: []string{"Admin"}},
})
if got, want := status.Code(err), codes.Unauthenticated; got != want {
t.Fatalf("PutIdentity without metadata: got code %v, want %v (err=%v)", got, want, err)
}
}
func TestS3IamCache_MissingAuthorizationHeader_Unauthenticated(t *testing.T) {
s := newTestS3IamCacheServer(t, testS3IamCacheSigningKey)
ctx := metadata.NewIncomingContext(context.Background(), metadata.New(map[string]string{"other": "value"}))
_, err := s.PutIdentity(ctx, &iam_pb.PutIdentityRequest{
Identity: &iam_pb.Identity{Name: "pwn", Actions: []string{"Admin"}},
})
if got, want := status.Code(err), codes.Unauthenticated; got != want {
t.Fatalf("PutIdentity with no authorization header: got code %v, want %v (err=%v)", got, want, err)
}
}
func TestS3IamCache_NonBearerAuthorization_Unauthenticated(t *testing.T) {
s := newTestS3IamCacheServer(t, testS3IamCacheSigningKey)
md := metadata.New(map[string]string{"authorization": "Basic dXNlcjpwYXNz"})
ctx := metadata.NewIncomingContext(context.Background(), md)
_, err := s.PutIdentity(ctx, &iam_pb.PutIdentityRequest{
Identity: &iam_pb.Identity{Name: "pwn", Actions: []string{"Admin"}},
})
if got, want := status.Code(err), codes.Unauthenticated; got != want {
t.Fatalf("PutIdentity with non-Bearer scheme: got code %v, want %v (err=%v)", got, want, err)
}
}
func TestS3IamCache_InvalidToken_Unauthenticated(t *testing.T) {
s := newTestS3IamCacheServer(t, testS3IamCacheSigningKey)
bad := security.GenJwtForFilerAdmin(security.SigningKey("a-different-key"), 60)
if bad == "" {
t.Fatal("GenJwtForFilerAdmin returned empty")
}
_, err := s.PutIdentity(s3IamCacheBearerCtx(string(bad)), &iam_pb.PutIdentityRequest{
Identity: &iam_pb.Identity{Name: "pwn", Actions: []string{"Admin"}},
})
if got, want := status.Code(err), codes.Unauthenticated; got != want {
t.Fatalf("PutIdentity with mis-signed token: got code %v, want %v (err=%v)", got, want, err)
}
}
func TestS3IamCache_GarbageToken_Unauthenticated(t *testing.T) {
s := newTestS3IamCacheServer(t, testS3IamCacheSigningKey)
_, err := s.PutIdentity(s3IamCacheBearerCtx("not.a.jwt"), &iam_pb.PutIdentityRequest{
Identity: &iam_pb.Identity{Name: "pwn", Actions: []string{"Admin"}},
})
if got, want := status.Code(err), codes.Unauthenticated; got != want {
t.Fatalf("PutIdentity with garbage token: got code %v, want %v (err=%v)", got, want, err)
}
}
func TestS3IamCache_ExpiredToken_Unauthenticated(t *testing.T) {
s := newTestS3IamCacheServer(t, testS3IamCacheSigningKey)
claims := security.SeaweedFilerAdminClaims{
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Hour)),
},
}
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
encoded, err := tok.SignedString([]byte(testS3IamCacheSigningKey))
if err != nil {
t.Fatalf("SignedString: %v", err)
}
_, err = s.PutIdentity(s3IamCacheBearerCtx(encoded), &iam_pb.PutIdentityRequest{
Identity: &iam_pb.Identity{Name: "pwn", Actions: []string{"Admin"}},
})
if got, want := status.Code(err), codes.Unauthenticated; got != want {
t.Fatalf("PutIdentity with expired token: got code %v, want %v (err=%v)", got, want, err)
}
}
func TestS3IamCache_ValidToken_WritesIdentity(t *testing.T) {
s := newTestS3IamCacheServer(t, testS3IamCacheSigningKey)
good := security.GenJwtForFilerAdmin(security.SigningKey(testS3IamCacheSigningKey), 60)
if good == "" {
t.Fatal("GenJwtForFilerAdmin returned empty")
}
_, err := s.PutIdentity(s3IamCacheBearerCtx(string(good)), &iam_pb.PutIdentityRequest{
Identity: &iam_pb.Identity{
Name: "pushed",
Actions: []string{"Admin"},
Credentials: []*iam_pb.Credential{
{AccessKey: "AKIAPUSHED", SecretKey: "s3cr3t"},
},
},
})
if err != nil {
t.Fatalf("PutIdentity with valid token: unexpected error %v", err)
}
s.iam.m.RLock()
id := s.iam.accessKeyIdent["AKIAPUSHED"]
s.iam.m.RUnlock()
if id == nil {
t.Fatalf("expected pushed identity to land in accessKeyIdent map")
}
if !id.isAdmin() {
t.Fatalf("expected pushed identity to be admin, actions=%v", id.Actions)
}
}
func TestS3IamCache_ValidTokenFollowingStaleToken_Succeeds(t *testing.T) {
s := newTestS3IamCacheServer(t, testS3IamCacheSigningKey)
bad := security.GenJwtForFilerAdmin(security.SigningKey("a-different-key"), 60)
good := security.GenJwtForFilerAdmin(security.SigningKey(testS3IamCacheSigningKey), 60)
multiCtx := metadata.NewIncomingContext(context.Background(), metadata.MD{
"authorization": []string{
security.BearerPrefix + string(bad),
security.BearerPrefix + string(good),
},
})
_, err := s.PutIdentity(multiCtx, &iam_pb.PutIdentityRequest{
Identity: &iam_pb.Identity{
Name: "pushed_multi",
Actions: []string{"Admin"},
},
})
if err != nil {
t.Fatalf("PutIdentity with valid token following stale token: unexpected error %v", err)
}
}
// Without a signing key remote callers cannot be authenticated, so admin
// RPCs are restricted to local clients: unix socket, loopback, or the
// server's own interface addresses. Remote peers and missing peer info are
// denied.
func TestS3IamCache_NoSigningKey_RemotePeer_Unauthenticated(t *testing.T) {
s := newTestS3IamCacheServer(t, "")
_, err := s.PutIdentity(context.Background(), &iam_pb.PutIdentityRequest{
Identity: &iam_pb.Identity{Name: "pwn", Actions: []string{"Admin"}},
})
if got, want := status.Code(err), codes.Unauthenticated; got != want {
t.Fatalf("PutIdentity with no peer info: got code %v, want %v (err=%v)", got, want, err)
}
remoteCtx := peer.NewContext(context.Background(),
&peer.Peer{Addr: &net.TCPAddr{IP: net.ParseIP("203.0.113.7"), Port: 11111}})
_, err = s.PutIdentity(remoteCtx, &iam_pb.PutIdentityRequest{
Identity: &iam_pb.Identity{Name: "pwn", Actions: []string{"Admin"}},
})
if got, want := status.Code(err), codes.Unauthenticated; got != want {
t.Fatalf("PutIdentity from remote peer without key: got code %v, want %v (err=%v)", got, want, err)
}
unguarded := &S3ApiServer{}
if _, err := unguarded.PutIdentity(remoteCtx, &iam_pb.PutIdentityRequest{
Identity: &iam_pb.Identity{Name: "pwn", Actions: []string{"Admin"}},
}); status.Code(err) != codes.Unauthenticated {
t.Fatalf("PutIdentity from remote peer with nil guard: got code %v, want Unauthenticated", status.Code(err))
}
}
func TestS3IamCache_NoSigningKey_LocalPeer_Allowed(t *testing.T) {
s := newTestS3IamCacheServer(t, "")
for name, addr := range map[string]net.Addr{
"unix": &net.UnixAddr{Name: "/tmp/x.sock", Net: "unix"},
"loopback": &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 11111},
"loopbackV6": &net.TCPAddr{IP: net.ParseIP("::1"), Port: 11111},
} {
ctx := peer.NewContext(context.Background(), &peer.Peer{Addr: addr})
if _, err := s.PutIdentity(ctx, &iam_pb.PutIdentityRequest{
Identity: &iam_pb.Identity{Name: "pushed-" + name, Actions: []string{"Read"}},
}); err != nil {
t.Fatalf("PutIdentity from %s peer without key: unexpected error %v", name, err)
}
}
if own := firstLocalTCPAddr(t); own != nil {
ctx := peer.NewContext(context.Background(), &peer.Peer{Addr: own})
if _, err := s.PutIdentity(ctx, &iam_pb.PutIdentityRequest{
Identity: &iam_pb.Identity{Name: "pushed-own-ip", Actions: []string{"Read"}},
}); err != nil {
t.Fatalf("PutIdentity from own interface address: unexpected error %v", err)
}
}
good := security.GenJwtForFilerAdmin(security.SigningKey(testS3IamCacheSigningKey), 60)
loopbackCtx := peer.NewContext(context.Background(),
&peer.Peer{Addr: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 11111}})
ctx := metadata.NewIncomingContext(loopbackCtx, metadata.New(map[string]string{"authorization": "Bearer " + string(good)}))
if _, err := s.PutIdentity(ctx, &iam_pb.PutIdentityRequest{
Identity: &iam_pb.Identity{Name: "pushed2", Actions: []string{"Read"}},
}); err != nil {
t.Fatalf("PutIdentity with stray token but no server key: unexpected error %v", err)
}
}
// With a signing key only a valid Bearer token is accepted; being local does
// not bypass it.
func TestS3IamCache_WithKey_LocalPeerStillNeedsToken(t *testing.T) {
s := newTestS3IamCacheServer(t, testS3IamCacheSigningKey)
ctx := peer.NewContext(context.Background(),
&peer.Peer{Addr: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 11111}})
_, err := s.PutIdentity(ctx, &iam_pb.PutIdentityRequest{
Identity: &iam_pb.Identity{Name: "pwn", Actions: []string{"Admin"}},
})
if got, want := status.Code(err), codes.Unauthenticated; got != want {
t.Fatalf("PutIdentity from loopback without token: got code %v, want %v (err=%v)", got, want, err)
}
}
func firstLocalTCPAddr(t *testing.T) *net.TCPAddr {
t.Helper()
addrs, err := net.InterfaceAddrs()
if err != nil {
t.Logf("interface addresses unavailable, skipping own-address case: %v", err)
return nil
}
for _, a := range addrs {
if ipNet, ok := a.(*net.IPNet); ok && !ipNet.IP.IsLoopback() {
return &net.TCPAddr{IP: ipNet.IP, Port: 11111}
}
}
return nil
}
func TestS3IamCache_RemoveIdentity_RequiresAuth(t *testing.T) {
s := newTestS3IamCacheServer(t, testS3IamCacheSigningKey)
_, err := s.RemoveIdentity(context.Background(), &iam_pb.RemoveIdentityRequest{Username: "anyone"})
if got, want := status.Code(err), codes.Unauthenticated; got != want {
t.Fatalf("RemoveIdentity without token: got code %v, want %v (err=%v)", got, want, err)
}
}
func TestS3IamCache_PutPolicy_RequiresAuth(t *testing.T) {
s := newTestS3IamCacheServer(t, testS3IamCacheSigningKey)
_, err := s.PutPolicy(context.Background(), &iam_pb.PutPolicyRequest{Name: "p", Content: "{}"})
if got, want := status.Code(err), codes.Unauthenticated; got != want {
t.Fatalf("PutPolicy without token: got code %v, want %v (err=%v)", got, want, err)
}
}
func TestS3IamCache_DeletePolicy_RequiresAuth(t *testing.T) {
s := newTestS3IamCacheServer(t, testS3IamCacheSigningKey)
_, err := s.DeletePolicy(context.Background(), &iam_pb.DeletePolicyRequest{Name: "p"})
if got, want := status.Code(err), codes.Unauthenticated; got != want {
t.Fatalf("DeletePolicy without token: got code %v, want %v (err=%v)", got, want, err)
}
}
func TestS3IamCache_GetPolicy_RequiresAuth(t *testing.T) {
s := newTestS3IamCacheServer(t, testS3IamCacheSigningKey)
_, err := s.GetPolicy(context.Background(), &iam_pb.GetPolicyRequest{Name: "p"})
if got, want := status.Code(err), codes.Unauthenticated; got != want {
t.Fatalf("GetPolicy without token: got code %v, want %v (err=%v)", got, want, err)
}
}
func TestS3IamCache_ListPolicies_RequiresAuth(t *testing.T) {
s := newTestS3IamCacheServer(t, testS3IamCacheSigningKey)
_, err := s.ListPolicies(context.Background(), &iam_pb.ListPoliciesRequest{})
if got, want := status.Code(err), codes.Unauthenticated; got != want {
t.Fatalf("ListPolicies without token: got code %v, want %v (err=%v)", got, want, err)
}
}
func TestS3IamCache_PutGroup_RequiresAuth(t *testing.T) {
s := newTestS3IamCacheServer(t, testS3IamCacheSigningKey)
_, err := s.PutGroup(context.Background(), &iam_pb.PutGroupRequest{Group: &iam_pb.Group{Name: "g"}})
if got, want := status.Code(err), codes.Unauthenticated; got != want {
t.Fatalf("PutGroup without token: got code %v, want %v (err=%v)", got, want, err)
}
}
func TestS3IamCache_RemoveGroup_RequiresAuth(t *testing.T) {
s := newTestS3IamCacheServer(t, testS3IamCacheSigningKey)
_, err := s.RemoveGroup(context.Background(), &iam_pb.RemoveGroupRequest{GroupName: "g"})
if got, want := status.Code(err), codes.Unauthenticated; got != want {
t.Fatalf("RemoveGroup without token: got code %v, want %v (err=%v)", got, want, err)
}
}