fix: evaluate EC2 policies against the request's resource ARNs (#866)

This commit is contained in:
Julian Sommer
2026-08-24 15:49:07 +10:00
committed by GitHub
parent 4bbc96f1e0
commit 4869f3b021
12 changed files with 1328 additions and 68 deletions
+5 -5
View File
@@ -177,9 +177,9 @@ func setFieldValue(field reflect.Value, value string) error {
return nil
}
// maxSliceLen caps list materialisation to prevent unbounded allocation
// from adversarial indexes like `Filter.999999`.
const maxSliceLen = 1024
// MaxSliceLen caps list materialisation to prevent unbounded allocation from
// adversarial indexes like `Filter.999999`.
const MaxSliceLen = 1024
// Records the 1-based indices of the entries stored under prefix, reporting
// whether it matched anything at all.
@@ -227,8 +227,8 @@ func setSliceField(field reflect.Value, params map[string]string, prefix, listNa
denseLen := 0
for indices[denseLen+1] {
denseLen++
if denseLen > maxSliceLen {
return fmt.Errorf("%w: %q (max %d)", ErrSliceTooLarge, prefix, maxSliceLen)
if denseLen > MaxSliceLen {
return fmt.Errorf("%w: %q (max %d)", ErrSliceTooLarge, prefix, MaxSliceLen)
}
}
if denseLen == 0 {
+1 -1
View File
@@ -663,7 +663,7 @@ func TestQueryParamsToStruct_GapAtIndexOne(t *testing.T) {
func TestQueryParamsToStruct_RejectsOversizedDenseList(t *testing.T) {
args := map[string]string{"Action": "DescribeInstances"}
for i := 1; i <= maxSliceLen+1; i++ {
for i := 1; i <= MaxSliceLen+1; i++ {
args[fmt.Sprintf("Filter.%d.Name", i)] = "x"
args[fmt.Sprintf("Filter.%d.Value.1", i)] = "y"
}
+104 -53
View File
@@ -13,6 +13,7 @@ import (
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/awsec2query"
"github.com/mulgadc/spinifex/spinifex/awserrors"
gateway_ec2 "github.com/mulgadc/spinifex/spinifex/gateway/ec2"
gateway_ec2_account "github.com/mulgadc/spinifex/spinifex/gateway/ec2/account"
gateway_ec2_capacityreservation "github.com/mulgadc/spinifex/spinifex/gateway/ec2/capacityreservation"
gateway_ec2_eigw "github.com/mulgadc/spinifex/spinifex/gateway/ec2/eigw"
@@ -36,9 +37,23 @@ import (
)
// EC2Handler processes parsed query args and returns XML response bytes.
// r is included for handlers that call gw.checkPolicyResource (e.g. iam:PassRole);
// most handlers ignore it.
type EC2Handler func(action string, q map[string]string, gw *GatewayConfig, accountID string, r *http.Request) ([]byte, error)
// r is included for handlers that enforce iam:PassRole; most ignore it.
type EC2Handler func(string, map[string]string, *GatewayConfig, string, *http.Request) ([]byte, error)
// ec2Action parses once so authorization and dispatch share one typed input.
type ec2Action struct {
parse func(map[string]string) (any, error)
dispatch func(string, any, *GatewayConfig, string, *http.Request) ([]byte, error)
}
func (h ec2Action) withQueryPreprocessor(preprocess func(map[string]string)) ec2Action {
parse := h.parse
h.parse = func(q map[string]string) (any, error) {
preprocess(q)
return parse(q)
}
return h
}
// requestContext returns r's context, or Background for a nil request
// (some callers and tests dispatch without an *http.Request). The SDK's
@@ -52,26 +67,32 @@ func requestContext(r *http.Request) context.Context {
return utils.WithIdempotencyKey(r.Context(), r.Header.Get(utils.SDKInvocationIDHeader))
}
// ec2Handler creates a type-safe EC2Handler: allocates the input struct,
// parses query params, calls the handler, and marshals output to XML.
func ec2Handler[In any](handler func(ctx context.Context, input *In, gw *GatewayConfig, accountID string) (any, error)) EC2Handler {
return func(action string, q map[string]string, gw *GatewayConfig, accountID string, r *http.Request) ([]byte, error) {
input := new(In)
if err := awsec2query.QueryParamsToStruct(q, input); err != nil {
if errors.Is(err, awsec2query.ErrSliceTooLarge) {
return nil, errors.New(awserrors.ErrorMalformedQueryString)
// ec2Handler creates a type-safe EC2Handler. Parsing is separate from dispatch
// so authorization and execution consume the exact same input.
func ec2Handler[In any](handler func(ctx context.Context, input *In, gw *GatewayConfig, accountID string) (any, error)) ec2Action {
return ec2Action{
parse: func(q map[string]string) (any, error) {
input := new(In)
if err := awsec2query.QueryParamsToStruct(q, input); err != nil {
return nil, err
}
return nil, err
}
output, err := handler(requestContext(r), input, gw, accountID)
if err != nil {
return nil, err
}
xmlOutput, err := marshalEC2Response(action, output)
if err != nil {
return nil, err
}
return xmlOutput, nil
return input, nil
},
dispatch: func(action string, parsed any, gw *GatewayConfig, accountID string, r *http.Request) ([]byte, error) {
input, ok := parsed.(*In)
if !ok {
return nil, errors.New(awserrors.ErrorInternalError)
}
output, err := handler(requestContext(r), input, gw, accountID)
if err != nil {
return nil, err
}
xmlOutput, err := marshalEC2Response(action, output)
if err != nil {
return nil, err
}
return xmlOutput, nil
},
}
}
@@ -94,28 +115,34 @@ func marshalEC2Response(action string, output any) ([]byte, error) {
// ec2HandlerWithReq is ec2Handler for actions that need the original *http.Request,
// e.g. RunInstances which enforces iam:PassRole on the supplied instance profile ARN.
func ec2HandlerWithReq[In any](handler func(ctx context.Context, input *In, gw *GatewayConfig, accountID string, r *http.Request) (any, error)) EC2Handler {
return func(action string, q map[string]string, gw *GatewayConfig, accountID string, r *http.Request) ([]byte, error) {
input := new(In)
if err := awsec2query.QueryParamsToStruct(q, input); err != nil {
if errors.Is(err, awsec2query.ErrSliceTooLarge) {
return nil, errors.New(awserrors.ErrorMalformedQueryString)
func ec2HandlerWithReq[In any](handler func(ctx context.Context, input *In, gw *GatewayConfig, accountID string, r *http.Request) (any, error)) ec2Action {
return ec2Action{
parse: func(q map[string]string) (any, error) {
input := new(In)
if err := awsec2query.QueryParamsToStruct(q, input); err != nil {
return nil, err
}
return nil, err
}
output, err := handler(requestContext(r), input, gw, accountID, r)
if err != nil {
return nil, err
}
xmlOutput, err := marshalEC2Response(action, output)
if err != nil {
return nil, err
}
return xmlOutput, nil
return input, nil
},
dispatch: func(action string, parsed any, gw *GatewayConfig, accountID string, r *http.Request) ([]byte, error) {
input, ok := parsed.(*In)
if !ok {
return nil, errors.New(awserrors.ErrorInternalError)
}
output, err := handler(requestContext(r), input, gw, accountID, r)
if err != nil {
return nil, err
}
xmlOutput, err := marshalEC2Response(action, output)
if err != nil {
return nil, err
}
return xmlOutput, nil
},
}
}
var ec2Actions = map[string]EC2Handler{
var ec2Actions = map[string]ec2Action{
"DescribeInstances": ec2Handler(func(ctx context.Context, input *ec2.DescribeInstancesInput, gw *GatewayConfig, accountID string) (any, error) {
out, err := gateway_ec2_instance.DescribeInstancesChecked(ctx, input, gw.NATSConn, gw.DiscoverActiveNodes(ctx), accountID)
if err != nil {
@@ -229,15 +256,14 @@ var ec2Actions = map[string]EC2Handler{
"DescribeKeyPairs": ec2Handler(func(ctx context.Context, input *ec2.DescribeKeyPairsInput, gw *GatewayConfig, accountID string) (any, error) {
return gateway_ec2_key.DescribeKeyPairs(ctx, input, gw.NATSConn, accountID)
}),
"ImportKeyPair": func(action string, q map[string]string, gw *GatewayConfig, accountID string, r *http.Request) ([]byte, error) {
"ImportKeyPair": ec2Handler(func(ctx context.Context, input *ec2.ImportKeyPairInput, gw *GatewayConfig, accountID string) (any, error) {
return gateway_ec2_key.ImportKeyPair(ctx, input, gw.NATSConn, accountID)
}).withQueryPreprocessor(func(q map[string]string) {
// Parser leaves Base64 padding URL-encoded; decode it before dispatch.
if strings.HasSuffix(q["PublicKeyMaterial"], "%3D%3D") {
q["PublicKeyMaterial"] = strings.Replace(q["PublicKeyMaterial"], "%3D%3D", "==", 1)
}
return ec2Handler(func(ctx context.Context, input *ec2.ImportKeyPairInput, gw *GatewayConfig, accountID string) (any, error) {
return gateway_ec2_key.ImportKeyPair(ctx, input, gw.NATSConn, accountID)
})(action, q, gw, accountID, r)
},
}),
"DescribeImages": ec2Handler(func(ctx context.Context, input *ec2.DescribeImagesInput, gw *GatewayConfig, accountID string) (any, error) {
return gateway_ec2_image.DescribeImages(ctx, input, gw.NATSConn, accountID)
}),
@@ -572,7 +598,38 @@ func (gw *GatewayConfig) EC2_Request(w http.ResponseWriter, r *http.Request) err
return errors.New(awserrors.ErrorInvalidAction)
}
if err := gw.checkPolicy(r, "ec2", action); err != nil {
// Hoisted above the policy check because the resolver builds ARNs from it.
// gw.Region, never the caller-supplied credential-scope region, which would
// let a caller sign for another region and slide out from under a Deny.
accountID, _ := r.Context().Value(ctxAccountID).(string)
if accountID == "" {
slog.Error("EC2_Request: no account ID in auth context")
// InternalError, not ServerInternal: the policy gate used to reach this
// case first and that is the code the caller has always seen.
return errors.New(awserrors.ErrorInternalError)
}
input, err := handler.parse(queryArgs)
if err != nil {
if errors.Is(err, awsec2query.ErrSliceTooLarge) {
return errors.New(awserrors.ErrorMalformedQueryString)
}
return err
}
// Launch templates supply effective RunInstances resources. Resolve them
// before authorization, and dispatch the same expanded input afterwards.
if runInput, ok := input.(*ec2.RunInstancesInput); ok && action == "RunInstances" {
if err := gateway_ec2_instance.ExpandLaunchTemplate(r.Context(), gw.NATSConn, runInput, accountID); err != nil {
return err
}
}
resources, err := gateway_ec2.ResourceARNs(action, gw.Region, accountID, input)
if err != nil {
return err
}
if err := gw.checkPolicyResources(r, "ec2", action, resources); err != nil {
return err
}
@@ -580,13 +637,7 @@ func (gw *GatewayConfig) EC2_Request(w http.ResponseWriter, r *http.Request) err
return errors.New(awserrors.ErrorServerInternal)
}
accountID, _ := r.Context().Value(ctxAccountID).(string)
if accountID == "" {
slog.Error("EC2_Request: no account ID in auth context")
return errors.New(awserrors.ErrorServerInternal)
}
xmlOutput, err := handler(action, queryArgs, gw, accountID, r)
xmlOutput, err := handler.dispatch(action, input, gw, accountID, r)
if err != nil {
return err
}
+430
View File
@@ -0,0 +1,430 @@
// Package gateway_ec2 resolves the resource ARNs an EC2 request authorizes
// against. Without it every EC2 action is evaluated against the literal "*", so
// a resource-scoped Deny never fires and a resource-scoped Allow never grants.
package gateway_ec2
import (
"errors"
"reflect"
"sort"
"strings"
"github.com/mulgadc/spinifex/spinifex/arn"
"github.com/mulgadc/spinifex/spinifex/awsec2query"
"github.com/mulgadc/spinifex/spinifex/awserrors"
)
// The resource a policy check evaluates against when the request names nothing
// in particular: the describes, and the actions whose identifier cannot be
// resolved without a lookup the gate cannot do.
const anyResource = "*"
// Where an action's identifier lives in its parsed SDK input and what it
// resolves to. Paths are tried in order, matching handler precedence. No paths
// means kind is a resource the action creates, whose id does not exist yet.
type resourceScope struct {
paths []string
kind arn.EC2ResourceType
byPrefix bool
optional bool
}
// Scopes shared across actions. Names ending in New resolve to <type>/*, which
// is what AWS evaluates for a resource the call is about to create.
//
// Where an action accepts a name as well as an id, only the id is read:
// GroupName is EC2-Classic, PublicIp is not an allocation id, and a launch
// template ARN carries the template id. A name-only request leaves the resource
// unresolved rather than building an ARN that names nothing.
var (
instanceListScope = &resourceScope{paths: []string{"InstanceIds"}, kind: arn.EC2Instance}
instanceScope = &resourceScope{paths: []string{"InstanceId"}, kind: arn.EC2Instance}
instanceOptScope = &resourceScope{paths: []string{"InstanceId"}, kind: arn.EC2Instance, optional: true}
instanceNewScope = &resourceScope{kind: arn.EC2Instance}
volumeScope = &resourceScope{paths: []string{"VolumeId"}, kind: arn.EC2Volume}
volumeNewScope = &resourceScope{kind: arn.EC2Volume}
imageScope = &resourceScope{paths: []string{"ImageId"}, kind: arn.EC2Image}
imageNewScope = &resourceScope{kind: arn.EC2Image}
snapshotScope = &resourceScope{paths: []string{"SnapshotId"}, kind: arn.EC2Snapshot}
snapshotOptScope = &resourceScope{paths: []string{"SnapshotId"}, kind: arn.EC2Snapshot, optional: true}
snapshotNewScope = &resourceScope{kind: arn.EC2Snapshot}
vpcScope = &resourceScope{paths: []string{"VpcId"}, kind: arn.EC2VPC}
vpcOptScope = &resourceScope{paths: []string{"VpcId"}, kind: arn.EC2VPC, optional: true}
vpcNewScope = &resourceScope{kind: arn.EC2VPC}
subnetScope = &resourceScope{paths: []string{"SubnetId"}, kind: arn.EC2Subnet}
subnetOptScope = &resourceScope{paths: []string{"SubnetId"}, kind: arn.EC2Subnet, optional: true}
runSubnetOptScope = &resourceScope{paths: []string{"SubnetId", "NetworkInterfaces.SubnetId"}, kind: arn.EC2Subnet, optional: true}
subnetNewScope = &resourceScope{kind: arn.EC2Subnet}
securityGroupScope = &resourceScope{paths: []string{"GroupId"}, kind: arn.EC2SecurityGroup}
securityGroupsOptList = &resourceScope{paths: []string{"SecurityGroupIds", "Groups"}, kind: arn.EC2SecurityGroup, optional: true}
runSecurityGroups = &resourceScope{paths: []string{"SecurityGroupIds", "NetworkInterfaces.Groups"}, kind: arn.EC2SecurityGroup, optional: true}
securityGroupNewScope = &resourceScope{kind: arn.EC2SecurityGroup}
routeTableScope = &resourceScope{paths: []string{"RouteTableId"}, kind: arn.EC2RouteTable}
routeTableNewScope = &resourceScope{kind: arn.EC2RouteTable}
igwScope = &resourceScope{paths: []string{"InternetGatewayId"}, kind: arn.EC2InternetGateway}
igwNewScope = &resourceScope{kind: arn.EC2InternetGateway}
eigwScope = &resourceScope{paths: []string{"EgressOnlyInternetGatewayId"}, kind: arn.EC2EgressOnlyInternetGateway}
eigwNewScope = &resourceScope{kind: arn.EC2EgressOnlyInternetGateway}
eniScope = &resourceScope{paths: []string{"NetworkInterfaceId"}, kind: arn.EC2NetworkInterface}
eniOptScope = &resourceScope{paths: []string{"NetworkInterfaceId"}, kind: arn.EC2NetworkInterface, optional: true}
eniNewScope = &resourceScope{kind: arn.EC2NetworkInterface}
addressScope = &resourceScope{paths: []string{"AllocationId"}, kind: arn.EC2ElasticIP}
addressOptScope = &resourceScope{paths: []string{"AllocationId"}, kind: arn.EC2ElasticIP, optional: true}
addressNewScope = &resourceScope{kind: arn.EC2ElasticIP}
natGatewayScope = &resourceScope{paths: []string{"NatGatewayId"}, kind: arn.EC2NATGateway}
natGatewayOptScope = &resourceScope{paths: []string{"NatGatewayId"}, kind: arn.EC2NATGateway, optional: true}
natGatewayNewScope = &resourceScope{kind: arn.EC2NATGateway}
keyPairScope = &resourceScope{paths: []string{"KeyPairId", "KeyName"}, kind: arn.EC2KeyPair}
keyPairOptScope = &resourceScope{paths: []string{"KeyName"}, kind: arn.EC2KeyPair, optional: true}
placementGroupScope = &resourceScope{paths: []string{"GroupName"}, kind: arn.EC2PlacementGroup}
launchTemplateScope = &resourceScope{paths: []string{"LaunchTemplateId"}, kind: arn.EC2LaunchTemplate}
launchTemplateNewScope = &resourceScope{kind: arn.EC2LaunchTemplate}
capacityReservationScope = &resourceScope{paths: []string{"CapacityReservationId"}, kind: arn.EC2CapacityReservation}
capacityReservationNewScope = &resourceScope{kind: arn.EC2CapacityReservation}
spotRequestListScope = &resourceScope{paths: []string{"SpotInstanceRequestIds"}, kind: arn.EC2SpotInstancesRequest}
spotRequestNewScope = &resourceScope{kind: arn.EC2SpotInstancesRequest}
taggedResourcesScope = &resourceScope{paths: []string{"Resources"}, byPrefix: true}
gatewayOptScope = &resourceScope{paths: []string{"GatewayId"}, byPrefix: true, optional: true}
spotImageScope = &resourceScope{paths: []string{"LaunchSpecification.ImageId"}, kind: arn.EC2Image}
spotSubnetScope = &resourceScope{
paths: []string{"LaunchSpecification.SubnetId", "LaunchSpecification.NetworkInterfaces.SubnetId"},
kind: arn.EC2Subnet,
optional: true,
}
spotSecurityGroups = &resourceScope{
paths: []string{"LaunchSpecification.SecurityGroupIds", "LaunchSpecification.NetworkInterfaces.Groups"},
kind: arn.EC2SecurityGroup,
optional: true,
}
spotKeyPairScope = &resourceScope{paths: []string{"LaunchSpecification.KeyName"}, kind: arn.EC2KeyPair, optional: true}
)
// unscoped is the explicit "this action authorizes account-wide" entry. It is a
// value in its own right, not a missing row: the completeness test cannot tell
// a sparse table from an incomplete one, so every action carries an entry.
var unscoped = []*resourceScope{{}}
// ec2Scopes covers every action in the gateway's EC2 dispatch table. An action
// missing from here is a bug, not an unscoped action — see ResourceARNs.
var ec2Scopes = map[string][]*resourceScope{
// Instances.
"RunInstances": {instanceNewScope, volumeNewScope, imageScope, runSubnetOptScope, runSecurityGroups, keyPairOptScope},
"StartInstances": {instanceListScope},
"StopInstances": {instanceListScope},
"RebootInstances": {instanceListScope},
"TerminateInstances": {instanceListScope},
"MonitorInstances": {instanceListScope},
"UnmonitorInstances": {instanceListScope},
"ModifyInstanceAttribute": {instanceScope},
"ModifyInstanceMetadataOptions": {instanceScope},
"GetConsoleOutput": {instanceScope},
"GetPasswordData": {instanceScope},
"AssociateIamInstanceProfile": {instanceScope},
// The association id needs a NATS lookup to reach the instance behind it, so
// a Deny scoped to that instance stays inert.
"DisassociateIamInstanceProfile": unscoped,
"ReplaceIamInstanceProfileAssociation": unscoped,
// Volumes and snapshots.
"CreateVolume": {volumeNewScope, snapshotOptScope},
"DeleteVolume": {volumeScope},
"ModifyVolume": {volumeScope},
"AttachVolume": {volumeScope, instanceScope},
"DetachVolume": {volumeScope, instanceOptScope},
"CreateSnapshot": {snapshotNewScope, volumeScope},
"DeleteSnapshot": {snapshotScope},
// The source snapshot lives in SourceRegion, so an ARN built from gw.Region
// would name a resource in the wrong region.
"CopySnapshot": {snapshotNewScope},
// Images.
"CreateImage": {imageNewScope, instanceScope},
"RegisterImage": {imageNewScope},
"DeregisterImage": {imageScope},
"ModifyImageAttribute": {imageScope},
"ResetImageAttribute": {imageScope},
// Source image is in SourceRegion, as with CopySnapshot.
"CopyImage": {imageNewScope},
// Key pairs.
"CreateKeyPair": {keyPairScope},
"ImportKeyPair": {keyPairScope},
"DeleteKeyPair": {keyPairScope},
// VPCs and subnets.
"CreateVpc": {vpcNewScope},
"DeleteVpc": {vpcScope},
"ModifyVpcAttribute": {vpcScope},
"CreateSubnet": {subnetNewScope, vpcScope},
"DeleteSubnet": {subnetScope},
"ModifySubnetAttribute": {subnetScope},
// Route tables.
"CreateRouteTable": {routeTableNewScope, vpcScope},
"DeleteRouteTable": {routeTableScope},
"CreateRoute": {routeTableScope, gatewayOptScope, natGatewayOptScope},
"ReplaceRoute": {routeTableScope, gatewayOptScope},
"DeleteRoute": {routeTableScope},
"AssociateRouteTable": {routeTableScope, subnetOptScope, gatewayOptScope},
"ReplaceRouteTableAssociation": {routeTableScope},
// Carries only an association id, which needs a lookup to reach the table.
"DisassociateRouteTable": unscoped,
// Gateways.
"CreateInternetGateway": {igwNewScope},
"DeleteInternetGateway": {igwScope},
"AttachInternetGateway": {igwScope, vpcScope},
"DetachInternetGateway": {igwScope, vpcScope},
"CreateEgressOnlyInternetGateway": {eigwNewScope, vpcScope},
"DeleteEgressOnlyInternetGateway": {eigwScope},
"CreateNatGateway": {natGatewayNewScope, subnetScope, addressOptScope},
"DeleteNatGateway": {natGatewayScope},
// Network interfaces.
"CreateNetworkInterface": {eniNewScope, subnetScope, securityGroupsOptList},
"DeleteNetworkInterface": {eniScope},
"ModifyNetworkInterfaceAttribute": {eniScope},
"AttachNetworkInterface": {eniScope, instanceScope},
// Carries only an attachment id, which needs a lookup to reach the interface.
"DetachNetworkInterface": unscoped,
// Security groups.
"CreateSecurityGroup": {securityGroupNewScope, vpcOptScope},
"DeleteSecurityGroup": {securityGroupScope},
"AuthorizeSecurityGroupIngress": {securityGroupScope},
"AuthorizeSecurityGroupEgress": {securityGroupScope},
"RevokeSecurityGroupIngress": {securityGroupScope},
"RevokeSecurityGroupEgress": {securityGroupScope},
// Addresses.
"AllocateAddress": {addressNewScope},
"ReleaseAddress": {addressScope},
"AssociateAddress": {addressScope, instanceOptScope, eniOptScope},
// Carries an eipassoc- id, which needs a lookup to reach the address.
"DisassociateAddress": unscoped,
// Placement groups.
"CreatePlacementGroup": {placementGroupScope},
"DeletePlacementGroup": {placementGroupScope},
// Launch templates.
"CreateLaunchTemplate": {launchTemplateNewScope},
"CreateLaunchTemplateVersion": {launchTemplateScope},
"ModifyLaunchTemplate": {launchTemplateScope},
"DeleteLaunchTemplate": {launchTemplateScope},
"DeleteLaunchTemplateVersions": {launchTemplateScope},
// Capacity reservations and spot.
"CreateCapacityReservation": {capacityReservationNewScope},
"CancelCapacityReservation": {capacityReservationScope},
"RequestSpotInstances": {spotRequestNewScope, instanceNewScope, volumeNewScope, spotImageScope, spotSubnetScope, spotSecurityGroups, spotKeyPairScope},
"CancelSpotInstanceRequests": {spotRequestListScope},
// Tags. The type comes from each id's prefix; an unrecognised prefix has no
// correct ARN, so it contributes "*" rather than a plausible-looking one.
"CreateTags": {taggedResourcesScope},
"DeleteTags": {taggedResourcesScope},
// Account-level settings: no resource to name.
"EnableEbsEncryptionByDefault": unscoped,
"DisableEbsEncryptionByDefault": unscoped,
"GetEbsEncryptionByDefault": unscoped,
"EnableSerialConsoleAccess": unscoped,
"DisableSerialConsoleAccess": unscoped,
"GetSerialConsoleAccessStatus": unscoped,
// Describes. "*" is fidelity, not a stub: EC2 describe actions do not
// support resource-level permissions in AWS either.
"DescribeAccountAttributes": unscoped,
"DescribeAddresses": unscoped,
"DescribeAddressesAttribute": unscoped,
"DescribeAvailabilityZones": unscoped,
"DescribeCapacityReservations": unscoped,
"DescribeEgressOnlyInternetGateways": unscoped,
"DescribeIamInstanceProfileAssociations": unscoped,
"DescribeImageAttribute": unscoped,
"DescribeImages": unscoped,
"DescribeInstanceAttribute": unscoped,
"DescribeInstanceCreditSpecifications": unscoped,
"DescribeInstanceStatus": unscoped,
"DescribeInstanceTypes": unscoped,
"DescribeInstances": unscoped,
"DescribeInternetGateways": unscoped,
"DescribeKeyPairs": unscoped,
"DescribeLaunchTemplateVersions": unscoped,
"DescribeLaunchTemplates": unscoped,
"DescribeNatGateways": unscoped,
"DescribeNetworkInterfaces": unscoped,
"DescribePlacementGroups": unscoped,
"DescribeRegions": unscoped,
"DescribeRouteTables": unscoped,
"DescribeSecurityGroupRules": unscoped,
"DescribeSecurityGroups": unscoped,
"DescribeSnapshots": unscoped,
"DescribeSpotInstanceRequests": unscoped,
"DescribeSubnets": unscoped,
"DescribeTags": unscoped,
"DescribeVolumeStatus": unscoped,
"DescribeVolumes": unscoped,
"DescribeVolumesModifications": unscoped,
"DescribeVpcAttribute": unscoped,
"DescribeVpcs": unscoped,
}
// HasScope reports whether the action has a scope table entry. ec2Actions lives
// in package gateway, so its completeness test needs this.
func HasScope(action string) bool {
_, ok := ec2Scopes[action]
return ok
}
// ScopedActions returns every action the scope table covers, so a scope left
// behind by a deleted or renamed action fails the completeness test too.
func ScopedActions() []string {
actions := make([]string, 0, len(ec2Scopes))
for action := range ec2Scopes {
actions = append(actions, action)
}
sort.Strings(actions)
return actions
}
// ResourceARNs builds every resource the action's policy check evaluates from
// the same parsed SDK input the handler receives. A missing action is a bug.
func ResourceARNs(action, region, accountID string, input any) ([]string, error) {
scopes, ok := ec2Scopes[action]
if !ok {
return nil, errors.New(awserrors.ErrorInvalidAction)
}
resources := make([]string, 0, len(scopes))
seen := make(map[string]struct{})
for _, scope := range scopes {
resolved, err := scope.resolve(region, accountID, input)
if err != nil {
return nil, err
}
for _, resource := range resolved {
if _, ok := seen[resource]; ok {
continue
}
seen[resource] = struct{}{}
resources = append(resources, resource)
if len(resources) > awsec2query.MaxSliceLen {
return nil, errors.New(awserrors.ErrorMalformedQueryString)
}
}
}
if len(resources) == 0 {
return []string{anyResource}, nil
}
return resources, nil
}
// resolve returns the ARNs one scope contributes, which may be none.
func (s *resourceScope) resolve(region, accountID string, input any) ([]string, error) {
if len(s.paths) == 0 {
if s.kind == "" {
return []string{anyResource}, nil
}
return []string{arn.FormatEC2(s.kind, region, accountID, anyResource)}, nil
}
ids := s.identifiers(input)
if len(ids) == 0 {
if s.optional {
return nil, nil
}
return []string{anyResource}, nil
}
if len(ids) > awsec2query.MaxSliceLen {
return nil, errors.New(awserrors.ErrorMalformedQueryString)
}
resources := make([]string, 0, len(ids))
for _, id := range ids {
kind := s.kind
if s.byPrefix {
resolved, ok := arn.EC2TypeForID(id)
if !ok {
resources = append(resources, anyResource)
continue
}
kind = resolved
}
resources = append(resources, arn.FormatEC2(kind, region, accountID, id))
}
return resources, nil
}
// identifiers reads the first populated field path, matching handler
// precedence. Values are deduplicated before policy evaluation.
func (s *resourceScope) identifiers(input any) []string {
for _, path := range s.paths {
values := stringValuesAt(reflect.ValueOf(input), strings.Split(path, "."))
seen := make(map[string]struct{}, len(values))
ids := make([]string, 0, len(values))
for _, value := range values {
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
ids = append(ids, value)
}
if len(ids) > 0 {
return ids
}
}
return nil
}
func stringValuesAt(value reflect.Value, path []string) []string {
for value.IsValid() && (value.Kind() == reflect.Pointer || value.Kind() == reflect.Interface) {
if value.IsNil() {
return nil
}
value = value.Elem()
}
if !value.IsValid() {
return nil
}
if value.Kind() == reflect.Slice || value.Kind() == reflect.Array {
var values []string
for i := 0; i < value.Len(); i++ {
values = append(values, stringValuesAt(value.Index(i), path)...)
}
return values
}
if len(path) == 0 {
if value.Kind() == reflect.String {
return []string{value.String()}
}
return nil
}
if value.Kind() != reflect.Struct {
return nil
}
return stringValuesAt(value.FieldByName(path[0]), path[1:])
}
+440
View File
@@ -0,0 +1,440 @@
//test:in-package — the fidelity test reads ec2Scopes and each scope's
// unexported params to prove they name a parameter the handler parses.
package gateway_ec2
import (
"reflect"
"strings"
"testing"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/awsec2query"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
testRegion = "ap-southeast-2"
testAccountID = "123456789012"
)
func ec2arn(kind, id string) string {
return "arn:aws:ec2:" + testRegion + ":" + testAccountID + ":" + kind + "/" + id
}
func resolve(t *testing.T, action string, q map[string]string) []string {
t.Helper()
var input any
if prototype, ok := ec2Inputs[action]; ok {
input = reflect.New(reflect.TypeOf(prototype).Elem()).Interface()
require.NoError(t, awsec2query.QueryParamsToStruct(q, input))
}
got, err := ResourceARNs(action, testRegion, testAccountID, input)
require.NoError(t, err)
return got
}
// TestResourceARNs pins the ARN each scoped action hands the evaluator. The
// order is the scope order, which the denial log reproduces.
func TestResourceARNs(t *testing.T) {
tests := []struct {
action string
q map[string]string
want []string
}{
// Instances.
{"RunInstances", map[string]string{
"ImageId": "ami-1", "SubnetId": "subnet-1", "SecurityGroupId.1": "sg-1", "KeyName": "k1",
}, []string{
ec2arn("instance", "*"), ec2arn("volume", "*"), ec2arn("image", "ami-1"),
ec2arn("subnet", "subnet-1"), ec2arn("security-group", "sg-1"), ec2arn("key-pair", "k1"),
}},
{"RunInstances", map[string]string{"ImageId": "ami-1"},
[]string{ec2arn("instance", "*"), ec2arn("volume", "*"), ec2arn("image", "ami-1")}},
{"RunInstances", map[string]string{
"ImageId": "ami-1", "NetworkInterface.1.SubnetId": "subnet-1",
"NetworkInterface.1.SecurityGroupId.1": "sg-1",
}, []string{
ec2arn("instance", "*"), ec2arn("volume", "*"), ec2arn("image", "ami-1"),
ec2arn("subnet", "subnet-1"), ec2arn("security-group", "sg-1"),
}},
{"StartInstances", map[string]string{"InstanceId.1": "i-1"}, []string{ec2arn("instance", "i-1")}},
{"StopInstances", map[string]string{"InstanceId.1": "i-1"}, []string{ec2arn("instance", "i-1")}},
{"RebootInstances", map[string]string{"InstanceId.1": "i-1"}, []string{ec2arn("instance", "i-1")}},
{"MonitorInstances", map[string]string{"InstanceId.1": "i-1"}, []string{ec2arn("instance", "i-1")}},
{"UnmonitorInstances", map[string]string{"InstanceId.1": "i-1"}, []string{ec2arn("instance", "i-1")}},
{"TerminateInstances", map[string]string{"InstanceId.1": "i-1", "InstanceId.2": "i-2"},
[]string{ec2arn("instance", "i-1"), ec2arn("instance", "i-2")}},
{"ModifyInstanceAttribute", map[string]string{"InstanceId": "i-1"}, []string{ec2arn("instance", "i-1")}},
{"ModifyInstanceAttribute", map[string]string{"instanceId": "i-2"}, []string{ec2arn("instance", "i-2")}},
{"ModifyInstanceMetadataOptions", map[string]string{"InstanceId": "i-1"}, []string{ec2arn("instance", "i-1")}},
{"GetConsoleOutput", map[string]string{"InstanceId": "i-1"}, []string{ec2arn("instance", "i-1")}},
{"GetPasswordData", map[string]string{"InstanceId": "i-1"}, []string{ec2arn("instance", "i-1")}},
{"AssociateIamInstanceProfile", map[string]string{"InstanceId": "i-1"}, []string{ec2arn("instance", "i-1")}},
{"DisassociateIamInstanceProfile", map[string]string{"AssociationId": "iip-assoc-1"}, []string{"*"}},
{"ReplaceIamInstanceProfileAssociation", map[string]string{"AssociationId": "iip-assoc-1"}, []string{"*"}},
// Volumes and snapshots.
{"CreateVolume", map[string]string{}, []string{ec2arn("volume", "*")}},
{"CreateVolume", map[string]string{"SnapshotId": "snap-1"},
[]string{ec2arn("volume", "*"), ec2arn("snapshot", "snap-1")}},
{"DeleteVolume", map[string]string{"VolumeId": "vol-1"}, []string{ec2arn("volume", "vol-1")}},
{"ModifyVolume", map[string]string{"VolumeId": "vol-1"}, []string{ec2arn("volume", "vol-1")}},
{"AttachVolume", map[string]string{"VolumeId": "vol-1", "InstanceId": "i-1"},
[]string{ec2arn("volume", "vol-1"), ec2arn("instance", "i-1")}},
{"DetachVolume", map[string]string{"VolumeId": "vol-1"}, []string{ec2arn("volume", "vol-1")}},
{"DetachVolume", map[string]string{"VolumeId": "vol-1", "InstanceId": "i-1"},
[]string{ec2arn("volume", "vol-1"), ec2arn("instance", "i-1")}},
{"CreateSnapshot", map[string]string{"VolumeId": "vol-1"},
[]string{ec2arn("snapshot", "*"), ec2arn("volume", "vol-1")}},
{"DeleteSnapshot", map[string]string{"SnapshotId": "snap-1"}, []string{ec2arn("snapshot", "snap-1")}},
{"CopySnapshot", map[string]string{"SourceSnapshotId": "snap-1", "SourceRegion": "us-east-1"},
[]string{ec2arn("snapshot", "*")}},
// Images.
{"CreateImage", map[string]string{"InstanceId": "i-1"},
[]string{ec2arn("image", "*"), ec2arn("instance", "i-1")}},
{"RegisterImage", map[string]string{"Name": "img"}, []string{ec2arn("image", "*")}},
{"CopyImage", map[string]string{"SourceImageId": "ami-1", "SourceRegion": "us-east-1"},
[]string{ec2arn("image", "*")}},
{"DeregisterImage", map[string]string{"ImageId": "ami-1"}, []string{ec2arn("image", "ami-1")}},
{"ModifyImageAttribute", map[string]string{"ImageId": "ami-1"}, []string{ec2arn("image", "ami-1")}},
{"ResetImageAttribute", map[string]string{"ImageId": "ami-1"}, []string{ec2arn("image", "ami-1")}},
// Key pairs. Either parameter names the same resource, so neither
// contributes a spurious "*" when the other is the one supplied.
{"CreateKeyPair", map[string]string{"KeyName": "k1"}, []string{ec2arn("key-pair", "k1")}},
{"ImportKeyPair", map[string]string{"KeyName": "k1"}, []string{ec2arn("key-pair", "k1")}},
{"DeleteKeyPair", map[string]string{"KeyName": "k1"}, []string{ec2arn("key-pair", "k1")}},
{"DeleteKeyPair", map[string]string{"KeyPairId": "key-1"}, []string{ec2arn("key-pair", "key-1")}},
{"DeleteKeyPair", map[string]string{"KeyName": "k1", "KeyPairId": "key-1"}, []string{ec2arn("key-pair", "key-1")}},
// VPCs and subnets.
{"CreateVpc", map[string]string{"CidrBlock": "10.0.0.0/16"}, []string{ec2arn("vpc", "*")}},
{"DeleteVpc", map[string]string{"VpcId": "vpc-1"}, []string{ec2arn("vpc", "vpc-1")}},
{"ModifyVpcAttribute", map[string]string{"VpcId": "vpc-1"}, []string{ec2arn("vpc", "vpc-1")}},
{"CreateSubnet", map[string]string{"VpcId": "vpc-1"},
[]string{ec2arn("subnet", "*"), ec2arn("vpc", "vpc-1")}},
{"DeleteSubnet", map[string]string{"SubnetId": "subnet-1"}, []string{ec2arn("subnet", "subnet-1")}},
{"ModifySubnetAttribute", map[string]string{"SubnetId": "subnet-1"}, []string{ec2arn("subnet", "subnet-1")}},
// Route tables.
{"CreateRouteTable", map[string]string{"VpcId": "vpc-1"},
[]string{ec2arn("route-table", "*"), ec2arn("vpc", "vpc-1")}},
{"DeleteRouteTable", map[string]string{"RouteTableId": "rtb-1"}, []string{ec2arn("route-table", "rtb-1")}},
{"DeleteRoute", map[string]string{"RouteTableId": "rtb-1"}, []string{ec2arn("route-table", "rtb-1")}},
{"CreateRoute", map[string]string{"RouteTableId": "rtb-1", "GatewayId": "igw-1"},
[]string{ec2arn("route-table", "rtb-1"), ec2arn("internet-gateway", "igw-1")}},
{"CreateRoute", map[string]string{"RouteTableId": "rtb-1", "NatGatewayId": "nat-1"},
[]string{ec2arn("route-table", "rtb-1"), ec2arn("natgateway", "nat-1")}},
{"ReplaceRoute", map[string]string{"RouteTableId": "rtb-1", "GatewayId": "igw-1"},
[]string{ec2arn("route-table", "rtb-1"), ec2arn("internet-gateway", "igw-1")}},
{"AssociateRouteTable", map[string]string{"RouteTableId": "rtb-1", "SubnetId": "subnet-1"},
[]string{ec2arn("route-table", "rtb-1"), ec2arn("subnet", "subnet-1")}},
{"ReplaceRouteTableAssociation", map[string]string{"RouteTableId": "rtb-1", "AssociationId": "rtbassoc-1"},
[]string{ec2arn("route-table", "rtb-1")}},
{"DisassociateRouteTable", map[string]string{"AssociationId": "rtbassoc-1"}, []string{"*"}},
// Gateways.
{"CreateInternetGateway", map[string]string{}, []string{ec2arn("internet-gateway", "*")}},
{"DeleteInternetGateway", map[string]string{"InternetGatewayId": "igw-1"},
[]string{ec2arn("internet-gateway", "igw-1")}},
{"AttachInternetGateway", map[string]string{"InternetGatewayId": "igw-1", "VpcId": "vpc-1"},
[]string{ec2arn("internet-gateway", "igw-1"), ec2arn("vpc", "vpc-1")}},
{"DetachInternetGateway", map[string]string{"InternetGatewayId": "igw-1", "VpcId": "vpc-1"},
[]string{ec2arn("internet-gateway", "igw-1"), ec2arn("vpc", "vpc-1")}},
{"CreateEgressOnlyInternetGateway", map[string]string{"VpcId": "vpc-1"},
[]string{ec2arn("egress-only-internet-gateway", "*"), ec2arn("vpc", "vpc-1")}},
{"DeleteEgressOnlyInternetGateway", map[string]string{"EgressOnlyInternetGatewayId": "eigw-1"},
[]string{ec2arn("egress-only-internet-gateway", "eigw-1")}},
{"CreateNatGateway", map[string]string{"SubnetId": "subnet-1", "AllocationId": "eipalloc-1"},
[]string{ec2arn("natgateway", "*"), ec2arn("subnet", "subnet-1"), ec2arn("elastic-ip", "eipalloc-1")}},
{"DeleteNatGateway", map[string]string{"NatGatewayId": "nat-1"}, []string{ec2arn("natgateway", "nat-1")}},
// Network interfaces.
{"CreateNetworkInterface", map[string]string{"SubnetId": "subnet-1", "SecurityGroupId.1": "sg-1"},
[]string{ec2arn("network-interface", "*"), ec2arn("subnet", "subnet-1"), ec2arn("security-group", "sg-1")}},
{"CreateNetworkInterface", map[string]string{
"SubnetId": "subnet-1", "Groups.1": "sg-handler", "SecurityGroupId.1": "sg-other",
}, []string{
ec2arn("network-interface", "*"), ec2arn("subnet", "subnet-1"), ec2arn("security-group", "sg-handler"),
}},
{"DeleteNetworkInterface", map[string]string{"NetworkInterfaceId": "eni-1"},
[]string{ec2arn("network-interface", "eni-1")}},
{"ModifyNetworkInterfaceAttribute", map[string]string{"NetworkInterfaceId": "eni-1"},
[]string{ec2arn("network-interface", "eni-1")}},
{"AttachNetworkInterface", map[string]string{"NetworkInterfaceId": "eni-1", "InstanceId": "i-1"},
[]string{ec2arn("network-interface", "eni-1"), ec2arn("instance", "i-1")}},
{"DetachNetworkInterface", map[string]string{"AttachmentId": "eni-attach-1"}, []string{"*"}},
// Security groups.
{"CreateSecurityGroup", map[string]string{"VpcId": "vpc-1"},
[]string{ec2arn("security-group", "*"), ec2arn("vpc", "vpc-1")}},
{"CreateSecurityGroup", map[string]string{"GroupName": "web"}, []string{ec2arn("security-group", "*")}},
{"DeleteSecurityGroup", map[string]string{"GroupId": "sg-1"}, []string{ec2arn("security-group", "sg-1")}},
{"AuthorizeSecurityGroupIngress", map[string]string{"GroupId": "sg-1"}, []string{ec2arn("security-group", "sg-1")}},
{"AuthorizeSecurityGroupEgress", map[string]string{"GroupId": "sg-1"}, []string{ec2arn("security-group", "sg-1")}},
{"RevokeSecurityGroupIngress", map[string]string{"GroupId": "sg-1"}, []string{ec2arn("security-group", "sg-1")}},
{"RevokeSecurityGroupEgress", map[string]string{"GroupId": "sg-1"}, []string{ec2arn("security-group", "sg-1")}},
// Addresses.
{"AllocateAddress", map[string]string{"Domain": "vpc"}, []string{ec2arn("elastic-ip", "*")}},
{"ReleaseAddress", map[string]string{"AllocationId": "eipalloc-1"}, []string{ec2arn("elastic-ip", "eipalloc-1")}},
{"AssociateAddress", map[string]string{"AllocationId": "eipalloc-1", "InstanceId": "i-1"},
[]string{ec2arn("elastic-ip", "eipalloc-1"), ec2arn("instance", "i-1")}},
{"AssociateAddress", map[string]string{"AllocationId": "eipalloc-1", "NetworkInterfaceId": "eni-1"},
[]string{ec2arn("elastic-ip", "eipalloc-1"), ec2arn("network-interface", "eni-1")}},
{"DisassociateAddress", map[string]string{"AssociationId": "eipassoc-1"}, []string{"*"}},
// Placement groups.
{"CreatePlacementGroup", map[string]string{"GroupName": "pg1"}, []string{ec2arn("placement-group", "pg1")}},
{"DeletePlacementGroup", map[string]string{"GroupName": "pg1"}, []string{ec2arn("placement-group", "pg1")}},
// Launch templates.
{"CreateLaunchTemplate", map[string]string{"LaunchTemplateName": "lt"},
[]string{ec2arn("launch-template", "*")}},
{"CreateLaunchTemplateVersion", map[string]string{"LaunchTemplateId": "lt-1"},
[]string{ec2arn("launch-template", "lt-1")}},
{"ModifyLaunchTemplate", map[string]string{"LaunchTemplateId": "lt-1"},
[]string{ec2arn("launch-template", "lt-1")}},
{"DeleteLaunchTemplate", map[string]string{"LaunchTemplateId": "lt-1"},
[]string{ec2arn("launch-template", "lt-1")}},
{"DeleteLaunchTemplateVersions", map[string]string{"LaunchTemplateId": "lt-1"},
[]string{ec2arn("launch-template", "lt-1")}},
// Capacity reservations and spot.
{"CreateCapacityReservation", map[string]string{"InstanceType": "t3.micro"},
[]string{ec2arn("capacity-reservation", "*")}},
{"CancelCapacityReservation", map[string]string{"CapacityReservationId": "cr-1"},
[]string{ec2arn("capacity-reservation", "cr-1")}},
{"RequestSpotInstances", map[string]string{
"LaunchSpecification.ImageId": "ami-1", "LaunchSpecification.SubnetId": "subnet-1",
"LaunchSpecification.SecurityGroupId.1": "sg-1", "LaunchSpecification.KeyName": "k1",
}, []string{
ec2arn("spot-instances-request", "*"), ec2arn("instance", "*"), ec2arn("volume", "*"),
ec2arn("image", "ami-1"), ec2arn("subnet", "subnet-1"),
ec2arn("security-group", "sg-1"), ec2arn("key-pair", "k1"),
}},
{"CancelSpotInstanceRequests", map[string]string{"SpotInstanceRequestId.1": "sir-1"},
[]string{ec2arn("spot-instances-request", "sir-1")}},
// Tags: the type comes from each id's prefix, one member per id.
{"CreateTags", map[string]string{"ResourceId.1": "i-1", "ResourceId.2": "vol-1"},
[]string{ec2arn("instance", "i-1"), ec2arn("volume", "vol-1")}},
{"DeleteTags", map[string]string{"ResourceId.1": "sg-1"}, []string{ec2arn("security-group", "sg-1")}},
// An untypable id has no correct ARN, so the fence stays inert rather
// than fencing a plausible-looking wrong object.
{"CreateTags", map[string]string{"ResourceId.1": "i-1", "ResourceId.2": "nope-1"},
[]string{ec2arn("instance", "i-1"), "*"}},
// Account-level settings name no resource.
{"EnableEbsEncryptionByDefault", map[string]string{}, []string{"*"}},
{"DisableEbsEncryptionByDefault", map[string]string{}, []string{"*"}},
{"GetEbsEncryptionByDefault", map[string]string{}, []string{"*"}},
{"EnableSerialConsoleAccess", map[string]string{}, []string{"*"}},
{"DisableSerialConsoleAccess", map[string]string{}, []string{"*"}},
{"GetSerialConsoleAccessStatus", map[string]string{}, []string{"*"}},
// Describes evaluate account-wide, as they do in AWS.
{"DescribeInstances", map[string]string{"InstanceId.1": "i-1"}, []string{"*"}},
{"DescribeVolumes", map[string]string{"VolumeId.1": "vol-1"}, []string{"*"}},
}
for _, tc := range tests {
t.Run(tc.action, func(t *testing.T) {
assert.Equal(t, tc.want, resolve(t, tc.action, tc.q))
})
}
}
// TestResourceARNsMissingIdentifier pins the property most easily lost: an
// absent identifier stays the handler's validation fault rather than becoming an
// authorization failure here.
func TestResourceARNsMissingIdentifier(t *testing.T) {
assert.Equal(t, []string{"*"}, resolve(t, "TerminateInstances", map[string]string{}))
assert.Equal(t, []string{"*"}, resolve(t, "DeleteVolume", map[string]string{}))
// GroupName names a security group only in EC2-Classic, which has no ARN.
assert.Equal(t, []string{"*"}, resolve(t, "DeleteSecurityGroup", map[string]string{"GroupName": "web"}))
// A name-only launch template request leaves the id unresolved.
assert.Equal(t, []string{"*"}, resolve(t, "DeleteLaunchTemplate", map[string]string{"LaunchTemplateName": "lt"}))
}
func TestResourceARNsUnknownAction(t *testing.T) {
_, err := ResourceARNs("NotAnAction", testRegion, testAccountID, nil)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidAction, err.Error())
}
// TestParsedListInputs covers the list forms and ordering produced by the
// canonical query parser used by authorization and dispatch.
func TestParsedListInputs(t *testing.T) {
t.Run("member wrapper", func(t *testing.T) {
assert.Equal(t, []string{ec2arn("instance", "i-1")},
resolve(t, "TerminateInstances", map[string]string{"InstanceId.member.1": "i-1"}))
})
t.Run("field-name alias", func(t *testing.T) {
assert.Equal(t, []string{ec2arn("instance", "i-1")},
resolve(t, "TerminateInstances", map[string]string{"InstanceIds.1": "i-1"}))
})
t.Run("location-name-list wrapper", func(t *testing.T) {
assert.Equal(t, []string{ec2arn("instance", "i-1")},
resolve(t, "TerminateInstances", map[string]string{"InstanceId.InstanceId.1": "i-1"}))
})
t.Run("numeric order", func(t *testing.T) {
q := map[string]string{"InstanceId.1": "i-1", "InstanceId.2": "i-2", "InstanceId.3": "i-3"}
assert.Equal(t, []string{ec2arn("instance", "i-1"), ec2arn("instance", "i-2"), ec2arn("instance", "i-3")},
resolve(t, "TerminateInstances", q))
})
t.Run("gap terminates the parsed list", func(t *testing.T) {
q := map[string]string{"InstanceId.1": "i-1", "InstanceId.3": "i-3"}
assert.Equal(t, []string{ec2arn("instance", "i-1")}, resolve(t, "TerminateInstances", q))
})
t.Run("nested keys are not collected", func(t *testing.T) {
q := map[string]string{"ResourceId.1": "i-1", "ResourceId.1.Value.1": "ignored"}
assert.Equal(t, []string{ec2arn("instance", "i-1")}, resolve(t, "CreateTags", q))
})
}
// TestResourceARNsIdentifierIsAValue covers the two shapes from the reverted STS
// work. Both build a value, not a pattern: an id containing / is not truncated,
// and an id that is literally * neither matches a scoped Deny nor widens a grant.
func TestResourceARNsIdentifierIsAValue(t *testing.T) {
assert.Equal(t, []string{ec2arn("instance", "i-1/admin")},
resolve(t, "TerminateInstances", map[string]string{"InstanceId.1": "i-1/admin"}))
assert.Equal(t, []string{ec2arn("instance", "*")},
resolve(t, "TerminateInstances", map[string]string{"InstanceId.1": "*"}))
}
// ec2Inputs is the input shape each scoped action's handler parses. It exists so
// the fidelity test below can prove the resolver reads a parameter the handler
// reads too, rather than checking the table against itself.
var ec2Inputs = map[string]any{
"AllocateAddress": &ec2.AllocateAddressInput{},
"AssociateAddress": &ec2.AssociateAddressInput{},
"AssociateIamInstanceProfile": &ec2.AssociateIamInstanceProfileInput{},
"AssociateRouteTable": &ec2.AssociateRouteTableInput{},
"AttachInternetGateway": &ec2.AttachInternetGatewayInput{},
"AttachNetworkInterface": &ec2.AttachNetworkInterfaceInput{},
"AttachVolume": &ec2.AttachVolumeInput{},
"AuthorizeSecurityGroupEgress": &ec2.AuthorizeSecurityGroupEgressInput{},
"AuthorizeSecurityGroupIngress": &ec2.AuthorizeSecurityGroupIngressInput{},
"CancelCapacityReservation": &ec2.CancelCapacityReservationInput{},
"CancelSpotInstanceRequests": &ec2.CancelSpotInstanceRequestsInput{},
"CopyImage": &ec2.CopyImageInput{},
"CopySnapshot": &ec2.CopySnapshotInput{},
"CreateCapacityReservation": &ec2.CreateCapacityReservationInput{},
"CreateEgressOnlyInternetGateway": &ec2.CreateEgressOnlyInternetGatewayInput{},
"CreateImage": &ec2.CreateImageInput{},
"CreateInternetGateway": &ec2.CreateInternetGatewayInput{},
"CreateKeyPair": &ec2.CreateKeyPairInput{},
"CreateLaunchTemplate": &ec2.CreateLaunchTemplateInput{},
"CreateLaunchTemplateVersion": &ec2.CreateLaunchTemplateVersionInput{},
"CreateNatGateway": &ec2.CreateNatGatewayInput{},
"CreateNetworkInterface": &ec2.CreateNetworkInterfaceInput{},
"CreatePlacementGroup": &ec2.CreatePlacementGroupInput{},
"CreateRoute": &ec2.CreateRouteInput{},
"CreateRouteTable": &ec2.CreateRouteTableInput{},
"CreateSecurityGroup": &ec2.CreateSecurityGroupInput{},
"CreateSnapshot": &ec2.CreateSnapshotInput{},
"CreateSubnet": &ec2.CreateSubnetInput{},
"CreateTags": &ec2.CreateTagsInput{},
"CreateVolume": &ec2.CreateVolumeInput{},
"CreateVpc": &ec2.CreateVpcInput{},
"DeleteEgressOnlyInternetGateway": &ec2.DeleteEgressOnlyInternetGatewayInput{},
"DeleteInternetGateway": &ec2.DeleteInternetGatewayInput{},
"DeleteKeyPair": &ec2.DeleteKeyPairInput{},
"DeleteLaunchTemplate": &ec2.DeleteLaunchTemplateInput{},
"DeleteLaunchTemplateVersions": &ec2.DeleteLaunchTemplateVersionsInput{},
"DeleteNatGateway": &ec2.DeleteNatGatewayInput{},
"DeleteNetworkInterface": &ec2.DeleteNetworkInterfaceInput{},
"DeletePlacementGroup": &ec2.DeletePlacementGroupInput{},
"DeleteRoute": &ec2.DeleteRouteInput{},
"DeleteRouteTable": &ec2.DeleteRouteTableInput{},
"DeleteSecurityGroup": &ec2.DeleteSecurityGroupInput{},
"DeleteSnapshot": &ec2.DeleteSnapshotInput{},
"DeleteSubnet": &ec2.DeleteSubnetInput{},
"DeleteTags": &ec2.DeleteTagsInput{},
"DeleteVolume": &ec2.DeleteVolumeInput{},
"DeleteVpc": &ec2.DeleteVpcInput{},
"DeregisterImage": &ec2.DeregisterImageInput{},
"DetachInternetGateway": &ec2.DetachInternetGatewayInput{},
"DetachNetworkInterface": &ec2.DetachNetworkInterfaceInput{},
"DetachVolume": &ec2.DetachVolumeInput{},
"DisassociateAddress": &ec2.DisassociateAddressInput{},
"DisassociateIamInstanceProfile": &ec2.DisassociateIamInstanceProfileInput{},
"DisassociateRouteTable": &ec2.DisassociateRouteTableInput{},
"GetConsoleOutput": &ec2.GetConsoleOutputInput{},
"GetPasswordData": &ec2.GetPasswordDataInput{},
"ImportKeyPair": &ec2.ImportKeyPairInput{},
"ModifyImageAttribute": &ec2.ModifyImageAttributeInput{},
"ModifyInstanceAttribute": &ec2.ModifyInstanceAttributeInput{},
"ModifyInstanceMetadataOptions": &ec2.ModifyInstanceMetadataOptionsInput{},
"ModifyLaunchTemplate": &ec2.ModifyLaunchTemplateInput{},
"ModifyNetworkInterfaceAttribute": &ec2.ModifyNetworkInterfaceAttributeInput{},
"ModifySubnetAttribute": &ec2.ModifySubnetAttributeInput{},
"ModifyVolume": &ec2.ModifyVolumeInput{},
"ModifyVpcAttribute": &ec2.ModifyVpcAttributeInput{},
"MonitorInstances": &ec2.MonitorInstancesInput{},
"RebootInstances": &ec2.RebootInstancesInput{},
"RegisterImage": &ec2.RegisterImageInput{},
"ReleaseAddress": &ec2.ReleaseAddressInput{},
"ReplaceIamInstanceProfileAssociation": &ec2.ReplaceIamInstanceProfileAssociationInput{},
"ReplaceRoute": &ec2.ReplaceRouteInput{},
"ReplaceRouteTableAssociation": &ec2.ReplaceRouteTableAssociationInput{},
"RequestSpotInstances": &ec2.RequestSpotInstancesInput{},
"ResetImageAttribute": &ec2.ResetImageAttributeInput{},
"RevokeSecurityGroupEgress": &ec2.RevokeSecurityGroupEgressInput{},
"RevokeSecurityGroupIngress": &ec2.RevokeSecurityGroupIngressInput{},
"RunInstances": &ec2.RunInstancesInput{},
"StartInstances": &ec2.StartInstancesInput{},
"StopInstances": &ec2.StopInstancesInput{},
"TerminateInstances": &ec2.TerminateInstancesInput{},
"UnmonitorInstances": &ec2.UnmonitorInstancesInput{},
}
// TestScopePathsReachTheHandlerInput proves each resolver path exists in the
// typed input consumed by the action's handler.
func TestScopePathsReachTheHandlerInput(t *testing.T) {
for action, scopes := range ec2Scopes {
input, ok := ec2Inputs[action]
if !ok {
continue
}
for i, scope := range scopes {
if len(scope.paths) == 0 {
continue
}
t.Run(action, func(t *testing.T) {
var reachable []string
for _, path := range scope.paths {
if hasFieldPath(reflect.TypeOf(input), strings.Split(path, ".")) {
reachable = append(reachable, path)
}
}
assert.NotEmpty(t, reachable,
"%s scope %d paths %v do not reach its handler input", action, i, scope.paths)
})
}
}
}
func hasFieldPath(typ reflect.Type, path []string) bool {
for typ.Kind() == reflect.Pointer || typ.Kind() == reflect.Slice || typ.Kind() == reflect.Array {
typ = typ.Elem()
}
if len(path) == 0 {
return typ.Kind() == reflect.String
}
if typ.Kind() != reflect.Struct {
return false
}
field, ok := typ.FieldByName(path[0])
return ok && hasFieldPath(field.Type, path[1:])
}
@@ -79,7 +79,7 @@ func ValidateRunInstancesInput(input *ec2.RunInstancesInput) (err error) {
func RunInstances(ctx context.Context, input *ec2.RunInstancesInput, natsConn *nats.Conn, iamSvc handlers_iam.IAMService, accountID string, passRoleCheck PassRoleChecker, launchQuotaCheck LaunchQuotaChecker, expectedNodes int) (reservation ec2.Reservation, err error) {
// Expand a referenced launch template before validation so validation, quota,
// and downstream routing all see the effective (post-merge) parameters.
if err = expandLaunchTemplate(ctx, natsConn, input, accountID); err != nil {
if err = ExpandLaunchTemplate(ctx, natsConn, input, accountID); err != nil {
return reservation, err
}
@@ -243,9 +243,9 @@ func capacityReservationTargetID(input *ec2.RunInstancesInput) string {
return aws.StringValue(spec.CapacityReservationTarget.CapacityReservationId)
}
// expandLaunchTemplate folds a referenced launch template into input, delegating
// ExpandLaunchTemplate folds a referenced launch template into input, delegating
// to the launch-template service over NATS. A no-op when no template is referenced.
func expandLaunchTemplate(ctx context.Context, natsConn *nats.Conn, input *ec2.RunInstancesInput, accountID string) error {
func ExpandLaunchTemplate(ctx context.Context, natsConn *nats.Conn, input *ec2.RunInstancesInput, accountID string) error {
if input == nil || input.LaunchTemplate == nil {
return nil
}
@@ -0,0 +1,27 @@
//test:in-package — ec2Actions is unexported here, and the completeness test
// exists to compare it against the scope table.
package gateway
import (
"testing"
gateway_ec2 "github.com/mulgadc/spinifex/spinifex/gateway/ec2"
"github.com/stretchr/testify/assert"
)
// TestEC2ScopeTableIsExhaustive is what stops the next EC2 action being added
// with a silent account-wide grant. It asserts both directions, so a scope left
// behind by a deleted or renamed action fails too.
func TestEC2ScopeTableIsExhaustive(t *testing.T) {
for action := range ec2Actions {
assert.True(t, gateway_ec2.HasScope(action),
"ec2 action %q has no resource scope entry: add one to ec2Scopes in gateway/ec2/authz.go", action)
}
for _, action := range gateway_ec2.ScopedActions() {
_, ok := ec2Actions[action]
assert.True(t, ok,
"ec2Scopes has an entry for %q, which the dispatch table does not serve: remove it from gateway/ec2/authz.go", action)
}
}
+244
View File
@@ -0,0 +1,244 @@
//test:in-package — drives EC2_Request through the gateway's unexported test
// helpers (setupEC2Request, policyMockIAMService) and auth context keys.
package gateway
import (
"net/http/httptest"
"strconv"
"strings"
"testing"
"github.com/mulgadc/spinifex/spinifex/awsec2query"
"github.com/mulgadc/spinifex/spinifex/awserrors"
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
authzAccountID = "123456789012"
authzRegion = "ap-southeast-2"
)
// scopedPolicyGateway serves one policy document to every principal.
func scopedPolicyGateway(statements ...handlers_iam.Statement) *GatewayConfig {
docs := []handlers_iam.PolicyDocument{{Version: "2012-10-17", Statement: statements}}
return &GatewayConfig{
DisableLogging: true,
Region: authzRegion,
IAMService: &policyMockIAMService{
getUserPoliciesFn: func(_, _ string) ([]handlers_iam.PolicyDocument, error) { return docs, nil },
getRolePoliciesFn: func(_, _ string) ([]handlers_iam.PolicyDocument, error) { return docs, nil },
},
}
}
func statement(effect, action, resource string) handlers_iam.Statement {
return handlers_iam.Statement{
Effect: effect,
Action: handlers_iam.StringOrArr{action},
Resource: handlers_iam.StringOrArr{resource},
}
}
// dispatchEC2 drives the gateway with no NATS connection. A permitted request
// therefore reaches the handler and fails there, which is what proves the policy
// check ran ahead of the resource existing at all.
func dispatchEC2(t *testing.T, gw *GatewayConfig, body string) error {
t.Helper()
return gw.EC2_Request(httptest.NewRecorder(), setupEC2Request(body, authzAccountID))
}
func assertDenied(t *testing.T, err error) {
t.Helper()
require.Error(t, err)
assert.Equal(t, awserrors.ErrorAccessDenied, err.Error())
}
// assertPermitted asserts the policy gate passed. The request then fails on the
// absent NATS connection rather than on authorization, so a denial cannot hide
// behind a not-found.
func assertPermitted(t *testing.T, err error) {
t.Helper()
require.Error(t, err)
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
}
// TestEC2Request_ScopedDenyFires is the bypass this work closes. An operator
// fences a production instance; before the resolver the fence was inert and
// TerminateInstances against it was permitted with nothing logged.
func TestEC2Request_ScopedDenyFires(t *testing.T) {
gw := scopedPolicyGateway(
statement("Allow", "ec2:*", "*"),
statement("Deny", "ec2:TerminateInstances", "arn:aws:ec2:*:*:instance/i-prod"),
)
assertDenied(t, dispatchEC2(t, gw, "Action=TerminateInstances&InstanceId.1=i-prod"))
assertPermitted(t, dispatchEC2(t, gw, "Action=TerminateInstances&InstanceId.1=i-dev"))
}
// TestEC2Request_ScopedAllowGrants is the other half: a least-privilege policy
// used to deny everything, so the only working policy shape was Resource "*".
func TestEC2Request_ScopedAllowGrants(t *testing.T) {
gw := scopedPolicyGateway(
statement("Allow", "ec2:TerminateInstances", "arn:aws:ec2:*:*:instance/i-dev"),
)
assertPermitted(t, dispatchEC2(t, gw, "Action=TerminateInstances&InstanceId.1=i-dev"))
assertDenied(t, dispatchEC2(t, gw, "Action=TerminateInstances&InstanceId.1=i-prod"))
}
// TestEC2Request_DenyOneMemberFailsTheBatch pins the AWS combining rule: a batch
// containing one fenced instance fails whole rather than terminating the rest.
func TestEC2Request_DenyOneMemberFailsTheBatch(t *testing.T) {
gw := scopedPolicyGateway(
statement("Allow", "ec2:*", "*"),
statement("Deny", "ec2:TerminateInstances", "arn:aws:ec2:*:*:instance/i-prod"),
)
assertDenied(t, dispatchEC2(t, gw,
"Action=TerminateInstances&InstanceId.1=i-dev&InstanceId.2=i-prod&InstanceId.3=i-other"))
assertPermitted(t, dispatchEC2(t, gw,
"Action=TerminateInstances&InstanceId.1=i-dev&InstanceId.2=i-other"))
}
// TestEC2Request_MultiResourceParity covers a resource beyond the primary one.
// A tenant constraining launches by instance would otherwise have the AMI and
// subnet unchecked, which is a smaller instance of the same bug.
func TestEC2Request_MultiResourceParity(t *testing.T) {
gw := scopedPolicyGateway(
statement("Allow", "ec2:*", "*"),
statement("Deny", "ec2:RunInstances", "arn:aws:ec2:*:*:image/ami-restricted"),
statement("Deny", "ec2:RunInstances", "arn:aws:ec2:*:*:subnet/subnet-restricted"),
)
assertDenied(t, dispatchEC2(t, gw, "Action=RunInstances&ImageId=ami-restricted&MinCount=1&MaxCount=1"))
assertDenied(t, dispatchEC2(t, gw,
"Action=RunInstances&ImageId=ami-allowed&NetworkInterface.1.SubnetId=subnet-restricted&MinCount=1&MaxCount=1"))
assertPermitted(t, dispatchEC2(t, gw, "Action=RunInstances&ImageId=ami-allowed&MinCount=1&MaxCount=1"))
}
func TestEC2Request_UsesCanonicalParsedResources(t *testing.T) {
t.Run("lower camel location name", func(t *testing.T) {
gw := scopedPolicyGateway(
statement("Allow", "ec2:*", "*"),
statement("Deny", "ec2:ModifyInstanceAttribute", "arn:aws:ec2:*:*:instance/i-prod"),
)
assertDenied(t, dispatchEC2(t, gw, "Action=ModifyInstanceAttribute&instanceId=i-prod"))
})
t.Run("location name list wrapper", func(t *testing.T) {
gw := scopedPolicyGateway(
statement("Allow", "ec2:*", "*"),
statement("Deny", "ec2:TerminateInstances", "arn:aws:ec2:*:*:instance/i-prod"),
)
assertDenied(t, dispatchEC2(t, gw, "Action=TerminateInstances&InstanceId.InstanceId.1=i-prod"))
})
t.Run("handler field precedence", func(t *testing.T) {
gw := scopedPolicyGateway(
statement("Allow", "ec2:*", "*"),
statement("Deny", "ec2:DeleteKeyPair", "arn:aws:ec2:*:*:key-pair/key-prod"),
)
assertDenied(t, dispatchEC2(t, gw, "Action=DeleteKeyPair&KeyName=dev&KeyPairId=key-prod"))
})
}
func TestEC2Request_CreateRouteChecksNATGateway(t *testing.T) {
gw := scopedPolicyGateway(
statement("Allow", "ec2:*", "*"),
statement("Deny", "ec2:CreateRoute", "arn:aws:ec2:*:*:natgateway/nat-prod"),
)
assertDenied(t, dispatchEC2(t, gw, "Action=CreateRoute&RouteTableId=rtb-dev&NatGatewayId=nat-prod"))
}
func TestEC2Request_RequestSpotInstancesChecksLaunchResources(t *testing.T) {
gw := scopedPolicyGateway(
statement("Allow", "ec2:*", "*"),
statement("Deny", "ec2:RequestSpotInstances", "arn:aws:ec2:*:*:subnet/subnet-prod"),
)
body := "Action=RequestSpotInstances&LaunchSpecification.ImageId=ami-1&" +
"LaunchSpecification.InstanceType=t3.micro&LaunchSpecification.SubnetId=subnet-prod"
assertDenied(t, dispatchEC2(t, gw, body))
}
func TestEC2Request_RejectsOversizedResourceList(t *testing.T) {
var body strings.Builder
body.WriteString("Action=TerminateInstances")
for i := 1; i <= awsec2query.MaxSliceLen+1; i++ {
body.WriteString("&InstanceId.")
body.WriteString(strconv.Itoa(i))
body.WriteString("=i-")
body.WriteString(strconv.Itoa(i))
}
gw := scopedPolicyGateway(statement("Allow", "ec2:*", "*"))
err := dispatchEC2(t, gw, body.String())
require.Error(t, err)
assert.Equal(t, awserrors.ErrorMalformedQueryString, err.Error())
}
// TestEC2Request_AccountWideAllowIsUnaffected is the property the remaining
// per-service beads rest on: passing a real ARN where "*" was passed cannot
// withdraw access that works today.
func TestEC2Request_AccountWideAllowIsUnaffected(t *testing.T) {
for _, policy := range []handlers_iam.Statement{
statement("Allow", "ec2:*", "*"),
statement("Allow", "*", "*"),
} {
gw := scopedPolicyGateway(policy)
for _, body := range []string{
"Action=TerminateInstances&InstanceId.1=i-abc",
"Action=RunInstances&ImageId=ami-1&SubnetId=subnet-1&KeyName=k1",
"Action=CreateTags&ResourceId.1=i-abc&Tag.1.Key=Name&Tag.1.Value=x",
"Action=CreateTags&ResourceId.1=nope-abc&Tag.1.Key=Name&Tag.1.Value=x",
"Action=DeleteVolume&VolumeId=vol-1",
"Action=DescribeInstances",
} {
t.Run(body, func(t *testing.T) {
assertPermitted(t, dispatchEC2(t, gw, body))
})
}
}
}
// TestEC2Request_MissingIdentifierStaysAValidationFault: the gate must not turn
// a malformed request into a denial. With an account-wide grant the absent
// identifier resolves to "*" and the handler reports its own fault.
func TestEC2Request_MissingIdentifierStaysAValidationFault(t *testing.T) {
gw := scopedPolicyGateway(statement("Allow", "ec2:*", "*"))
assertPermitted(t, dispatchEC2(t, gw, "Action=TerminateInstances"))
assertPermitted(t, dispatchEC2(t, gw, "Action=DeleteVolume"))
}
// TestEC2Request_IdentifierIsAValueNotAPattern: an id of literally * builds an
// ARN ending /*, which neither matches a scoped Deny on a real instance nor
// widens a grant to one.
func TestEC2Request_IdentifierIsAValueNotAPattern(t *testing.T) {
fenced := scopedPolicyGateway(
statement("Allow", "ec2:*", "*"),
statement("Deny", "ec2:TerminateInstances", "arn:aws:ec2:*:*:instance/i-prod"),
)
assertPermitted(t, dispatchEC2(t, fenced, "Action=TerminateInstances&InstanceId.1=*"))
// An id containing / is not truncated on the way to the evaluator. The
// reverted STS work built an ARN from the segment after the final /, so a
// Deny on i-prod fenced i-prod/admin and a grant fenced the wrong object.
assertPermitted(t, dispatchEC2(t, fenced, "Action=TerminateInstances&InstanceId.1=i-prod/admin"))
assertDenied(t, dispatchEC2(t, fenced, "Action=TerminateInstances&InstanceId.1=i-prod"))
}
// TestEC2Request_ARNIsBuiltFromTheNodeRegion: the credential-scope region is
// caller-supplied and never validated, so an ARN built from it would let a
// caller sign for another region and slide out from under a Deny scoped to the
// real one.
func TestEC2Request_ARNIsBuiltFromTheNodeRegion(t *testing.T) {
gw := scopedPolicyGateway(
statement("Allow", "ec2:*", "*"),
statement("Deny", "ec2:TerminateInstances", "arn:aws:ec2:"+authzRegion+":*:instance/i-prod"),
)
assertDenied(t, dispatchEC2(t, gw, "Action=TerminateInstances&InstanceId.1=i-prod"))
}
+3 -2
View File
@@ -2,7 +2,6 @@ package gateway
import (
"context"
"net/http"
"regexp"
"strings"
"testing"
@@ -22,7 +21,9 @@ func render[In any](t *testing.T, action string, out any) string {
h := ec2Handler(func(_ context.Context, _ *In, _ *GatewayConfig, _ string) (any, error) {
return out, nil
})
xmlOutput, err := h(action, map[string]string{}, &GatewayConfig{}, "acct-123", (*http.Request)(nil))
input, err := h.parse(map[string]string{})
require.NoError(t, err)
xmlOutput, err := h.dispatch(action, input, &GatewayConfig{}, "acct-123", nil)
require.NoError(t, err)
return string(xmlOutput)
}
+4 -1
View File
@@ -515,7 +515,10 @@ func isNATSTransient(err error) bool {
errors.Is(err, nats.ErrNoStreamResponse))
}
// checkPolicy evaluates IAM policies against resource "*".
// checkPolicy evaluates IAM policies against the literal resource "*", so it
// grants account-wide: a caller's resource-scoped statements do not participate
// at all, neither a Deny that fences a resource nor an Allow that names one.
// Prefer checkPolicyResources with the request's real ARNs.
func (gw *GatewayConfig) checkPolicy(r *http.Request, service, action string) error {
return gw.checkPolicyResources(r, service, action, []string{"*"})
}
+2 -3
View File
@@ -1343,9 +1343,8 @@ func TestImportKeyPair_Base64PaddingWorkaround(t *testing.T) {
"PublicKeyMaterial": "c3NoLXJzYSBBQUFBQjNOemFDMXljMkVBQUFBREFRQUJBQUFCZ1FD%3D%3D",
}
gw := &GatewayConfig{DisableLogging: true, NATSConn: nil, IAMService: allowAllIAMService()}
// NATS is nil so the handler errors, but PublicKeyMaterial is modified before that.
_, _ = handler("ImportKeyPair", q, gw, "123456789012", nil)
_, err := handler.parse(q)
require.NoError(t, err)
assert.True(t, strings.HasSuffix(q["PublicKeyMaterial"], "=="),
"Expected PublicKeyMaterial to end with == but got: %s", q["PublicKeyMaterial"])
+65
View File
@@ -106,3 +106,68 @@ func TestIAMAction(t *testing.T) {
t.Fatalf("IAMAction(ec2, RunInstances) = %q, want %q", got, "ec2:RunInstances")
}
}
// --- Resource scoping ---
// TestEvaluate_ResourceScopedStatementsNeedARealARN pins why the gateway must
// pass the request's ARN rather than "*". The evaluator matches the statement's
// Resource as a pattern against the request's resource as a value, so a scoped
// statement never matches "*" in either direction: a Deny fails open and an
// Allow fails closed.
func TestEvaluate_ResourceScopedStatementsNeedARealARN(t *testing.T) {
const prod = "arn:aws:ec2:ap-southeast-2:123456789012:instance/i-prod"
const dev = "arn:aws:ec2:ap-southeast-2:123456789012:instance/i-dev"
fenced := []handlers_iam.PolicyDocument{
doc("Allow", "ec2:*", "*"),
doc("Deny", "ec2:TerminateInstances", "arn:aws:ec2:*:*:instance/i-prod"),
}
if got := iampolicy.EvaluateWithKeys("ec2:TerminateInstances", "*", fenced, nil); got != iampolicy.Allow {
t.Errorf("fenced policy against %q: got %v, want Allow — the guardrail is inert", "*", got)
}
if got := iampolicy.EvaluateWithKeys("ec2:TerminateInstances", prod, fenced, nil); got != iampolicy.Deny {
t.Errorf("fenced policy against the fenced instance: got %v, want Deny", got)
}
if got := iampolicy.EvaluateWithKeys("ec2:TerminateInstances", dev, fenced, nil); got != iampolicy.Allow {
t.Errorf("fenced policy against an unfenced instance: got %v, want Allow", got)
}
scopedAllow := []handlers_iam.PolicyDocument{
doc("Allow", "ec2:*", "arn:aws:ec2:*:*:instance/i-dev"),
}
if got := iampolicy.EvaluateWithKeys("ec2:TerminateInstances", "*", scopedAllow, nil); got != iampolicy.Deny {
t.Errorf("scoped Allow against %q: got %v, want Deny — least privilege is unexpressible", "*", got)
}
if got := iampolicy.EvaluateWithKeys("ec2:TerminateInstances", dev, scopedAllow, nil); got != iampolicy.Allow {
t.Errorf("scoped Allow against the named instance: got %v, want Allow", got)
}
if got := iampolicy.EvaluateWithKeys("ec2:TerminateInstances", prod, scopedAllow, nil); got != iampolicy.Deny {
t.Errorf("scoped Allow against a sibling instance: got %v, want Deny", got)
}
}
// TestEvaluate_PassingARealARNCannotWithdrawAccess is the property the
// per-service rollout rests on. Every policy that functions today carries
// Resource "*" for the action, and "*" as a pattern matches any ARN value, so
// handing the evaluator a real ARN cannot turn an Allow into a Deny — not even
// a malformed one, which bounds a resolver bug to the newly-scoped statements.
func TestEvaluate_PassingARealARNCannotWithdrawAccess(t *testing.T) {
resources := []string{
"*",
"arn:aws:ec2:ap-southeast-2:123456789012:instance/i-abc",
"arn:aws:ec2:ap-southeast-2:123456789012:instance/*",
"arn:aws:ec2:ap-southeast-2:123456789012:instance/i-abc/admin",
"not-an-arn",
}
policies := [][]handlers_iam.PolicyDocument{
{doc("Allow", "ec2:*", "*")},
{doc("Allow", "*", "*")},
}
for _, p := range policies {
for _, resource := range resources {
if got := iampolicy.EvaluateWithKeys("ec2:RunInstances", resource, p, nil); got != iampolicy.Allow {
t.Errorf("account-wide Allow against %q: got %v, want Allow", resource, got)
}
}
}
}