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
+14 -5
View File
@@ -229,10 +229,11 @@ func doesPresignedSignatureMatch(hashedPayload string, r *http.Request, region s
return errCode
}
// Check if the metadata headers are equal with signedheaders
errMetaCode := checkMetaHeaders(extractedSignedHeaders, r)
if errMetaCode != ErrNone {
return errMetaCode
// Reject any x-amz-* header that the client did not sign. Without this an
// unsigned header (e.g. x-amz-copy-source) could alter the request that the
// presigned URL actually authorized.
if errUnsigned := checkUnsignedHeaders(extractedSignedHeaders, r); errUnsigned != ErrNone {
return errUnsigned
}
// If the host which signed the request is slightly ahead in time (by less than globalMaxSkewTime) the
@@ -335,7 +336,7 @@ func doesPresignedSignatureMatch(hashedPayload string, r *http.Request, region s
return ErrSignatureDoesNotMatch
}
r.Header.Set("x-amz-signature-age", strconv.FormatInt(UTCNow().Sub(pSignValues.Date).Milliseconds(), 10))
r.Header.Set(xhttp.AmzSignatureAge, strconv.FormatInt(UTCNow().Sub(pSignValues.Date).Milliseconds(), 10))
return ErrNone
}
@@ -363,6 +364,14 @@ func doesSignatureMatch(hashedPayload string, r *http.Request, region string, st
return errCode
}
// Reject any x-amz-* header that the client did not sign. The Authorization
// header path shares extractSignedHeaders with the presigned path but, prior
// to this, never inspected the headers that actually arrived, so an unsigned
// x-amz-copy-source could redirect a signed PUT into a server-side copy.
if errUnsigned := checkUnsignedHeaders(extractedSignedHeaders, r); errUnsigned != ErrNone {
return errUnsigned
}
cred, _, s3Err := checkKeyValid(r, signV4Values.Credential.accessKey)
if s3Err != ErrNone {
return s3Err