feat: support response header overrides in HeadObject

Closes #1967

Add support for response header override query parameters(`response-cache-control`, `response-content-disposition`, `response-content-encoding`, `response-content-language`, `response-content-type`, `response-expires`) in `HeadObject`. Anonymous requests with override params are rejected with `ErrAnonymousResponseHeaders`.
This commit is contained in:
niksis02
2026-03-31 17:19:03 +04:00
parent 69b23fe139
commit 3b17f05d17
4 changed files with 427 additions and 6 deletions
+35 -6
View File
@@ -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,
+29
View File
@@ -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{
+357
View File
@@ -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())
}
+6
View File
@@ -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,