mirror of
https://github.com/pgsty/minio.git
synced 2026-10-11 04:05:59 +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:
@@ -25,6 +25,8 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
)
|
||||
|
||||
func niceError(code APIErrorCode) string {
|
||||
@@ -313,3 +315,42 @@ func TestDoesPresignedSignatureMatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPresignedVerifyIdempotent guards against a regression where verifying the
|
||||
// same presigned request twice began to fail. doesPresignedSignatureMatch
|
||||
// writes an internal x-amz-signature-age header after validating the signature;
|
||||
// the unsigned-header check must exempt that scratch header (and an unsigned
|
||||
// x-amz-content-sha256 the client may carry) so a second verification of the
|
||||
// same *http.Request still succeeds.
|
||||
func TestPresignedVerifyIdempotent(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
|
||||
obj, fsDir, err := prepareFS(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(fsDir)
|
||||
if err = newTestConfig(globalMinioDefaultRegion, obj); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req, err := newTestRequest(http.MethodGet, "http://127.0.0.1:9000/bucket/object", 0, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = preSignV4(req, globalActiveCred.AccessKey, globalActiveCred.SecretKey, int64(10*60)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = req.ParseForm(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := reqSignatureV4Verify(req, globalSite.Region(), serviceS3); got != ErrNone {
|
||||
t.Fatalf("first verification: expected ErrNone, got %s", niceError(got))
|
||||
}
|
||||
if got := reqSignatureV4Verify(req, globalSite.Region(), serviceS3); got != ErrNone {
|
||||
t.Fatalf("second verification of the same request: expected ErrNone, got %s (x-amz-signature-age=%q)",
|
||||
niceError(got), req.Header.Get(xhttp.AmzSignatureAge))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user