Files
seaweedfs/weed/s3api/s3tables/iam.go
T
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

267 lines
7.2 KiB
Go

package s3tables
import (
"context"
"fmt"
"net/http"
"reflect"
"strings"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/iam/integration"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
)
// IAMAuthorizer allows s3tables handlers to evaluate IAM policies without importing s3api.
type IAMAuthorizer interface {
IsActionAllowed(ctx context.Context, request *integration.ActionRequest) (bool, error)
}
// SetIAMAuthorizer injects the IAM authorizer for policy-based access checks.
func (h *S3TablesHandler) SetIAMAuthorizer(authorizer IAMAuthorizer) {
h.iamAuthorizer = authorizer
}
func (h *S3TablesHandler) shouldUseIAM(r *http.Request, identityActions, identityPolicyNames []string) bool {
if h.iamAuthorizer == nil || r == nil {
return false
}
if s3_constants.GetIdentityFromContext(r) == nil {
return false
}
// When default-allow is enabled, keep anonymous requests on the legacy path
// to preserve zero-config behavior (IAM policies are not available for anonymous).
if h.defaultAllow && isAnonymousIdentity(r) {
return false
}
// An empty inline `identityActions` slice doesn't mean the identity has no
// permissions—it just means authorization lives in IAM policies or session
// tokens instead of static action lists. We therefore prefer the IAM path
// whenever inline actions are absent and fall back to default policy names
// or session tokens.
if hasSessionToken(r) {
return true
}
if len(identityActions) == 0 {
return true
}
return len(identityPolicyNames) > 0
}
func isAnonymousIdentity(r *http.Request) bool {
val, ok := getIdentityStructValue(r)
if !ok {
return false
}
if nameField := val.FieldByName("Name"); nameField.IsValid() && nameField.Kind() == reflect.String {
if nameField.String() == s3_constants.AccountAnonymousId {
return true
}
}
accountField := val.FieldByName("Account")
if accountField.IsValid() && !accountField.IsNil() {
if accountField.Kind() == reflect.Ptr {
accountField = accountField.Elem()
}
if accountField.Kind() == reflect.Struct {
if idField := accountField.FieldByName("Id"); idField.IsValid() && idField.Kind() == reflect.String {
if idField.String() == s3_constants.AccountAnonymousId {
return true
}
}
}
}
return false
}
func hasSessionToken(r *http.Request) bool {
return extractSessionToken(r) != ""
}
func extractSessionToken(r *http.Request) string {
if token := r.Header.Get(s3_constants.SeaweedFSSessionTokenHeader); token != "" {
return token
}
if token := r.Header.Get("X-Amz-Security-Token"); token != "" {
return token
}
return r.URL.Query().Get("X-Amz-Security-Token")
}
func (h *S3TablesHandler) authorizeIAMAction(r *http.Request, identityPolicyNames []string, action string, resources ...string) (bool, error) {
if h.iamAuthorizer == nil {
err := fmt.Errorf("nil iamAuthorizer in authorizeIAMAction")
glog.V(2).Infof("S3Tables: %v", err)
return false, err
}
principal := r.Header.Get(s3_constants.SeaweedFSPrincipalHeader)
if principal == "" {
principal = getIdentityPrincipalArn(r)
}
if principal == "" {
return false, fmt.Errorf("missing principal for IAM authorization")
}
if !strings.Contains(action, ":") {
action = "s3tables:" + action
}
sessionToken := extractSessionToken(r)
requestContext := buildIAMRequestContext(r, getIdentityClaims(r))
policyNames := identityPolicyNames
if len(policyNames) == 0 {
policyNames = getIdentityPolicyNames(r)
}
if len(resources) == 0 {
return false, fmt.Errorf("no resources provided to authorizeIAMAction")
}
checkedResource := false
for _, resource := range resources {
if resource == "" {
continue
}
checkedResource = true
allowed, err := h.iamAuthorizer.IsActionAllowed(r.Context(), &integration.ActionRequest{
Principal: principal,
Action: action,
Resource: resource,
SessionToken: sessionToken,
RequestContext: requestContext,
PolicyNames: policyNames,
})
if err != nil {
glog.V(2).Infof("S3Tables: IAM authorization error action=%s resource=%s principal=%s: %v", action, resource, principal, err)
return false, err
}
if !allowed {
err := fmt.Errorf("access denied by IAM for resource %s", resource)
return false, err
}
}
if !checkedResource {
return false, fmt.Errorf("no non-empty resources provided to authorizeIAMAction")
}
return true, nil
}
func getIdentityPrincipalArn(r *http.Request) string {
val, ok := getIdentityStructValue(r)
if !ok {
return ""
}
field := val.FieldByName("PrincipalArn")
if field.IsValid() && field.Kind() == reflect.String {
return field.String()
}
return ""
}
func getIdentityPolicyNames(r *http.Request) []string {
val, ok := getIdentityStructValue(r)
if !ok {
return nil
}
field := val.FieldByName("PolicyNames")
if !field.IsValid() || field.Kind() != reflect.Slice {
return nil
}
policies := make([]string, 0, field.Len())
for i := 0; i < field.Len(); i++ {
item := field.Index(i)
if item.Kind() == reflect.String {
policies = append(policies, item.String())
} else if item.CanInterface() {
policies = append(policies, fmt.Sprint(item.Interface()))
}
}
if len(policies) == 0 {
return nil
}
return policies
}
func getIdentityClaims(r *http.Request) map[string]interface{} {
val, ok := getIdentityStructValue(r)
if !ok {
return nil
}
field := val.FieldByName("Claims")
if !field.IsValid() || field.Kind() != reflect.Map || field.IsNil() {
return nil
}
if field.Type().Key().Kind() != reflect.String {
return nil
}
claims := make(map[string]interface{}, field.Len())
for _, key := range field.MapKeys() {
if key.Kind() != reflect.String {
continue
}
val := field.MapIndex(key)
if !val.IsValid() {
continue
}
claims[key.String()] = val.Interface()
}
if len(claims) == 0 {
return nil
}
return claims
}
func buildIAMRequestContext(r *http.Request, claims map[string]interface{}) map[string]interface{} {
ctx := make(map[string]interface{})
if ua := r.Header.Get("User-Agent"); ua != "" {
ctx["userAgent"] = ua
}
if referer := r.Header.Get("Referer"); referer != "" {
ctx["referer"] = referer
}
for k, v := range claims {
if strings.HasPrefix(k, "jwt:") {
if _, exists := ctx[k]; !exists {
ctx[k] = v
}
}
}
for k, v := range claims {
if strings.HasPrefix(k, "jwt:") {
continue
}
if _, exists := ctx[k]; !exists {
ctx[k] = v
}
jwtKey := "jwt:" + k
if _, exists := ctx[jwtKey]; !exists {
ctx[jwtKey] = v
}
}
if len(ctx) == 0 {
return nil
}
return ctx
}
// getIdentityStructValue fetches the identity struct held in the request context.
// The identity is expected to be a pointer to a struct with the fields used by
// the reflection helpers (PrincipalArn string, PolicyNames []string,
// Claims map[string]interface{}).
// This helper centralizes the nil-check and ptr-deref logic so callers focus on
// reading the specific fields they need.
func getIdentityStructValue(r *http.Request) (reflect.Value, bool) {
identityRaw := s3_constants.GetIdentityFromContext(r)
if identityRaw == nil {
return reflect.Value{}, false
}
val := reflect.ValueOf(identityRaw)
if val.Kind() == reflect.Ptr {
val = val.Elem()
}
if val.Kind() != reflect.Struct {
return reflect.Value{}, false
}
return val, true
}