diff --git a/.changelog/d32ee107754148dba31cd1944f3ffcbf.json b/.changelog/d32ee107754148dba31cd1944f3ffcbf.json new file mode 100644 index 00000000000..f823229025e --- /dev/null +++ b/.changelog/d32ee107754148dba31cd1944f3ffcbf.json @@ -0,0 +1,8 @@ +{ + "id": "d32ee107-7541-48db-a31c-d1944f3ffcbf", + "type": "bugfix", + "description": "Add client-side validation to ensure PutObject requests have a derivable content length.", + "modules": [ + "service/s3" + ] +} \ No newline at end of file diff --git a/codegen/smithy-aws-go-codegen/src/main/java/software/amazon/smithy/aws/go/codegen/customization/s3/ValidatePutObjectContentLength.java b/codegen/smithy-aws-go-codegen/src/main/java/software/amazon/smithy/aws/go/codegen/customization/s3/ValidatePutObjectContentLength.java new file mode 100644 index 00000000000..05b92068dc6 --- /dev/null +++ b/codegen/smithy-aws-go-codegen/src/main/java/software/amazon/smithy/aws/go/codegen/customization/s3/ValidatePutObjectContentLength.java @@ -0,0 +1,33 @@ +package software.amazon.smithy.aws.go.codegen.customization.s3; + +import software.amazon.smithy.go.codegen.integration.GoIntegration; +import software.amazon.smithy.go.codegen.integration.MiddlewareRegistrar; +import software.amazon.smithy.go.codegen.integration.RuntimeClientPlugin; +import software.amazon.smithy.model.shapes.ShapeId; + +import java.util.List; + +import static software.amazon.smithy.go.codegen.SymbolUtils.buildPackageSymbol; + +/** + * Adds validation to PutObject to ensure that content-length is derivable _somehow_ (either through the body being + * seekable or a length being set) - which is required for the operation to function since the service doesn't support + * chunked transfer-encoding. + */ +public class ValidatePutObjectContentLength implements GoIntegration { + private static final ShapeId PUT_OBJECT_SHAPE_ID = ShapeId.from("com.amazonaws.s3#PutObject"); + + private static final MiddlewareRegistrar MIDDLEWARE = MiddlewareRegistrar.builder() + .resolvedFunction(buildPackageSymbol("addValidatePutObjectContentLength")) + .build(); + + @Override + public List getClientPlugins() { + return List.of( + RuntimeClientPlugin.builder() + .operationPredicate((model, service, operation) -> operation.getId().equals(PUT_OBJECT_SHAPE_ID)) + .registerMiddleware(MIDDLEWARE) + .build() + ); + } +} diff --git a/codegen/smithy-aws-go-codegen/src/main/resources/META-INF/services/software.amazon.smithy.go.codegen.integration.GoIntegration b/codegen/smithy-aws-go-codegen/src/main/resources/META-INF/services/software.amazon.smithy.go.codegen.integration.GoIntegration index 25a74f25eea..fb30b9427b1 100644 --- a/codegen/smithy-aws-go-codegen/src/main/resources/META-INF/services/software.amazon.smithy.go.codegen.integration.GoIntegration +++ b/codegen/smithy-aws-go-codegen/src/main/resources/META-INF/services/software.amazon.smithy.go.codegen.integration.GoIntegration @@ -83,3 +83,4 @@ software.amazon.smithy.aws.go.codegen.customization.AccountIDEndpointRouting software.amazon.smithy.aws.go.codegen.customization.RetryModeUserAgent software.amazon.smithy.aws.go.codegen.customization.RequestCompressionUserAgent software.amazon.smithy.aws.go.codegen.customization.s3.ExpressUserAgent +software.amazon.smithy.aws.go.codegen.customization.s3.ValidatePutObjectContentLength diff --git a/service/s3/api_op_PutObject.go b/service/s3/api_op_PutObject.go index fffa96a7aba..400c4916b0a 100644 --- a/service/s3/api_op_PutObject.go +++ b/service/s3/api_op_PutObject.go @@ -716,6 +716,9 @@ func (c *Client) addOperationPutObjectMiddlewares(stack *middleware.Stack, optio if err = addIsExpressUserAgent(stack); err != nil { return err } + if err = addValidatePutObjectContentLength(stack); err != nil { + return err + } if err = addOpPutObjectValidationMiddleware(stack); err != nil { return err } diff --git a/service/s3/put_object_content_length.go b/service/s3/put_object_content_length.go new file mode 100644 index 00000000000..f28d6b3be9b --- /dev/null +++ b/service/s3/put_object_content_length.go @@ -0,0 +1,55 @@ +package s3 + +import ( + "context" + "errors" + "fmt" + "io" + + presignedurl "github.com/aws/aws-sdk-go-v2/service/internal/presigned-url" + "github.com/aws/smithy-go/middleware" +) + +var errNoContentLength = errors.New( + "The operation input had an undefined content length. PutObject MUST have a " + + "derivable content length from either (1) an explicit value for the " + + "ContentLength input member (2) the Body input member implementing io.Seeker " + + "such that the SDK can derive a value.", +) + +// PutObject MUST have a derivable content length for the body in some form, +// since the service does not implement chunked transfer-encoding (and +// aws-chunked encoding requires the length anyway). +// +// We gate this constraint at the client level through additional validation +// rather than letting the request through, which would fail with a 501. +type validatePutObjectContentLength struct{} + +func (*validatePutObjectContentLength) ID() string { + return "validatePutObjectContentLength" +} + +func (*validatePutObjectContentLength) HandleInitialize( + ctx context.Context, in middleware.InitializeInput, next middleware.InitializeHandler, +) ( + out middleware.InitializeOutput, metadata middleware.Metadata, err error, +) { + if presignedurl.GetIsPresigning(ctx) { // won't have a body + return next.HandleInitialize(ctx, in) + } + + input, ok := in.Parameters.(*PutObjectInput) + if !ok { + return out, metadata, fmt.Errorf("unknown input parameters type %T", in.Parameters) + } + + _, ok = input.Body.(io.Seeker) + if !ok && input.ContentLength == nil { + return out, metadata, errNoContentLength + } + return next.HandleInitialize(ctx, in) +} + +func addValidatePutObjectContentLength(stack *middleware.Stack) error { + return stack.Initialize.Add(&validatePutObjectContentLength{}, middleware.After) +} diff --git a/service/s3/put_object_content_length_test.go b/service/s3/put_object_content_length_test.go new file mode 100644 index 00000000000..4e6dd5e636b --- /dev/null +++ b/service/s3/put_object_content_length_test.go @@ -0,0 +1,73 @@ +package s3 + +import ( + "bytes" + "context" + "errors" + "io" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" +) + +type reader struct { + p []byte + read bool +} + +func (r *reader) Read(p []byte) (int, error) { + if r.read { + return 0, io.EOF + } + + r.read = true + copy(p, r.p) + return len(r.p), nil +} + +func TestValidatePutObjectContentLength(t *testing.T) { + for name, cs := range map[string]struct { + Input *PutObjectInput + ExpectErr bool + }{ + "noseek,nolen": { + Input: &PutObjectInput{ + Bucket: aws.String("bucket"), + Key: aws.String("key"), + Body: &reader{p: []byte("foo")}, + }, + ExpectErr: true, + }, + "noseek,len": { + Input: &PutObjectInput{ + Bucket: aws.String("bucket"), + Key: aws.String("key"), + Body: &reader{p: []byte("foo")}, + ContentLength: aws.Int64(3), + }, + ExpectErr: false, + }, + "seek,nolen": { + Input: &PutObjectInput{ + Bucket: aws.String("bucket"), + Key: aws.String("key"), + Body: bytes.NewReader([]byte("foo")), + }, + ExpectErr: false, + }, + } { + t.Run(name, func(t *testing.T) { + svc := New(Options{ + Region: "us-east-1", + }) + + _, err := svc.PutObject(context.Background(), cs.Input) + if cs.ExpectErr && !errors.Is(err, errNoContentLength) { + t.Errorf("expected errNoContentLength, got %v", err) + } + if !cs.ExpectErr && errors.Is(err, errNoContentLength) { + t.Errorf("expected no errNoContentLength but got it") + } + }) + } +} diff --git a/service/s3/snapshot/api_op_PutObject.go.snap b/service/s3/snapshot/api_op_PutObject.go.snap index e87107024e5..8a39ac451c9 100644 --- a/service/s3/snapshot/api_op_PutObject.go.snap +++ b/service/s3/snapshot/api_op_PutObject.go.snap @@ -5,6 +5,7 @@ PutObject legacyEndpointContextSetter S3Shared:ARNLookup SetLogger + validatePutObjectContentLength OperationInputValidation Serialize stack step putBucketContext