mirror of
https://github.com/versity/versitygw.git
synced 2026-09-21 07:24:29 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -76,6 +76,8 @@ type testInput struct {
|
||||
beErr error
|
||||
extraMockErr error
|
||||
extraMockResp any
|
||||
readonly bool
|
||||
disableACL bool
|
||||
}
|
||||
|
||||
type testOutput struct {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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-----`
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 == "/" {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user