mirror of
https://github.com/pgsty/minio.git
synced 2026-08-09 15:53:28 +03:00
fix: bind s3:versionid conditions to the effective object version
A bucket policy that allows s3:DeleteObject only when s3:versionid is null
-- Condition {"Null": {"s3:versionid": "true"}}, the idiom for "let clients
delete current objects but not roll back versions" -- denied every delete,
including the version-less ones it was meant to permit (upstream issue
minio/minio#21735).
getConditionValues wrote "versionid": {""} unconditionally. The condition
engine decides Null by slice length (nullfunc.evaluate), so a present-but-
empty value reads as "key present": Null:true never matched and Null:false
always did. Absent and empty were indistinguishable.
Writing the key only when the request names a version fixes the reported
case but, alone, opens a worse one. DeleteObjects carries each object's
version in the XML body, which getConditionValues -- reading only r.Form --
never sees. A body version would then vanish from the map, read as null,
and a policy meant to protect old versions would authorize deleting a
specific one. So authorization also rebinds versionid to the effective,
server-resolved reqInfo.VersionID for DeleteObjectAction: the per-entry
body value that checkRequestAuthTypeWithVID already sets in the
DeleteObjects loop, deleting the key when that value is empty. A
query-level ?versionId on a DeleteObjects POST no longer leaks into any
entry's decision.
Finally, trim the version the condition builder reads. newContext and
getOpts both TrimSpace it before the object layer acts, so an untrimmed
value here let a padded ?versionId=V%20 present a different s3:versionid
than the version actually operated on, sidestepping a Deny keyed on
StringEquals s3:versionid. DeleteObjectAction was already immune via the
trimmed reqInfo value; this covers GetObject, tagging, retention, and the
copy-source read.
Tests: an end-to-end DeleteObjects against a Null:{s3:versionid:true}
policy over versioned objects (with a decoy query versionId proving the
per-entry body value wins), and a unit test asserting key presence,
trimming, and the copy-source fallback.
Co-authored-by: ChatGPT <noreply@openai.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/minio/minio/internal/auth"
|
||||
"github.com/minio/minio/internal/handlers"
|
||||
xhttp "github.com/minio/minio/internal/http"
|
||||
"github.com/minio/pkg/v3/policy"
|
||||
"github.com/minio/pkg/v3/policy/condition"
|
||||
@@ -161,6 +162,65 @@ func TestGetConditionValuesUsesActualRequestSource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConditionValuesVersionIDPresence(t *testing.T) {
|
||||
nullVersionID, err := condition.NewNullFunc(condition.S3VersionID.ToKey(), true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
withoutVersionID := condValuesForRequest(t, "http://minio.local/bkt/obj", nil)
|
||||
if _, ok := withoutVersionID["versionid"]; ok {
|
||||
t.Fatalf("an absent versionId was exposed to policy evaluation as %v", withoutVersionID["versionid"])
|
||||
}
|
||||
if !condition.NewFunctions(nullVersionID).Evaluate(withoutVersionID) {
|
||||
t.Fatal("Null s3:versionid=true did not match a request without versionId")
|
||||
}
|
||||
|
||||
const versionID = "7f4b6b5f-bf25-4e98-95df-90cba8070dd8"
|
||||
withVersionID := condValuesForRequest(t,
|
||||
"http://minio.local/bkt/obj?"+url.Values{xhttp.VersionID: {versionID}}.Encode(), nil)
|
||||
if got := withVersionID["versionid"]; !slices.Equal(got, []string{versionID}) {
|
||||
t.Fatalf("expected versionId %q, got %v", versionID, got)
|
||||
}
|
||||
if condition.NewFunctions(nullVersionID).Evaluate(withVersionID) {
|
||||
t.Fatal("Null s3:versionid=true matched a request with versionId")
|
||||
}
|
||||
|
||||
copySourceVersion := condValuesForRequest(t, "http://minio.local/bkt/copied", map[string]string{
|
||||
xhttp.AmzCopySource: "/source-bucket/source-object?" + url.Values{xhttp.VersionID: {versionID}}.Encode(),
|
||||
})
|
||||
if got := copySourceVersion["versionid"]; !slices.Equal(got, []string{versionID}) {
|
||||
t.Fatalf("copy source versionId was lost: got %v", got)
|
||||
}
|
||||
|
||||
// The object layer trims the version before acting on it; the condition value
|
||||
// must be the same effective string, or a padded ?versionId=V%20 would let a
|
||||
// StringEquals/Deny on s3:versionid see a different value than the one deleted.
|
||||
paddedVersion := condValuesForRequest(t,
|
||||
"http://minio.local/bkt/obj?"+url.Values{xhttp.VersionID: {versionID + " "}}.Encode(), nil)
|
||||
if got := paddedVersion["versionid"]; !slices.Equal(got, []string{versionID}) {
|
||||
t.Fatalf("a padded versionId was not trimmed to the effective value: got %v", got)
|
||||
}
|
||||
|
||||
paddedCopySource := condValuesForRequest(t, "http://minio.local/bkt/copied", map[string]string{
|
||||
xhttp.AmzCopySource: "/source-bucket/source-object?" + url.Values{xhttp.VersionID: {versionID + " "}}.Encode(),
|
||||
})
|
||||
if got := paddedCopySource["versionid"]; !slices.Equal(got, []string{versionID}) {
|
||||
t.Fatalf("a padded copy source versionId was not trimmed: got %v", got)
|
||||
}
|
||||
|
||||
// A whitespace-only versionId names no version once trimmed, exactly as the
|
||||
// object layer treats it, so the key must be absent and Null:true must match.
|
||||
blankVersion := condValuesForRequest(t,
|
||||
"http://minio.local/bkt/obj?"+url.Values{xhttp.VersionID: {" "}}.Encode(), nil)
|
||||
if _, ok := blankVersion["versionid"]; ok {
|
||||
t.Fatalf("a whitespace-only versionId was exposed to policy evaluation as %v", blankVersion["versionid"])
|
||||
}
|
||||
if !condition.NewFunctions(nullVersionID).Evaluate(blankVersion) {
|
||||
t.Fatal("Null s3:versionid=true did not match a request whose versionId was only whitespace")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConditionValuesUsesEffectiveRequestTags(t *testing.T) {
|
||||
rawURL := "http://minio.local/bkt/obj?" + url.Values{
|
||||
strings.ToLower(xhttp.AmzObjectTagging): {"security=public&virus=true"},
|
||||
@@ -257,6 +317,42 @@ func TestBucketPolicySourceIPCannotBeForged(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// aws:SourceIp must be whatever the hardened resolver decided and nothing else.
|
||||
// The resolver is where the forwarded-header trust policy is enforced and where
|
||||
// its three modes are tested (internal/handlers/proxy_test.go); this pins the
|
||||
// join, so the condition value cannot drift onto some other derivation that the
|
||||
// policy would not cover.
|
||||
//
|
||||
// It also records the default-mode contract: with no trust policy configured,
|
||||
// each of the three forwarded headers still sets aws:SourceIp, and so an
|
||||
// IpAddress condition is only as good as the network path to the API port.
|
||||
// Enforcing such a condition against a client with direct access requires
|
||||
// MINIO_API_TRUSTED_PROXIES or _MINIO_API_XFF_HEADER=off.
|
||||
func TestGetConditionValuesSourceIPMatchesResolver(t *testing.T) {
|
||||
for _, header := range []map[string]string{
|
||||
nil,
|
||||
{"X-Forwarded-For": "10.1.2.3"},
|
||||
{"X-Real-IP": "10.1.2.3"},
|
||||
{"Forwarded": "for=10.1.2.3"},
|
||||
{"X-Forwarded-For": "10.1.2.3, 198.51.100.9"},
|
||||
} {
|
||||
r, err := http.NewRequest(http.MethodGet, "http://minio.local/bkt/obj", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.RemoteAddr = testCondRemoteILP
|
||||
for k, v := range header {
|
||||
r.Header.Set(k, v)
|
||||
}
|
||||
|
||||
got := resolvedConditionValues(condValuesForRequest(t, "http://minio.local/bkt/obj", header), condition.AWSSourceIP.ToKey().Name())
|
||||
want := handlers.GetSourceIPRaw(r)
|
||||
if len(got) != 1 || got[0] != want {
|
||||
t.Errorf("headers %v: aws:SourceIp = %v, resolver returned %q", header, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "Deny unless the connection is TLS" is the usual hardening statement, and
|
||||
// aws:SecureTransport is computed from r.TLS.
|
||||
func TestBucketPolicySecureTransportCannotBeForged(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user