fix: reject unknown response-* query parameters in GetObject and HeadObject

getobject and headobject silently ignored any response-* query parameter outside the six overridable headers. they now return 400 InvalidArgument naming the parameter and its value, like s3, and the rest tests skipped for this issue are enabled.

Fixes #2075
This commit is contained in:
dualfroz committed 2026-10-07 19:40:28 +02:00
1 parent 5a14104c85
commit 86ff18f8c1
13 files changed
+208 -6

No files matched your search

+8
View File
@@ -451,6 +451,14 @@ func (c S3ApiController) GetObject(ctx fiber.Ctx) (*Response, error) {
isPublicBucketRequest := utils.ContextKeyPublicBucket.IsSet(ctx) isPublicBucketRequest := utils.ContextKeyPublicBucket.IsSet(ctx)
utils.ContextKeySkipResBodyLog.Set(ctx, true) utils.ContextKeySkipResBodyLog.Set(ctx, true)
if err := utils.ValidateResponseOverrides(ctx); err != nil {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, err
}
// Validate that response override parameters are not used with anonymous requests // Validate that response override parameters are not used with anonymous requests
if hasResponseOverrides && isPublicBucketRequest { if hasResponseOverrides && isPublicBucketRequest {
return &Response{ return &Response{
+17
View File
@@ -770,6 +770,23 @@ func TestS3ApiController_GetObject(t *testing.T) {
err: s3err.GetInvalidArgumentErr(s3err.InvalidArgEmptyVersionId, ""), err: s3err.GetInvalidArgumentErr(s3err.InvalidArgEmptyVersionId, ""),
}, },
}, },
{
name: "invalid response override query param",
input: testInput{
locals: defaultLocals,
queries: map[string]string{
"response-invalid": "dummy",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetInvalidArgResponseOverride("response-invalid", "dummy"),
},
},
{ {
name: "verify access fails", name: "verify access fails",
input: testInput{ input: testInput{
+8
View File
@@ -61,6 +61,14 @@ func (c S3ApiController) HeadObject(ctx fiber.Ctx) (*Response, error) {
} }
} }
if err := utils.ValidateResponseOverrides(ctx); err != nil {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, err
}
// Validate that response override parameters are not used with anonymous requests // Validate that response override parameters are not used with anonymous requests
if hasResponseOverrides && isPublicBucket { if hasResponseOverrides && isPublicBucket {
debuglogger.Logf("anonymous access is denied with response override params") debuglogger.Logf("anonymous access is denied with response override params")
+17
View File
@@ -56,6 +56,23 @@ func TestS3ApiController_HeadObject(t *testing.T) {
err: s3err.GetInvalidArgumentErr(s3err.InvalidArgEmptyVersionId, ""), err: s3err.GetInvalidArgumentErr(s3err.InvalidArgEmptyVersionId, ""),
}, },
}, },
{
name: "invalid response override query param",
input: testInput{
locals: defaultLocals,
queries: map[string]string{
"response-invalid": "dummy",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetInvalidArgResponseOverride("response-invalid", "dummy"),
},
},
{ {
name: "verify access fails", name: "verify access fails",
input: testInput{ input: testInput{
+30
View File
@@ -999,6 +999,36 @@ func ValidateVersionId(ctx fiber.Ctx) error {
return nil return nil
} }
// overridableResponseHeaders lists the response-* query parameters that
// GetObject and HeadObject accept to override response headers.
var overridableResponseHeaders = map[string]struct{}{
"response-cache-control": {},
"response-content-disposition": {},
"response-content-encoding": {},
"response-content-language": {},
"response-content-type": {},
"response-expires": {},
}
// ValidateResponseOverrides rejects the first response-* query parameter that
// isn't an overridable response header. S3 answers such a request with 400
// InvalidArgument instead of ignoring the parameter.
func ValidateResponseOverrides(ctx fiber.Ctx) error {
for key, value := range ctx.Request().URI().QueryArgs().All() {
name := string(key)
if !strings.HasPrefix(name, "response-") {
continue
}
if _, ok := overridableResponseHeaders[name]; ok {
continue
}
debuglogger.Logf("invalid response override query parameter: %s", name)
return s3err.GetInvalidArgResponseOverride(name, string(value))
}
return nil
}
// ApplyOverride returns the override value if it exists and status is 200, otherwise returns original // ApplyOverride returns the override value if it exists and status is 200, otherwise returns original
func ApplyOverride(original, override *string) *string { func ApplyOverride(original, override *string) *string {
if override != nil { if override != nil {
+49
View File
@@ -1677,3 +1677,52 @@ func TestSetRegionMismatchHeader(t *testing.T) {
}) })
} }
} }
func TestValidateResponseOverrides(t *testing.T) {
tests := []struct {
name string
query string
want error
}{
{"no query", "", nil},
{"no response override", "versionId=id&partNumber=1", nil},
{
"every overridable response header",
"response-cache-control=a&response-content-disposition=b&response-content-encoding=c&response-content-language=d&response-content-type=e&response-expires=f",
nil,
},
{
"unknown response header",
"response-fdsjkljj=dummy",
s3err.GetInvalidArgResponseOverride("response-fdsjkljj", "dummy"),
},
{
"unknown response header without a value",
"response-invalid",
s3err.GetInvalidArgResponseOverride("response-invalid", ""),
},
{
"bare prefix",
"response-=dummy",
s3err.GetInvalidArgResponseOverride("response-", "dummy"),
},
{
"unknown response header next to a valid one",
"response-content-type=text%2Fplain&response-invalid=dummy",
s3err.GetInvalidArgResponseOverride("response-invalid", "dummy"),
},
{
"first of several unknown response headers",
"response-one=1&response-two=2",
s3err.GetInvalidArgResponseOverride("response-one", "1"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := fiber.New().AcquireCtx(&fasthttp.RequestCtx{})
ctx.Request().URI().SetQueryString(tt.query)
assert.Equal(t, tt.want, ValidateResponseOverrides(ctx))
})
}
}
+10
View File
@@ -401,3 +401,13 @@ func GetInvalidArgBucketRegion(value string) InvalidArgumentError {
Description: description, Description: description,
} }
} }
// GetInvalidArgResponseOverride is returned for a response-* query parameter
// that isn't one of the response headers GetObject and HeadObject can override.
func GetInvalidArgResponseOverride(name, value string) InvalidArgumentError {
return InvalidArgumentError{
ArgumentName: name,
ArgumentValue: value,
Description: fmt.Sprintf("%s is not in the set of overridable response headers. Please refer to the S3 API documentation for a complete list of overridable response headers.", name),
}
}
+4
View File
@@ -1305,6 +1305,10 @@ func GetObject_overrides_fail_public(s *S3Conf) error {
}, withAnonymousClient()) }, withAnonymousClient())
} }
func GetObject_overrides_fail_invalid_header(s *S3Conf) error {
return testInvalidResponseOverride(s, "GetObject_overrides_fail_invalid_header", http.MethodGet)
}
func GetObject_invalid_part_number(s *S3Conf) error { func GetObject_invalid_part_number(s *S3Conf) error {
testName := "GetObject_invalid_part_number" testName := "GetObject_invalid_part_number"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
+4
View File
@@ -1511,6 +1511,10 @@ func HeadObject_overrides_fail_public(s *S3Conf) error {
}, withAnonymousClient()) }, withAnonymousClient())
} }
func HeadObject_overrides_fail_invalid_header(s *S3Conf) error {
return testInvalidResponseOverride(s, "HeadObject_overrides_fail_invalid_header", http.MethodHead)
}
func HeadObject_empty_version_id(s *S3Conf) error { func HeadObject_empty_version_id(s *S3Conf) error {
return testEmptyVersionId(s, "HeadObject_empty_version_id", http.MethodHead, "", nil) return testEmptyVersionId(s, "HeadObject_empty_version_id", http.MethodHead, "", nil)
} }
+4
View File
@@ -246,6 +246,7 @@ func TestHeadObject(ts *TestState) {
ts.Run(HeadObject_overrides_success) ts.Run(HeadObject_overrides_success)
ts.Run(HeadObject_overrides_presign_success) ts.Run(HeadObject_overrides_presign_success)
ts.Run(HeadObject_overrides_fail_public) ts.Run(HeadObject_overrides_fail_public)
ts.Run(HeadObject_overrides_fail_invalid_header)
ts.Run(HeadObject_range_and_part_number) ts.Run(HeadObject_range_and_part_number)
ts.Run(HeadObject_mp_part_number_exceeds_parts_count) ts.Run(HeadObject_mp_part_number_exceeds_parts_count)
ts.Run(HeadObject_mp_part_number_success) ts.Run(HeadObject_mp_part_number_success)
@@ -294,6 +295,7 @@ func TestGetObject(ts *TestState) {
ts.Run(GetObject_overrides_success) ts.Run(GetObject_overrides_success)
ts.Run(GetObject_overrides_presign_success) ts.Run(GetObject_overrides_presign_success)
ts.Run(GetObject_overrides_fail_public) ts.Run(GetObject_overrides_fail_public)
ts.Run(GetObject_overrides_fail_invalid_header)
ts.Run(GetObject_invalid_part_number) ts.Run(GetObject_invalid_part_number)
ts.Run(GetObject_range_and_part_number) ts.Run(GetObject_range_and_part_number)
ts.Run(GetObject_mp_part_number_exceeds_parts_count) ts.Run(GetObject_mp_part_number_exceeds_parts_count)
@@ -3064,6 +3066,7 @@ func GetIntTests() IntTests {
"HeadObject_overrides_success": HeadObject_overrides_success, "HeadObject_overrides_success": HeadObject_overrides_success,
"HeadObject_overrides_presign_success": HeadObject_overrides_presign_success, "HeadObject_overrides_presign_success": HeadObject_overrides_presign_success,
"HeadObject_overrides_fail_public": HeadObject_overrides_fail_public, "HeadObject_overrides_fail_public": HeadObject_overrides_fail_public,
"HeadObject_overrides_fail_invalid_header": HeadObject_overrides_fail_invalid_header,
"HeadObject_range_and_part_number": HeadObject_range_and_part_number, "HeadObject_range_and_part_number": HeadObject_range_and_part_number,
"HeadObject_mp_part_number_exceeds_parts_count": HeadObject_mp_part_number_exceeds_parts_count, "HeadObject_mp_part_number_exceeds_parts_count": HeadObject_mp_part_number_exceeds_parts_count,
"HeadObject_mp_part_number_success": HeadObject_mp_part_number_success, "HeadObject_mp_part_number_success": HeadObject_mp_part_number_success,
@@ -3100,6 +3103,7 @@ func GetIntTests() IntTests {
"GetObject_overrides_success": GetObject_overrides_success, "GetObject_overrides_success": GetObject_overrides_success,
"GetObject_overrides_presign_success": GetObject_overrides_presign_success, "GetObject_overrides_presign_success": GetObject_overrides_presign_success,
"GetObject_overrides_fail_public": GetObject_overrides_fail_public, "GetObject_overrides_fail_public": GetObject_overrides_fail_public,
"GetObject_overrides_fail_invalid_header": GetObject_overrides_fail_invalid_header,
"GetObject_invalid_part_number": GetObject_invalid_part_number, "GetObject_invalid_part_number": GetObject_invalid_part_number,
"GetObject_range_and_part_number": GetObject_range_and_part_number, "GetObject_range_and_part_number": GetObject_range_and_part_number,
"GetObject_mp_part_number_exceeds_parts_count": GetObject_mp_part_number_exceeds_parts_count, "GetObject_mp_part_number_exceeds_parts_count": GetObject_mp_part_number_exceeds_parts_count,
+57
View File
@@ -612,6 +612,63 @@ func testEmptyVersionId(s *S3Conf, testName, method, subresource string, body []
}) })
} }
// testInvalidResponseOverride verifies GetObject or HeadObject rejects a
// response-* query parameter that isn't an overridable response header. The
// SDK only sends the known overrides, so the request is signed and sent by
// hand.
func testInvalidResponseOverride(s *S3Conf, testName, method string) error {
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
obj := "my-obj"
_, err := putObjects(s3client, []string{obj}, bucket)
if err != nil {
return err
}
for _, test := range []struct {
query string
expected s3err.InvalidArgumentError
}{
{
query: "response-fdsjkljj=dummy",
expected: s3err.GetInvalidArgResponseOverride("response-fdsjkljj", "dummy"),
},
// a valid override doesn't let an unknown one through
{
query: "response-content-type=text%2Fplain&response-invalid=dummy",
expected: s3err.GetInvalidArgResponseOverride("response-invalid", "dummy"),
},
} {
req, err := createSignedReq(method, s.endpoint,
fmt.Sprintf("%v/%v?%v", bucket, obj, test.query), s.awsID, s.awsSecret,
"s3", s.awsRegion, "", nil, time.Now(), nil)
if err != nil {
return err
}
resp, err := s.httpClient.Do(req)
if err != nil {
return err
}
// HEAD responses carry no body: only the status code is verifiable
if method == http.MethodHead {
resp.Body.Close()
if resp.StatusCode != test.expected.StatusCode() {
return fmt.Errorf("%v: expected response status code to be %v, instead got %v",
test.query, test.expected.StatusCode(), resp.StatusCode)
}
continue
}
if err := checkHTTPResponseApiErr(resp, test.expected); err != nil {
return fmt.Errorf("%v: %w", test.query, err)
}
}
return nil
})
}
func checkIAMAuthRequest(s *S3Conf, req *http.Request, expected iamerr.APIError) error { func checkIAMAuthRequest(s *S3Conf, req *http.Request, expected iamerr.APIError) error {
resp, err := s.httpClient.Do(req) resp, err := s.httpClient.Do(req)
if err != nil { if err != nil {
-3
View File
@@ -119,9 +119,6 @@ source ./tests/setup.sh
# tags: curl,GetObject,invalid-header,response-headers # tags: curl,GetObject,invalid-header,response-headers
@test "REST - GetObject - response query - invalid response type" { @test "REST - GetObject - response query - invalid response type" {
if [ "$DIRECT" != "true" ]; then
skip "https://github.com/versity/versitygw/issues/2075"
fi
run setup_bucket_and_file_v3 "$BUCKET_ONE_NAME" run setup_bucket_and_file_v3 "$BUCKET_ONE_NAME"
assert_success assert_success
read -r bucket_name file_name <<< "$output" read -r bucket_name file_name <<< "$output"
-3
View File
@@ -103,9 +103,6 @@ source ./tests/drivers/string.sh
# tags: curl,HeadObject,invalid-query # tags: curl,HeadObject,invalid-query
@test "REST - HeadObject - invalid request header type" { @test "REST - HeadObject - invalid request header type" {
if [ "$DIRECT" != "true" ]; then
skip "https://github.com/versity/versitygw/issues/2075"
fi
run setup_bucket_and_add_file_v3 "$BUCKET_ONE_NAME" run setup_bucket_and_add_file_v3 "$BUCKET_ONE_NAME"
assert_success assert_success
read -r bucket_name file_name <<< "$output" read -r bucket_name file_name <<< "$output"