diff --git a/s3api/controllers/object-head.go b/s3api/controllers/object-head.go index f4fe4b6e..62baf3fa 100644 --- a/s3api/controllers/object-head.go +++ b/s3api/controllers/object-head.go @@ -41,6 +41,35 @@ func (c S3ApiController) HeadObject(ctx *fiber.Ctx) (*Response, error) { objRange := ctx.Get("Range") key := strings.TrimPrefix(ctx.Path(), fmt.Sprintf("/%s/", bucket)) + // Extract response override query parameters + responseOverrides := map[string]*string{ + "Cache-Control": utils.GetQueryParam(ctx, "response-cache-control"), + "Content-Disposition": utils.GetQueryParam(ctx, "response-content-disposition"), + "Content-Encoding": utils.GetQueryParam(ctx, "response-content-encoding"), + "Content-Language": utils.GetQueryParam(ctx, "response-content-language"), + "Content-Type": utils.GetQueryParam(ctx, "response-content-type"), + "Expires": utils.GetQueryParam(ctx, "response-expires"), + } + + // Check if any response override parameters are present + hasResponseOverrides := false + for _, override := range responseOverrides { + if override != nil { + hasResponseOverrides = true + break + } + } + + // Validate that response override parameters are not used with anonymous requests + if hasResponseOverrides && isPublicBucket { + debuglogger.Logf("anonymous access is denied with response override params") + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, s3err.GetAPIError(s3err.ErrAnonymousResponseHeaders) + } + action := auth.GetObjectAction if ctx.Request().URI().QueryArgs().Has("versionId") { action = auth.GetObjectVersionAction @@ -137,13 +166,13 @@ func (c S3ApiController) HeadObject(ctx *fiber.Ctx) (*Response, error) { return &Response{ Headers: map[string]*string{ "Content-Range": res.ContentRange, - "Content-Disposition": res.ContentDisposition, - "Content-Encoding": res.ContentEncoding, - "Content-Language": res.ContentLanguage, - "Cache-Control": res.CacheControl, + "Content-Disposition": utils.ApplyOverride(res.ContentDisposition, responseOverrides["Content-Disposition"]), + "Content-Encoding": utils.ApplyOverride(res.ContentEncoding, responseOverrides["Content-Encoding"]), + "Content-Language": utils.ApplyOverride(res.ContentLanguage, responseOverrides["Content-Language"]), + "Cache-Control": utils.ApplyOverride(res.CacheControl, responseOverrides["Cache-Control"]), "Content-Length": utils.ConvertPtrToStringPtr(res.ContentLength), - "Content-Type": res.ContentType, - "Expires": res.ExpiresString, + "Content-Type": utils.ApplyOverride(res.ContentType, responseOverrides["Content-Type"]), + "Expires": utils.ApplyOverride(res.ExpiresString, responseOverrides["Expires"]), "ETag": res.ETag, "Last-Modified": utils.FormatDatePtrToString(res.LastModified, timefmt), "x-amz-restore": res.Restore, diff --git a/s3api/controllers/object-head_test.go b/s3api/controllers/object-head_test.go index 13db79c6..e975ec53 100644 --- a/s3api/controllers/object-head_test.go +++ b/s3api/controllers/object-head_test.go @@ -20,6 +20,7 @@ import ( "time" "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/versity/versitygw/auth" "github.com/versity/versitygw/s3api/utils" "github.com/versity/versitygw/s3err" ) @@ -51,6 +52,34 @@ func TestS3ApiController_HeadObject(t *testing.T) { err: s3err.GetAPIError(s3err.ErrAccessDenied), }, }, + { + name: "anonymous access with override params", + input: testInput{ + locals: map[utils.ContextKey]any{ + utils.ContextKeyIsRoot: true, + utils.ContextKeyParsedAcl: auth.ACL{ + Owner: "root", + }, + utils.ContextKeyAccount: auth.Account{ + Access: "root", + Role: auth.RoleAdmin, + }, + utils.ContextKeyRegion: "us-east-1", + utils.ContextKeyPublicBucket: true, + }, + queries: map[string]string{ + "response-expires": "something", + }, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + err: s3err.GetAPIError(s3err.ErrAnonymousResponseHeaders), + }, + }, { name: "invalid versionId", input: testInput{ diff --git a/tests/integration/HeadObject.go b/tests/integration/HeadObject.go index c9928f78..5536d0d5 100644 --- a/tests/integration/HeadObject.go +++ b/tests/integration/HeadObject.go @@ -18,12 +18,14 @@ import ( "context" "errors" "fmt" + "net/http" "strings" "time" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/aws/smithy-go" + "github.com/versity/versitygw/s3err" ) func HeadObject_non_existing_object(s *S3Conf) error { @@ -676,3 +678,358 @@ func HeadObject_success(s *S3Conf) error { return nil }) } + +func HeadObject_overrides_success(s *S3Conf) error { + testName := "HeadObject_overrides_success" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + objKey := "test-object" + exp := time.Now() + + _, err := s3client.PutObject(context.Background(), &s3.PutObjectInput{ + Bucket: &bucket, + Key: &objKey, + }) + if err != nil { + return fmt.Errorf("failed to put object: %v", err) + } + + for _, test := range []PublicBucketTestCase{ + { + Action: "HeadObject", + Call: func(ctx context.Context) error { + _, err := s3client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: &bucket, + Key: &objKey, + ResponseCacheControl: getPtr("max-age=90"), + }) + return err + }, + ExpectedErr: nil, + }, + { + Action: "HeadObject", + Call: func(ctx context.Context) error { + _, err := s3client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: &bucket, + Key: &objKey, + ResponseContentDisposition: getPtr("inline"), + }) + return err + }, + ExpectedErr: nil, + }, + { + Action: "HeadObject", + Call: func(ctx context.Context) error { + _, err := s3client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: &bucket, + Key: &objKey, + ResponseContentEncoding: getPtr("txt"), + }) + return err + }, + ExpectedErr: nil, + }, + { + Action: "HeadObject", + Call: func(ctx context.Context) error { + _, err := s3client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: &bucket, + Key: &objKey, + ResponseContentLanguage: getPtr("en"), + }) + return err + }, + ExpectedErr: nil, + }, + { + Action: "HeadObject", + Call: func(ctx context.Context) error { + _, err := s3client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: &bucket, + Key: &objKey, + ResponseContentType: getPtr("application/json"), + }) + return err + }, + ExpectedErr: nil, + }, + { + Action: "HeadObject", + Call: func(ctx context.Context) error { + _, err := s3client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: &bucket, + Key: &objKey, + ResponseExpires: &exp, + }) + return err + }, + ExpectedErr: nil, + }, + } { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + err := test.Call(ctx) + cancel() + if err == nil && test.ExpectedErr != nil { + return fmt.Errorf("%v: expected err %v, instead got successful response", test.Action, test.ExpectedErr) + } + if err != nil { + if test.ExpectedErr == nil { + return fmt.Errorf("%v: expected no error, instead got %v", test.Action, err) + } + + apiErr, ok := test.ExpectedErr.(s3err.APIError) + if !ok { + return fmt.Errorf("invalid error type provided in the test, expected s3err.APIError") + } + + if err := checkApiErr(err, apiErr); err != nil { + return err + } + } + } + + return nil + }) +} + +func HeadObject_overrides_presign_success(s *S3Conf) error { + testName := "HeadObject_overrides_presign_success" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + objKey := "test-object" + + _, err := s3client.PutObject(context.Background(), &s3.PutObjectInput{ + Bucket: &bucket, + Key: &objKey, + }) + if err != nil { + return fmt.Errorf("failed to put object: %v", err) + } + + testCases := []struct { + name string + queryParam string + expectedHeader string + expectedValue string + }{ + { + name: "response-cache-control", + queryParam: "response-cache-control=no-cache", + expectedHeader: "Cache-Control", + expectedValue: "no-cache", + }, + { + name: "response-content-disposition", + queryParam: "response-content-disposition=attachment%3B%20filename%3D%22test.txt%22", + expectedHeader: "Content-Disposition", + expectedValue: "attachment; filename=\"test.txt\"", + }, + { + name: "response-content-encoding", + queryParam: "response-content-encoding=txt", + expectedHeader: "Content-Encoding", + expectedValue: "txt", + }, + { + name: "response-content-language", + queryParam: "response-content-language=en-US", + expectedHeader: "Content-Language", + expectedValue: "en-US", + }, + { + name: "response-content-type", + queryParam: "response-content-type=text%2Fplain", + expectedHeader: "Content-Type", + expectedValue: "text/plain", + }, + { + name: "response-expires", + queryParam: "response-expires=Thu%2C%2001%20Dec%202024%2016%3A00%3A00%20GMT", + expectedHeader: "Expires", + expectedValue: "Thu, 01 Dec 2024 16:00:00 GMT", + }, + } + + for _, tc := range testCases { + req, err := createSignedReq( + http.MethodHead, + s.endpoint, + fmt.Sprintf("%s/%s?%s", bucket, objKey, tc.queryParam), + s.awsID, + s.awsSecret, + "s3", + s.awsRegion, + nil, + time.Now(), + nil, + ) + if err != nil { + return fmt.Errorf("failed to create signed request for %s: %v", tc.name, err) + } + + resp, err := s.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to execute request for %s: %v", tc.name, err) + } + resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("expected status 200 for %s, got %d", tc.name, resp.StatusCode) + } + + actualValue := resp.Header.Get(tc.expectedHeader) + if actualValue != tc.expectedValue { + return fmt.Errorf("expected %s header to be %q for %s, got %q", + tc.expectedHeader, tc.expectedValue, tc.name, actualValue) + } + } + + // Test multiple override parameters together + multiParam := "response-cache-control=max-age%3D3600&response-content-type=application%2Fjson&response-content-disposition=inline" + req, err := createSignedReq( + http.MethodHead, + s.endpoint, + fmt.Sprintf("%s/%s?%s", bucket, objKey, multiParam), + s.awsID, + s.awsSecret, + "s3", + s.awsRegion, + nil, + time.Now(), + nil, + ) + if err != nil { + return fmt.Errorf("failed to create signed request for multiple overrides: %v", err) + } + + resp, err := s.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to execute request for multiple overrides: %v", err) + } + resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("expected status 200 for multiple overrides, got %d", resp.StatusCode) + } + + expectedHeaders := map[string]string{ + "Cache-Control": "max-age=3600", + "Content-Type": "application/json", + "Content-Disposition": "inline", + } + + for headerName, expectedValue := range expectedHeaders { + actualValue := resp.Header.Get(headerName) + if actualValue != expectedValue { + return fmt.Errorf("expected %s header to be %q for multiple overrides, got %q", + headerName, expectedValue, actualValue) + } + } + + return nil + }) +} + +func HeadObject_overrides_fail_public(s *S3Conf) error { + testName := "HeadObject_overrides_fail_public" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + rootClient := s.GetClient() + err := grantPublicBucketPolicy(rootClient, bucket, policyTypeObject) + if err != nil { + return err + } + + objKey := "test-object" + exp := time.Now() + + _, err = rootClient.PutObject(context.Background(), &s3.PutObjectInput{ + Bucket: &bucket, + Key: &objKey, + }) + if err != nil { + return fmt.Errorf("failed to put object: %v", err) + } + + for _, test := range []PublicBucketTestCase{ + { + Action: "HeadObject", + Call: func(ctx context.Context) error { + _, err := s3client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: &bucket, + Key: &objKey, + ResponseCacheControl: getPtr("max-age=90"), + }) + return err + }, + }, + { + Action: "HeadObject", + Call: func(ctx context.Context) error { + _, err := s3client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: &bucket, + Key: &objKey, + ResponseContentDisposition: getPtr("inline"), + }) + return err + }, + }, + { + Action: "HeadObject", + Call: func(ctx context.Context) error { + _, err := s3client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: &bucket, + Key: &objKey, + ResponseContentEncoding: getPtr("txt"), + }) + return err + }, + }, + { + Action: "HeadObject", + Call: func(ctx context.Context) error { + _, err := s3client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: &bucket, + Key: &objKey, + ResponseContentLanguage: getPtr("en"), + }) + return err + }, + }, + { + Action: "HeadObject", + Call: func(ctx context.Context) error { + _, err := s3client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: &bucket, + Key: &objKey, + ResponseContentType: getPtr("application/json"), + }) + return err + }, + }, + { + Action: "HeadObject", + Call: func(ctx context.Context) error { + _, err := s3client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: &bucket, + Key: &objKey, + ResponseExpires: &exp, + }) + return err + }, + }, + } { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + err := test.Call(ctx) + cancel() + if err == nil { + return fmt.Errorf("%v: expected a BadRequest error, instead got successful response", test.Action) + } + + if err := checkSdkApiErr(err, "BadRequest"); err != nil { + return err + } + } + + return nil + }, withAnonymousClient()) +} diff --git a/tests/integration/group-tests.go b/tests/integration/group-tests.go index b161abe6..8dc86a1a 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -215,6 +215,9 @@ func TestHeadObject(ts *TestState) { ts.Run(HeadObject_checksums) } ts.Run(HeadObject_success) + ts.Run(HeadObject_overrides_success) + ts.Run(HeadObject_overrides_presign_success) + ts.Run(HeadObject_overrides_fail_public) } func TestGetObjectAttributes(ts *TestState) { @@ -1347,6 +1350,9 @@ func GetIntTests() IntTests { "HeadObject_not_enabled_checksum_mode": HeadObject_not_enabled_checksum_mode, "HeadObject_checksums": HeadObject_checksums, "HeadObject_success": HeadObject_success, + "HeadObject_overrides_success": HeadObject_overrides_success, + "HeadObject_overrides_presign_success": HeadObject_overrides_presign_success, + "HeadObject_overrides_fail_public": HeadObject_overrides_fail_public, "GetObjectAttributes_non_existing_bucket": GetObjectAttributes_non_existing_bucket, "GetObjectAttributes_non_existing_object": GetObjectAttributes_non_existing_object, "GetObjectAttributes_invalid_attrs": GetObjectAttributes_invalid_attrs,