feat: integrate standalone IAM service with S3 gateway for identity-based policy enforcement

Fixes #1327
Fixes #1567
Closes #2264

Wires the S3 gateway up to the standalone IAM service so identity policies, not just bucket policies and ACLs, are enforced on the S3 data plane. The gateway authenticates SigV4 requests by calling new private derive-signing-key and resolve-identity endpoints on the IAM service instead of holding secrets itself, and evaluates identity policy through the same PolicyEvaluator path added to auth.VerifyAccess, combined with the bucket policy using explicit-deny-wins precedence. The private endpoints are served over their own mTLS listener (new iamapi/private package, genmtlscerts.sh to generate test material, and client-cert support in internal/netutil), separate from the public IAM API. As part of this the vendored aws/signer/v4 package is deleted and replaced by a pure-Go SigV4 implementation in internal/sigv4auth, which now reads canonical request data directly off the fiber.Ctx instead of reconstructing an http.Request, and is shared by both the S3 request-signing verification and the new private-endpoint signing.

DeleteObjects moves from an all-or-nothing authorization check to true partial success: VerifyObjectsAccess evaluates every object in a batch independently against both the identity policy and any object lock, so a denial or a locked object only removes that key from the batch instead of failing the whole request. It also batches the identity-policy round trip and the bucket-policy fetch once per request rather than once per object, and separates plain deletes from versioned ones since a versioned delete needs s3:DeleteObjectVersion rather than s3:DeleteObject. Object lock handling got a few correctness fixes alongside this: a bypass is now modeled as BypassNone/BypassRequested/BypassOverwrite rather than a single bool, because root's blanket ability to override a GOVERNANCE retention should only apply when the client actually asked to bypass it (DeleteObject/DeleteObjects/PutObjectRetention), not when the gateway is silently replacing a locked object via an overwrite, which needs the permission from everyone including root. Retention changes are now correctly classified as an extension (allowed under plain s3:PutObjectRetention) versus a weakening (date or mode change, which needs the bypass permission), and a COMPLIANCE lock can never be weakened by anyone regardless of permissions, matching AWS. Separately, VerifyObjectCopyAccess had a readonly-mode gap: it returned early for root/admin before ever calling VerifyAccess, so the readonly check inside VerifyAccess never ran for them on CopyObject; access checks are now ordered so the readonly gate always applies before any root/admin bypass, for copy as well as every other write path.

Bucket policies also gained Condition block support, via a new shared internal/condition package moved out of the IAM policy package since both bucket and identity policies share the same evaluation semantics. It implements the full AWS operator set — String{Equals,NotEquals,EqualsIgnoreCase,NotEqualsIgnoreCase,Like,NotLike}, Numeric{Equals,NotEquals,LessThan,LessThanEquals,GreaterThan,GreaterThanEquals}, Date{Equals,NotEquals,LessThan,LessThanEquals,GreaterThan,GreaterThanEquals}, Bool, BinaryEquals, Arn{Equals,Like,NotEquals,NotLike}, IpAddress/NotIpAddress, and Null — along with the ForAllValues/ForAnyValue set qualifiers and the IfExists modifier. A new requestConditionContext builds the per-request keys a bucket policy's Condition block can reference — aws:SourceIp, aws:SecureTransport, aws:CurrentTime, aws:EpochTime, aws:UserAgent, aws:Referer, s3:prefix, s3:delimiter, s3:max-keys, s3:x-amz-acl, s3:VersionId — following AWS's own per-action rules for which keys a given S3 operation actually populates. Identity-derived keys such as aws:PrincipalArn and aws:username are deliberately left unwired here, since the gateway has no way to know them; the standalone IAM service fills those in itself when it evaluates an identity policy.

Also added new integration test suites for S3-side IAM: s3_iam_access_control.go and s3_iam_session_access_control.go cover identity-policy enforcement and session-credential requests against real S3 operations, alongside expanded OIDC/web-identity coverage and a new runoidctests.sh runner wired into the OIDC GitHub Actions workflow.
This commit is contained in:
niksis02
2026-08-15 18:27:50 +04:00
parent eeff64c255
commit 11e10b45a8
156 changed files with 12964 additions and 4376 deletions
+6 -6
View File
@@ -25,9 +25,9 @@ import (
"github.com/versity/versitygw/auth"
"github.com/versity/versitygw/backend"
"github.com/versity/versitygw/debuglogger"
"github.com/versity/versitygw/internal/netutil"
"github.com/versity/versitygw/s3api/controllers"
"github.com/versity/versitygw/s3api/middlewares"
"github.com/versity/versitygw/s3api/utils"
"github.com/versity/versitygw/s3log"
)
@@ -35,7 +35,7 @@ type S3AdminServer struct {
app *fiber.App
backend backend.Backend
router *S3AdminRouter
CertStorage *utils.CertStorage
CertStorage *netutil.CertStorage
quiet bool
debug bool
corsAllowOrigin string
@@ -100,7 +100,7 @@ func NewAdminServer(be backend.Backend, root middlewares.RootUserConfig, region
type AdminOpt func(s *S3AdminServer)
func WithAdminSrvTLS(cs *utils.CertStorage) AdminOpt {
func WithAdminSrvTLS(cs *netutil.CertStorage) AdminOpt {
return func(s *S3AdminServer) { s.CertStorage = cs }
}
@@ -152,9 +152,9 @@ func (sa *S3AdminServer) ServeMultiPort(ports []string) error {
var err error
if sa.CertStorage != nil {
ln, err = utils.NewMultiAddrTLSListener(fiber.NetworkTCP, portSpec, sa.CertStorage.GetCertificate, utils.ListenerOptions{SocketPerm: sa.socketPerm})
ln, err = netutil.NewMultiAddrTLSListener(fiber.NetworkTCP, portSpec, sa.CertStorage.GetCertificate, netutil.ListenerOptions{SocketPerm: sa.socketPerm})
} else {
ln, err = utils.NewMultiAddrListener(fiber.NetworkTCP, portSpec, utils.ListenerOptions{SocketPerm: sa.socketPerm})
ln, err = netutil.NewMultiAddrListener(fiber.NetworkTCP, portSpec, netutil.ListenerOptions{SocketPerm: sa.socketPerm})
}
if err != nil {
@@ -169,7 +169,7 @@ func (sa *S3AdminServer) ServeMultiPort(ports []string) error {
}
// Combine all listeners
finalListener := utils.NewMultiListener(listeners...)
finalListener := netutil.NewMultiListener(listeners...)
return sa.app.Listener(finalListener, fiber.ListenConfig{
DisableStartupMessage: true,
+1 -1
View File
@@ -135,7 +135,7 @@ func (c AdminController) ChangeBucketOwner(ctx fiber.Ctx) (*Response, error) {
owner := ctx.Query("owner")
bucket := ctx.Query("bucket")
accs, err := auth.CheckIfAccountsExist([]string{owner}, c.iam)
accs, err := c.iam.ResolveAccounts([]string{owner})
if err != nil {
return &Response{
MetaOpts: &MetaOptions{},
+13 -2
View File
@@ -18,6 +18,7 @@ import (
"context"
"encoding/xml"
"errors"
"fmt"
"net/http"
"testing"
@@ -487,8 +488,15 @@ func TestAdminController_ChangeBucketOwner(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
iam := &IAMServiceMock{
GetUserAccountFunc: func(access string) (auth.Account, error) {
return auth.Account{}, tt.input.extraMockErr
ResolveAccountsFunc: func(accessKeyIDs []string) ([]string, error) {
switch tt.input.extraMockErr {
case nil:
return []string{}, nil
case auth.ErrNoSuchUser:
return accessKeyIDs, nil
default:
return nil, fmt.Errorf("check user account: %w", tt.input.extraMockErr)
}
},
}
be := &BackendMock{
@@ -674,6 +682,9 @@ func TestAdminController_CreateBucket(t *testing.T) {
GetUserAccountFunc: func(access string) (auth.Account, error) {
return auth.Account{}, tt.input.extraMockErr
},
ResolveAccountsFunc: func(accessKeyIDs []string) ([]string, error) {
return []string{}, nil
},
}
be := &BackendMock{
CreateBucketFunc: func(contextMoqParam context.Context, createBucketInput *s3.CreateBucketInput, defaultACL []byte) error {
+29
View File
@@ -21,6 +21,7 @@ import (
"sort"
"strings"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/gofiber/fiber/v3"
"github.com/versity/versitygw/auth"
"github.com/versity/versitygw/backend"
@@ -76,6 +77,34 @@ func New(be backend.Backend, iam auth.IAMService, logger s3log.AuditLogger, evs
}
}
// verifyAccess wraps auth.VerifyAccess, always injecting the controller's
// own configured IAM backend, readonly mode, and disableACL setting into opts
func (c S3ApiController) verifyAccess(ctx fiber.Ctx, opts auth.AccessOptions) error {
opts.Iam = c.iam
opts.Readonly = c.readonly
opts.DisableACL = c.disableACL
return auth.VerifyAccess(ctx, c.be, opts)
}
// verifyObjectsAccess wraps auth.VerifyObjectsAccess, for the one request
// shape (DeleteObjects) that names several objects at once. The returned
// slice has one entry per object (nil where it may proceed); the error
// return is a whole-request failure, not about any one object.
func (c S3ApiController) verifyObjectsAccess(ctx fiber.Ctx, opts auth.AccessOptions, objects []types.ObjectIdentifier, bypass auth.BypassMode) ([]error, error) {
opts.Iam = c.iam
opts.Readonly = c.readonly
opts.DisableACL = c.disableACL
return auth.VerifyObjectsAccess(ctx, c.be, opts, objects, bypass)
}
// verifyObjectCopyAccess wraps auth.VerifyObjectCopyAccess
func (c S3ApiController) verifyObjectCopyAccess(ctx fiber.Ctx, copySource string, opts auth.AccessOptions) error {
opts.Iam = c.iam
opts.Readonly = c.readonly
opts.DisableACL = c.disableACL
return auth.VerifyObjectCopyAccess(ctx, c.be, copySource, opts)
}
func (c S3ApiController) getAclHeaderValue(ctx fiber.Ctx, key string, defaultValues ...string) string {
if c.disableACL {
return ""
+2
View File
@@ -76,6 +76,8 @@ type testInput struct {
beErr error
extraMockErr error
extraMockResp any
readonly bool
disableACL bool
}
type testOutput struct {
+6 -18
View File
@@ -29,9 +29,8 @@ func (c S3ApiController) DeleteBucketTagging(ctx fiber.Ctx) (*Response, error) {
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
IsBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx)
err := auth.VerifyAccess(ctx.RequestCtx(), c.be,
err := c.verifyAccess(ctx,
auth.AccessOptions{
Readonly: c.readonly,
Acl: parsedAcl,
AclPermission: auth.PermissionWrite,
IsRoot: isRoot,
@@ -39,7 +38,6 @@ func (c S3ApiController) DeleteBucketTagging(ctx fiber.Ctx) (*Response, error) {
Bucket: bucket,
Actions: []auth.Action{auth.PutBucketTaggingAction},
IsPublicRequest: IsBucketPublic,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -64,16 +62,14 @@ func (c S3ApiController) DeleteBucketOwnershipControls(ctx fiber.Ctx) (*Response
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
err := auth.VerifyAccess(ctx.RequestCtx(), c.be,
err := c.verifyAccess(ctx,
auth.AccessOptions{
Readonly: c.readonly,
Acl: parsedAcl,
AclPermission: auth.PermissionWrite,
IsRoot: isRoot,
Acc: acct,
Bucket: bucket,
Actions: []auth.Action{auth.PutBucketOwnershipControlsAction},
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -98,16 +94,14 @@ func (c S3ApiController) DeleteBucketPolicy(ctx fiber.Ctx) (*Response, error) {
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
err := auth.VerifyAccess(ctx.RequestCtx(), c.be,
err := c.verifyAccess(ctx,
auth.AccessOptions{
Readonly: c.readonly,
Acl: parsedAcl,
AclPermission: auth.PermissionWrite,
IsRoot: isRoot,
Acc: acct,
Bucket: bucket,
Actions: []auth.Action{auth.DeleteBucketPolicyAction},
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -133,9 +127,8 @@ func (c S3ApiController) DeleteBucketCors(ctx fiber.Ctx) (*Response, error) {
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
IsBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx)
err := auth.VerifyAccess(ctx.RequestCtx(), c.be,
err := c.verifyAccess(ctx,
auth.AccessOptions{
Readonly: c.readonly,
Acl: parsedAcl,
AclPermission: auth.PermissionWrite,
IsRoot: isRoot,
@@ -143,7 +136,6 @@ func (c S3ApiController) DeleteBucketCors(ctx fiber.Ctx) (*Response, error) {
Bucket: bucket,
Actions: []auth.Action{auth.PutBucketCorsAction},
IsPublicRequest: IsBucketPublic,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -169,9 +161,8 @@ func (c S3ApiController) DeleteBucketWebsite(ctx fiber.Ctx) (*Response, error) {
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
IsBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx)
err := auth.VerifyAccess(ctx.RequestCtx(), c.be,
err := c.verifyAccess(ctx,
auth.AccessOptions{
Readonly: c.readonly,
Acl: parsedAcl,
AclPermission: auth.PermissionWrite,
IsRoot: isRoot,
@@ -179,7 +170,6 @@ func (c S3ApiController) DeleteBucketWebsite(ctx fiber.Ctx) (*Response, error) {
Bucket: bucket,
Actions: []auth.Action{auth.DeleteBucketWebsiteAction},
IsPublicRequest: IsBucketPublic,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -205,9 +195,8 @@ func (c S3ApiController) DeleteBucket(ctx fiber.Ctx) (*Response, error) {
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
IsBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx)
err := auth.VerifyAccess(ctx.RequestCtx(), c.be,
err := c.verifyAccess(ctx,
auth.AccessOptions{
Readonly: c.readonly,
Acl: parsedAcl,
AclPermission: auth.PermissionWrite,
IsRoot: isRoot,
@@ -215,7 +204,6 @@ func (c S3ApiController) DeleteBucket(ctx fiber.Ctx) (*Response, error) {
Bucket: bucket,
Actions: []auth.Action{auth.DeleteBucketAction},
IsPublicRequest: IsBucketPublic,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
+14 -42
View File
@@ -32,8 +32,7 @@ func (c S3ApiController) GetBucketTagging(ctx fiber.Ctx) (*Response, error) {
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{
Readonly: c.readonly,
err := c.verifyAccess(ctx, auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionRead,
IsRoot: isRoot,
@@ -41,7 +40,6 @@ func (c S3ApiController) GetBucketTagging(ctx fiber.Ctx) (*Response, error) {
Bucket: bucket,
Actions: []auth.Action{auth.GetBucketTaggingAction},
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -85,8 +83,7 @@ func (c S3ApiController) GetBucketOwnershipControls(ctx fiber.Ctx) (*Response, e
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{
Readonly: c.readonly,
err := c.verifyAccess(ctx, auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionRead,
IsRoot: isRoot,
@@ -94,7 +91,6 @@ func (c S3ApiController) GetBucketOwnershipControls(ctx fiber.Ctx) (*Response, e
Bucket: bucket,
Actions: []auth.Action{auth.GetBucketOwnershipControlsAction},
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -126,8 +122,7 @@ func (c S3ApiController) GetBucketVersioning(ctx fiber.Ctx) (*Response, error) {
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{
Readonly: c.readonly,
err := c.verifyAccess(ctx, auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionRead,
IsRoot: isRoot,
@@ -135,7 +130,6 @@ func (c S3ApiController) GetBucketVersioning(ctx fiber.Ctx) (*Response, error) {
Bucket: bucket,
Actions: []auth.Action{auth.GetBucketVersioningAction},
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -169,8 +163,7 @@ func (c S3ApiController) GetBucketCors(ctx fiber.Ctx) (*Response, error) {
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{
Readonly: c.readonly,
err := c.verifyAccess(ctx, auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionRead,
IsRoot: isRoot,
@@ -178,7 +171,6 @@ func (c S3ApiController) GetBucketCors(ctx fiber.Ctx) (*Response, error) {
Bucket: bucket,
Actions: []auth.Action{auth.GetBucketCorsAction},
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -213,8 +205,7 @@ func (c S3ApiController) GetBucketWebsite(ctx fiber.Ctx) (*Response, error) {
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{
Readonly: c.readonly,
err := c.verifyAccess(ctx, auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionRead,
IsRoot: isRoot,
@@ -222,7 +213,6 @@ func (c S3ApiController) GetBucketWebsite(ctx fiber.Ctx) (*Response, error) {
Bucket: bucket,
Actions: []auth.Action{auth.GetBucketWebsiteAction},
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -257,8 +247,7 @@ func (c S3ApiController) GetBucketPolicy(ctx fiber.Ctx) (*Response, error) {
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{
Readonly: c.readonly,
err := c.verifyAccess(ctx, auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionRead,
IsRoot: isRoot,
@@ -266,7 +255,6 @@ func (c S3ApiController) GetBucketPolicy(ctx fiber.Ctx) (*Response, error) {
Bucket: bucket,
Actions: []auth.Action{auth.GetBucketPolicyAction},
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -292,8 +280,7 @@ func (c S3ApiController) GetBucketPolicyStatus(ctx fiber.Ctx) (*Response, error)
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{
Readonly: c.readonly,
err := c.verifyAccess(ctx, auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionRead,
IsRoot: isRoot,
@@ -301,7 +288,6 @@ func (c S3ApiController) GetBucketPolicyStatus(ctx fiber.Ctx) (*Response, error)
Bucket: bucket,
Actions: []auth.Action{auth.GetBucketPolicyStatusAction},
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -354,8 +340,7 @@ func (c S3ApiController) ListObjectVersions(ctx fiber.Ctx) (*Response, error) {
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{
Readonly: c.readonly,
err := c.verifyAccess(ctx, auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionRead,
IsRoot: isRoot,
@@ -363,7 +348,6 @@ func (c S3ApiController) ListObjectVersions(ctx fiber.Ctx) (*Response, error) {
Bucket: bucket,
Actions: []auth.Action{auth.ListBucketVersionsAction},
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -408,8 +392,7 @@ func (c S3ApiController) GetObjectLockConfiguration(ctx fiber.Ctx) (*Response, e
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{
Readonly: c.readonly,
err := c.verifyAccess(ctx, auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionRead,
IsRoot: isRoot,
@@ -417,7 +400,6 @@ func (c S3ApiController) GetObjectLockConfiguration(ctx fiber.Ctx) (*Response, e
Bucket: bucket,
Actions: []auth.Action{auth.GetBucketObjectLockConfigurationAction},
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -454,8 +436,7 @@ func (c S3ApiController) GetBucketAcl(ctx fiber.Ctx) (*Response, error) {
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{
Readonly: c.readonly,
err := c.verifyAccess(ctx, auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionReadAcp,
IsRoot: isRoot,
@@ -463,7 +444,6 @@ func (c S3ApiController) GetBucketAcl(ctx fiber.Ctx) (*Response, error) {
Bucket: bucket,
Actions: []auth.Action{auth.GetBucketAclAction},
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -506,8 +486,7 @@ func (c S3ApiController) ListMultipartUploads(ctx fiber.Ctx) (*Response, error)
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{
Readonly: c.readonly,
err := c.verifyAccess(ctx, auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionRead,
IsRoot: isRoot,
@@ -515,7 +494,6 @@ func (c S3ApiController) ListMultipartUploads(ctx fiber.Ctx) (*Response, error)
Bucket: bucket,
Actions: []auth.Action{auth.ListBucketMultipartUploadsAction},
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -568,8 +546,7 @@ func (c S3ApiController) ListObjectsV2(ctx fiber.Ctx) (*Response, error) {
region = defaultRegion
}
err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{
Readonly: c.readonly,
err := c.verifyAccess(ctx, auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionRead,
IsRoot: isRoot,
@@ -577,7 +554,6 @@ func (c S3ApiController) ListObjectsV2(ctx fiber.Ctx) (*Response, error) {
Bucket: bucket,
Actions: []auth.Action{auth.ListBucketAction},
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -641,8 +617,7 @@ func (c S3ApiController) ListObjects(ctx fiber.Ctx) (*Response, error) {
region = defaultRegion
}
err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{
Readonly: c.readonly,
err := c.verifyAccess(ctx, auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionRead,
IsRoot: isRoot,
@@ -650,7 +625,6 @@ func (c S3ApiController) ListObjects(ctx fiber.Ctx) (*Response, error) {
Bucket: bucket,
Actions: []auth.Action{auth.ListBucketAction},
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -704,8 +678,7 @@ func (c S3ApiController) GetBucketLocation(ctx fiber.Ctx) (*Response, error) {
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{
Readonly: c.readonly,
err := c.verifyAccess(ctx, auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionRead,
IsRoot: isRoot,
@@ -713,7 +686,6 @@ func (c S3ApiController) GetBucketLocation(ctx fiber.Ctx) (*Response, error) {
Bucket: bucket,
Actions: []auth.Action{auth.GetBucketLocationAction},
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
+1 -3
View File
@@ -32,9 +32,8 @@ func (c S3ApiController) HeadBucket(ctx fiber.Ctx) (*Response, error) {
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
err := auth.VerifyAccess(ctx.RequestCtx(), c.be,
err := c.verifyAccess(ctx,
auth.AccessOptions{
Readonly: c.readonly,
Acl: parsedAcl,
AclPermission: auth.PermissionRead,
IsRoot: isRoot,
@@ -42,7 +41,6 @@ func (c S3ApiController) HeadBucket(ctx fiber.Ctx) (*Response, error) {
Bucket: bucket,
Actions: []auth.Action{auth.ListBucketAction},
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
+44 -34
View File
@@ -34,34 +34,19 @@ import (
func (c S3ApiController) DeleteObjects(ctx fiber.Ctx) (*Response, error) {
bucket := ctx.Params("bucket")
bypass := strings.EqualFold(ctx.Get("X-Amz-Bypass-Governance-Retention"), "true")
bypass := auth.BypassModeForRequest(strings.EqualFold(ctx.Get("X-Amz-Bypass-Governance-Retention"), "true"))
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
IsBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx)
err := auth.VerifyAccess(ctx.RequestCtx(), c.be,
auth.AccessOptions{
Readonly: c.readonly,
Acl: parsedAcl,
AclPermission: auth.PermissionWrite,
IsRoot: isRoot,
Acc: acct,
Bucket: bucket,
Actions: []auth.Action{auth.DeleteObjectAction},
IsPublicRequest: IsBucketPublic,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, err
}
// The body has to be parsed before authorization, not after: real AWS
// authorizes s3:DeleteObject against each object's own ARN, so the keys
// are part of what is being authorized. The parsed objects go straight
// to VerifyObjectsAccess, which checks policy and object locks for all
// of them in one pass.
var dObj s3response.DeleteObjects
err = xml.Unmarshal(ctx.BodyRaw(), &dObj)
err := xml.Unmarshal(ctx.BodyRaw(), &dObj)
if err != nil {
debuglogger.Logf("error unmarshalling delete objects: %v", err)
return &Response{
@@ -71,7 +56,23 @@ func (c S3ApiController) DeleteObjects(ctx fiber.Ctx) (*Response, error) {
}, s3err.GetAPIError(s3err.ErrInvalidRequest)
}
err = auth.CheckObjectAccess(ctx.RequestCtx(), bucket, acct.Access, dObj.Objects, bypass, IsBucketPublic, c.be, false)
// checkErrs holds one entry per requested object — nil where it may
// proceed to the backend, an AWS-shaped denial otherwise. DeleteObjects
// supports partial success, so a denial on one object (policy or object
// lock) must not fail any other: only the objects that clear this check
// are sent to the backend, and the rest are reported as per-object
// errors directly from checkErrs. err here is a whole-request failure
// (readonly mode, or an error resolving policy/lock state), not about
// any one object.
checkErrs, err := c.verifyObjectsAccess(ctx,
auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionWrite,
IsRoot: isRoot,
Acc: acct,
Bucket: bucket,
IsPublicRequest: IsBucketPublic,
}, dObj.Objects, bypass)
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
@@ -80,15 +81,26 @@ func (c S3ApiController) DeleteObjects(ctx fiber.Ctx) (*Response, error) {
}, err
}
res, err := c.be.DeleteObjects(ctx.RequestCtx(),
&s3.DeleteObjectsInput{
Bucket: &bucket,
Delete: &types.Delete{
Objects: dObj.Objects,
},
})
toDelete := make([]types.ObjectIdentifier, 0, len(dObj.Objects))
for i, obj := range dObj.Objects {
if checkErrs[i] == nil {
toDelete = append(toDelete, obj)
}
}
var backendResult s3response.DeleteResult
if len(toDelete) > 0 {
backendResult, err = c.be.DeleteObjects(ctx.RequestCtx(),
&s3.DeleteObjectsInput{
Bucket: &bucket,
Delete: &types.Delete{
Objects: toDelete,
},
})
}
return &Response{
Data: res,
Data: utils.MergeDeleteObjectsResult(dObj.Objects, checkErrs, backendResult),
MetaOpts: &MetaOptions{
ObjectCount: int64(len(dObj.Objects)),
BucketOwner: parsedAcl.Owner,
@@ -115,9 +127,8 @@ func (c S3ApiController) POSTObject(ctx fiber.Ctx) (*Response, error) {
key := parsed.Fields["key"]
err := auth.VerifyAccess(ctx.RequestCtx(), c.be,
err := c.verifyAccess(ctx,
auth.AccessOptions{
Readonly: c.readonly,
Acl: parsedAcl,
AclPermission: auth.PermissionWrite,
IsRoot: isRoot,
@@ -125,7 +136,6 @@ func (c S3ApiController) POSTObject(ctx fiber.Ctx) (*Response, error) {
Bucket: bucket,
Actions: []auth.Action{auth.PutObjectAction},
IsPublicRequest: IsBucketPublic,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
+74 -4
View File
@@ -46,19 +46,36 @@ func TestS3ApiController_DeleteObjects(t *testing.T) {
validRes := s3response.DeleteResult{
Deleted: []types.DeletedObject{
{Key: utils.GetStringPtr("key")},
{Key: utils.GetStringPtr("obj")},
},
}
partialSuccessBody, err := xml.Marshal(s3response.DeleteObjects{
Objects: []types.ObjectIdentifier{
{Key: utils.GetStringPtr("locked")},
{Key: utils.GetStringPtr("ok")},
},
})
assert.NoError(t, err)
lockConfig, err := json.Marshal(auth.BucketLockConfig{Enabled: true})
assert.NoError(t, err)
legalHoldOn, legalHoldOff := true, false
lockedObjectCode := "AccessDenied"
lockedObjectMessage := "Access Denied because object protected by object lock."
tests := []struct {
name string
input testInput
output testOutput
name string
input testInput
output testOutput
configureMock func(be *BackendMock)
}{
{
name: "verify access fails",
input: testInput{
locals: accessDeniedLocals,
body: validBody,
},
output: testOutput{
response: &Response{
@@ -140,6 +157,56 @@ func TestS3ApiController_DeleteObjects(t *testing.T) {
},
},
},
{
name: "partial success: one object locked, one succeeds",
input: testInput{
locals: defaultLocals,
body: partialSuccessBody,
},
output: testOutput{
response: &Response{
Data: s3response.DeleteResult{
Deleted: []types.DeletedObject{
{Key: utils.GetStringPtr("ok")},
},
Error: []types.Error{
{Key: utils.GetStringPtr("locked"), Code: &lockedObjectCode, Message: &lockedObjectMessage},
},
},
MetaOpts: &MetaOptions{
BucketOwner: "root",
EventName: s3event.EventObjectRemovedDeleteObjects,
ObjectCount: 2,
},
},
},
configureMock: func(be *BackendMock) {
be.GetObjectLockConfigurationFunc = func(contextMoqParam context.Context, bucket string) ([]byte, error) {
return lockConfig, nil
}
be.GetBucketVersioningFunc = func(contextMoqParam context.Context, bucket string) (s3response.GetBucketVersioningOutput, error) {
return s3response.GetBucketVersioningOutput{}, nil
}
be.GetObjectRetentionFunc = func(contextMoqParam context.Context, bucket, object, versionId string) ([]byte, error) {
return []byte("{}"), nil
}
be.GetObjectLegalHoldFunc = func(contextMoqParam context.Context, bucket, object, versionId string) (*bool, error) {
if object == "locked" {
return &legalHoldOn, nil
}
return &legalHoldOff, nil
}
be.DeleteObjectsFunc = func(contextMoqParam context.Context, deleteObjectsInput *s3.DeleteObjectsInput) (s3response.DeleteResult, error) {
// Only the object that cleared the lock check should
// ever reach the backend.
assert.Len(t, deleteObjectsInput.Delete.Objects, 1)
assert.Equal(t, "ok", *deleteObjectsInput.Delete.Objects[0].Key)
return s3response.DeleteResult{
Deleted: []types.DeletedObject{{Key: utils.GetStringPtr("ok")}},
}, nil
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -154,6 +221,9 @@ func TestS3ApiController_DeleteObjects(t *testing.T) {
return nil, tt.input.extraMockErr
},
}
if tt.configureMock != nil {
tt.configureMock(be)
}
ctrl := S3ApiController{
be: be,
+11 -26
View File
@@ -37,8 +37,7 @@ func (c S3ApiController) PutBucketTagging(ctx fiber.Ctx) (*Response, error) {
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{
Readonly: c.readonly,
err := c.verifyAccess(ctx, auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionWrite,
IsRoot: isRoot,
@@ -46,7 +45,6 @@ func (c S3ApiController) PutBucketTagging(ctx fiber.Ctx) (*Response, error) {
Bucket: bucket,
Actions: []auth.Action{auth.PutBucketTaggingAction},
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -80,15 +78,13 @@ func (c S3ApiController) PutBucketOwnershipControls(ctx fiber.Ctx) (*Response, e
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
if err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{
Readonly: c.readonly,
if err := c.verifyAccess(ctx, auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionWrite,
IsRoot: isRoot,
Acc: acct,
Bucket: bucket,
Actions: []auth.Action{auth.PutBucketOwnershipControlsAction},
DisableACL: c.disableACL,
}); err != nil {
return &Response{
MetaOpts: &MetaOptions{
@@ -140,8 +136,7 @@ func (c S3ApiController) PutBucketVersioning(ctx fiber.Ctx) (*Response, error) {
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{
Readonly: c.readonly,
err := c.verifyAccess(ctx, auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionWrite,
IsRoot: isRoot,
@@ -149,7 +144,6 @@ func (c S3ApiController) PutBucketVersioning(ctx fiber.Ctx) (*Response, error) {
Bucket: bucket,
Actions: []auth.Action{auth.PutBucketVersioningAction},
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -195,8 +189,7 @@ func (c S3ApiController) PutObjectLockConfiguration(ctx fiber.Ctx) (*Response, e
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
if err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{
Readonly: c.readonly,
if err := c.verifyAccess(ctx, auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionWrite,
IsRoot: isRoot,
@@ -204,7 +197,6 @@ func (c S3ApiController) PutObjectLockConfiguration(ctx fiber.Ctx) (*Response, e
Bucket: bucket,
Actions: []auth.Action{auth.PutBucketObjectLockConfigurationAction},
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
}); err != nil {
return &Response{
MetaOpts: &MetaOptions{
@@ -237,8 +229,7 @@ func (c S3ApiController) PutBucketCors(ctx fiber.Ctx) (*Response, error) {
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{
Readonly: c.readonly,
err := c.verifyAccess(ctx, auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionWrite,
IsRoot: isRoot,
@@ -246,7 +237,6 @@ func (c S3ApiController) PutBucketCors(ctx fiber.Ctx) (*Response, error) {
Bucket: bucket,
Actions: []auth.Action{auth.PutBucketCorsAction},
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -294,8 +284,7 @@ func (c S3ApiController) PutBucketWebsite(ctx fiber.Ctx) (*Response, error) {
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{
Readonly: c.readonly,
err := c.verifyAccess(ctx, auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionWrite,
IsRoot: isRoot,
@@ -303,7 +292,6 @@ func (c S3ApiController) PutBucketWebsite(ctx fiber.Ctx) (*Response, error) {
Bucket: bucket,
Actions: []auth.Action{auth.PutBucketWebsiteAction},
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -357,15 +345,13 @@ func (c S3ApiController) PutBucketPolicy(ctx fiber.Ctx) (*Response, error) {
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{
Readonly: c.readonly,
err := c.verifyAccess(ctx, auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionWrite,
IsRoot: isRoot,
Acc: acct,
Bucket: bucket,
Actions: []auth.Action{auth.PutBucketPolicyAction},
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -409,16 +395,14 @@ func (c S3ApiController) PutBucketAcl(ctx fiber.Ctx) (*Response, error) {
grants := grantFullControl + grantRead + grantReadACP + grantWrite + grantWriteACP
var input *auth.PutBucketAclInput
err := auth.VerifyAccess(ctx.RequestCtx(), c.be,
err := c.verifyAccess(ctx,
auth.AccessOptions{
Readonly: c.readonly,
Acl: parsedAcl,
AclPermission: auth.PermissionWriteAcp,
IsRoot: isRoot,
Acc: acct,
Bucket: bucket,
Actions: []auth.Action{auth.PutBucketAclAction},
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -585,11 +569,12 @@ func (c S3ApiController) CreateBucket(ctx fiber.Ctx) (*Response, error) {
utils.ContextKeyBucketOwner.Set(ctx, creator)
}
bucketOwner := utils.ContextKeyBucketOwner.Get(ctx).(auth.Account)
isRoot, _ := utils.ContextKeyIsRoot.Get(ctx).(bool)
if creator.Role != auth.RoleAdmin && creator.Role != auth.RoleUserPlus {
if err := auth.VerifyCreateBucketAccess(ctx, c.iam, isRoot, creator, bucket); err != nil {
return &Response{
MetaOpts: &MetaOptions{},
}, s3err.GetAPIError(s3err.ErrAccessDenied)
}, err
}
// validate the bucket name
+46
View File
@@ -716,6 +716,10 @@ func TestS3ApiController_CreateBucket(t *testing.T) {
Access: "user",
Role: auth.RoleUser,
}
userPlusAcc := auth.Account{
Access: "userplus",
Role: auth.RoleUserPlus,
}
invLocConstBody, err := xml.Marshal(s3response.CreateBucketConfiguration{
LocationConstraint: utils.GetStringPtr("us-west-1"),
@@ -732,6 +736,7 @@ func TestS3ApiController_CreateBucket(t *testing.T) {
input: testInput{
locals: map[utils.ContextKey]any{
utils.ContextKeyAccount: userAcc,
utils.ContextKeyIsRoot: false,
},
},
output: testOutput{
@@ -916,6 +921,47 @@ func TestS3ApiController_CreateBucket(t *testing.T) {
},
},
},
{
name: "userplus role can create bucket",
input: testInput{
locals: map[utils.ContextKey]any{
utils.ContextKeyAccount: userPlusAcc,
},
bucket: "my-bucket",
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: userPlusAcc.Access,
},
Headers: map[string]*string{
"Location": utils.GetStringPtr("/my-bucket"),
"x-amz-bucket-arn": utils.GetStringPtr("arn:aws:s3:::my-bucket"),
},
},
},
},
{
name: "root bypasses role check",
input: testInput{
locals: map[utils.ContextKey]any{
utils.ContextKeyAccount: userAcc,
utils.ContextKeyIsRoot: true,
},
bucket: "my-bucket",
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: userAcc.Access,
},
Headers: map[string]*string{
"Location": utils.GetStringPtr("/my-bucket"),
"x-amz-bucket-arn": utils.GetStringPtr("arn:aws:s3:::my-bucket"),
},
},
},
},
}
for _, tt := range tests {
+44
View File
@@ -30,6 +30,9 @@ var _ auth.IAMService = &IAMServiceMock{}
// ListUserAccountsFunc: func() ([]auth.Account, error) {
// panic("mock out the ListUserAccounts method")
// },
// ResolveAccountsFunc: func(accessKeyIDs []string) ([]string, error) {
// panic("mock out the ResolveAccounts method")
// },
// ShutdownFunc: func() error {
// panic("mock out the Shutdown method")
// },
@@ -55,6 +58,9 @@ type IAMServiceMock struct {
// ListUserAccountsFunc mocks the ListUserAccounts method.
ListUserAccountsFunc func() ([]auth.Account, error)
// ResolveAccountsFunc mocks the ResolveAccounts method.
ResolveAccountsFunc func(accessKeyIDs []string) ([]string, error)
// ShutdownFunc mocks the Shutdown method.
ShutdownFunc func() error
@@ -81,6 +87,11 @@ type IAMServiceMock struct {
// ListUserAccounts holds details about calls to the ListUserAccounts method.
ListUserAccounts []struct {
}
// ResolveAccounts holds details about calls to the ResolveAccounts method.
ResolveAccounts []struct {
// AccessKeyIDs is the accessKeyIDs argument value.
AccessKeyIDs []string
}
// Shutdown holds details about calls to the Shutdown method.
Shutdown []struct {
}
@@ -96,6 +107,7 @@ type IAMServiceMock struct {
lockDeleteUserAccount sync.RWMutex
lockGetUserAccount sync.RWMutex
lockListUserAccounts sync.RWMutex
lockResolveAccounts sync.RWMutex
lockShutdown sync.RWMutex
lockUpdateUserAccount sync.RWMutex
}
@@ -223,6 +235,38 @@ func (mock *IAMServiceMock) ListUserAccountsCalls() []struct {
return calls
}
// ResolveAccounts calls ResolveAccountsFunc.
func (mock *IAMServiceMock) ResolveAccounts(accessKeyIDs []string) ([]string, error) {
if mock.ResolveAccountsFunc == nil {
panic("IAMServiceMock.ResolveAccountsFunc: method is nil but IAMService.ResolveAccounts was just called")
}
callInfo := struct {
AccessKeyIDs []string
}{
AccessKeyIDs: accessKeyIDs,
}
mock.lockResolveAccounts.Lock()
mock.calls.ResolveAccounts = append(mock.calls.ResolveAccounts, callInfo)
mock.lockResolveAccounts.Unlock()
return mock.ResolveAccountsFunc(accessKeyIDs)
}
// ResolveAccountsCalls gets all the calls that were made to ResolveAccounts.
// Check the length with:
//
// len(mockedIAMService.ResolveAccountsCalls())
func (mock *IAMServiceMock) ResolveAccountsCalls() []struct {
AccessKeyIDs []string
} {
var calls []struct {
AccessKeyIDs []string
}
mock.lockResolveAccounts.RLock()
calls = mock.calls.ResolveAccounts
mock.lockResolveAccounts.RUnlock()
return calls
}
// Shutdown calls ShutdownFunc.
func (mock *IAMServiceMock) Shutdown() error {
if mock.ShutdownFunc == nil {
+7 -12
View File
@@ -41,9 +41,8 @@ func (c S3ApiController) DeleteObjectTagging(ctx fiber.Ctx) (*Response, error) {
action = auth.DeleteObjectVersionTaggingAction
}
err := auth.VerifyAccess(ctx.RequestCtx(), c.be,
err := c.verifyAccess(ctx,
auth.AccessOptions{
Readonly: c.readonly,
Acl: parsedAcl,
AclPermission: auth.PermissionWrite,
IsRoot: isRoot,
@@ -52,7 +51,6 @@ func (c S3ApiController) DeleteObjectTagging(ctx fiber.Ctx) (*Response, error) {
Object: key,
Actions: []auth.Action{action},
IsPublicRequest: isBucketPublic,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -85,9 +83,8 @@ func (c S3ApiController) AbortMultipartUpload(ctx fiber.Ctx) (*Response, error)
isBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx)
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
err := auth.VerifyAccess(ctx.RequestCtx(), c.be,
err := c.verifyAccess(ctx,
auth.AccessOptions{
Readonly: c.readonly,
Acl: parsedAcl,
AclPermission: auth.PermissionWrite,
IsRoot: isRoot,
@@ -96,7 +93,6 @@ func (c S3ApiController) AbortMultipartUpload(ctx fiber.Ctx) (*Response, error)
Object: key,
Actions: []auth.Action{auth.AbortMultipartUploadAction},
IsPublicRequest: isBucketPublic,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -125,7 +121,7 @@ func (c S3ApiController) DeleteObject(ctx fiber.Ctx) (*Response, error) {
bucket := ctx.Params("bucket")
key := strings.TrimPrefix(ctx.Path(), fmt.Sprintf("/%s/", bucket))
versionId := ctx.Query("versionId")
bypass := strings.EqualFold(ctx.Get("X-Amz-Bypass-Governance-Retention"), "true")
bypass := auth.BypassModeForRequest(strings.EqualFold(ctx.Get("X-Amz-Bypass-Governance-Retention"), "true"))
ifMatch := utils.GetStringPtr(strings.Trim(ctx.Get("If-Match"), `"`))
ifMatchLastModTime := utils.ParsePreconditionDateHeader(ctx.Get("X-Amz-If-Match-Last-Modified-Time"))
ifMatchSize := utils.ParseIfMatchSize(ctx)
@@ -140,9 +136,8 @@ func (c S3ApiController) DeleteObject(ctx fiber.Ctx) (*Response, error) {
action = auth.DeleteObjectVersionAction
}
err := auth.VerifyAccess(ctx.RequestCtx(), c.be,
err := c.verifyAccess(ctx,
auth.AccessOptions{
Readonly: c.readonly,
Acl: parsedAcl,
AclPermission: auth.PermissionWrite,
IsRoot: isRoot,
@@ -151,7 +146,6 @@ func (c S3ApiController) DeleteObject(ctx fiber.Ctx) (*Response, error) {
Object: key,
Actions: []auth.Action{action},
IsPublicRequest: isBucketPublic,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -162,9 +156,9 @@ func (c S3ApiController) DeleteObject(ctx fiber.Ctx) (*Response, error) {
}
err = auth.CheckObjectAccess(
ctx.RequestCtx(),
ctx,
bucket,
acct.Access,
acct,
[]types.ObjectIdentifier{
{
Key: &key,
@@ -174,6 +168,7 @@ func (c S3ApiController) DeleteObject(ctx fiber.Ctx) (*Response, error) {
bypass,
isBucketPublic,
c.be,
c.iam,
false,
)
if err != nil {
+7 -21
View File
@@ -45,8 +45,7 @@ func (c S3ApiController) GetObjectTagging(ctx fiber.Ctx) (*Response, error) {
action = auth.GetObjectVersionTaggingAction
}
err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{
Readonly: c.readonly,
err := c.verifyAccess(ctx, auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionRead,
IsRoot: isRoot,
@@ -55,7 +54,6 @@ func (c S3ApiController) GetObjectTagging(ctx fiber.Ctx) (*Response, error) {
Object: key,
Actions: []auth.Action{action},
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -103,8 +101,7 @@ func (c S3ApiController) GetObjectRetention(ctx fiber.Ctx) (*Response, error) {
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{
Readonly: c.readonly,
err := c.verifyAccess(ctx, auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionRead,
IsRoot: isRoot,
@@ -113,7 +110,6 @@ func (c S3ApiController) GetObjectRetention(ctx fiber.Ctx) (*Response, error) {
Object: key,
Actions: []auth.Action{auth.GetObjectRetentionAction},
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -151,8 +147,7 @@ func (c S3ApiController) GetObjectLegalHold(ctx fiber.Ctx) (*Response, error) {
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{
Readonly: c.readonly,
err := c.verifyAccess(ctx, auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionRead,
IsRoot: isRoot,
@@ -161,7 +156,6 @@ func (c S3ApiController) GetObjectLegalHold(ctx fiber.Ctx) (*Response, error) {
Object: key,
Actions: []auth.Action{auth.GetObjectLegalHoldAction},
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -189,8 +183,7 @@ func (c S3ApiController) GetObjectAcl(ctx fiber.Ctx) (*Response, error) {
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{
Readonly: c.readonly,
err := c.verifyAccess(ctx, auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionReadAcp,
IsRoot: isRoot,
@@ -199,7 +192,6 @@ func (c S3ApiController) GetObjectAcl(ctx fiber.Ctx) (*Response, error) {
Object: key,
Actions: []auth.Action{auth.GetObjectAclAction},
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -232,8 +224,7 @@ func (c S3ApiController) ListParts(ctx fiber.Ctx) (*Response, error) {
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{
Readonly: c.readonly,
err := c.verifyAccess(ctx, auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionRead,
IsRoot: isRoot,
@@ -242,7 +233,6 @@ func (c S3ApiController) ListParts(ctx fiber.Ctx) (*Response, error) {
Object: key,
Actions: []auth.Action{auth.ListMultipartUploadPartsAction},
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -304,8 +294,7 @@ func (c S3ApiController) GetObjectAttributes(ctx fiber.Ctx) (*Response, error) {
action = auth.GetObjectVersionAttributesAction
}
err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{
Readonly: c.readonly,
err := c.verifyAccess(ctx, auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionRead,
IsRoot: isRoot,
@@ -314,7 +303,6 @@ func (c S3ApiController) GetObjectAttributes(ctx fiber.Ctx) (*Response, error) {
Object: key,
Actions: []auth.Action{action},
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -429,8 +417,7 @@ func (c S3ApiController) GetObject(ctx fiber.Ctx) (*Response, error) {
action = auth.GetObjectVersionAction
}
err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{
Readonly: c.readonly,
err := c.verifyAccess(ctx, auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionRead,
IsRoot: isRoot,
@@ -439,7 +426,6 @@ func (c S3ApiController) GetObject(ctx fiber.Ctx) (*Response, error) {
Object: key,
Actions: []auth.Action{action},
IsPublicRequest: isPublicBucketRequest,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
+1 -3
View File
@@ -76,9 +76,8 @@ func (c S3ApiController) HeadObject(ctx fiber.Ctx) (*Response, error) {
action = auth.GetObjectVersionAction
}
err := auth.VerifyAccess(ctx.RequestCtx(), c.be,
err := c.verifyAccess(ctx,
auth.AccessOptions{
Readonly: c.readonly,
Acl: parsedAcl,
AclPermission: auth.PermissionRead,
IsRoot: isRoot,
@@ -87,7 +86,6 @@ func (c S3ApiController) HeadObject(ctx fiber.Ctx) (*Response, error) {
Object: key,
Actions: []auth.Action{action},
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
+5 -13
View File
@@ -39,9 +39,8 @@ func (c S3ApiController) RestoreObject(ctx fiber.Ctx) (*Response, error) {
isBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx)
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
err := auth.VerifyAccess(ctx.RequestCtx(), c.be,
err := c.verifyAccess(ctx,
auth.AccessOptions{
Readonly: c.readonly,
Acl: parsedAcl,
AclPermission: auth.PermissionWrite,
IsRoot: isRoot,
@@ -50,7 +49,6 @@ func (c S3ApiController) RestoreObject(ctx fiber.Ctx) (*Response, error) {
Object: key,
Actions: []auth.Action{auth.RestoreObjectAction},
IsPublicRequest: isBucketPublic,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -91,9 +89,8 @@ func (c S3ApiController) SelectObjectContent(ctx fiber.Ctx) (*Response, error) {
isBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx)
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
err := auth.VerifyAccess(ctx.RequestCtx(), c.be,
err := c.verifyAccess(ctx,
auth.AccessOptions{
Readonly: c.readonly,
Acl: parsedAcl,
AclPermission: auth.PermissionRead,
IsRoot: isRoot,
@@ -102,7 +99,6 @@ func (c S3ApiController) SelectObjectContent(ctx fiber.Ctx) (*Response, error) {
Object: key,
Actions: []auth.Action{auth.GetObjectAction},
IsPublicRequest: isBucketPublic,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -175,9 +171,8 @@ func (c S3ApiController) CreateMultipartUpload(ctx fiber.Ctx) (*Response, error)
actions = append(actions, auth.PutObjectRetentionAction)
}
err := auth.VerifyAccess(ctx.RequestCtx(), c.be,
err := c.verifyAccess(ctx,
auth.AccessOptions{
Readonly: c.readonly,
Acl: parsedAcl,
AclPermission: auth.PermissionWrite,
IsRoot: isRoot,
@@ -185,7 +180,6 @@ func (c S3ApiController) CreateMultipartUpload(ctx fiber.Ctx) (*Response, error)
Bucket: bucket,
Object: key,
Actions: actions,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -278,9 +272,8 @@ func (c S3ApiController) CompleteMultipartUpload(ctx fiber.Ctx) (*Response, erro
isBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx)
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
err := auth.VerifyAccess(ctx.RequestCtx(), c.be,
err := c.verifyAccess(ctx,
auth.AccessOptions{
Readonly: c.readonly,
Acl: parsedAcl,
AclPermission: auth.PermissionWrite,
IsRoot: isRoot,
@@ -289,7 +282,6 @@ func (c S3ApiController) CompleteMultipartUpload(ctx fiber.Ctx) (*Response, erro
Object: key,
Actions: []auth.Action{auth.PutObjectAction},
IsPublicRequest: isBucketPublic,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -363,7 +355,7 @@ func (c S3ApiController) CompleteMultipartUpload(ctx fiber.Ctx) (*Response, erro
ifMatch, ifNoneMatch := utils.ParsePreconditionMatchHeaders(ctx)
err = auth.CheckObjectAccess(ctx.RequestCtx(), bucket, acct.Access, []types.ObjectIdentifier{{Key: &key}}, true, isBucketPublic, c.be, true)
err = auth.CheckObjectAccess(ctx, bucket, acct, []types.ObjectIdentifier{{Key: &key}}, auth.BypassOverwrite, isBucketPublic, c.be, c.iam, true)
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
+11 -23
View File
@@ -47,8 +47,7 @@ func (c S3ApiController) PutObjectTagging(ctx fiber.Ctx) (*Response, error) {
action = auth.PutObjectVersionTaggingAction
}
err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{
Readonly: c.readonly,
err := c.verifyAccess(ctx, auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionWrite,
IsRoot: isRoot,
@@ -57,7 +56,6 @@ func (c S3ApiController) PutObjectTagging(ctx fiber.Ctx) (*Response, error) {
Object: key,
Actions: []auth.Action{action},
IsPublicRequest: IsBucketPublic,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -98,8 +96,7 @@ func (c S3ApiController) PutObjectRetention(ctx fiber.Ctx) (*Response, error) {
IsBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx)
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{
Readonly: c.readonly,
err := c.verifyAccess(ctx, auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionWrite,
IsRoot: isRoot,
@@ -108,7 +105,6 @@ func (c S3ApiController) PutObjectRetention(ctx fiber.Ctx) (*Response, error) {
Object: key,
Actions: []auth.Action{auth.PutObjectRetentionAction},
IsPublicRequest: IsBucketPublic,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -129,7 +125,7 @@ func (c S3ApiController) PutObjectRetention(ctx fiber.Ctx) (*Response, error) {
}
// check if the operation is allowed
err = auth.IsObjectLockRetentionPutAllowed(ctx.RequestCtx(), c.be, bucket, key, versionId, acct.Access, retention, bypass)
err = auth.IsObjectLockRetentionPutAllowed(ctx, c.be, c.iam, bucket, key, versionId, acct, retention, bypass)
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
@@ -165,8 +161,7 @@ func (c S3ApiController) PutObjectLegalHold(ctx fiber.Ctx) (*Response, error) {
IsBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx)
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
err := auth.VerifyAccess(ctx.RequestCtx(), c.be, auth.AccessOptions{
Readonly: c.readonly,
err := c.verifyAccess(ctx, auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionWrite,
IsRoot: isRoot,
@@ -175,7 +170,6 @@ func (c S3ApiController) PutObjectLegalHold(ctx fiber.Ctx) (*Response, error) {
Object: key,
Actions: []auth.Action{auth.PutObjectLegalHoldAction},
IsPublicRequest: IsBucketPublic,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -234,9 +228,8 @@ func (c S3ApiController) UploadPart(ctx fiber.Ctx) (*Response, error) {
contentLengthStr = decodedLength
}
err := auth.VerifyAccess(ctx.RequestCtx(), c.be,
err := c.verifyAccess(ctx,
auth.AccessOptions{
Readonly: c.readonly,
Acl: parsedAcl,
AclPermission: auth.PermissionWrite,
IsRoot: isRoot,
@@ -245,7 +238,6 @@ func (c S3ApiController) UploadPart(ctx fiber.Ctx) (*Response, error) {
Object: key,
Actions: []auth.Action{auth.PutObjectAction},
IsPublicRequest: IsBucketPublic,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -361,7 +353,7 @@ func (c S3ApiController) UploadPartCopy(ctx fiber.Ctx) (*Response, error) {
}, err
}
err = auth.VerifyObjectCopyAccess(ctx.RequestCtx(), c.be, copySource,
err = c.verifyObjectCopyAccess(ctx, copySource,
auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionWrite,
@@ -371,7 +363,6 @@ func (c S3ApiController) UploadPartCopy(ctx fiber.Ctx) (*Response, error) {
Object: key,
Actions: []auth.Action{auth.PutObjectAction},
IsPublicRequest: IsBucketPublic,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -444,9 +435,8 @@ func (c S3ApiController) PutObjectAcl(ctx fiber.Ctx) (*Response, error) {
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
err := auth.VerifyAccess(ctx.RequestCtx(), c.be,
err := c.verifyAccess(ctx,
auth.AccessOptions{
Readonly: c.readonly,
Acl: parsedAcl,
AclPermission: auth.PermissionWrite,
IsRoot: isRoot,
@@ -525,7 +515,7 @@ func (c S3ApiController) CopyObject(ctx fiber.Ctx) (*Response, error) {
actions = append(actions, auth.PutObjectRetentionAction)
}
err = auth.VerifyObjectCopyAccess(ctx.RequestCtx(), c.be, copySource,
err = c.verifyObjectCopyAccess(ctx, copySource,
auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionWrite,
@@ -609,7 +599,7 @@ func (c S3ApiController) CopyObject(ctx fiber.Ctx) (*Response, error) {
preconditionHdrs := utils.ParsePreconditionHeaders(ctx, utils.WithCopySource())
err = auth.CheckObjectAccess(ctx.RequestCtx(), bucket, acct.Access, []types.ObjectIdentifier{{Key: &key}}, true, false, c.be, true)
err = auth.CheckObjectAccess(ctx, bucket, acct, []types.ObjectIdentifier{{Key: &key}}, auth.BypassOverwrite, false, c.be, c.iam, true)
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
@@ -710,9 +700,8 @@ func (c S3ApiController) PutObject(ctx fiber.Ctx) (*Response, error) {
actions = append(actions, auth.PutObjectRetentionAction)
}
err := auth.VerifyAccess(ctx.RequestCtx(), c.be,
err := c.verifyAccess(ctx,
auth.AccessOptions{
Readonly: c.readonly,
Acl: parsedAcl,
AclPermission: auth.PermissionWrite,
IsRoot: isRoot,
@@ -721,7 +710,6 @@ func (c S3ApiController) PutObject(ctx fiber.Ctx) (*Response, error) {
Object: key,
Actions: actions,
IsPublicRequest: IsBucketPublic,
DisableACL: c.disableACL,
})
if err != nil {
return &Response{
@@ -750,7 +738,7 @@ func (c S3ApiController) PutObject(ctx fiber.Ctx) (*Response, error) {
}, err
}
err = auth.CheckObjectAccess(ctx.RequestCtx(), bucket, acct.Access, []types.ObjectIdentifier{{Key: &key}}, true, IsBucketPublic, c.be, true)
err = auth.CheckObjectAccess(ctx, bucket, acct, []types.ObjectIdentifier{{Key: &key}}, auth.BypassOverwrite, IsBucketPublic, c.be, c.iam, true)
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
+81 -4
View File
@@ -603,6 +603,27 @@ func TestS3ApiController_UploadPartCopy(t *testing.T) {
err: s3err.GetAPIError(s3err.ErrAccessDenied),
},
},
{
name: "readonly mode blocks upload part copy",
input: testInput{
locals: defaultLocals,
headers: map[string]string{
"X-Amz-Copy-Source": "bucket/key",
},
queries: map[string]string{
"partNumber": "2",
},
readonly: true,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrAccessDenied),
},
},
{
name: "invalid copy source",
input: testInput{
@@ -729,7 +750,9 @@ func TestS3ApiController_UploadPartCopy(t *testing.T) {
}
ctrl := S3ApiController{
be: be,
be: be,
readonly: tt.input.readonly,
disableACL: tt.input.disableACL,
}
testController(
@@ -805,7 +828,7 @@ func TestS3ApiController_PutObjectAcl(t *testing.T) {
return tt.input.beErr
},
GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
return nil, s3err.GetAPIError(s3err.ErrNoSuchBucketPolicy)
},
}
@@ -849,6 +872,58 @@ func TestS3ApiController_CopyObject(t *testing.T) {
err: s3err.GetAPIError(s3err.ErrAccessDenied),
},
},
{
name: "readonly mode blocks copy object",
input: testInput{
locals: defaultLocals,
headers: map[string]string{
"X-Amz-Copy-Source": "bucket/object",
},
readonly: true,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrAccessDenied),
},
},
{
name: "disableACL blocks a non-owner's grantee-based access",
input: testInput{
locals: map[utils.ContextKey]any{
utils.ContextKeyIsRoot: false,
utils.ContextKeyParsedAcl: auth.ACL{
Owner: "root",
Grantees: []auth.Grantee{
{
Access: "user",
Permission: auth.PermissionWrite,
Type: types.TypeCanonicalUser,
},
},
},
utils.ContextKeyAccount: auth.Account{
Access: "user",
Role: auth.RoleUser,
},
},
headers: map[string]string{
"X-Amz-Copy-Source": "bucket/object",
},
disableACL: true,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrAccessDenied),
},
},
{
name: "invalid copy source",
input: testInput{
@@ -1059,7 +1134,7 @@ func TestS3ApiController_CopyObject(t *testing.T) {
return tt.input.beRes.(s3response.CopyObjectOutput), tt.input.beErr
},
GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
return nil, s3err.GetAPIError(s3err.ErrNoSuchBucketPolicy)
},
GetBucketVersioningFunc: func(contextMoqParam context.Context, bucket string) (s3response.GetBucketVersioningOutput, error) {
return s3response.GetBucketVersioningOutput{}, s3err.GetAPIError(s3err.ErrNotImplemented)
@@ -1070,7 +1145,9 @@ func TestS3ApiController_CopyObject(t *testing.T) {
}
ctrl := S3ApiController{
be: be,
be: be,
readonly: tt.input.readonly,
disableACL: tt.input.disableACL,
}
testController(
+11 -21
View File
@@ -17,12 +17,14 @@ package middlewares
import (
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"strconv"
"time"
"github.com/gofiber/fiber/v3"
"github.com/versity/versitygw/auth"
"github.com/versity/versitygw/internal/sigv4auth"
"github.com/versity/versitygw/s3api/utils"
"github.com/versity/versitygw/s3err"
)
@@ -39,7 +41,7 @@ type RootUserConfig struct {
}
func VerifyV4Signature(root RootUserConfig, iam auth.IAMService, region string, streamBody, requireContentSha256, allowDefaultRegion bool) fiber.Handler {
acct := accounts{root: root, iam: iam}
rootAccount := auth.Account{Access: root.Access, Secret: root.Secret, Role: auth.RoleAdmin}
return func(ctx fiber.Ctx) error {
// The bucket is public, no need to check this signature
@@ -89,10 +91,15 @@ func VerifyV4Signature(root RootUserConfig, iam auth.IAMService, region string,
utils.ContextKeyIsRoot.Set(ctx, authData.Access == root.Access)
account, err := acct.getAccount(authData.Access)
sessionToken := ctx.Get(sigv4auth.HeaderSecurityToken)
derivedKey, account, err := auth.ResolveDerivedKey(iam, rootAccount, authData.Access, sessionToken, authData.Date, authData.Region, sigv4auth.ServiceS3)
if err == auth.ErrNoSuchUser {
return s3err.GetInvalidAccessKeyIdErr(authData.Access)
}
if errors.Is(err, auth.ErrInvalidSessionToken) {
return s3err.GetAPIError(s3err.ErrInvalidToken)
}
if err != nil {
return err
}
@@ -126,7 +133,7 @@ func VerifyV4Signature(root RootUserConfig, iam auth.IAMService, region string,
return s3err.GetAPIError(s3err.ErrInvalidSHA256PayloadUsage)
}
canonicalString, err := utils.CheckValidSignature(ctx, authData, account.Secret, hashPayload, tdate, contentLength)
canonicalString, err := utils.CheckValidSignature(ctx, authData, derivedKey, hashPayload, tdate, contentLength)
if err != nil {
return err
}
@@ -153,7 +160,7 @@ func VerifyV4Signature(root RootUserConfig, iam auth.IAMService, region string,
if utils.IsStreamingPayload(hashPayload) {
wrapBodyReader(ctx, func(r io.Reader) io.Reader {
var cr io.Reader
cr, err = utils.NewChunkReader(ctx, r, authData, canonicalString, account.Secret, tdate)
cr, err = utils.NewChunkReader(ctx, r, authData, canonicalString, derivedKey, tdate)
return cr
})
if err != nil {
@@ -190,20 +197,3 @@ func VerifyV4Signature(root RootUserConfig, iam auth.IAMService, region string,
return nil
}
}
type accounts struct {
root RootUserConfig
iam auth.IAMService
}
func (a accounts) getAccount(access string) (auth.Account, error) {
if access == a.root.Access {
return auth.Account{
Access: a.root.Access,
Secret: a.root.Secret,
Role: auth.RoleAdmin,
}, nil
}
return a.iam.GetUserAccount(access)
}
+6
View File
@@ -19,6 +19,7 @@ import (
"strings"
"github.com/gofiber/fiber/v3"
"github.com/versity/versitygw/internal/httpctx"
)
// HostStyleParser is a middleware which parses the bucket name
@@ -31,6 +32,11 @@ func HostStyleParser(virtualDomain string) fiber.Handler {
if !found || bucket == "" {
return ctx.Next()
}
// SigV4 verification signs the request's original, on-the-wire path,
// not the bucket-prefixed one used for routing here — save it
// before ctx.Path() below overwrites fasthttp's URI.PathOriginal too.
httpctx.ContextKeyOriginalURIPath.Set(ctx, string(ctx.Request().URI().PathOriginal()))
path := ctx.Path()
if path == "/" {
// omit the trailing / for bucket operations
+15 -8
View File
@@ -16,6 +16,7 @@ package middlewares
import (
"bytes"
"errors"
"mime"
"strconv"
"time"
@@ -23,16 +24,18 @@ import (
"github.com/gofiber/fiber/v3"
"github.com/versity/versitygw/auth"
"github.com/versity/versitygw/debuglogger"
"github.com/versity/versitygw/internal/sigv4auth"
"github.com/versity/versitygw/s3api/utils"
"github.com/versity/versitygw/s3err"
)
const (
formFieldPolicy = "policy"
formFieldAlgorithm = "x-amz-algorithm"
formFieldCredential = "x-amz-credential"
formFieldDate = "x-amz-date"
formFieldSignature = "x-amz-signature"
formFieldPolicy = "policy"
formFieldAlgorithm = "x-amz-algorithm"
formFieldCredential = "x-amz-credential"
formFieldDate = "x-amz-date"
formFieldSignature = "x-amz-signature"
formFieldSecurityToken = "x-amz-security-token"
aws4HMACSHA256 = "AWS4-HMAC-SHA256"
hourSeconds = 60 * 60
@@ -47,7 +50,7 @@ type PostObjectResult struct {
}
func AuthorizePostObject(root RootUserConfig, iam auth.IAMService, region string) fiber.Handler {
acct := accounts{root: root, iam: iam}
rootAccount := auth.Account{Access: root.Access, Secret: root.Secret, Role: auth.RoleAdmin}
return func(ctx fiber.Ctx) error {
contentLengthStr := ctx.Get("Content-Length")
@@ -164,11 +167,15 @@ func AuthorizePostObject(root RootUserConfig, iam auth.IAMService, region string
return s3err.PostAuth.IncorrectRegion(credentialStr, region, creds.Region)
}
account, err := acct.getAccount(creds.Access)
derivedKey, account, err := auth.ResolveDerivedKey(iam, rootAccount, creds.Access, fields[formFieldSecurityToken], creds.Date, creds.Region, sigv4auth.ServiceS3)
if err == auth.ErrNoSuchUser {
debuglogger.Logf("POST object access key not found: %s", creds.Access)
return s3err.GetInvalidAccessKeyIdErr(creds.Access)
}
if errors.Is(err, auth.ErrInvalidSessionToken) {
debuglogger.Logf("invalid POST object security token for access key %s", creds.Access)
return s3err.GetAPIError(s3err.ErrInvalidToken)
}
if err != nil {
debuglogger.Logf("failed to resolve POST object account %q: %v", creds.Access, err)
return err
@@ -177,7 +184,7 @@ func AuthorizePostObject(root RootUserConfig, iam auth.IAMService, region string
utils.ContextKeyAccount.Set(ctx, account)
utils.ContextKeyIsRoot.Set(ctx, account.Access == root.Access)
expectedSig, err := utils.SignPostPolicy(policyB64, creds.Date, region, account.Secret)
expectedSig, err := utils.SignPostPolicy(policyB64, derivedKey)
if err != nil {
return err
}
+3 -1
View File
@@ -28,6 +28,7 @@ import (
"github.com/gofiber/fiber/v3"
"github.com/stretchr/testify/assert"
"github.com/versity/versitygw/internal/sigv4auth"
"github.com/versity/versitygw/s3api/utils"
"github.com/versity/versitygw/s3err"
)
@@ -186,7 +187,8 @@ func TestAuthorizePostObject_SignedRequest(t *testing.T) {
map[string]string{"bucket": "mybucket"},
[]any{"starts-with", "$key", "uploads/"},
})
sig, err := utils.SignPostPolicy(policyB64, dateShort, region, secretKey)
derivedKey := sigv4auth.DeriveKey(secretKey, dateShort, region, sigv4auth.ServiceS3)
sig, err := utils.SignPostPolicy(policyB64, derivedKey)
assert.NoError(t, err)
var gotAuthenticated bool
+10 -8
View File
@@ -15,17 +15,19 @@
package middlewares
import (
"errors"
"io"
"strconv"
"github.com/gofiber/fiber/v3"
"github.com/versity/versitygw/auth"
"github.com/versity/versitygw/internal/sigv4auth"
"github.com/versity/versitygw/s3api/utils"
"github.com/versity/versitygw/s3err"
)
func VerifyPresignedV4Signature(root RootUserConfig, iam auth.IAMService, region string, streamBody bool) fiber.Handler {
acct := accounts{root: root, iam: iam}
rootAccount := auth.Account{Access: root.Access, Secret: root.Secret, Role: auth.RoleAdmin}
return func(ctx fiber.Ctx) error {
// The bucket is public, no need to check this signature
@@ -40,11 +42,6 @@ func VerifyPresignedV4Signature(root RootUserConfig, iam auth.IAMService, region
return s3err.GetAPIError(s3err.ErrUnsupportedAuthorizationMechanism)
}
if ctx.Request().URI().QueryArgs().Has("X-Amz-Security-Token") {
// OIDC Authorization with X-Amz-Security-Token is not supported
return s3err.QueryAuthErrors.SecurityTokenNotSupported()
}
// Set in the context the "authenticated" key, in case the authentication succeeds,
// otherwise the middleware will return the caucht error
utils.ContextKeyAuthenticated.Set(ctx, true)
@@ -56,10 +53,15 @@ func VerifyPresignedV4Signature(root RootUserConfig, iam auth.IAMService, region
utils.ContextKeyIsRoot.Set(ctx, authData.Access == root.Access)
account, err := acct.getAccount(authData.Access)
sessionToken := ctx.Query(sigv4auth.QuerySecurityToken)
derivedKey, account, err := auth.ResolveDerivedKey(iam, rootAccount, authData.Access, sessionToken, authData.Date[:8], authData.Region, sigv4auth.ServiceS3)
if err == auth.ErrNoSuchUser {
return s3err.GetInvalidAccessKeyIdErr(authData.Access)
}
if errors.Is(err, auth.ErrInvalidSessionToken) {
return s3err.GetAPIError(s3err.ErrInvalidToken)
}
if err != nil {
return err
}
@@ -75,7 +77,7 @@ func VerifyPresignedV4Signature(root RootUserConfig, iam auth.IAMService, region
}
}
err = utils.CheckPresignedSignature(ctx, authData, account.Secret)
err = utils.CheckPresignedSignature(ctx, authData, derivedKey)
if err != nil {
return err
}
+1 -1
View File
@@ -57,7 +57,7 @@ func AuthorizePublicBucketAccess(be backend.Backend, s3action string, policyPerm
}
bucket, object := parsePath(ctx.Path())
err := auth.VerifyPublicAccess(ctx.RequestCtx(), be, policyPermission, permission, bucket, object)
err := auth.VerifyPublicAccess(ctx, be, policyPermission, permission, bucket, object)
if err != nil {
if s3action == metrics.ActionHeadBucket {
// add the bucket region header for HeadBucket
+6 -5
View File
@@ -29,6 +29,7 @@ import (
"github.com/versity/versitygw/auth"
"github.com/versity/versitygw/backend"
"github.com/versity/versitygw/debuglogger"
"github.com/versity/versitygw/internal/netutil"
"github.com/versity/versitygw/metrics"
"github.com/versity/versitygw/s3api/controllers"
"github.com/versity/versitygw/s3api/middlewares"
@@ -48,7 +49,7 @@ type S3ApiServer struct {
Router *S3ApiRouter
app *fiber.App
backend backend.Backend
CertStorage *utils.CertStorage
CertStorage *netutil.CertStorage
quiet bool
keepAlive bool
health string
@@ -237,7 +238,7 @@ func validateMiddlewareMount(mount middlewareMount) error {
type Option func(*S3ApiServer)
// WithTLS sets TLS Credentials
func WithTLS(cs *utils.CertStorage) Option {
func WithTLS(cs *netutil.CertStorage) Option {
return func(s *S3ApiServer) { s.CertStorage = cs }
}
@@ -365,9 +366,9 @@ func (sa *S3ApiServer) ServeMultiPort(ports []string) error {
var err error
if sa.CertStorage != nil {
ln, err = utils.NewMultiAddrTLSListener(fiber.NetworkTCP, portSpec, sa.CertStorage.GetCertificate, utils.ListenerOptions{SocketPerm: sa.socketPerm})
ln, err = netutil.NewMultiAddrTLSListener(fiber.NetworkTCP, portSpec, sa.CertStorage.GetCertificate, netutil.ListenerOptions{SocketPerm: sa.socketPerm})
} else {
ln, err = utils.NewMultiAddrListener(fiber.NetworkTCP, portSpec, utils.ListenerOptions{SocketPerm: sa.socketPerm})
ln, err = netutil.NewMultiAddrListener(fiber.NetworkTCP, portSpec, netutil.ListenerOptions{SocketPerm: sa.socketPerm})
}
if err != nil {
return fmt.Errorf("failed to bind s3 listener %s: %w", portSpec, err)
@@ -381,7 +382,7 @@ func (sa *S3ApiServer) ServeMultiPort(ports []string) error {
}
// Combine all listeners
finalListener := utils.NewMultiListener(listeners...)
finalListener := netutil.NewMultiListener(listeners...)
if sa.onListen != nil {
fn := sa.onListen
+2 -2
View File
@@ -25,8 +25,8 @@ import (
"github.com/gofiber/fiber/v3"
"github.com/versity/versitygw/auth"
"github.com/versity/versitygw/backend"
"github.com/versity/versitygw/internal/netutil"
"github.com/versity/versitygw/s3api/middlewares"
"github.com/versity/versitygw/s3api/utils"
)
func newTestS3ApiServer(opts ...Option) (*S3ApiServer, error) {
@@ -69,7 +69,7 @@ func TestS3ApiServer_Serve(t *testing.T) {
app: fiber.New(),
backend: backend.BackendUnsupported{},
Router: &S3ApiRouter{},
CertStorage: &utils.CertStorage{},
CertStorage: &netutil.CertStorage{},
},
port: "localhost:notaport",
},
+12 -23
View File
@@ -39,11 +39,13 @@ const (
service = sigv4auth.ServiceS3
)
// CheckValidSignature validates the ctx v4 auth signature
func CheckValidSignature(ctx fiber.Ctx, auth AuthData, secret, checksum string, tdate time.Time, contentLen int64) (string, error) {
result, err := sigv4auth.CheckSignature(ctx, auth, secret, checksum, tdate, contentLen, sigv4auth.CheckOptions{
Service: service,
DisableURIPathEscaping: true,
// CheckValidSignature validates the ctx v4 auth signature against
// derivedKey — the request's kSigning value, either derived locally from a
// known secret or obtained from a standalone IAM service that never reveals
// the secret itself.
func CheckValidSignature(ctx fiber.Ctx, auth AuthData, derivedKey []byte, checksum string, tdate time.Time, contentLen int64) (string, error) {
result, err := sigv4auth.CheckSignature(ctx, auth, derivedKey, checksum, tdate, contentLen, sigv4auth.CheckOptions{
Service: service,
})
if err != nil {
return "", mapSigV4Error(err)
@@ -86,24 +88,11 @@ func ParseCredentials(input string, errHandler CredsError) (*CredentialsScope, e
return creds, nil
}
func SignPostPolicy(base64Policy, yyyymmdd, region, secretKey string) (string, error) {
signingKey := deriveSigningKey(secretKey, yyyymmdd, region)
sig := hmacSHA256(signingKey, []byte(base64Policy))
return hex.EncodeToString(sig), nil
}
func deriveSigningKey(secretKey, yyyymmdd, region string) []byte {
kDate := hmacSHA256([]byte("AWS4"+secretKey), []byte(yyyymmdd))
kRegion := hmacSHA256(kDate, []byte(region))
kService := hmacSHA256(kRegion, []byte(service))
kSigning := hmacSHA256(kService, []byte("aws4_request"))
return kSigning
}
func hmacSHA256(key, data []byte) []byte {
h := hmac.New(sha256.New, key)
h.Write(data)
return h.Sum(nil)
// SignPostPolicy signs a POST-policy document with derivedKey
func SignPostPolicy(base64Policy string, derivedKey []byte) (string, error) {
h := hmac.New(sha256.New, derivedKey)
h.Write([]byte(base64Policy))
return hex.EncodeToString(h.Sum(nil)), nil
}
func mapSigV4Error(err error) error {
+24 -31
View File
@@ -16,14 +16,14 @@ package utils
import (
"net"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/gofiber/fiber/v3"
"github.com/valyala/fasthttp"
"github.com/valyala/fasthttp/fasthttputil"
v4 "github.com/versity/versitygw/aws/signer/v4"
"github.com/versity/versitygw/internal/sigv4auth"
)
func TestAuthParse(t *testing.T) {
@@ -93,36 +93,19 @@ func Test_Client_UserAgent(t *testing.T) {
}
app.Get("/", func(c fiber.Ctx) error {
req, err := createHttpRequestFromCtx(c, signedHdrs, int64(c.Request().Header.ContentLength()))
if err != nil {
t.Fatal(err)
auth := sigv4auth.AuthData{
Access: access,
Region: region,
Service: service,
SignedHeaders: strings.Join(signedHdrs, ";"),
Signature: expectedSig,
}
derivedKey := sigv4auth.DeriveKey(secret, dateStr[:8], region, service)
opts := sigv4auth.CheckOptions{DisableURIPathEscaping: true}
contentLen := int64(c.Request().Header.ContentLength())
req.Host = host
req.Header.Set("X-Amz-Content-Sha256", zeroLenSig)
signer := v4.NewSigner()
_, signErr := signer.SignHTTP(req.Context(),
aws.Credentials{
AccessKeyID: access,
SecretAccessKey: secret,
},
req, zeroLenSig, service, region, tdate, signedHdrs,
func(options *v4.SignerOptions) {
options.DisableURIPathEscaping = true
})
if signErr != nil {
t.Fatalf("sign generated http request: %v", err)
}
genAuth, err := ParseAuthorization(req.Header.Get("Authorization"))
if err != nil {
return err
}
if genAuth.Signature != expectedSig {
t.Errorf("SIG: %v\nexpected: %v\n", genAuth.Signature, expectedSig)
if _, err := sigv4auth.CheckSignature(c, auth, derivedKey, zeroLenSig, tdate, contentLen, opts); err != nil {
t.Errorf("CheckSignature: %v", err)
}
return c.Send(c.Request().Header.UserAgent())
@@ -145,9 +128,19 @@ func Test_Client_UserAgent(t *testing.T) {
defer fasthttp.ReleaseRequest(req)
defer fasthttp.ReleaseResponse(resp)
req.SetRequestURI("http://example.com")
// Host/User-Agent/X-Amz-Content-Sha256/X-Amz-Date are sent as real
// headers, reproducing the captured request verbatim, so CheckSignature
// extracts them straight off the live fiber.Ctx like it does for any
// real request.
req.SetRequestURI("http://" + host + "/")
req.Header.SetUserAgent(agent)
req.Header.Set("X-Amz-Content-Sha256", zeroLenSig)
req.Header.Set("X-Amz-Date", dateStr)
if err := client.Do(req, resp); err != nil {
t.Fatal(err)
}
if got := string(resp.Body()); got != agent {
t.Errorf("user-agent got %q, expected %q", got, agent)
}
}
+3 -3
View File
@@ -192,7 +192,7 @@ func ParseDecodedContentLength(ctx fiber.Ctx) (int64, error) {
return decContLength, nil
}
func NewChunkReader(ctx fiber.Ctx, r io.Reader, authdata AuthData, canonicalString, secret string, date time.Time) (io.Reader, error) {
func NewChunkReader(ctx fiber.Ctx, r io.Reader, authdata AuthData, canonicalString string, derivedKey []byte, date time.Time) (io.Reader, error) {
cLength, err := ParseDecodedContentLength(ctx)
if err != nil {
return nil, err
@@ -214,9 +214,9 @@ func NewChunkReader(ctx fiber.Ctx, r io.Reader, authdata AuthData, canonicalStri
case payloadTypeStreamingUnsignedTrailer:
return NewUnsignedChunkReader(r, checksumType, cLength)
case payloadTypeStreamingSignedTrailer:
return NewSignedChunkReader(r, authdata, canonicalString, secret, date, checksumType, true, cLength)
return NewSignedChunkReader(r, authdata, canonicalString, derivedKey, date, checksumType, true, cLength)
case payloadTypeStreamingSigned:
return NewSignedChunkReader(r, authdata, canonicalString, secret, date, "", false, cLength)
return NewSignedChunkReader(r, authdata, canonicalString, derivedKey, date, "", false, cLength)
// return not supported for:
// - STREAMING-AWS4-ECDSA-P256-SHA256-PAYLOAD
// - STREAMING-AWS4-ECDSA-P256-SHA256-PAYLOAD-TRAILER
-399
View File
@@ -1,399 +0,0 @@
// Copyright 2026 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package utils
import (
"crypto/tls"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"sync"
)
// MultiListener implements net.Listener and accepts connections from multiple
// underlying listeners. This is useful for listening on multiple IP addresses
// that a hostname resolves to (e.g., both IPv4 and IPv6 for "localhost").
type MultiListener struct {
listeners []net.Listener
acceptCh chan acceptResult
closeCh chan struct{}
closeOnce sync.Once
wg sync.WaitGroup
}
type acceptResult struct {
conn net.Conn
err error
}
// NewMultiListener creates a new MultiListener that accepts connections from
// all provided listeners.
func NewMultiListener(listeners ...net.Listener) *MultiListener {
if len(listeners) == 0 {
return nil
}
ml := &MultiListener{
listeners: listeners,
acceptCh: make(chan acceptResult, 2*len(listeners)),
closeCh: make(chan struct{}),
}
// Start accepting from each listener in its own goroutine
for _, ln := range listeners {
ml.wg.Add(1)
go ml.acceptLoop(ln)
}
return ml
}
// acceptLoop continuously accepts connections from a single listener
// and forwards them to the accept channel
func (ml *MultiListener) acceptLoop(ln net.Listener) {
defer ml.wg.Done()
for {
conn, err := ln.Accept()
select {
case <-ml.closeCh:
// MultiListener is closing
if conn != nil {
conn.Close()
}
return
case ml.acceptCh <- acceptResult{conn: conn, err: err}:
// Connection or error sent successfully
if err != nil {
return
}
}
}
}
// Accept waits for and returns the next connection from any of the listeners
func (ml *MultiListener) Accept() (net.Conn, error) {
select {
case <-ml.closeCh:
return nil, errors.New("listener closed")
case result, ok := <-ml.acceptCh:
if !ok {
// Channel closed
return nil, errors.New("listener closed")
}
return result.conn, result.err
}
}
// Close closes all underlying listeners
func (ml *MultiListener) Close() error {
var errs []error
ml.closeOnce.Do(func() {
close(ml.closeCh)
// Close all listeners
for _, ln := range ml.listeners {
if err := ln.Close(); err != nil {
errs = append(errs, err)
}
}
// Wait for all accept loops to finish
ml.wg.Wait()
// Drain any remaining accepts
close(ml.acceptCh)
for range ml.acceptCh {
}
})
if len(errs) > 0 {
return fmt.Errorf("errors closing listeners: %v", errs)
}
return nil
}
// Addr returns the address of the first listener
func (ml *MultiListener) Addr() net.Addr {
if len(ml.listeners) > 0 {
return ml.listeners[0].Addr()
}
return nil
}
// IsUnixSocketPath reports whether addr should be treated as a UNIX domain
// socket path rather than a TCP/IP address. It does so by attempting to parse
// addr as a host:port spec using net.SplitHostPort; anything that cannot be
// parsed that way (e.g. "/path/to/socket", "./rel/socket", "@abstract") is
// considered a socket path.
func IsUnixSocketPath(addr string) bool {
_, _, err := net.SplitHostPort(addr)
return err != nil
}
// AbsSocketPaths converts any relative UNIX socket paths in addrs to absolute
// paths using the current working directory. Non-socket addresses (TCP/IP) and
// abstract sockets ("@name") are returned unchanged. This should be called
// early in program startup — before any backend that calls os.Chdir — so that
// relative paths are resolved against the shell's working directory.
func AbsSocketPaths(addrs []string) ([]string, error) {
result := make([]string, len(addrs))
for i, addr := range addrs {
if strings.HasPrefix(addr, "./") {
abs, err := filepath.Abs(addr)
if err != nil {
return nil, fmt.Errorf("failed to resolve socket path %q: %w", addr, err)
}
result[i] = abs
} else {
result[i] = addr
}
}
return result, nil
}
// isAbstractSocket reports whether addr is a Linux abstract namespace socket.
// Abstract sockets start with "@"; Go's net package maps this to a leading
// null byte (\0) in the sockaddr, so no socket file is created on disk.
func isAbstractSocket(addr string) bool {
return strings.HasPrefix(addr, "@")
}
// removeStaleSocket removes a leftover UNIX socket file at path so the
// address can be reused. It returns an error if the path exists but is not
// a socket, protecting regular files and directories from accidental deletion.
func removeStaleSocket(path string) error {
fi, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("failed to stat socket path %q: %w", path, err)
}
if fi.Mode()&os.ModeSocket == 0 {
return fmt.Errorf("path %q already exists and is not a socket (mode %s)", path, fi.Mode())
}
return os.Remove(path)
}
// ResolveHostnameIPs resolves a hostname to all its IP addresses (IPv4 and IPv6).
// If the input is already an IP address or empty, it returns it as-is.
// This is useful for determining all addresses a server will listen on.
func ResolveHostnameIPs(address string) ([]string, error) {
if IsUnixSocketPath(address) {
return []string{address}, nil
}
host, _, err := net.SplitHostPort(address)
if err != nil {
return nil, fmt.Errorf("invalid address %q: %w", address, err)
}
// Handle empty host (e.g., ":8080" means all interfaces)
if host == "" {
return []string{""}, nil
}
// If already an IP address, return as is
if net.ParseIP(host) != nil {
return []string{host}, nil
}
// Resolve hostname to all IP addresses
ips, err := net.LookupIP(host)
if err != nil {
return nil, fmt.Errorf("failed to resolve hostname %q: %w", host, err)
}
if len(ips) == 0 {
return nil, fmt.Errorf("no addresses found for hostname %q", host)
}
// Convert IPs to strings
result := make([]string, 0, len(ips))
for _, ip := range ips {
result = append(result, ip.String())
}
return result, nil
}
// resolveHostnameAddrs resolves a hostname to all its IP addresses (IPv4 and IPv6)
// and returns them as a list of addresses with the port attached.
func resolveHostnameAddrs(address string) ([]string, error) {
if IsUnixSocketPath(address) {
return []string{address}, nil
}
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, fmt.Errorf("invalid address %q: %w", address, err)
}
// If host is empty or already an IP address, return as is
if host == "" || net.ParseIP(host) != nil {
return []string{address}, nil
}
// Resolve hostname to all IP addresses
ips, err := net.LookupIP(host)
if err != nil {
return nil, fmt.Errorf("failed to resolve hostname %q: %w", host, err)
}
if len(ips) == 0 {
return nil, fmt.Errorf("no addresses found for hostname %q", host)
}
// Build list of addresses with port
addrs := make([]string, 0, len(ips))
for _, ip := range ips {
addr := net.JoinHostPort(ip.String(), port)
addrs = append(addrs, addr)
}
return addrs, nil
}
// ListenerOptions configures optional behaviour for NewMultiAddrListener and
// NewMultiAddrTLSListener.
type ListenerOptions struct {
// SocketPerm, when non-zero, sets the file-mode permissions on file-backed
// UNIX sockets after binding. It is ignored for TCP/IP addresses and
// abstract namespace sockets.
SocketPerm os.FileMode
}
// NewMultiAddrListener creates listeners for all IP addresses that the hostname
// in the address resolves to. If the address is already an IP, it creates a
// single listener. Returns a MultiListener if multiple addresses are resolved,
// or a single listener if only one address is found.
//
// UNIX domain socket forms are also supported:
// - "/path/to/socket" or "./rel/socket" — file-backed socket; any stale
// socket file is removed before binding.
// - "@name" — Linux abstract namespace socket; no file is created or removed.
//
// opts.SocketPerm, when non-zero, sets the file-mode permissions on file-backed
// sockets after binding. It is ignored for TCP/IP addresses and abstract sockets.
func NewMultiAddrListener(network, address string, opts ListenerOptions) (net.Listener, error) {
if IsUnixSocketPath(address) {
// For file-backed sockets, remove any stale socket file so re-binding works cleanly.
// Abstract sockets (@name) have no filesystem entry; skip removal for them.
if !isAbstractSocket(address) {
if err := removeStaleSocket(address); err != nil {
return nil, err
}
}
ln, err := net.Listen("unix", address)
if err != nil {
return nil, fmt.Errorf("failed to bind unix socket listener %s: %w", address, err)
}
if opts.SocketPerm != 0 && !isAbstractSocket(address) {
if err := os.Chmod(address, opts.SocketPerm); err != nil {
ln.Close()
return nil, fmt.Errorf("failed to set permissions on socket %s: %w", address, err)
}
}
return NewMultiListener(ln), nil
}
addrs, err := resolveHostnameAddrs(address)
if err != nil {
return nil, err
}
// Create listeners for all resolved addresses
listeners := make([]net.Listener, 0, len(addrs))
for _, addr := range addrs {
ln, err := net.Listen(network, addr)
if err != nil {
// Close any listeners we've already created
for _, l := range listeners {
l.Close()
}
return nil, fmt.Errorf("failed to bind listener %s: %w", addr, err)
}
listeners = append(listeners, ln)
}
// Return MultiListener for multiple addresses
return NewMultiListener(listeners...), nil
}
// NewMultiAddrTLSListener creates TLS listeners for all IP addresses that the
// hostname in the address resolves to. Similar to NewMultiAddrListener but with TLS.
//
// UNIX domain socket forms are also supported:
// - "/path/to/socket" or "./rel/socket" — file-backed socket; any stale
// socket file is removed before binding.
// - "@name" — Linux abstract namespace socket; no file is created or removed.
//
// opts.SocketPerm, when non-zero, sets the file-mode permissions on file-backed
// sockets after binding. It is ignored for TCP/IP addresses and abstract sockets.
func NewMultiAddrTLSListener(network, address string, getCertificateFunc func(*tls.ClientHelloInfo) (*tls.Certificate, error), opts ListenerOptions) (net.Listener, error) {
config := &tls.Config{
MinVersion: tls.VersionTLS12,
GetCertificate: getCertificateFunc,
}
if IsUnixSocketPath(address) {
if !isAbstractSocket(address) {
if err := removeStaleSocket(address); err != nil {
return nil, err
}
}
ln, err := net.Listen("unix", address)
if err != nil {
return nil, fmt.Errorf("failed to bind unix TLS socket listener %s: %w", address, err)
}
if opts.SocketPerm != 0 && !isAbstractSocket(address) {
if err := os.Chmod(address, opts.SocketPerm); err != nil {
ln.Close()
return nil, fmt.Errorf("failed to set permissions on socket %s: %w", address, err)
}
}
return NewMultiListener(tls.NewListener(ln, config)), nil
}
addrs, err := resolveHostnameAddrs(address)
if err != nil {
return nil, err
}
// Create TLS listeners for all resolved addresses
listeners := make([]net.Listener, 0, len(addrs))
for _, addr := range addrs {
ln, err := net.Listen(network, addr)
if err != nil {
// Close any listeners we've already created
for _, l := range listeners {
l.Close()
}
return nil, fmt.Errorf("failed to bind TLS listener %s: %w", addr, err)
}
listeners = append(listeners, tls.NewListener(ln, config))
}
// Return MultiListener for multiple addresses
return NewMultiListener(listeners...), nil
}
-407
View File
@@ -1,407 +0,0 @@
// Copyright 2026 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package utils
import (
"crypto/tls"
"io"
"net"
"testing"
"time"
)
func TestMultiListener(t *testing.T) {
// Create multiple underlying listeners
ln1, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Failed to create listener 1: %v", err)
}
defer ln1.Close()
ln2, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Failed to create listener 2: %v", err)
}
defer ln2.Close()
// Create MultiListener
ml := NewMultiListener(ln1, ln2)
if ml == nil {
t.Fatal("NewMultiListener returned nil")
}
defer ml.Close()
// Test connections to both listeners
addr1 := ln1.Addr().String()
addr2 := ln2.Addr().String()
// Connect to first listener
go func() {
conn, err := net.Dial("tcp", addr1)
if err != nil {
t.Errorf("Failed to dial first address: %v", err)
return
}
defer conn.Close()
conn.Write([]byte("hello from ln1"))
}()
// Accept from MultiListener
conn1, err := ml.Accept()
if err != nil {
t.Fatalf("Failed to accept from MultiListener: %v", err)
}
defer conn1.Close()
buf := make([]byte, 100)
n, _ := conn1.Read(buf)
if string(buf[:n]) != "hello from ln1" {
t.Errorf("Unexpected data from first connection: %s", string(buf[:n]))
}
// Connect to second listener
go func() {
conn, err := net.Dial("tcp", addr2)
if err != nil {
t.Errorf("Failed to dial second address: %v", err)
return
}
defer conn.Close()
conn.Write([]byte("hello from ln2"))
}()
// Accept from MultiListener
conn2, err := ml.Accept()
if err != nil {
t.Fatalf("Failed to accept second connection: %v", err)
}
defer conn2.Close()
n, _ = conn2.Read(buf)
if string(buf[:n]) != "hello from ln2" {
t.Errorf("Unexpected data from second connection: %s", string(buf[:n]))
}
}
func TestMultiListenerClose(t *testing.T) {
ln1, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Failed to create listener: %v", err)
}
ml := NewMultiListener(ln1)
if ml == nil {
t.Fatal("NewMultiListener returned nil")
}
// Start accepting in a goroutine
acceptErrors := make(chan error, 1)
go func() {
_, err := ml.Accept()
acceptErrors <- err
}()
// Give the accept goroutine time to start
time.Sleep(100 * time.Millisecond)
// Close the MultiListener
if err := ml.Close(); err != nil {
t.Errorf("Close() returned error: %v", err)
}
// The accept should now return an error
select {
case err := <-acceptErrors:
if err == nil {
t.Error("Accept() should fail after Close()")
}
case <-time.After(2 * time.Second):
t.Error("Accept() did not return after Close()")
}
// Try to accept after close - should fail immediately
_, err = ml.Accept()
if err == nil {
t.Error("Accept() should fail after Close() on subsequent calls")
}
}
func TestResolveHostnameAddrs(t *testing.T) {
tests := []struct {
name string
address string
wantErr bool
checkResult func([]string) bool
}{
{
name: "IPv4 address",
address: "127.0.0.1:8080",
wantErr: false,
checkResult: func(addrs []string) bool {
return len(addrs) == 1 && addrs[0] == "127.0.0.1:8080"
},
},
{
name: "IPv6 address",
address: "[::1]:8080",
wantErr: false,
checkResult: func(addrs []string) bool {
return len(addrs) == 1 && addrs[0] == "[::1]:8080"
},
},
{
name: "localhost hostname",
address: "localhost:8080",
wantErr: false,
checkResult: func(addrs []string) bool {
// localhost should resolve to at least one address
return len(addrs) >= 1
},
},
{
name: "no port treated as unix socket path",
address: "invalid-no-port",
wantErr: false,
checkResult: func(addrs []string) bool {
return len(addrs) == 1 && addrs[0] == "invalid-no-port"
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
addrs, err := resolveHostnameAddrs(tt.address)
if (err != nil) != tt.wantErr {
t.Errorf("resolveHostnameAddrs() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && tt.checkResult != nil {
if !tt.checkResult(addrs) {
t.Errorf("resolveHostnameAddrs() returned unexpected result: %v", addrs)
}
}
})
}
}
func TestResolveHostnameIPs(t *testing.T) {
tests := []struct {
name string
address string
wantErr bool
checkResult func([]string) bool
}{
{
name: "IPv4 address",
address: "127.0.0.1:8080",
wantErr: false,
checkResult: func(ips []string) bool {
return len(ips) == 1 && ips[0] == "127.0.0.1"
},
},
{
name: "IPv6 address",
address: "[::1]:8080",
wantErr: false,
checkResult: func(ips []string) bool {
return len(ips) == 1 && ips[0] == "::1"
},
},
{
name: "localhost hostname",
address: "localhost:8080",
wantErr: false,
checkResult: func(ips []string) bool {
// localhost should resolve to at least one address
// On most systems, it resolves to both 127.0.0.1 and ::1
return len(ips) >= 1
},
},
{
name: "empty host",
address: ":8080",
wantErr: false,
checkResult: func(ips []string) bool {
return len(ips) == 1 && ips[0] == ""
},
},
{
name: "unix socket path",
address: "/tmp/test.sock",
wantErr: false,
checkResult: func(ips []string) bool {
return len(ips) == 1 && ips[0] == "/tmp/test.sock"
},
},
{
name: "relative unix socket path",
address: "./test.sock",
wantErr: false,
checkResult: func(ips []string) bool {
return len(ips) == 1 && ips[0] == "./test.sock"
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ips, err := ResolveHostnameIPs(tt.address)
if (err != nil) != tt.wantErr {
t.Errorf("ResolveHostnameIPs() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && tt.checkResult != nil {
if !tt.checkResult(ips) {
t.Errorf("ResolveHostnameIPs() returned unexpected result: %v", ips)
}
}
})
}
}
func TestNewMultiAddrListener(t *testing.T) {
tests := []struct {
name string
address string
wantErr bool
}{
{
name: "IPv4 loopback",
address: "127.0.0.1:0",
wantErr: false,
},
{
name: "IPv6 loopback",
address: "[::1]:0",
wantErr: false,
},
{
name: "localhost with port",
address: "localhost:0",
wantErr: false,
},
{
name: "invalid hostname",
address: "this-hostname-should-not-exist-12345.invalid:8080",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ln, err := NewMultiAddrListener("tcp", tt.address, ListenerOptions{})
if (err != nil) != tt.wantErr {
t.Errorf("NewMultiAddrListener() error = %v, wantErr %v", err, tt.wantErr)
return
}
if ln != nil {
defer ln.Close()
// Try to connect to verify listener is working
addr := ln.Addr().String()
go func() {
conn, err := net.Dial("tcp", addr)
if err != nil {
return
}
conn.Close()
}()
// Accept connection with timeout
type result struct {
conn net.Conn
err error
}
ch := make(chan result, 1)
go func() {
conn, err := ln.Accept()
ch <- result{conn, err}
}()
select {
case res := <-ch:
if res.err != nil {
t.Errorf("Failed to accept connection: %v", res.err)
}
if res.conn != nil {
res.conn.Close()
}
case <-time.After(2 * time.Second):
t.Error("Timeout waiting for connection")
}
}
})
}
}
func TestNewMultiAddrTLSListener(t *testing.T) {
// Create a simple test certificate
getCertFunc := func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
cert, err := tls.X509KeyPair([]byte(testCert), []byte(testKey))
return &cert, err
}
ln, err := NewMultiAddrTLSListener("tcp", "127.0.0.1:0", getCertFunc, ListenerOptions{})
if err != nil {
t.Fatalf("NewMultiAddrTLSListener() error = %v", err)
}
defer ln.Close()
addr := ln.Addr().String()
// Try to connect with TLS
go func() {
conn, err := tls.Dial("tcp", addr, &tls.Config{
InsecureSkipVerify: true,
})
if err != nil {
return
}
conn.Write([]byte("test"))
conn.Close()
}()
// Accept connection
conn, err := ln.Accept()
if err != nil {
t.Fatalf("Failed to accept TLS connection: %v", err)
}
defer conn.Close()
buf := make([]byte, 100)
_, err = io.ReadAtLeast(conn, buf, 4)
if err != nil {
t.Errorf("Failed to read from TLS connection: %v", err)
}
}
// Test certificate and key for TLS tests
const testCert = `-----BEGIN CERTIFICATE-----
MIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw
DgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow
EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d
7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B
5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr
BgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1
NDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l
Wf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc
6MF9+Yw1Yy0t
-----END CERTIFICATE-----`
const testKey = `-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIIrYSSNQFaA2Hwf1duRSxKtLYX5CB04fSeQ6tF1aY/PuoAoGCCqGSM49
AwEHoUQDQgAEPR3tU2Fta9ktY+6P9G0cWO+0kETA6SFs38GecTyudlHz6xvCdz8q
EKTcWGekdmdDPsHloRNtsiCa697B2O9IFA==
-----END EC PRIVATE KEY-----`
+6 -6
View File
@@ -28,8 +28,9 @@ const (
unsignedPayload string = "UNSIGNED-PAYLOAD"
)
// CheckPresignedSignature validates presigned request signature
func CheckPresignedSignature(ctx fiber.Ctx, auth AuthData, secret string) error {
// CheckPresignedSignature validates a presigned request's signature against
// derivedKey
func CheckPresignedSignature(ctx fiber.Ctx, auth AuthData, derivedKey []byte) error {
var contentLength int64
var err error
contentLengthStr := ctx.Get("Content-Length")
@@ -42,9 +43,8 @@ func CheckPresignedSignature(ctx fiber.Ctx, auth AuthData, secret string) error
date, _ := time.Parse(iso8601Format, auth.Date)
_, err = sigv4auth.CheckQuerySignature(ctx, auth, secret, unsignedPayload, date, contentLength, sigv4auth.CheckOptions{
Service: service,
DisableURIPathEscaping: true,
_, err = sigv4auth.CheckQuerySignature(ctx, auth, derivedKey, unsignedPayload, date, contentLength, sigv4auth.CheckOptions{
Service: service,
})
if err != nil {
return mapSigV4Error(err)
@@ -133,7 +133,7 @@ func mapQueryAuthError(err error) error {
queryErr.ServerTime.Format(time.RFC3339),
)
case sigv4auth.ErrQuerySecurityToken:
return s3err.QueryAuthErrors.SecurityTokenNotSupported()
return s3err.GetAPIError(s3err.ErrInvalidToken)
}
}
+5 -16
View File
@@ -40,7 +40,6 @@ import (
const (
zeroLenSig = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
awsV4 = "AWS4"
awsS3Service = "s3"
awsV4Request = "aws4_request"
trailerSignatureHeader = "x-amz-trailer-signature:"
@@ -84,11 +83,13 @@ type ChunkReader struct {
// NewChunkReader reads from request body io.Reader and parses out the
// chunk metadata in stream. The headers are validated for proper signatures.
// Reading from the chunk reader will read only the object data stream
// without the chunk headers/trailers.
func NewSignedChunkReader(r io.Reader, authdata AuthData, canonicalString, secret string, date time.Time, chType checksumType, requireTrailer bool, cLength int64) (io.Reader, error) {
// without the chunk headers/trailers. derivedKey is the same SigV4 kSigning
// value the seed request's Authorization header was already checked
// against, reused here rather than re-derived or re-fetched.
func NewSignedChunkReader(r io.Reader, authdata AuthData, canonicalString string, derivedKey []byte, date time.Time, chType checksumType, requireTrailer bool, cLength int64) (io.Reader, error) {
chRdr := &ChunkReader{
r: r,
signingKey: getSigningKey(secret, authdata.Region, date),
signingKey: derivedKey,
// the authdata.Signature is validated in the auth-reader,
// so we can use that here without any other checks
prevSig: authdata.Signature,
@@ -353,18 +354,6 @@ func (cr *ChunkReader) parseAndRemoveChunkInfo(p []byte) (int, error) {
return n, nil
}
// https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html
// Task 3: Calculate Signature
// https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html#signing-request-intro
func getSigningKey(secret, region string, date time.Time) []byte {
dateKey := hmac256([]byte(awsV4+secret), []byte(date.Format(yyyymmdd)))
dateRegionKey := hmac256(dateKey, []byte(region))
dateRegionServiceKey := hmac256(dateRegionKey, []byte(awsS3Service))
signingKey := hmac256(dateRegionServiceKey, []byte(awsV4Request))
debuglogger.Infof("signing key: %s", hex.EncodeToString(signingKey))
return signingKey
}
func hmac256(key []byte, data []byte) []byte {
hash := hmac.New(sha256.New, key)
hash.Write(data)
+87 -43
View File
@@ -14,23 +14,24 @@
package utils
import (
"context"
"net/http"
"net/url"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/gofiber/fiber/v3"
"github.com/stretchr/testify/require"
"github.com/valyala/fasthttp"
v4 "github.com/versity/versitygw/aws/signer/v4"
"github.com/versity/versitygw/internal/sigv4auth"
"github.com/versity/versitygw/s3err"
)
const signedHeadersTestRegion = "us-east-1"
var signedHeadersTestCreds = aws.Credentials{
var signedHeadersTestCreds = struct {
AccessKeyID string
SecretAccessKey string
}{
AccessKeyID: "AKID",
SecretAccessKey: "SECRET",
}
@@ -43,7 +44,7 @@ func TestCheckPresignedSignatureRejectsUnsignedAmzHeader(t *testing.T) {
authData, err := ParsePresignedURIParts(ctx, signedHeadersTestRegion)
require.NoError(t, err)
err = CheckPresignedSignature(ctx, authData, signedHeadersTestCreds.SecretAccessKey)
err = CheckPresignedSignature(ctx, authData, derivedKeyFor(authData))
requireHeadersNotSigned(t, err, "x-amz-copy-source")
}
@@ -56,7 +57,7 @@ func TestCheckPresignedSignatureAllowsSignedAmzHeader(t *testing.T) {
authData, err := ParsePresignedURIParts(ctx, signedHeadersTestRegion)
require.NoError(t, err)
err = CheckPresignedSignature(ctx, authData, signedHeadersTestCreds.SecretAccessKey)
err = CheckPresignedSignature(ctx, authData, derivedKeyFor(authData))
require.NoError(t, err)
}
@@ -69,7 +70,7 @@ func TestCheckPresignedSignatureAllowsUnsignedNonAmzHeader(t *testing.T) {
authData, err := ParsePresignedURIParts(ctx, signedHeadersTestRegion)
require.NoError(t, err)
err = CheckPresignedSignature(ctx, authData, signedHeadersTestCreds.SecretAccessKey)
err = CheckPresignedSignature(ctx, authData, derivedKeyFor(authData))
require.NoError(t, err)
}
@@ -78,7 +79,7 @@ func TestCheckValidSignatureRejectsUnsignedAmzHeader(t *testing.T) {
"X-Amz-Tagging": []string{"a=b"},
})
_, err := CheckValidSignature(ctx, authData, signedHeadersTestCreds.SecretAccessKey, unsignedPayload, signingTime, 0)
_, err := CheckValidSignature(ctx, authData, derivedKeyFor(authData), unsignedPayload, signingTime, 0)
requireHeadersNotSigned(t, err, "x-amz-tagging")
}
@@ -87,7 +88,7 @@ func TestCheckValidSignatureAllowsSignedAmzHeader(t *testing.T) {
"X-Amz-Tagging": []string{"a=b"},
}, nil)
_, err := CheckValidSignature(ctx, authData, signedHeadersTestCreds.SecretAccessKey, unsignedPayload, signingTime, 0)
_, err := CheckValidSignature(ctx, authData, derivedKeyFor(authData), unsignedPayload, signingTime, 0)
require.NoError(t, err)
}
@@ -97,7 +98,7 @@ func TestCheckValidSignatureAllowsUnsignedNonAmzHeader(t *testing.T) {
"X-Custom-Header": []string{"value"},
})
_, err := CheckValidSignature(ctx, authData, signedHeadersTestCreds.SecretAccessKey, unsignedPayload, signingTime, 0)
_, err := CheckValidSignature(ctx, authData, derivedKeyFor(authData), unsignedPayload, signingTime, 0)
require.NoError(t, err)
}
@@ -109,7 +110,7 @@ func TestCheckPresignedSignatureRejectsUnsignedAmzHeaderPattern(t *testing.T) {
authData, err := ParsePresignedURIParts(ctx, signedHeadersTestRegion)
require.NoError(t, err)
err = CheckPresignedSignature(ctx, authData, signedHeadersTestCreds.SecretAccessKey)
err = CheckPresignedSignature(ctx, authData, derivedKeyFor(authData))
requireHeadersNotSigned(t, err, "x-amz-some-other-header")
}
@@ -118,10 +119,14 @@ func TestCheckValidSignatureRejectsUnsignedAmzHeaderPattern(t *testing.T) {
"X-Amz-Some-Other-Header": []string{"value"},
})
_, err := CheckValidSignature(ctx, authData, signedHeadersTestCreds.SecretAccessKey, unsignedPayload, signingTime, 0)
_, err := CheckValidSignature(ctx, authData, derivedKeyFor(authData), unsignedPayload, signingTime, 0)
requireHeadersNotSigned(t, err, "x-amz-some-other-header")
}
func derivedKeyFor(authData AuthData) []byte {
return sigv4auth.DeriveKey(signedHeadersTestCreds.SecretAccessKey, authData.Date[:8], signedHeadersTestRegion, service)
}
func buildPresignedURL(t *testing.T, headers http.Header) string {
t.Helper()
@@ -132,23 +137,22 @@ func buildPresignedURL(t *testing.T, headers http.Header) string {
req.Header = make(http.Header)
}
signer := v4.NewSigner()
signedURL, _, _, err := signer.PresignHTTP(
context.Background(),
signedHeadersTestCreds,
req,
unsignedPayload,
service,
signedHeadersTestRegion,
time.Now().UTC(),
nil,
func(options *v4.SignerOptions) {
options.DisableURIPathEscaping = true
},
)
require.NoError(t, err)
signingTime := time.Now().UTC()
yyyymmdd := signingTime.Format(sigv4auth.YYYYMMDD)
derivedKey := sigv4auth.DeriveKey(signedHeadersTestCreds.SecretAccessKey, yyyymmdd, signedHeadersTestRegion, service)
return signedURL
in := sigv4auth.SigningInputFromRequest(req)
in.AccessKeyID = signedHeadersTestCreds.AccessKeyID
in.CredentialScope = sigv4auth.BuildCredentialScope(yyyymmdd, signedHeadersTestRegion, service)
in.PayloadHash = unsignedPayload
in.SigningTime = signingTime
in.DisableURIPathEscaping = true
in.IsPreSign = true
result := sigv4auth.BuildAndSign(derivedKey, in)
signedURL := *req.URL
signedURL.RawQuery = result.RawQuery
return signedURL.String()
}
func signedHeaderAuthCtx(t *testing.T, signedHeaders, extraHeaders http.Header) (fiber.Ctx, AuthData, time.Time) {
@@ -162,21 +166,18 @@ func signedHeaderAuthCtx(t *testing.T, signedHeaders, extraHeaders http.Header)
req.Header = make(http.Header)
}
signer := v4.NewSigner()
_, err = signer.SignHTTP(
context.Background(),
signedHeadersTestCreds,
req,
unsignedPayload,
service,
signedHeadersTestRegion,
signingTime,
nil,
func(options *v4.SignerOptions) {
options.DisableURIPathEscaping = true
},
)
require.NoError(t, err)
yyyymmdd := signingTime.Format(sigv4auth.YYYYMMDD)
derivedKey := sigv4auth.DeriveKey(signedHeadersTestCreds.SecretAccessKey, yyyymmdd, signedHeadersTestRegion, service)
in := sigv4auth.SigningInputFromRequest(req)
in.AccessKeyID = signedHeadersTestCreds.AccessKeyID
in.CredentialScope = sigv4auth.BuildCredentialScope(yyyymmdd, signedHeadersTestRegion, service)
in.PayloadHash = unsignedPayload
in.SigningTime = signingTime
in.DisableURIPathEscaping = true
result := sigv4auth.BuildAndSign(derivedKey, in)
req.Header.Set("X-Amz-Date", result.AmzDate)
req.Header.Set("Authorization", result.AuthorizationHeader)
headers := req.Header.Clone()
for key, values := range extraHeaders {
@@ -225,3 +226,46 @@ func requireHeadersNotSigned(t *testing.T, err error, expected string) {
require.Equal(t, "AccessDenied", serr.Code)
require.Equal(t, expected, serr.HeadersNotSigned)
}
// TestCheckValidSignatureRejectsUnsignedSecurityToken pins the entire
// binding argument for a session credential presented via header auth.
//
// The gateway adds no RequiredSignedHeaders entry for
// X-Amz-Security-Token, and deliberately so: passing a non-nil list
// *replaces* sigv4auth's default rule (host plus every X-Amz-* header)
// rather than adding to it, which would weaken the binding for every other
// X-Amz-* header. What keeps the token bound to the signature is that
// default rule alone — so if anyone ever hands CheckValidSignature an
// explicit list, this test is what catches it.
func TestCheckValidSignatureRejectsUnsignedSecurityToken(t *testing.T) {
ctx, authData, signingTime := signedHeaderAuthCtx(t, nil, http.Header{
"X-Amz-Security-Token": []string{"a-session-token"},
})
_, err := CheckValidSignature(ctx, authData, derivedKeyFor(authData), unsignedPayload, signingTime, 0)
requireHeadersNotSigned(t, err, "x-amz-security-token")
}
// TestCheckValidSignatureAllowsSignedSecurityToken is the positive half:
// a token that *was* part of the signed request passes, so a legitimate
// session credential is not rejected by the rule above.
func TestCheckValidSignatureAllowsSignedSecurityToken(t *testing.T) {
ctx, authData, signingTime := signedHeaderAuthCtx(t, http.Header{
"X-Amz-Security-Token": []string{"a-session-token"},
}, nil)
_, err := CheckValidSignature(ctx, authData, derivedKeyFor(authData), unsignedPayload, signingTime, 0)
require.NoError(t, err)
}
// TestCheckValidSignatureRejectsSwappedSecurityToken confirms the token
// cannot be swapped for another session's after signing: it is part of the
// canonical request, so altering it invalidates the signature.
func TestCheckValidSignatureRejectsSwappedSecurityToken(t *testing.T) {
signed := http.Header{"X-Amz-Security-Token": []string{"the-real-session-token"}}
ctx, authData, signingTime := signedHeaderAuthCtx(t, signed, nil)
ctx.Request().Header.Set("X-Amz-Security-Token", "somebody-elses-session-token")
_, err := CheckValidSignature(ctx, authData, derivedKeyFor(authData), unsignedPayload, signingTime, 0)
require.Error(t, err, "swapping the security token after signing must invalidate the signature")
}
+50 -90
View File
@@ -18,14 +18,11 @@ import (
"crypto/tls"
"encoding/base64"
"encoding/xml"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"regexp"
"slices"
"strconv"
"strings"
"sync/atomic"
@@ -34,7 +31,7 @@ import (
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/gofiber/fiber/v3"
"github.com/valyala/fasthttp"
signerV4 "github.com/versity/versitygw/aws/signer/v4"
"github.com/versity/versitygw/backend"
"github.com/versity/versitygw/debuglogger"
"github.com/versity/versitygw/s3err"
"github.com/versity/versitygw/s3response"
@@ -135,41 +132,6 @@ func ExtractMetadataFromFields(fields map[string]string) (map[string]string, err
return metadata, nil
}
func createHttpRequestFromCtx(ctx fiber.Ctx, signedHdrs []string, contentLength int64) (*http.Request, error) {
req := ctx.Request()
uri := ctx.OriginalURL()
httpReq, err := http.NewRequest(string(req.Header.Method()), uri, nil)
if err != nil {
return nil, errors.New("error in creating an http request")
}
if err := addRequestHeadersFromCtx(ctx, httpReq, signedHdrs); err != nil {
return nil, err
}
// make sure all headers in the signed headers are present
for _, header := range signedHdrs {
if httpReq.Header.Get(header) == "" {
httpReq.Header.Set(header, "")
}
}
// Check if Content-Length in signed headers
// If content length is non 0, then the header will be included
if !includeHeader("Content-Length", signedHdrs) {
httpReq.ContentLength = 0
} else {
httpReq.ContentLength = contentLength
}
// Set the Host header
httpReq.Host = string(req.Header.Host())
return httpReq, nil
}
func SetMetaHeaders(ctx fiber.Ctx, meta map[string]string) {
ctx.Response().Header.DisableNormalizing()
for key, val := range meta {
@@ -296,34 +258,6 @@ func IsValidBucketName(bucket string) bool {
return true
}
func includeHeader(hdr string, signedHdrs []string) bool {
return slices.ContainsFunc(signedHdrs, func(shdr string) bool {
return strings.EqualFold(hdr, shdr)
})
}
func addRequestHeadersFromCtx(ctx fiber.Ctx, httpReq *http.Request, signedHdrs []string) error {
headersNotSigned := []string{}
for key, value := range ctx.Request().Header.All() {
keyStr := string(key)
if includeHeader(keyStr, signedHdrs) || signerV4.IsIgnoredHeader(keyStr) {
httpReq.Header.Add(keyStr, string(value))
continue
}
if signerV4.IsRequiredSignedHeader(keyStr) {
lowerKey := strings.ToLower(keyStr)
headersNotSigned = append(headersNotSigned, lowerKey)
}
}
if len(headersNotSigned) != 0 {
debuglogger.Logf("headers present in request but not included in SignedHeaders: %q", strings.Join(headersNotSigned, ", "))
return s3err.GetHeadersNotSignedErr(headersNotSigned)
}
return nil
}
// expiration time window
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/RESTAuthentication.html#RESTAuthenticationTimeStamp
const timeExpirationSec = 15 * 60 // seconds
@@ -1059,29 +993,6 @@ func GenerateObjectLocation(ctx fiber.Ctx, virtualDomain, bucket, object string)
)
}
type CertStorage struct {
cert atomic.Pointer[tls.Certificate]
}
func NewCertStorage() *CertStorage {
return &CertStorage{}
}
func (cs *CertStorage) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
return cs.cert.Load(), nil
}
func (cs *CertStorage) SetCertificate(certFile string, keyFile string) error {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return fmt.Errorf("unable to set certificate: %w", err)
}
cs.cert.Store(&cert)
return nil
}
func NewTLSListener(network string, address string, getCertificateFunc func(*tls.ClientHelloInfo) (*tls.Certificate, error)) (net.Listener, error) {
config := &tls.Config{
MinVersion: tls.VersionTLS12,
@@ -1095,6 +1006,55 @@ func NewTLSListener(network string, address string, getCertificateFunc func(*tls
return tls.NewListener(ln, config), nil
}
// MergeDeleteObjectsResult builds the final DeleteObjects response,
// preserving the order objects were requested in across both the Deleted
// and Error lists. objects is the full request; checkErrs is
// VerifyObjectsAccess's per-object result for it (nil entries were sent to
// the backend); backendResult is the backend's response for just those.
//
// The backend's own Deleted/Error order is not assumed to match the order
// its objects were sent in, so objects are matched back to their backend
// result by identity (key + version) rather than by position, with a FIFO
// queue per identity to keep duplicate keys in the same request each paired
// with their own result. versionID is "" for a keyed (unversioned) delete,
// matching how both the request and every backend's response represent "no
// version specified" — as a nil pointer.
func MergeDeleteObjectsResult(objects []types.ObjectIdentifier, checkErrs []error, backendResult s3response.DeleteResult) s3response.DeleteResult {
type objectKey struct{ key, versionID string }
deletedByKey := make(map[objectKey][]types.DeletedObject, len(backendResult.Deleted))
for _, d := range backendResult.Deleted {
k := objectKey{backend.GetStringFromPtr(d.Key), backend.GetStringFromPtr(d.VersionId)}
deletedByKey[k] = append(deletedByKey[k], d)
}
errorByKey := make(map[objectKey][]types.Error, len(backendResult.Error))
for _, e := range backendResult.Error {
k := objectKey{backend.GetStringFromPtr(e.Key), backend.GetStringFromPtr(e.VersionId)}
errorByKey[k] = append(errorByKey[k], e)
}
var result s3response.DeleteResult
for i, obj := range objects {
if checkErrs[i] != nil {
result.Error = append(result.Error, s3err.ObjectDeleteError(obj.Key, obj.VersionId, checkErrs[i]))
continue
}
k := objectKey{backend.GetStringFromPtr(obj.Key), backend.GetStringFromPtr(obj.VersionId)}
if queue := deletedByKey[k]; len(queue) > 0 {
result.Deleted = append(result.Deleted, queue[0])
deletedByKey[k] = queue[1:]
continue
}
if queue := errorByKey[k]; len(queue) > 0 {
result.Error = append(result.Error, queue[0])
errorByKey[k] = queue[1:]
}
}
return result
}
func DetectResourceType(ctx fiber.Ctx) s3err.ResourceType {
path := ctx.Path()
if path == "" || path == "/" {
-99
View File
@@ -20,7 +20,6 @@ import (
"encoding/xml"
"errors"
"math/rand"
"net/http"
"net/url"
"reflect"
"strings"
@@ -28,7 +27,6 @@ import (
"time"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/gofiber/fiber/v3"
"github.com/stretchr/testify/assert"
"github.com/valyala/fasthttp"
"github.com/versity/versitygw/backend"
@@ -36,67 +34,6 @@ import (
"github.com/versity/versitygw/s3response"
)
func TestCreateHttpRequestFromCtx(t *testing.T) {
type args struct {
ctx fiber.Ctx
}
app := fiber.New()
// Expected output, Case 1
ctx := app.AcquireCtx(&fasthttp.RequestCtx{})
req := ctx.Request()
request, _ := http.NewRequest(string(req.Header.Method()), req.URI().String(), bytes.NewReader(req.Body()))
// Case 2
ctx2 := app.AcquireCtx(&fasthttp.RequestCtx{})
req2 := ctx2.Request()
req2.Header.Add("X-Amz-Mfa", "Some valid Mfa")
request2, _ := http.NewRequest(string(req2.Header.Method()), req2.URI().String(), bytes.NewReader(req2.Body()))
request2.Header.Add("X-Amz-Mfa", "Some valid Mfa")
tests := []struct {
name string
args args
want *http.Request
wantErr bool
hdrs []string
}{
{
name: "Success-response",
args: args{
ctx: ctx,
},
want: request,
wantErr: false,
hdrs: []string{},
},
{
name: "Success-response-With-Headers",
args: args{
ctx: ctx2,
},
want: request2,
wantErr: false,
hdrs: []string{"X-Amz-Mfa"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := createHttpRequestFromCtx(tt.args.ctx, tt.hdrs, 0)
if (err != nil) != tt.wantErr {
t.Errorf("CreateHttpRequestFromCtx() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got.Header, tt.want.Header) {
t.Errorf("CreateHttpRequestFromCtx() got = %v, want %v", got, tt.want)
}
})
}
}
// a helper method to construct a raw http request with the given http request headers
// to further parse with fasthttp.Request.Read and return fasthttp.RequestHeader
func createHeadersFromRawRequest(t *testing.T, hdrs [][2]string) *fasthttp.RequestHeader {
@@ -229,42 +166,6 @@ func TestGetUserMetaData(t *testing.T) {
}
}
func Test_includeHeader(t *testing.T) {
type args struct {
hdr string
signedHdrs []string
}
tests := []struct {
name string
args args
want bool
}{
{
name: "include-header-falsy-case",
args: args{
hdr: "Content-Type",
signedHdrs: []string{"X-Amz-Acl", "Content-Encoding"},
},
want: false,
},
{
name: "include-header-falsy-case",
args: args{
hdr: "Content-Type",
signedHdrs: []string{"X-Amz-Acl", "Content-Type"},
},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := includeHeader(tt.args.hdr, tt.args.signedHdrs); got != tt.want {
t.Errorf("includeHeader() = %v, want %v", got, tt.want)
}
})
}
}
func TestIsValidBucketName(t *testing.T) {
type args struct {
bucket string