fix(auth): reject unsigned x-amz-* headers to close CopyObject confused-deputy

A presigned or signed PUT authorized for a single object could be turned
into a server-side copy of any object the signing key can read by adding
an unsigned x-amz-copy-source header, executed as the signer. SigV4
verification only walked the signed-headers list, never the headers that
actually arrived; the meta-header check matched only X-Amz-Meta- and ran
only on the presigned path, so an unsigned x-amz-* header outside the
list was never seen while the router still dispatched the PUT to
CopyObjectHandler.

Reject any x-amz-* request header not covered by the signed headers, on
both the presigned (doesPresignedSignatureMatch) and Authorization-header
(doesSignatureMatch) paths, matching AWS S3. The check tests membership
in the signed set rather than value equality, so a header whose first
value is empty (e.g. {"", "/src/secret"}) cannot slip through.
X-Amz-Content-Sha256 is exempt (payload hash: read from the query for
presigned requests and bound into the string-to-sign for signed ones, so
it is self-protected) and X-Amz-Signature-Age is exempt (an internal
scratch header written after verification, so repeated verification of
the same request stays idempotent). The synthesized X-Amz-Tagging header
in PutObjectTagging is now injected after signature verification.

Tests that previously added x-amz-copy-source and friends after signing
(relying on the vulnerable behavior) now re-sign, mirroring real S3
clients. Adds checkUnsignedHeaders unit cases and TestPresignedVerifyIdempotent.

Reported by Oren Yomtov. Inherited unchanged from upstream minio/minio.
Tracked as SN-2026-011.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Feng Ruohang <rh@vonng.com>
This commit is contained in:
Feng Ruohang
2026-09-10 09:57:01 +08:00
parent 8a2fe9b7a0
commit 1233254309
12 changed files with 231 additions and 24 deletions
+43 -8
View File
@@ -264,14 +264,49 @@ func signV4TrimAll(input string) string {
return strings.Join(strings.Fields(input), " ")
}
// checkMetaHeaders will check if the metadata from header/url is the same with the one from signed headers
func checkMetaHeaders(signedHeadersMap http.Header, r *http.Request) APIErrorCode {
// check values from http header
for k, val := range r.Header {
if stringsHasPrefixFold(k, "X-Amz-Meta-") {
if signedHeadersMap.Get(k) == val[0] {
continue
}
// checkUnsignedHeaders rejects any x-amz-* request header that is not covered by
// the SigV4 signed-headers list. AWS S3 requires every x-amz-* header to be
// signed and returns AccessDenied ("There were headers present in the request
// which were not signed") otherwise. Enforcing the same here prevents an
// unsigned x-amz-* header (for example x-amz-copy-source) from changing the
// semantics of an already-signed or presigned request: without this check a
// presigned PUT grant could be turned into a server-side copy that reads any
// object the signing key can reach.
//
// Only headers actually sent by the client are inspected. Server-synthesized
// x-amz-* headers (e.g. x-amz-tagging derived from a request body, or the
// post-verification x-amz-signature-age scratch header) are set after signature
// verification and therefore never reach this walk.
func checkUnsignedHeaders(signedHeadersMap http.Header, r *http.Request) APIErrorCode {
// check headers that arrived on the request
for k := range r.Header {
if !stringsHasPrefixFold(k, "X-Amz-") {
continue
}
// X-Amz-Content-Sha256 carries the payload hash, not an operation or
// authorization input, and is handled specially: for presigned requests
// it is read from the query string (getContentSha256Cksum) and any
// header copy is ignored, while for signed requests it is bound into the
// string-to-sign as the payload hash, so a tampered value fails
// signature verification regardless of the signed-headers list. Some
// clients send it as an unsigned header, so exempt it to preserve
// compatibility without weakening the operation-header protection.
if strings.EqualFold(k, xhttp.AmzContentSha256) {
continue
}
// X-Amz-Signature-Age is an internal scratch header written by the
// presigned verifier itself, after this check, purely so bucket-policy
// evaluation can expose s3:signatureAge. It is never sent or signed by a
// client, and exempting it keeps signature verification idempotent when
// the same request is verified more than once.
if strings.EqualFold(k, xhttp.AmzSignatureAge) {
continue
}
// The header must be a member of the signed-headers list. Testing
// membership (not value equality) is essential: an unsigned header whose
// first value is empty would otherwise compare equal to the empty string
// returned for an absent key and slip through.
if _, ok := signedHeadersMap[http.CanonicalHeaderKey(k)]; !ok {
return ErrUnsignedHeaders
}
}