fix: enforce required SignedHeaders validation for SigV4 requests

Validate required signed headers for both Authorization-header SigV4 requests and presigned URLs. The required signed header set is now `host` plus every incoming header with the `x-amz-` prefix.

During request reconstruction, signed headers and explicitly ignored headers are copied into the generated request used for signature verification. If an incoming `x-amz-*` header is present but missing from the client-provided `SignedHeaders`, return `AccessDenied` with a `HeadersNotSigned` field. The `host` header remains part of the canonical request and signed header calculation.

Previously, a client could sign a request without an S3 control header and then add that header after signing. For example, a presigned `PUT` URL could be generated with only `host` signed, then the actual request could include an unsigned `X-Amz-Tagging` or `X-Amz-Copy-Source` header. Because the verifier reconstructed the request only from `SignedHeaders`, that extra header was omitted from signature calculation and could pass authentication even though it changed the request semantics. This is now rejected with `AccessDenied`.

Expose v4 helper methods for checking required and ignored headers, and update canonical header signing so ignored headers can still be included when a client explicitly lists them in `SignedHeaders`, while `Authorization` remains excluded from signature calculation.
This commit is contained in:
niksis02
2026-05-30 21:16:26 +04:00
parent f7cc70b157
commit 577470214d
15 changed files with 577 additions and 100 deletions
+8
View File
@@ -37,6 +37,8 @@ func TestAuthentication(ts *TestState) {
ts.Run(Authentication_incorrect_payload_hash)
ts.Run(Authentication_invalid_sha256_payload_hash)
ts.Run(Authentication_md5)
ts.Run(Authentication_unsigned_required_header)
ts.Run(Authentication_unsigned_non_required_header)
ts.Run(Authentication_signature_error_incorrect_secret_key)
ts.Run(Authentication_sigv2_not_supported)
ts.Run(Authentication_with_expect_header)
@@ -57,6 +59,8 @@ func TestPresignedAuthentication(ts *TestState) {
ts.Run(PresignedAuth_dates_mismatch)
ts.Run(PresignedAuth_non_existing_access_key_id)
ts.Run(PresignedAuth_missing_signed_headers_query_param)
ts.Run(PresignedAuth_unsigned_required_header)
ts.Run(PresignedAuth_unsigned_non_required_header)
ts.Run(PresignedAuth_missing_expiration_query_param)
ts.Run(PresignedAuth_invalid_expiration_query_param)
ts.Run(PresignedAuth_negative_expiration_query_param)
@@ -1304,6 +1308,8 @@ func GetIntTests() IntTests {
"Authentication_incorrect_payload_hash": Authentication_incorrect_payload_hash,
"Authentication_invalid_sha256_payload_hash": Authentication_invalid_sha256_payload_hash,
"Authentication_md5": Authentication_md5,
"Authentication_unsigned_required_header": Authentication_unsigned_required_header,
"Authentication_unsigned_non_required_header": Authentication_unsigned_non_required_header,
"Authentication_signature_error_incorrect_secret_key": Authentication_signature_error_incorrect_secret_key,
"Authentication_sigv2_not_supported": Authentication_sigv2_not_supported,
"Authentication_with_expect_header": Authentication_with_expect_header,
@@ -1321,6 +1327,8 @@ func GetIntTests() IntTests {
"PresignedAuth_dates_mismatch": PresignedAuth_dates_mismatch,
"PresignedAuth_non_existing_access_key_id": PresignedAuth_non_existing_access_key_id,
"PresignedAuth_missing_signed_headers_query_param": PresignedAuth_missing_signed_headers_query_param,
"PresignedAuth_unsigned_required_header": PresignedAuth_unsigned_required_header,
"PresignedAuth_unsigned_non_required_header": PresignedAuth_unsigned_non_required_header,
"PresignedAuth_missing_expiration_query_param": PresignedAuth_missing_expiration_query_param,
"PresignedAuth_invalid_expiration_query_param": PresignedAuth_invalid_expiration_query_param,
"PresignedAuth_negative_expiration_query_param": PresignedAuth_negative_expiration_query_param,
+60
View File
@@ -459,6 +459,66 @@ func PresignedAuth_missing_signed_headers_query_param(s *S3Conf) error {
})
}
func PresignedAuth_unsigned_required_header(s *S3Conf) error {
testName := "PresignedAuth_unsigned_required_header"
return presignedAuthHandler(s, testName, func(client *s3.PresignClient, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
v4req, err := client.PresignPutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("my-obj")})
cancel()
if err != nil {
return err
}
req, err := http.NewRequest(v4req.Method, v4req.URL, nil)
if err != nil {
return err
}
req.Header.Set("X-Amz-Copy-Source", "source-bucket/source-key")
req.Header.Set("X-Amz-Tagging", "a=b")
resp, err := s.httpClient.Do(req)
if err != nil {
return err
}
return checkHTTPResponseApiErr(resp, s3err.GetHeadersNotSignedErr([]string{"x-amz-copy-source", "x-amz-tagging"}))
})
}
func PresignedAuth_unsigned_non_required_header(s *S3Conf) error {
testName := "PresignedAuth_unsigned_non_required_header"
return presignedAuthHandler(s, testName, func(client *s3.PresignClient, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
v4req, err := client.PresignPutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("my-obj")})
cancel()
if err != nil {
return err
}
req, err := http.NewRequest(v4req.Method, v4req.URL, nil)
if err != nil {
return err
}
req.Header.Set("Content-Type", "text/plain")
req.Header.Set("X-Custom-Header", "value")
req.Header.Set("X-Another-Custom-Header", "value")
resp, err := s.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("expected response status code to be %v, instead got %v", http.StatusOK, resp.StatusCode)
}
return nil
})
}
func PresignedAuth_missing_expiration_query_param(s *S3Conf) error {
testName := "PresignedAuth_missing_expiration_query_param"
return presignedAuthHandler(s, testName, func(client *s3.PresignClient, bucket string) error {
+51 -17
View File
@@ -25,6 +25,7 @@ import (
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/versity/versitygw/s3err"
)
@@ -501,20 +502,7 @@ func Authentication_incorrect_payload_hash(s *S3Conf) error {
func Authentication_md5(s *S3Conf) error {
testName := "Authentication_md5"
bucket := getBucketName()
return authHandler(s, &authConfig{
testName: testName,
method: http.MethodPut,
body: nil,
service: "s3",
date: time.Now(),
path: fmt.Sprintf("%s/obj", bucket),
}, func(req *http.Request) error {
err := setup(s, bucket)
if err != nil {
return err
}
return actionHandler(s, testName, func(_ *s3.Client, bucket string) error {
sum := md5.Sum(nil)
emptyMd5 := base64.StdEncoding.EncodeToString(sum[:])
@@ -528,7 +516,12 @@ func Authentication_md5(s *S3Conf) error {
// valid md5, but incorrect
{"XrY7u+Ae7tCTyyK7j1rNww==", s3err.GetBadDigestErr(emptyMd5, base64ToHexString("XrY7u+Ae7tCTyyK7j1rNww=="))},
} {
req.Header.Set("Content-Md5", test.md5)
req, err := createSignedReq(http.MethodPut, s.endpoint, fmt.Sprintf("%s/obj", bucket), s.awsID, s.awsSecret, "s3", s.awsRegion, "", nil, time.Now(), map[string]string{
"Content-Md5": test.md5,
})
if err != nil {
return err
}
resp, err := s.httpClient.Do(req)
if err != nil {
@@ -536,15 +529,56 @@ func Authentication_md5(s *S3Conf) error {
}
if err := checkHTTPResponseApiErr(resp, test.err); err != nil {
return fmt.Errorf("test %v failed: %v", i+1, err)
return fmt.Errorf("test %v failed: %w", i+1, err)
}
}
err = teardown(s, bucket)
return nil
})
}
func Authentication_unsigned_required_header(s *S3Conf) error {
testName := "Authentication_unsigned_required_header"
return actionHandler(s, testName, func(_ *s3.Client, bucket string) error {
req, err := createSignedReq(http.MethodPut, s.endpoint, fmt.Sprintf("%s/obj", bucket), s.awsID, s.awsSecret, "s3", s.awsRegion, "", nil, time.Now(), nil)
if err != nil {
return err
}
req.Header.Set("X-Amz-Copy-Source", "source-bucket/source-key")
req.Header.Set("X-Amz-Tagging", "a=b")
resp, err := s.httpClient.Do(req)
if err != nil {
return err
}
return checkHTTPResponseApiErr(resp, s3err.GetHeadersNotSignedErr([]string{"x-amz-copy-source", "x-amz-tagging"}))
})
}
func Authentication_unsigned_non_required_header(s *S3Conf) error {
testName := "Authentication_unsigned_non_required_header"
return actionHandler(s, testName, func(_ *s3.Client, bucket string) error {
req, err := createSignedReq(http.MethodPut, s.endpoint, fmt.Sprintf("%s/obj", bucket), s.awsID, s.awsSecret, "s3", s.awsRegion, "", nil, time.Now(), nil)
if err != nil {
return err
}
req.Header.Set("Content-Type", "text/plain")
req.Header.Set("X-Custom-Header", "value")
req.Header.Set("X-Another-Custom-Header", "value")
resp, err := s.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("expected response status code to be %v, instead got %v", http.StatusOK, resp.StatusCode)
}
return nil
})
}
+3
View File
@@ -426,6 +426,7 @@ type APIErrorResponse struct {
StringToSignBytes string `xml:"StringToSignBytes,omitempty"`
CanonicalRequest string `xml:"CanonicalRequest,omitempty"`
CanonicalRequestBytes string `xml:"CanonicalRequestBytes,omitempty"`
HeadersNotSigned string `xml:"HeadersNotSigned,omitempty"`
RequestID string `xml:"RequestId,omitempty"`
HostID string `xml:"HostId,omitempty"`
}
@@ -558,6 +559,8 @@ func compareS3ApiErr(expected s3err.S3Error, received *APIErrorResponse) error {
)
case s3err.MalformedAuthError:
return compareErrField("Region", err.Region, received.Region)
case s3err.HeadersNotSignedError:
return compareErrField("HeadersNotSigned", err.HeadersNotSigned, received.HeadersNotSigned)
case s3err.NoSuchUploadError:
return compareErrField("UploadId", err.UploadId, received.UploadId)
case s3err.NoSuchVersionError: