mirror of
https://github.com/versity/versitygw.git
synced 2026-09-20 06:54:47 +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:
@@ -30,8 +30,12 @@ type MapRule map[string]struct{}
|
||||
|
||||
// IsValid for the map Rule satisfies whether it exists in the map
|
||||
func (m MapRule) IsValid(value string) bool {
|
||||
_, ok := m[value]
|
||||
return ok
|
||||
for key := range m {
|
||||
if strings.EqualFold(key, value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AllowList is a generic Rule for include listing
|
||||
|
||||
@@ -4,65 +4,24 @@ package v4
|
||||
var IgnoredHeaders = Rules{
|
||||
ExcludeList{
|
||||
MapRule{
|
||||
"Authorization": struct{}{},
|
||||
// some clients use user-agent in signed headers
|
||||
// "User-Agent": struct{}{},
|
||||
"X-Amzn-Trace-Id": struct{}{},
|
||||
// Expect might appear in signed headers
|
||||
// "Expect": struct{}{},
|
||||
"Authorization": struct{}{},
|
||||
"User-Agent": struct{}{},
|
||||
"X-Amzn-Trace-Id": struct{}{},
|
||||
"Expect": struct{}{},
|
||||
"Transfer-Encoding": struct{}{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// RequiredSignedHeaders is a allow list for Build canonical headers.
|
||||
// RequiredSignedHeaders are request headers that must be part of SignedHeaders
|
||||
// whenever they are present on the request.
|
||||
var RequiredSignedHeaders = Rules{
|
||||
AllowList{
|
||||
MapRule{
|
||||
"Cache-Control": struct{}{},
|
||||
"Content-Disposition": struct{}{},
|
||||
"Content-Encoding": struct{}{},
|
||||
"Content-Language": struct{}{},
|
||||
"Content-Md5": struct{}{},
|
||||
"Content-Type": struct{}{},
|
||||
"Expires": struct{}{},
|
||||
"If-Match": struct{}{},
|
||||
"If-Modified-Since": struct{}{},
|
||||
"If-None-Match": struct{}{},
|
||||
"If-Unmodified-Since": struct{}{},
|
||||
"Range": struct{}{},
|
||||
"X-Amz-Acl": struct{}{},
|
||||
"X-Amz-Copy-Source": struct{}{},
|
||||
"X-Amz-Copy-Source-If-Match": struct{}{},
|
||||
"X-Amz-Copy-Source-If-Modified-Since": struct{}{},
|
||||
"X-Amz-Copy-Source-If-None-Match": struct{}{},
|
||||
"X-Amz-Copy-Source-If-Unmodified-Since": struct{}{},
|
||||
"X-Amz-Copy-Source-Range": struct{}{},
|
||||
"X-Amz-Copy-Source-Server-Side-Encryption-Customer-Algorithm": struct{}{},
|
||||
"X-Amz-Copy-Source-Server-Side-Encryption-Customer-Key": struct{}{},
|
||||
"X-Amz-Copy-Source-Server-Side-Encryption-Customer-Key-Md5": struct{}{},
|
||||
"X-Amz-Expected-Bucket-Owner": struct{}{},
|
||||
"X-Amz-Grant-Full-control": struct{}{},
|
||||
"X-Amz-Grant-Read": struct{}{},
|
||||
"X-Amz-Grant-Read-Acp": struct{}{},
|
||||
"X-Amz-Grant-Write": struct{}{},
|
||||
"X-Amz-Grant-Write-Acp": struct{}{},
|
||||
"X-Amz-Metadata-Directive": struct{}{},
|
||||
"X-Amz-Mfa": struct{}{},
|
||||
"X-Amz-Request-Payer": struct{}{},
|
||||
"X-Amz-Server-Side-Encryption": struct{}{},
|
||||
"X-Amz-Server-Side-Encryption-Aws-Kms-Key-Id": struct{}{},
|
||||
"X-Amz-Server-Side-Encryption-Context": struct{}{},
|
||||
"X-Amz-Server-Side-Encryption-Customer-Algorithm": struct{}{},
|
||||
"X-Amz-Server-Side-Encryption-Customer-Key": struct{}{},
|
||||
"X-Amz-Server-Side-Encryption-Customer-Key-Md5": struct{}{},
|
||||
"X-Amz-Storage-Class": struct{}{},
|
||||
"X-Amz-Website-Redirect-Location": struct{}{},
|
||||
"X-Amz-Content-Sha256": struct{}{},
|
||||
"X-Amz-Tagging": struct{}{},
|
||||
"Host": struct{}{},
|
||||
},
|
||||
},
|
||||
Patterns{"X-Amz-Object-Lock-"},
|
||||
Patterns{"X-Amz-Meta-"},
|
||||
Patterns{"X-Amz-"},
|
||||
}
|
||||
|
||||
// AllowedQueryHoisting is a allowed list for Build query headers. The boolean value
|
||||
|
||||
@@ -17,7 +17,19 @@ func TestAllowedQueryHoisting(t *testing.T) {
|
||||
},
|
||||
"another header": {
|
||||
Header: "X-Amz-SomeOtherHeader",
|
||||
ExpectHoist: true,
|
||||
ExpectHoist: false,
|
||||
},
|
||||
"lowercase amz header": {
|
||||
Header: "x-amz-someotherheader",
|
||||
ExpectHoist: false,
|
||||
},
|
||||
"mixed case amz header": {
|
||||
Header: "x-AmZ-someotherheader",
|
||||
ExpectHoist: false,
|
||||
},
|
||||
"non-amz content header": {
|
||||
Header: "Content-Type",
|
||||
ExpectHoist: false,
|
||||
},
|
||||
"non X-AMZ header": {
|
||||
Header: "X-SomeOtherHeader",
|
||||
@@ -34,6 +46,62 @@ func TestAllowedQueryHoisting(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequiredSignedHeaders(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
Header string
|
||||
ExpectRequired bool
|
||||
}{
|
||||
"known content header": {
|
||||
Header: "Content-Type",
|
||||
ExpectRequired: false,
|
||||
},
|
||||
"known content header lowercase": {
|
||||
Header: "content-type",
|
||||
ExpectRequired: false,
|
||||
},
|
||||
"known conditional header": {
|
||||
Header: "If-Match",
|
||||
ExpectRequired: false,
|
||||
},
|
||||
"range header": {
|
||||
Header: "Range",
|
||||
ExpectRequired: false,
|
||||
},
|
||||
"content md5 header": {
|
||||
Header: "Content-Md5",
|
||||
ExpectRequired: false,
|
||||
},
|
||||
"arbitrary amz header": {
|
||||
Header: "X-Amz-SomeOtherHeader",
|
||||
ExpectRequired: true,
|
||||
},
|
||||
"arbitrary amz header lowercase": {
|
||||
Header: "x-amz-someotherheader",
|
||||
ExpectRequired: true,
|
||||
},
|
||||
"object-lock amz header": {
|
||||
Header: "X-Amz-Object-Lock-Mode",
|
||||
ExpectRequired: true,
|
||||
},
|
||||
"metadata amz header": {
|
||||
Header: "X-Amz-Meta-SomeName",
|
||||
ExpectRequired: true,
|
||||
},
|
||||
"non-amz custom header": {
|
||||
Header: "X-SomeOtherHeader",
|
||||
ExpectRequired: false,
|
||||
},
|
||||
}
|
||||
|
||||
for name, c := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if e, a := c.ExpectRequired, RequiredSignedHeaders.IsValid(c.Header); e != a {
|
||||
t.Errorf("expect required %v, was %v", e, a)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIgnoredHeaders(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
Header string
|
||||
@@ -41,12 +109,28 @@ func TestIgnoredHeaders(t *testing.T) {
|
||||
}{
|
||||
"expect": {
|
||||
Header: "Expect",
|
||||
ExpectIgnored: false,
|
||||
ExpectIgnored: true,
|
||||
},
|
||||
"user-agent": {
|
||||
Header: "User-Agent",
|
||||
ExpectIgnored: true,
|
||||
},
|
||||
"transfer-encoding": {
|
||||
Header: "Transfer-Encoding",
|
||||
ExpectIgnored: true,
|
||||
},
|
||||
"authorization": {
|
||||
Header: "Authorization",
|
||||
ExpectIgnored: true,
|
||||
},
|
||||
"authorization lowercase": {
|
||||
Header: "authorization",
|
||||
ExpectIgnored: true,
|
||||
},
|
||||
"trace id lowercase": {
|
||||
Header: "x-amzn-trace-id",
|
||||
ExpectIgnored: true,
|
||||
},
|
||||
"X-AMZ header": {
|
||||
Header: "X-Amz-Content-Sha256",
|
||||
ExpectIgnored: false,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package v4
|
||||
|
||||
import v4Internal "github.com/versity/versitygw/aws/signer/internal/v4"
|
||||
|
||||
// IsRequiredSignedHeader reports whether a header must be signed when it is
|
||||
// present on an incoming request.
|
||||
func IsRequiredSignedHeader(header string) bool {
|
||||
return v4Internal.RequiredSignedHeaders.IsValid(header)
|
||||
}
|
||||
|
||||
// IsIgnoredHeader reports whether a header is normally excluded from signing.
|
||||
func IsIgnoredHeader(header string) bool {
|
||||
return !v4Internal.IgnoredHeaders.IsValid(header)
|
||||
}
|
||||
+13
-1
@@ -443,7 +443,7 @@ func (s *httpSigner) buildCanonicalHeaders(host string, rule v4Internal.Rule, he
|
||||
}
|
||||
|
||||
for k, v := range header {
|
||||
if !rule.IsValid(k) {
|
||||
if !s.shouldSignHeader(k, rule) {
|
||||
continue // ignored header
|
||||
}
|
||||
if strings.EqualFold(k, contentLengthHeader) {
|
||||
@@ -493,6 +493,18 @@ func (s *httpSigner) buildCanonicalHeaders(host string, rule v4Internal.Rule, he
|
||||
return signed, signedHeaders, canonicalHeadersStr
|
||||
}
|
||||
|
||||
func (s *httpSigner) shouldSignHeader(header string, rule v4Internal.Rule) bool {
|
||||
if rule.IsValid(header) {
|
||||
return true
|
||||
}
|
||||
if strings.EqualFold(header, authorizationHeader) {
|
||||
return false
|
||||
}
|
||||
return slices.ContainsFunc(s.SignedHdrs, func(signedHeader string) bool {
|
||||
return strings.EqualFold(signedHeader, header)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *httpSigner) buildCanonicalString(method, uri, query, signedHeaders, canonicalHeaders string) string {
|
||||
return strings.Join([]string{
|
||||
method,
|
||||
|
||||
@@ -71,10 +71,9 @@ func TestPresignRequest(t *testing.T) {
|
||||
}
|
||||
|
||||
expectedDate := "19700101T000000Z"
|
||||
expectedHeaders := "content-length;content-type;host;x-amz-meta-other-header;x-amz-meta-other-header_with_underscore"
|
||||
expectedSig := "122f0b9e091e4ba84286097e2b3404a1f1f4c4aad479adda95b7dff0ccbe5581"
|
||||
expectedHeaders := "content-length;content-type;host;x-amz-meta-other-header;x-amz-meta-other-header_with_underscore;x-amz-target"
|
||||
expectedSig := "266528f4c66b4b20807f199141c606c7aa81dd793592b4c6f8dc301c05691e54"
|
||||
expectedCred := "AKID/19700101/us-east-1/dynamodb/aws4_request"
|
||||
expectedTarget := "prefix.Operation"
|
||||
|
||||
q, err := url.ParseQuery(signed[strings.Index(signed, "?"):])
|
||||
if err != nil {
|
||||
@@ -96,8 +95,8 @@ func TestPresignRequest(t *testing.T) {
|
||||
if a := q.Get("X-Amz-Meta-Other-Header"); len(a) != 0 {
|
||||
t.Errorf("expect %v to be empty", a)
|
||||
}
|
||||
if e, a := expectedTarget, q.Get("X-Amz-Target"); e != a {
|
||||
t.Errorf("expect %v, got %v", e, a)
|
||||
if a := q.Get("X-Amz-Target"); len(a) != 0 {
|
||||
t.Errorf("expect X-Amz-Target to be empty, got %v", a)
|
||||
}
|
||||
|
||||
for h := range strings.SplitSeq(expectedHeaders, ";") {
|
||||
@@ -129,10 +128,9 @@ func TestPresignBodyWithArrayRequest(t *testing.T) {
|
||||
}
|
||||
|
||||
expectedDate := "19700101T000000Z"
|
||||
expectedHeaders := "content-length;content-type;host;x-amz-meta-other-header;x-amz-meta-other-header_with_underscore"
|
||||
expectedSig := "e3ac55addee8711b76c6d608d762cff285fe8b627a057f8b5ec9268cf82c08b1"
|
||||
expectedHeaders := "content-length;content-type;host;x-amz-meta-other-header;x-amz-meta-other-header_with_underscore;x-amz-target"
|
||||
expectedSig := "f8a1f60771366686c04045b64ae1381d302c83d67d84a02567926000e3e653c4"
|
||||
expectedCred := "AKID/19700101/us-east-1/dynamodb/aws4_request"
|
||||
expectedTarget := "prefix.Operation"
|
||||
|
||||
if e, a := expectedSig, q.Get("X-Amz-Signature"); e != a {
|
||||
t.Errorf("expect %v, got %v", e, a)
|
||||
@@ -149,8 +147,8 @@ func TestPresignBodyWithArrayRequest(t *testing.T) {
|
||||
if a := q.Get("X-Amz-Meta-Other-Header"); len(a) != 0 {
|
||||
t.Errorf("expect %v to be empty, was not", a)
|
||||
}
|
||||
if e, a := expectedTarget, q.Get("X-Amz-Target"); e != a {
|
||||
t.Errorf("expect %v, got %v", e, a)
|
||||
if a := q.Get("X-Amz-Target"); len(a) != 0 {
|
||||
t.Errorf("expect X-Amz-Target to be empty, got %v", a)
|
||||
}
|
||||
|
||||
for h := range strings.SplitSeq(expectedHeaders, ";") {
|
||||
|
||||
Reference in New Issue
Block a user