mirror of
https://github.com/pgsty/minio.git
synced 2026-10-09 11:15:57 +03:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user