mirror of
https://github.com/versity/versitygw.git
synced 2026-08-19 05:36:19 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user