mirror of
https://github.com/pgsty/minio.git
synced 2026-09-11 21:14:04 +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:
@@ -255,8 +255,8 @@ func getConditionValuesWithTags(r *http.Request, lc string, cred auth.Credential
|
|||||||
}
|
}
|
||||||
|
|
||||||
cloneHeader := r.Header.Clone()
|
cloneHeader := r.Header.Clone()
|
||||||
signatureAge := cloneHeader.Get("x-amz-signature-age")
|
signatureAge := cloneHeader.Get(xhttp.AmzSignatureAge)
|
||||||
cloneHeader.Del("x-amz-signature-age")
|
cloneHeader.Del(xhttp.AmzSignatureAge)
|
||||||
// The presigned V4 verifier overwrites this internal scratch header after
|
// The presigned V4 verifier overwrites this internal scratch header after
|
||||||
// validating the signature. Ignore a value supplied on every other request
|
// validating the signature. Ignore a value supplied on every other request
|
||||||
// type, where it would otherwise synthesize s3:signatureAge.
|
// type, where it would otherwise synthesize s3:signatureAge.
|
||||||
|
|||||||
@@ -157,6 +157,13 @@ func copyPartWithoutChecksumHTTP(t *testing.T, apiRouter http.Handler, creds aut
|
|||||||
if sourceRange != "" {
|
if sourceRange != "" {
|
||||||
req.Header.Set(xhttp.AmzCopySourceRange, sourceRange)
|
req.Header.Set(xhttp.AmzCopySourceRange, sourceRange)
|
||||||
}
|
}
|
||||||
|
// Re-sign so the copy-source x-amz-* headers are covered by the signature,
|
||||||
|
// as real S3 clients send them; the verifier rejects unsigned x-amz-*.
|
||||||
|
if creds.AccessKey != "" && creds.SecretKey != "" {
|
||||||
|
if err := signRequestV4(req, creds.AccessKey, creds.SecretKey); err != nil {
|
||||||
|
t.Fatalf("failed to re-sign UploadPartCopy request: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
apiRouter.ServeHTTP(rec, req)
|
apiRouter.ServeHTTP(rec, req)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
|
|||||||
@@ -62,6 +62,13 @@ func copyChecksumRequest(t *testing.T, apiRouter http.Handler, credentials auth.
|
|||||||
t.Fatalf("failed to build CopyObject request: %v", err)
|
t.Fatalf("failed to build CopyObject request: %v", err)
|
||||||
}
|
}
|
||||||
req.Header.Set(xhttp.AmzCopySource, SlashSeparator+pathJoin(bucket, source))
|
req.Header.Set(xhttp.AmzCopySource, SlashSeparator+pathJoin(bucket, source))
|
||||||
|
// Re-sign so x-amz-copy-source is covered by the signature, as real S3
|
||||||
|
// clients send it; the verifier rejects unsigned x-amz-* headers.
|
||||||
|
if credentials.AccessKey != "" && credentials.SecretKey != "" {
|
||||||
|
if err := signRequestV4(req, credentials.AccessKey, credentials.SecretKey); err != nil {
|
||||||
|
t.Fatalf("failed to re-sign CopyObject request: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
apiRouter.ServeHTTP(rec, req)
|
apiRouter.ServeHTTP(rec, req)
|
||||||
return rec
|
return rec
|
||||||
|
|||||||
@@ -197,6 +197,13 @@ func federatedCopyRequest(t *testing.T, apiRouter http.Handler, credentials auth
|
|||||||
t.Fatalf("failed to build federated CopyObject request: %v", err)
|
t.Fatalf("failed to build federated CopyObject request: %v", err)
|
||||||
}
|
}
|
||||||
req.Header.Set(xhttp.AmzCopySource, SlashSeparator+pathJoin(srcBucket, srcObject))
|
req.Header.Set(xhttp.AmzCopySource, SlashSeparator+pathJoin(srcBucket, srcObject))
|
||||||
|
// Re-sign so x-amz-copy-source is covered by the signature, as real S3
|
||||||
|
// clients send it; the verifier rejects unsigned x-amz-* headers.
|
||||||
|
if credentials.AccessKey != "" && credentials.SecretKey != "" {
|
||||||
|
if err := signRequestV4(req, credentials.AccessKey, credentials.SecretKey); err != nil {
|
||||||
|
t.Fatalf("failed to re-sign federated CopyObject request: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
apiRouter.ServeHTTP(rec, req)
|
apiRouter.ServeHTTP(rec, req)
|
||||||
return rec
|
return rec
|
||||||
|
|||||||
@@ -3658,9 +3658,6 @@ func (api objectAPIHandlers) PutObjectTaggingHandler(w http.ResponseWriter, r *h
|
|||||||
}
|
}
|
||||||
tagsStr := tags.String()
|
tagsStr := tags.String()
|
||||||
|
|
||||||
// Set this such that authorization policies can be applied on the object tags.
|
|
||||||
r.Header.Set(xhttp.AmzObjectTagging, tagsStr)
|
|
||||||
|
|
||||||
logger.GetReqInfo(ctx).BucketName = bucket
|
logger.GetReqInfo(ctx).BucketName = bucket
|
||||||
logger.GetReqInfo(ctx).ObjectName = object
|
logger.GetReqInfo(ctx).ObjectName = object
|
||||||
if s3Error := authenticateRequest(ctx, r, policy.PutObjectTaggingAction); s3Error != ErrNone {
|
if s3Error := authenticateRequest(ctx, r, policy.PutObjectTaggingAction); s3Error != ErrNone {
|
||||||
@@ -3668,6 +3665,12 @@ func (api objectAPIHandlers) PutObjectTaggingHandler(w http.ResponseWriter, r *h
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set this such that authorization policies can be applied on the object
|
||||||
|
// tags. This is derived from the request body, so it must be injected only
|
||||||
|
// after signature verification: the SigV4 verifier now rejects unsigned
|
||||||
|
// x-amz-* request headers, and this synthesized header is never signed.
|
||||||
|
r.Header.Set(xhttp.AmzObjectTagging, tagsStr)
|
||||||
|
|
||||||
opts, err := getOpts(ctx, r, bucket, object)
|
opts, err := getOpts(ctx, r, bucket, object)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||||
|
|||||||
@@ -1799,6 +1799,12 @@ func testAPICopyObjectPartHandlerSanity(obj ObjectLayer, instanceType, bucketNam
|
|||||||
req.Header.Set("X-Amz-Copy-Source", url.QueryEscape(pathJoin(bucketName, objectName)))
|
req.Header.Set("X-Amz-Copy-Source", url.QueryEscape(pathJoin(bucketName, objectName)))
|
||||||
req.Header.Set("X-Amz-Copy-Source-Range", fmt.Sprintf("bytes=%d-%d", a, b))
|
req.Header.Set("X-Amz-Copy-Source-Range", fmt.Sprintf("bytes=%d-%d", a, b))
|
||||||
|
|
||||||
|
// Re-sign so the copy-source x-amz-* headers are covered by the
|
||||||
|
// signature, as real clients do; the verifier rejects unsigned x-amz-*.
|
||||||
|
if err = signRequestV4(req, credentials.AccessKey, credentials.SecretKey); err != nil {
|
||||||
|
t.Fatalf("Test failed to re-sign HTTP request for copy object part: <ERROR> %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Since `apiRouter` satisfies `http.Handler` it has a ServeHTTP to execute the logic of the handler.
|
// Since `apiRouter` satisfies `http.Handler` it has a ServeHTTP to execute the logic of the handler.
|
||||||
// Call the ServeHTTP to execute the handler, `func (api objectAPIHandlers) CopyObjectHandler` handles the request.
|
// Call the ServeHTTP to execute the handler, `func (api objectAPIHandlers) CopyObjectHandler` handles the request.
|
||||||
a = globalMinPartSize + 1
|
a = globalMinPartSize + 1
|
||||||
@@ -2200,6 +2206,15 @@ func testAPICopyObjectPartHandler(obj ObjectLayer, instanceType, bucketName stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-sign so the copy-source x-amz-* headers set above are covered by
|
||||||
|
// the signature, as real clients do; the verifier rejects unsigned
|
||||||
|
// x-amz-* headers.
|
||||||
|
if testCase.accessKey != "" && testCase.secretKey != "" {
|
||||||
|
if err = signRequestV4(req, testCase.accessKey, testCase.secretKey); err != nil {
|
||||||
|
t.Fatalf("Test %d: Failed to re-sign HTTP request for copy Object: <ERROR> %v", i+1, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Since `apiRouter` satisfies `http.Handler` it has a ServeHTTP to execute the logic of the handler.
|
// Since `apiRouter` satisfies `http.Handler` it has a ServeHTTP to execute the logic of the handler.
|
||||||
// Call the ServeHTTP to execute the handler, `func (api objectAPIHandlers) CopyObjectHandler` handles the request.
|
// Call the ServeHTTP to execute the handler, `func (api objectAPIHandlers) CopyObjectHandler` handles the request.
|
||||||
apiRouter.ServeHTTP(rec, req)
|
apiRouter.ServeHTTP(rec, req)
|
||||||
@@ -2626,6 +2641,16 @@ func testAPICopyObjectHandler(obj ObjectLayer, instanceType, bucketName string,
|
|||||||
if testCase.metadataGarbage {
|
if testCase.metadataGarbage {
|
||||||
req.Header.Set("X-Amz-Metadata-Directive", "Unknown")
|
req.Header.Set("X-Amz-Metadata-Directive", "Unknown")
|
||||||
}
|
}
|
||||||
|
// The x-amz-copy-source and related x-amz-* headers set above must be
|
||||||
|
// part of the SigV4 signature, exactly as real S3 clients send them.
|
||||||
|
// Re-sign now that they are present; the verifier rejects unsigned
|
||||||
|
// x-amz-* headers (an unsigned x-amz-copy-source could otherwise turn a
|
||||||
|
// PUT grant into a server-side copy).
|
||||||
|
if testCase.accessKey != "" && testCase.secretKey != "" {
|
||||||
|
if err = signRequestV4(req, testCase.accessKey, testCase.secretKey); err != nil {
|
||||||
|
t.Fatalf("Test %d: Failed to re-sign HTTP request for copy Object: <ERROR> %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
// Since `apiRouter` satisfies `http.Handler` it has a ServeHTTP to execute the logic of the handler.
|
// Since `apiRouter` satisfies `http.Handler` it has a ServeHTTP to execute the logic of the handler.
|
||||||
// Call the ServeHTTP to execute the handler, `func (api objectAPIHandlers) CopyObjectHandler` handles the request.
|
// Call the ServeHTTP to execute the handler, `func (api objectAPIHandlers) CopyObjectHandler` handles the request.
|
||||||
apiRouter.ServeHTTP(rec, req)
|
apiRouter.ServeHTTP(rec, req)
|
||||||
|
|||||||
@@ -165,6 +165,11 @@ func testAPIZeroByteSSECAuthenticatesKey(obj ObjectLayer, instanceType, bucketNa
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
req.Header.Set(xhttp.AmzCopySource, SlashSeparator+pathJoin(bucketName, object))
|
req.Header.Set(xhttp.AmzCopySource, SlashSeparator+pathJoin(bucketName, object))
|
||||||
|
// Re-sign so x-amz-copy-source is covered by the signature, as real S3
|
||||||
|
// clients send it; the verifier rejects unsigned x-amz-* headers.
|
||||||
|
if err = signRequestV4(req, credentials.AccessKey, credentials.SecretKey); err != nil {
|
||||||
|
t.Fatalf("%s: failed to re-sign UploadPartCopy request: %v", instanceType, err)
|
||||||
|
}
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
apiRouter.ServeHTTP(rec, req)
|
apiRouter.ServeHTTP(rec, req)
|
||||||
if rec.Code != http.StatusForbidden {
|
if rec.Code != http.StatusForbidden {
|
||||||
|
|||||||
@@ -264,14 +264,49 @@ func signV4TrimAll(input string) string {
|
|||||||
return strings.Join(strings.Fields(input), " ")
|
return strings.Join(strings.Fields(input), " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkMetaHeaders will check if the metadata from header/url is the same with the one from signed headers
|
// checkUnsignedHeaders rejects any x-amz-* request header that is not covered by
|
||||||
func checkMetaHeaders(signedHeadersMap http.Header, r *http.Request) APIErrorCode {
|
// the SigV4 signed-headers list. AWS S3 requires every x-amz-* header to be
|
||||||
// check values from http header
|
// signed and returns AccessDenied ("There were headers present in the request
|
||||||
for k, val := range r.Header {
|
// which were not signed") otherwise. Enforcing the same here prevents an
|
||||||
if stringsHasPrefixFold(k, "X-Amz-Meta-") {
|
// unsigned x-amz-* header (for example x-amz-copy-source) from changing the
|
||||||
if signedHeadersMap.Get(k) == val[0] {
|
// semantics of an already-signed or presigned request: without this check a
|
||||||
continue
|
// 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
|
return ErrUnsignedHeaders
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -363,8 +363,8 @@ func TestGetContentSha256Cksum(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test TestCheckMetaHeaders tests the logic of checkMetaHeaders() function
|
// Test TestCheckUnsignedHeaders tests the logic of checkUnsignedHeaders() function
|
||||||
func TestCheckMetaHeaders(t *testing.T) {
|
func TestCheckUnsignedHeaders(t *testing.T) {
|
||||||
signedHeadersMap := map[string][]string{
|
signedHeadersMap := map[string][]string{
|
||||||
"X-Amz-Meta-Test": {"test"},
|
"X-Amz-Meta-Test": {"test"},
|
||||||
"X-Amz-Meta-Extension": {"png"},
|
"X-Amz-Meta-Extension": {"png"},
|
||||||
@@ -384,7 +384,7 @@ func TestCheckMetaHeaders(t *testing.T) {
|
|||||||
inputHeader.Set("X-Amz-Meta-Extension", expectedMetaExtension)
|
inputHeader.Set("X-Amz-Meta-Extension", expectedMetaExtension)
|
||||||
inputHeader.Set("X-Amz-Meta-Name", expectedMetaName)
|
inputHeader.Set("X-Amz-Meta-Name", expectedMetaName)
|
||||||
// calling the function being tested.
|
// calling the function being tested.
|
||||||
errCode := checkMetaHeaders(signedHeadersMap, r)
|
errCode := checkUnsignedHeaders(signedHeadersMap, r)
|
||||||
if errCode != ErrNone {
|
if errCode != ErrNone {
|
||||||
t.Fatalf("Expected the APIErrorCode to be %d, but got %d", ErrNone, errCode)
|
t.Fatalf("Expected the APIErrorCode to be %d, but got %d", ErrNone, errCode)
|
||||||
}
|
}
|
||||||
@@ -392,7 +392,7 @@ func TestCheckMetaHeaders(t *testing.T) {
|
|||||||
// Add new metadata in inputHeader
|
// Add new metadata in inputHeader
|
||||||
inputHeader.Set("X-Amz-Meta-Clone", "fail")
|
inputHeader.Set("X-Amz-Meta-Clone", "fail")
|
||||||
// calling the function being tested.
|
// calling the function being tested.
|
||||||
errCode = checkMetaHeaders(signedHeadersMap, r)
|
errCode = checkUnsignedHeaders(signedHeadersMap, r)
|
||||||
if errCode != ErrUnsignedHeaders {
|
if errCode != ErrUnsignedHeaders {
|
||||||
t.Fatalf("Expected the APIErrorCode to be %d, but got %d", ErrUnsignedHeaders, errCode)
|
t.Fatalf("Expected the APIErrorCode to be %d, but got %d", ErrUnsignedHeaders, errCode)
|
||||||
}
|
}
|
||||||
@@ -400,7 +400,7 @@ func TestCheckMetaHeaders(t *testing.T) {
|
|||||||
// Delete extra metadata from header to don't affect other test
|
// Delete extra metadata from header to don't affect other test
|
||||||
inputHeader.Del("X-Amz-Meta-Clone")
|
inputHeader.Del("X-Amz-Meta-Clone")
|
||||||
// calling the function being tested.
|
// calling the function being tested.
|
||||||
errCode = checkMetaHeaders(signedHeadersMap, r)
|
errCode = checkUnsignedHeaders(signedHeadersMap, r)
|
||||||
if errCode != ErrNone {
|
if errCode != ErrNone {
|
||||||
t.Fatalf("Expected the APIErrorCode to be %d, but got %d", ErrNone, errCode)
|
t.Fatalf("Expected the APIErrorCode to be %d, but got %d", ErrNone, errCode)
|
||||||
}
|
}
|
||||||
@@ -413,8 +413,71 @@ func TestCheckMetaHeaders(t *testing.T) {
|
|||||||
|
|
||||||
r.ParseForm()
|
r.ParseForm()
|
||||||
// calling the function being tested.
|
// calling the function being tested.
|
||||||
errCode = checkMetaHeaders(signedHeadersMap, r)
|
errCode = checkUnsignedHeaders(signedHeadersMap, r)
|
||||||
if errCode != ErrNone {
|
if errCode != ErrNone {
|
||||||
t.Fatalf("Expected the APIErrorCode to be %d, but got %d", ErrNone, errCode)
|
t.Fatalf("Expected the APIErrorCode to be %d, but got %d", ErrNone, errCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regression for the unsigned x-amz-copy-source coverage gap: an x-amz-*
|
||||||
|
// header outside the signed-headers list (here x-amz-copy-source, which the
|
||||||
|
// router uses to select CopyObjectHandler) must be rejected. Previously only
|
||||||
|
// x-amz-meta-* headers were inspected, so this header slipped through and a
|
||||||
|
// presigned/authorized PUT could be turned into a server-side copy.
|
||||||
|
r, err = http.NewRequest(http.MethodPut, "http://play.min.io:9000", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("Unable to create http.Request :", err)
|
||||||
|
}
|
||||||
|
r.Header.Set("X-Amz-Copy-Source", "/src/secret.txt")
|
||||||
|
if errCode = checkUnsignedHeaders(signedHeadersMap, r); errCode != ErrUnsignedHeaders {
|
||||||
|
t.Fatalf("unsigned x-amz-copy-source: expected %d, got %d", ErrUnsignedHeaders, errCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the same header is part of the signed-headers list with a matching
|
||||||
|
// value it is allowed through, exactly as for x-amz-meta-*.
|
||||||
|
signedWithCopy := http.Header{}
|
||||||
|
for k, v := range signedHeadersMap {
|
||||||
|
signedWithCopy[k] = v
|
||||||
|
}
|
||||||
|
signedWithCopy.Set("X-Amz-Copy-Source", "/src/secret.txt")
|
||||||
|
if errCode = checkUnsignedHeaders(signedWithCopy, r); errCode != ErrNone {
|
||||||
|
t.Fatalf("signed x-amz-copy-source: expected %d, got %d", ErrNone, errCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Membership, not value equality: an unsigned x-amz-* header whose first
|
||||||
|
// value is empty must still be rejected. A value-equality check would
|
||||||
|
// compare "" against the empty string returned for an absent signed header
|
||||||
|
// and wrongly let it through, so a multi-value header like
|
||||||
|
// {"", "/src/secret.txt"} could smuggle an unsigned copy-source.
|
||||||
|
r, err = http.NewRequest(http.MethodPut, "http://play.min.io:9000", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("Unable to create http.Request :", err)
|
||||||
|
}
|
||||||
|
r.Header["X-Amz-Copy-Source"] = []string{"", "/src/secret.txt"}
|
||||||
|
if errCode = checkUnsignedHeaders(signedHeadersMap, r); errCode != ErrUnsignedHeaders {
|
||||||
|
t.Fatalf("empty-first unsigned x-amz-copy-source: expected %d, got %d", ErrUnsignedHeaders, errCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// X-Amz-Content-Sha256 is exempt: it carries the payload hash (handled from
|
||||||
|
// the query for presigned and bound into the string-to-sign for signed
|
||||||
|
// requests), so it is allowed even when it is not in the signed-headers map.
|
||||||
|
r, err = http.NewRequest(http.MethodPut, "http://play.min.io:9000", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("Unable to create http.Request :", err)
|
||||||
|
}
|
||||||
|
r.Header.Set(xhttp.AmzContentSha256, unsignedPayload)
|
||||||
|
if errCode = checkUnsignedHeaders(signedHeadersMap, r); errCode != ErrNone {
|
||||||
|
t.Fatalf("unsigned x-amz-content-sha256 must be exempt: expected %d, got %d", ErrNone, errCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// X-Amz-Signature-Age is the presigned verifier's own scratch header,
|
||||||
|
// written after this check. Exempting it keeps verification idempotent when
|
||||||
|
// the same request object is verified more than once.
|
||||||
|
r, err = http.NewRequest(http.MethodPut, "http://play.min.io:9000", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("Unable to create http.Request :", err)
|
||||||
|
}
|
||||||
|
r.Header.Set(xhttp.AmzSignatureAge, "1234")
|
||||||
|
if errCode = checkUnsignedHeaders(signedHeadersMap, r); errCode != ErrNone {
|
||||||
|
t.Fatalf("internal x-amz-signature-age must be exempt: expected %d, got %d", ErrNone, errCode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-5
@@ -229,10 +229,11 @@ func doesPresignedSignatureMatch(hashedPayload string, r *http.Request, region s
|
|||||||
return errCode
|
return errCode
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the metadata headers are equal with signedheaders
|
// Reject any x-amz-* header that the client did not sign. Without this an
|
||||||
errMetaCode := checkMetaHeaders(extractedSignedHeaders, r)
|
// unsigned header (e.g. x-amz-copy-source) could alter the request that the
|
||||||
if errMetaCode != ErrNone {
|
// presigned URL actually authorized.
|
||||||
return errMetaCode
|
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
|
// 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
|
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
|
return ErrNone
|
||||||
}
|
}
|
||||||
@@ -363,6 +364,14 @@ func doesSignatureMatch(hashedPayload string, r *http.Request, region string, st
|
|||||||
return errCode
|
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)
|
cred, _, s3Err := checkKeyValid(r, signV4Values.Credential.accessKey)
|
||||||
if s3Err != ErrNone {
|
if s3Err != ErrNone {
|
||||||
return s3Err
|
return s3Err
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
xhttp "github.com/minio/minio/internal/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
func niceError(code APIErrorCode) string {
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -132,6 +132,11 @@ const (
|
|||||||
AmzMaxParts = "X-Amz-Max-Parts"
|
AmzMaxParts = "X-Amz-Max-Parts"
|
||||||
AmzPartNumberMarker = "X-Amz-Part-Number-Marker"
|
AmzPartNumberMarker = "X-Amz-Part-Number-Marker"
|
||||||
|
|
||||||
|
// AmzSignatureAge is an internal scratch header the presigned verifier
|
||||||
|
// writes after validating the signature so that bucket-policy evaluation can
|
||||||
|
// expose s3:signatureAge. It is never sent or signed by a client.
|
||||||
|
AmzSignatureAge = "X-Amz-Signature-Age"
|
||||||
|
|
||||||
// Constants used for GetObjectAttributes and GetObjectVersionAttributes
|
// Constants used for GetObjectAttributes and GetObjectVersionAttributes
|
||||||
AmzObjectAttributes = "X-Amz-Object-Attributes"
|
AmzObjectAttributes = "X-Amz-Object-Attributes"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user