mirror of
https://github.com/versity/versitygw.git
synced 2026-09-02 22:27:13 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user