mirror of
https://github.com/pgsty/minio.git
synced 2026-09-15 23:14:04 +03:00
87d8b5967f
Signed-off-by: Feng Ruohang <rh@vonng.com>
325 lines
14 KiB
Go
325 lines
14 KiB
Go
// Copyright (c) 2026 PGSTY
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/minio/madmin-go/v3"
|
|
"github.com/minio/minio/internal/auth"
|
|
xhttp "github.com/minio/minio/internal/http"
|
|
"github.com/pgsty/silo-pkg/v3/policy"
|
|
)
|
|
|
|
// forgedSignatureAgeHeader is the name of the scratch header the presigned
|
|
// verifier once wrote s3:signatureAge through. Production code no longer knows
|
|
// it; a client that sends it is sending an ordinary unsigned x-amz-* header.
|
|
const forgedSignatureAgeHeader = "X-Amz-Signature-Age"
|
|
|
|
// Presign at a chosen time, including exactly the supplied operation headers.
|
|
func presignBoundaryRequest(t *testing.T, r *http.Request, date time.Time, signedHeaders []string, cred auth.Credentials) {
|
|
t.Helper()
|
|
query := r.URL.Query()
|
|
query.Del(xhttp.AmzSignature)
|
|
query.Set(xhttp.AmzAlgorithm, signV4Algorithm)
|
|
query.Set(xhttp.AmzDate, date.Format(iso8601Format))
|
|
query.Set(xhttp.AmzExpires, "3600")
|
|
query.Set(xhttp.AmzSignedHeaders, strings.Join(signedHeaders, ";"))
|
|
query.Set(xhttp.AmzCredential, cred.AccessKey+"/"+getScope(date, globalSite.Region()))
|
|
r.Form = query
|
|
headers, code := extractSignedHeaders(signedHeaders, r)
|
|
if code != ErrNone {
|
|
t.Fatal(niceError(code))
|
|
}
|
|
canonical := getCanonicalRequest(headers, getContentSha256Cksum(r, serviceS3), query.Encode(), r.URL.Path, r.Method)
|
|
key := getSigningKey(cred.SecretKey, date, globalSite.Region(), serviceS3)
|
|
query.Set(xhttp.AmzSignature, getSignature(key, getStringToSign(canonical, date, getScope(date, globalSite.Region()))))
|
|
r.URL.RawQuery = query.Encode()
|
|
r.Form = query
|
|
}
|
|
|
|
func setupSignatureBoundaryTest(t *testing.T) {
|
|
t.Helper()
|
|
obj, fsDir, err := prepareFS(t.Context())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { os.RemoveAll(fsDir) })
|
|
if err = newTestConfig(globalMinioDefaultRegion, obj); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// Canonicalization comma-joins repeated header fields, so a signature over one
|
|
// x-amz-copy-source value containing a comma also covers the same text split
|
|
// into two fields, while the copy handlers act on Header.Get alone. A single
|
|
// value may contain a literal or percent-encoded comma; a repeated header is
|
|
// rejected at the shared SigV4 boundary for both signed and presigned requests.
|
|
func TestV4CopySourceMultiplicity(t *testing.T) {
|
|
setupSignatureBoundaryTest(t)
|
|
for _, presigned := range []bool{false, true} {
|
|
for _, source := range []string{"/source/allowed,tail", "/source/allowed%2Ctail"} {
|
|
t.Run(fmt.Sprintf("presigned=%v/%s", presigned, source), func(t *testing.T) {
|
|
r := httptest.NewRequest(http.MethodPut, "http://minio.local/destination/object", nil)
|
|
r.Header.Set(xhttp.AmzContentSha256, emptySHA256)
|
|
r.Header.Set(xhttp.AmzCopySource, source)
|
|
if presigned {
|
|
presignBoundaryRequest(t, r, UTCNow(), []string{"host", "x-amz-copy-source"}, globalActiveCred)
|
|
} else {
|
|
if err := signRequestV4(r, globalActiveCred.AccessKey, globalActiveCred.SecretKey); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r.Form = r.URL.Query()
|
|
}
|
|
if code := reqSignatureV4Verify(r, globalSite.Region(), serviceS3); code != ErrNone {
|
|
t.Fatalf("a single source key containing a comma must remain valid: %s", niceError(code))
|
|
}
|
|
if source != "/source/allowed,tail" {
|
|
return
|
|
}
|
|
r.Header[xhttp.AmzCopySource] = []string{"/source/allowed", "tail"}
|
|
if code := reqSignatureV4Verify(r, globalSite.Region(), serviceS3); code != ErrInvalidCopySource {
|
|
t.Fatalf("split copy source: got %s, want %s; handler would copy %q",
|
|
niceError(code), niceError(ErrInvalidCopySource), r.Header.Get(xhttp.AmzCopySource))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// PutObject and UploadPart authorize before they verify the signature, so
|
|
// s3:signatureAge must come from the signed X-Amz-Date on the first policy
|
|
// evaluation. A client header under the former scratch name must neither
|
|
// supply the value nor survive verification, and verifying the same request
|
|
// twice must give the same answer.
|
|
func TestGetConditionValuesPresignedAgeFromDate(t *testing.T) {
|
|
setupSignatureBoundaryTest(t)
|
|
for _, tc := range []struct {
|
|
name, header string
|
|
age time.Duration
|
|
wantVerify APIErrorCode
|
|
}{
|
|
{name: "old", age: 10 * time.Minute},
|
|
{name: "old forged 0", age: 10 * time.Minute, header: "0", wantVerify: ErrUnsignedHeaders},
|
|
{name: "old forged 1", age: 10 * time.Minute, header: "1", wantVerify: ErrUnsignedHeaders},
|
|
// A signer slightly ahead of the server stays within globalMaxSkewTime.
|
|
{name: "future within skew", age: -2 * time.Minute},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
r := httptest.NewRequest(http.MethodPut, "http://minio.local/bucket/object", nil)
|
|
presignBoundaryRequest(t, r, UTCNow().Add(-tc.age), []string{"host"}, globalActiveCred)
|
|
if tc.header != "" {
|
|
r.Header.Set(forgedSignatureAgeHeader, tc.header)
|
|
}
|
|
values := getConditionValues(r, "", globalActiveCred)
|
|
age, err := strconv.ParseInt(strings.Join(values["signatureAge"], ""), 10, 64)
|
|
lo, hi := (tc.age - time.Minute).Milliseconds(), (tc.age + time.Minute).Milliseconds()
|
|
if err != nil || age < lo || age > hi {
|
|
t.Errorf("pre-verification policy got age %v; want the age of the signed date (about %d ms)", values["signatureAge"], tc.age.Milliseconds())
|
|
}
|
|
code := reqSignatureV4Verify(r, globalSite.Region(), serviceS3)
|
|
if code != tc.wantVerify {
|
|
t.Fatalf("verification: got %s, want %s", niceError(code), niceError(tc.wantVerify))
|
|
}
|
|
if again := reqSignatureV4Verify(r, globalSite.Region(), serviceS3); again != code {
|
|
t.Fatalf("second verification changed the outcome: %s -> %s", niceError(code), niceError(again))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The s3:x-amz-content-sha256 policy value must be the single payload hash the
|
|
// request is verified and enforced against. Header presence decides whether the
|
|
// key exists at all; the value is the one getContentSha256Cksum selects, so a
|
|
// presigned query value wins over the header and a repeated header contributes
|
|
// only its first value. None of these requests is rejected at the protocol
|
|
// level; the policy simply sees what verification bound.
|
|
func TestGetConditionValuesPayloadHashMatchesVerifiedValue(t *testing.T) {
|
|
setupSignatureBoundaryTest(t)
|
|
hashA, hashB := getSHA256Hash([]byte("a")), getSHA256Hash([]byte("b"))
|
|
for _, tc := range []struct {
|
|
name string
|
|
presigned bool
|
|
query string
|
|
header []string
|
|
want []string
|
|
}{
|
|
{name: "signed header", header: []string{hashA}, want: []string{hashA}},
|
|
{name: "signed absent"},
|
|
{name: "signed present empty", header: []string{""}, want: []string{""}},
|
|
{name: "signed duplicate header", header: []string{hashA, hashB}, want: []string{hashA}},
|
|
{name: "signed streaming with second value", header: []string{streamingContentSHA256, hashA}, want: []string{streamingContentSHA256}},
|
|
{name: "presigned query only", presigned: true, query: hashA},
|
|
{name: "presigned header only", presigned: true, header: []string{hashA}, want: []string{hashA}},
|
|
{name: "presigned matching query and header", presigned: true, query: hashA, header: []string{hashA}, want: []string{hashA}},
|
|
{name: "presigned unsigned query with forged header", presigned: true, query: unsignedPayload, header: []string{hashA}, want: []string{unsignedPayload}},
|
|
{name: "presigned duplicate header", presigned: true, header: []string{hashA, hashB}, want: []string{hashA}},
|
|
{name: "presigned query with empty header", presigned: true, query: hashA, header: []string{""}, want: []string{hashA}},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
target := "http://minio.local/bucket/object"
|
|
if tc.query != "" {
|
|
target += "?" + xhttp.AmzContentSha256 + "=" + tc.query
|
|
}
|
|
r := httptest.NewRequest(http.MethodPut, target, nil)
|
|
if tc.header != nil {
|
|
r.Header[xhttp.AmzContentSha256] = tc.header
|
|
}
|
|
if tc.presigned {
|
|
presignBoundaryRequest(t, r, UTCNow(), []string{"host"}, globalActiveCred)
|
|
} else {
|
|
r.Header.Set(xhttp.Authorization, signV4Algorithm+" Credential=x/20260910/us-east-1/s3/aws4_request, SignedHeaders=host, Signature=x")
|
|
r.Form = r.URL.Query()
|
|
}
|
|
got, ok := getConditionValues(r, "", globalActiveCred)[xhttp.AmzContentSha256]
|
|
if ok != (tc.want != nil) || !slices.Equal(got, tc.want) {
|
|
t.Fatalf("policy value = %v (present=%v), want %v (present=%v)", got, ok, tc.want, tc.want != nil)
|
|
}
|
|
if tc.presigned {
|
|
if code := reqSignatureV4Verify(r, globalSite.Region(), serviceS3); code != ErrNone {
|
|
t.Fatalf("the presigned request must still verify: %s", niceError(code))
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func newSignatureBoundaryUser(t *testing.T, bucket, statements string) auth.Credentials {
|
|
t.Helper()
|
|
cred, err := auth.GetNewCredentials()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := globalIAMSys.CreateUser(t.Context(), cred.AccessKey, madmin.AddOrUpdateUserReq{SecretKey: cred.SecretKey, Status: madmin.AccountEnabled}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p, err := policy.ParseConfig(strings.NewReader(fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::%s/*"},%s]}`, bucket, statements)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
name := "signature-boundary-" + mustGetUUID()
|
|
if _, err := globalIAMSys.SetPolicy(t.Context(), name, *p); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := globalIAMSys.PolicyDBSet(t.Context(), cred.AccessKey, name, regUser, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return cred
|
|
}
|
|
|
|
func TestAPIPresignedSignatureAgeBeforeAuthorization(t *testing.T) {
|
|
defer DetectTestLeak(t)()
|
|
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
|
t: t,
|
|
endpoints: []string{"NewMultipart", "PutObjectPart", "PutObject"},
|
|
objAPITest: func(obj ObjectLayer, instanceType, bucket string, router http.Handler, root auth.Credentials, t *testing.T) {
|
|
user := newSignatureBoundaryUser(t, bucket, fmt.Sprintf(`{"Effect":"Deny","Action":"s3:PutObject","Resource":"arn:aws:s3:::%s/*","Condition":{"NumericGreaterThan":{"s3:signatureAge":"60000"}}}`, bucket))
|
|
initReq, err := newTestSignedRequestV4(http.MethodPost, getNewMultipartURL("", bucket, "multipart"), 0, nil, root.AccessKey, root.SecretKey, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
initRec := httptest.NewRecorder()
|
|
router.ServeHTTP(initRec, initReq)
|
|
var upload InitiateMultipartUploadResponse
|
|
if initRec.Code != http.StatusOK || xml.Unmarshal(initRec.Body.Bytes(), &upload) != nil {
|
|
t.Fatalf("multipart initiation: %d %s", initRec.Code, initRec.Body.String())
|
|
}
|
|
for _, operation := range []string{"put", "part"} {
|
|
for _, tc := range []struct {
|
|
name, header string
|
|
age time.Duration
|
|
want int
|
|
}{
|
|
{name: "fresh", want: http.StatusOK},
|
|
{name: "old without header", age: 10 * time.Minute, want: http.StatusForbidden},
|
|
{name: "old forged header", age: 10 * time.Minute, header: "0", want: http.StatusForbidden},
|
|
// Policy allows a fresh signature; the unsigned header then fails
|
|
// verification with the existing ErrUnsignedHeaders (HTTP 400).
|
|
{name: "fresh with unsigned header", header: "0", want: http.StatusBadRequest},
|
|
} {
|
|
t.Run(instanceType+"/"+operation+"/"+tc.name, func(t *testing.T) {
|
|
target := getPutObjectURL("", bucket, "put-"+strings.ReplaceAll(tc.name, " ", "-"))
|
|
if operation == "part" {
|
|
target = getPutObjectPartURL("", bucket, "multipart", upload.UploadID, "1")
|
|
}
|
|
r, err := newTestRequest(http.MethodPut, target, 4, bytes.NewReader([]byte("body")))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r.Header.Del(xhttp.AmzContentSha256)
|
|
presignBoundaryRequest(t, r, UTCNow().Add(-tc.age), []string{"host"}, user)
|
|
if tc.header != "" {
|
|
r.Header.Set(forgedSignatureAgeHeader, tc.header)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, r)
|
|
if rec.Code != tc.want {
|
|
t.Errorf("got %d %s, want %d", rec.Code, rec.Body.String(), tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
},
|
|
})
|
|
}
|
|
|
|
func TestAPIPayloadHashPolicyMatchesVerifiedValue(t *testing.T) {
|
|
defer DetectTestLeak(t)()
|
|
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
|
t: t,
|
|
endpoints: []string{"PutObject"},
|
|
objAPITest: func(_ ObjectLayer, instanceType, bucket string, router http.Handler, _ auth.Credentials, t *testing.T) {
|
|
allowedHash := getSHA256Hash([]byte("expected"))
|
|
user := newSignatureBoundaryUser(t, bucket, fmt.Sprintf(`{"Effect":"Deny","Action":"s3:PutObject","Resource":"arn:aws:s3:::%s/*","Condition":{"StringNotEquals":{"s3:x-amz-content-sha256":"%s"}}}`, bucket, allowedHash))
|
|
for _, kind := range []string{"signed control", "presigned control", "unsigned query forged header", "signed duplicate header", "presigned duplicate header"} {
|
|
t.Run(instanceType+"/"+kind, func(t *testing.T) {
|
|
control := strings.HasSuffix(kind, "control")
|
|
body := "modified"
|
|
if control {
|
|
body = "expected"
|
|
}
|
|
r, err := newTestRequest(http.MethodPut, getPutObjectURL("", bucket, strings.ReplaceAll(kind, " ", "-")), int64(len(body)), strings.NewReader(body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(kind, "duplicate") {
|
|
r.Header.Add(xhttp.AmzContentSha256, allowedHash)
|
|
}
|
|
if kind == "unsigned query forged header" {
|
|
q := r.URL.Query()
|
|
q.Set(xhttp.AmzContentSha256, unsignedPayload)
|
|
r.URL.RawQuery = q.Encode()
|
|
r.Header.Set(xhttp.AmzContentSha256, allowedHash)
|
|
}
|
|
if strings.HasPrefix(kind, "signed") {
|
|
if err := signRequestV4(r, user.AccessKey, user.SecretKey); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
} else {
|
|
presignBoundaryRequest(t, r, UTCNow(), []string{"host"}, user)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, r)
|
|
if control && rec.Code != http.StatusOK {
|
|
t.Errorf("control rejected: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if !control && rec.Code < http.StatusBadRequest {
|
|
t.Errorf("payload-hash policy bypass returned %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
},
|
|
})
|
|
}
|