This is the multi-page printable view of this section. .
SILO Security Chronicle
- 1: October Security Update: Object Lock, Admin Credentials and JWK Parsing
- 2: SN-2026-015 & SN-2026-016: S3 Object Lock Governance Retention Bypass and Version-Addressed Overwrites
- 3: September Security Repairs: Payload Integrity, IAM Revocation and Console Sharing
- 4: SN-2026-011: Fix and Release Status
- 5: CVE-2025-62506: Session-Policy Privilege Escalation
- 6: SILO 20260903 Security Notes: SN-2026-006 through 010
- 7: CVE-2026-32285: The jsonparser Advisory That Required No Patch
- 8: CVE-2026-33322: OIDC JWT Algorithm Confusion
- 9: CVE-2026-33419: LDAP STS Enumeration and the Throttling Chain
- 10: CVE-2026-34204: Replication Metadata Injection
- 11: CVE-2026-39414: Oversized S3 Select Records and a SIMD Bypass
- 12: CVE-2026-40344: Snowball Auto-Extract Authentication Bypass
- 13: CVE-2026-41145: Unsigned-Trailer Query Authentication Bypass
- 14: CVE-2026-42600: ReadMultiple Storage-REST Path Traversal
- 15: SN-2026-002: Internode Path Containment Audit — Paying Off What CVE-2026-42600 Left Owing
- 16: SN-2026-003: s3:versionid Condition Evaluation and Request Condition Hardening
- 17: SN-2026-004: Object-Scoped Policy Grants Reaching Bucket Mutations
- 18: SN-2026-018: Client Source Address Trust, Forwarded Headers, and aws:SourceIp
- 19: SN-2026-019: Notification Keyspace Registration and Validation Redaction
- 20: Duplicate Part Numbers in CompleteMultipartUpload
This section publishes technical security advisories for PGSTY SILO, ordered from newest to oldest. Each advisory documents the affected versions, trigger conditions, root cause, code fix, and upgrade requirements.
For a single-page index of all CVE-* and SN-2026-* identifiers, fix commits, and dependency security updates, see the Security Advisory Ledger.
1 - October Security Update: Object Lock, Admin Credentials and JWK Parsing
SILO Server 20261018 delivers the fixes for SN-2026-015, SN-2026-016, and SN-2026-017, scoped STS token revocation, and Go toolchain and dependency updates across silo, mcli, and silo-console. Server 20260916 and 20260903 are affected (and 20261015 was a superseded pre-release draft).
Object Lock: SN-2026-015 and SN-2026-016
The SN-2026-015 & SN-2026-016 advisory and repair design note document the affected PutObjectRetention and in-place version overwrite paths, reproductions, and mitigations. PR #234 (14042b5aa) and PR #235 (0d3ba5d4c) enforce s3:PutObjectRetention before object lookup, require both x-amz-bypass-governance-retention: true and s3:BypassGovernanceRetention when weakening active GOVERNANCE retention, and reject caller-addressed ?versionId= overwrites while enforcing WORM checks under the object write lock for null-version overwrites.
Every cluster and replication site that accepts S3 writes must be upgraded to 20261018.
SN-2026-017: Admin credential authorization boundaries
SN-2026-017 groups the authenticated Admin API handler authorization defects fixed in PR #242 (commit 2616d43fb) and its bulk access-key listing follow-up 995439260.
| Boundary | Before 20261018 |
20261018 Behavior |
|---|---|---|
| Self inspection and cleanup | Ownership could override an explicit policy or plugin denial; self service-account listing could skip authorization | Preserves self-service access for ordinary users (DenyOnly), while enforcing explicit user/group Deny rules, inline session policies, and external plugin denials |
| Restricted credential delegation | A derived credential allowed the create/update action could mint a key inheriting broader parent permissions or strip a target service account’s session policy | Under built-in authorization, callers constrained by an active inline session policy cannot create or update service accounts (including LDAP creation and metadata-only updates) |
| Root-derived credentials | A non-root administrator with broad admin:* grants could inspect, modify, or delete root’s derived service accounts |
Only an authorized root credential or an unrestricted root-derived identity can manage root-owned credentials (the internal site-replication account is excluded) |
The actor passed to IAM policy evaluation is always the authenticated access key (cred.AccessKey), while parent resolution (ParentUser) is used only for comparing ownership. Full handler contracts and benchmark measurements are recorded in IAM Admin API Authorization Boundaries.
Compatibility repairs retained alongside the security fix
- Semantic empty policy normalization on issuance: Creating a service account filters reserved parent policy/expiration claims and normalizes semantic empty policy documents (
null,{},{"Statement":null}) to parent inheritance, while preserving explicit empty statement arrays ({"Version":"2012-10-17","Statement":[]}). - Damaged non-root key cleanup: Deleting or inspecting a damaged non-root service account falls back to stored identity metadata so operators can clean up corrupted or expired keys without allowing a damaged token to mask a root parent.
- Bulk access-key listing (
995439260): Applies the same self-service authorization helper to bulk key listing (ListAccessKeysBulk), resolving omitted users, the caller’s own key, or its parent to the caller’s parent inventory while enforcing explicit denies and cross-user restrictions. - Error codes: Denied self-service operations return
403 AccessDenied; unauthorized attempts to delete another user’s or root’s service account retain their404response.
Operator action
- Verify administrative automation using your production user, service-account, STS, and external-plugin identities. Any script that relied on a session-restricted credential to create further child service accounts, or a non-root admin to manage root service accounts, must switch to an unrestricted parent or root identity.
- Audit child service accounts and policy updates created prior to upgrading, revoke unexpected keys, and follow the IAM upgrade and recovery guide.
Follow-up: scoped STS revocation
Release commit 0cd388c66 fixes cache-only RevokeTokens enumeration and incomplete propagation across peer nodes and sites. It persists deletion tombstones for tokens durably known to the cluster—scoped by parent identity, token type, and operation cutoff timestamp—and propagates them through peer notification and deletion healing. See Scoped STS Revocation and the Server 20261018 release notes.
Dependency security and login compatibility
Server 20261018 builds with Go 1.27.2, golang.org/x/net v0.61.0, golang.org/x/crypto v0.58.0, golang.org/x/text v0.43.0, silo-pkg v3.14.3, and go-jose v4.1.6 in go.mod:
go-josev4.1.6 (GHSA-mgcc-cww5-fp2q): Bounds CPU consumption when parsing crafted RSA JWKs on the OIDC path and rejects RSA JWKs larger than 4096 bits. Verify OIDC discovery, JWKS endpoints, and login flows before upgrading if your identity provider uses oversized RSA keys.- Standalone Console: See Console v2.4.2 for standalone Console dependency updates.
Verification and scope
Handler regressions in cmd/ cover explicit self denials, session-restricted delegation, root-credential protection, empty-policy issuance, damaged-key cleanup, decrypted bulk-list identity contents, and Object Lock WORM invariants. See the IAM authorization design note, Object Lock repair design note, and component version matrix.
2 - SN-2026-015 & SN-2026-016: S3 Object Lock Governance Retention Bypass and Version-Addressed Overwrites
SN-2026-015 and SN-2026-016 cover two S3 Object Lock retention and immutability vulnerabilities fixed in SILO Server 20261018 via PR #234 (14042b5aa) and PR #235 (0d3ba5d4c), as detailed in the companion repair design note. While the delete path already enforced s3:BypassGovernanceRetention (CVE-2023-25812), the retention-write path (PutObjectRetention) and four in-place version overwrite paths remained bypassable.
Advisory Scope: SN-2026-015 and SN-2026-016
SN-2026-015: PutObjectRetention Authorization and Governance Bypass (#231)
In PutObjectRetentionHandler (cmd/object-handlers.go), three interacting flaws in enforceRetentionBypassForPut and isPutRetentionAllowed broke S3 Object Lock authorization:
- F1 (Unconditional
s3:PutObjectRetentionbypass via header): Passingx-amz-bypass-governance-retention: truecausedisPutRetentionAllowedto check onlys3:BypassGovernanceRetention(!byPassSet), allowing an authenticated principal with zeros3:PutObjectRetentionand zeros3:BypassGovernanceRetentionpermissions to place or extend retention on any unlocked object! - F2 (Governance retention shortening/clearing without
s3:BypassGovernanceRetention): BecauseisPutRetentionAllowedevaluatedbyPassSet || retSet, a caller who helds3:PutObjectRetention(retSet == true) but lackeds3:BypassGovernanceRetention(byPassSet == false) could passx-amz-bypass-governance-retention: trueto clear (<Retention/>) or shorten an activeGOVERNANCEretention lock, and then delete the object (GHSA-67mv-7hc9-wj88, reported byszopi00). - F3 (
s3:object-lock-remaining-retention-dayscondition bypass on governance bypass): Whenx-amz-bypass-governance-retention: truewas set on an existing governance-locked object,s3:PutObjectRetentionwas skipped whenevers3:BypassGovernanceRetentionsucceeded, bypassing IAM policy conditions such asNumericGreaterThanEquals: {"s3:object-lock-remaining-retention-days": 30}attached tos3:PutObjectRetention.
SN-2026-016: Version-Addressed and In-Place Object Overwrites (#234)
While auditing every write path that can mutate an existing object version in place, we found and closed four write-path bypasses (14042b5aa):
- Caller-supplied
?versionId=onPutObjectandCopyObject: Non-replication callers could pass?versionId=<existing-uuid>onPutObjectorCopyObject(orminio.versionIdin SnowballPutObjectExtractPAX headers) to overwrite an existing version in place without checking WORM retention or legal hold on that target version. - Directory object (
key/)null-version overwrite: Creating or overwriting a directory object (strings.HasSuffix(object, "/")) wrote tonullVersionIDeven in versioned/Object-Lock buckets without checking existing retention or legal hold on thenullversion. CompleteMultipartUploadin-placenull-version overwrite: In an Object-Lock-enabled bucket with versioning suspended (or targeting an existingnullversion),CompleteMultipartUploaddid not enforce retention/legal-hold checks on an existingnullversion before replacing it.- Replication header trust: Unprivileged callers with
s3:PutObjectcould spoofX-Minio-Source-Replication-Requestheaders unless gated bys3:ReplicateObject.
Expected S3 Object Lock Semantics
Under the AWS S3 Object Lock specification, PutObjectRetention must enforce two independent orthogonal boundaries:
| Target Object State | Proposed <Retention> Change |
Required IAM Permissions | Required Request Header |
|---|---|---|---|
| Unlocked, or retention expired | Set GOVERNANCE or COMPLIANCE with future date |
s3:PutObjectRetention |
None |
Active GOVERNANCE lock |
Extend RetainUntilDate to a strictly later date (same mode) |
s3:PutObjectRetention |
None |
Active GOVERNANCE lock |
Clear (<Retention/>), shorten RetainUntilDate, or switch GOVERNANCE -> COMPLIANCE |
s3:PutObjectRetention AND s3:BypassGovernanceRetention |
x-amz-bypass-governance-retention: true |
Active COMPLIANCE lock |
Extend RetainUntilDate to a strictly later date (COMPLIANCE mode) |
s3:PutObjectRetention |
None |
Active COMPLIANCE lock |
Clear, shorten, or switch COMPLIANCE -> GOVERNANCE |
Forbidden for all principals | Forbidden (AccessDenied) |
Reproduction and Verification
The three SN-2026-015 failures can be reproduced directly against pre-20261018 builds (verified against baseline b0a540190):
Reproduction 1: Clearing an Active Governance Lock Without s3:BypassGovernanceRetention (F2)
Assume user nobypass has Allow on s3:PutObjectRetention and s3:DeleteObjectVersion, and no s3:BypassGovernanceRetention grant:
| Step | Action by nobypass |
Pre-20261018 Result |
Server 20261018 Result |
|---|---|---|---|
| 1 | Object doc.txt (version v1) is locked in GOVERNANCE mode until 2030-01-01 |
Locked | Locked |
| 2 | DELETE /bucket/doc.txt?versionId=v1 |
403 AccessDenied |
403 AccessDenied |
| 3 | PUT /bucket/doc.txt?retention&versionId=v1 with header x-amz-bypass-governance-retention: true and body <Retention/> |
200 OK (lock cleared!) |
403 AccessDenied |
| 4 | DELETE /bucket/doc.txt?versionId=v1 |
204 No Content (version permanently deleted!) |
Object remains locked and protected |
Reproduction 2: Locking an Object with Zero Retention Permissions (F1)
Assume user zeroperm has an empty/unrelated policy with neither s3:PutObjectRetention nor s3:BypassGovernanceRetention:
| Step | Action by zeroperm |
Pre-20261018 Result |
Server 20261018 Result |
|---|---|---|---|
| 1 | PUT /bucket/unlocked.txt?retention (without bypass header), body <Mode>COMPLIANCE</Mode><RetainUntilDate>2099-01-01T00:00:00Z</RetainUntilDate> |
403 AccessDenied |
403 AccessDenied |
| 2 | Repeat the exact same PUT /bucket/unlocked.txt?retention adding header x-amz-bypass-governance-retention: true |
200 OK (irreversible 2099 COMPLIANCE lock applied!) |
403 AccessDenied |
Reproduction 3: Bypassing s3:object-lock-remaining-retention-days Conditions (F3)
Assume user govadmin holds s3:BypassGovernanceRetention unconditionally, and holds s3:PutObjectRetention conditioned on NumericGreaterThanEquals: {"s3:object-lock-remaining-retention-days": 30}. On an object already under GOVERNANCE lock, sending x-amz-bypass-governance-retention: true with a 5-day retention period previously succeeded (byPassSet == true satisfied byPassSet || retSet even though retSet was false), bypassing the 30-day minimum policy condition.
Root Cause Analysis
In cmd/object-handlers.go, PutObjectRetentionHandler authenticated the caller (authenticateRequest) and delegated all authorization to enforceRetentionBypassForPut, which called isPutRetentionAllowed:
And in enforceRetentionBypassForPut:
- When the existing object had no active retention (
opts.Mode == ""), the function checkedif !retSet && !govBypassand returnednilwheneverbyPassSet(x-amz-bypass-governance-retention: true) was true—because on the unlocked path it never even calledisPutRetentionAllowedproperly, or trusted the header boolean directly (F1)! - When the existing object was in
GOVERNANCEmode and the caller passedx-amz-bypass-governance-retention: true,isPutRetentionAllowedreturnedbyPass || retSetas the second return value (govBypass). ThusretSet == true(s3:PutObjectRetentionalone) madegovBypass == true(F2), and converselybyPass == true(s3:BypassGovernanceRetentionalone) madegovBypass == trueeven whenretSet == false(F3).
Upstream History
Tracing git log shows how three separate changes over five years combined to create F1–F3:
- 2020-04 (
43a3778b4,minio/minio#9259): IntroducedenforceRetentionBypassForPutandisPutRetentionAllowedwith thebyPass || retSetboolean conflation. - 2025-02 (
437dd4e32,minio/minio#20929): Refactored retention checks inPutObjectRetentionHandler. - 2025-04 (
8c7097528,minio/minio#21103): Replaced the entrycheckRequestAuthTypecall inPutObjectRetentionHandlerwithauthenticateRequest(which only checks SigV4 signature validity, nots3:PutObjectRetention), leavingenforceRetentionBypassForPutas the sole authorization gate and exposing F1 to any authenticated user with zero permissions.
Repair Design
Five Security Invariants
Our repair enforces five explicit invariants across PutObjectRetention (0d3ba5d4c) and object write paths (14042b5aa):
- Invariant 1 (Mandatory
s3:PutObjectRetention): EveryPutObjectRetentionrequest must satisfys3:PutObjectRetention(including all policy conditions such ass3:object-lock-remaining-retention-days,s3:object-lock-retention-mode, ands3:object-lock-retain-until-date), regardless of whetherx-amz-bypass-governance-retentionis present. - Invariant 2 (Additional
s3:BypassGovernanceRetentionon governance weakening): If the target version has an active (unexpired)GOVERNANCElock and the request clears retention (Mode == "") or shortensRetainUntilDate(!RetainUntilDate.After(existing.RetainUntilDate)), the request must additionally carryx-amz-bypass-governance-retention: trueand satisfys3:BypassGovernanceRetention(including itss3:ExistingObjectTag/<key>conditions). - Invariant 3 (
COMPLIANCEimmutability): An active (unexpired)COMPLIANCElock can never be cleared, shortened, or downgraded toGOVERNANCEby any principal. - Invariant 4 (No caller-specified version overwrite): Non-replication S3 clients cannot supply
?versionId=onPutObject,CopyObject, orPutObjectExtractto overwrite an existing version ID. - Invariant 5 (In-place
null-version write-lock enforcement): Any write path that replaces an existingnullversion in place (PutObjecton a suspended-versioning bucket, directory objectkey/, orCompleteMultipartUpload) must enforce existing WORM retention and legal-hold checks on thatnullversion.
Where Each Check Lives
In PutObjectRetentionHandler and enforceRetentionBypassForPut:
s3:PutObjectRetention(retOK) is always evaluated against the proposed retention parameters (retDays,retDate,retMode) and existing object tags.s3:BypassGovernanceRetention(bypassOK) is evaluated independently and required whenever an activeGOVERNANCElock is cleared or shortened (or when switching an activeGOVERNANCElock toCOMPLIANCEwith a shorter date).
Complete Decision Table for PutObjectRetention
| Row | Existing Lock State | Requested Mutation | x-amz-bypass-governance-retention |
retOK (s3:PutObjectRetention) |
bypassOK (s3:BypassGovernanceRetention) |
Outcome |
|---|---|---|---|---|---|---|
| 1 | Any | Any | false or true |
false |
Any | 403 AccessDenied (closes F1 & F3) |
| 2 | Unlocked or expired | Set future retention | false or true |
true |
Not required | 200 OK |
| 3 | Active GOVERNANCE |
Extend RetainUntilDate (same mode or upgrade to COMPLIANCE with >= date) |
false or true |
true |
Not required | 200 OK |
| 4 | Active GOVERNANCE |
Clear (<Retention/>) or shorten RetainUntilDate |
false |
true |
Any | 403 AccessDenied |
| 5 | Active GOVERNANCE |
Clear (<Retention/>) or shorten RetainUntilDate |
true |
true |
false |
403 AccessDenied (closes F2) |
| 6 | Active GOVERNANCE |
Clear (<Retention/>) or shorten RetainUntilDate |
true |
true |
true |
200 OK |
| 7 | Active COMPLIANCE |
Clear, shorten, or downgrade to GOVERNANCE |
false or true |
Any | Any | 403 AccessDenied |
Five Rejected Alternatives
During design review we evaluated and rejected five simpler-looking patches:
- Changing only
byPass || retSettobyPass && retSetinsideisPutRetentionAllowed: Would fix F2 and F3, but would still leave F1 open on unlocked objects (whereisPutRetentionAllowedwas bypassed) and would wrongly requires3:BypassGovernanceRetentionwhen a user withs3:PutObjectRetentionextends a lock while happening to passx-amz-bypass-governance-retention: true. - Adding a
checkRequestAuthType(..., PutObjectRetentionAction)call at the top ofPutObjectRetentionHandler: At handler entry, the XML body (<Retention>) has not been parsed yet and the existing object’s tags have not been read from disk, so policy conditions ons3:object-lock-remaining-retention-days,s3:object-lock-retain-until-date,s3:object-lock-retention-mode, ands3:ExistingObjectTag/<key>would all fail! - Requiring
s3:BypassGovernanceRetentionwheneverx-amz-bypass-governance-retention: trueis present on the request: S3 only requiress3:BypassGovernanceRetentionwhen the operation actually needs governance bypass (clearing or shortening an activeGOVERNANCElock), not when locking an unlocked object or extending an existing lock. - Blocking
<Retention/>empty XML payloads: Clearing an activeGOVERNANCElock via<Retention/>is a documented MinIO/SILO extension used bymcli retention clear; it must remain supported when the caller holds boths3:PutObjectRetentionands3:BypassGovernanceRetentionand sendsx-amz-bypass-governance-retention: true. - Enforcing
SN-2026-016?versionId=rejection only on Object-Lock-enabled buckets: Allowing arbitrary clients to overwrite historical versions in place via?versionId=violates S3 versioning immutability on all versioned buckets, not just Object Lock buckets.
Implementation Notes
ParseObjectRetentionand empty<Retention/>(s3:object-lock-remaining-retention-days): When a caller sends<Retention/>to clear governance retention,retDateis the zerotime.Time{}. Previously,getRetentionDayscomputed a negative integer fromtime.Until(zeroTime)(~-106751days due totime.Duration290-year saturation!). Commit0d3ba5d4cnormalizes zeroretDateto0days and computes future remaining days using non-saturating calendar/seconds arithmetic so dates beyond year 2316 do not overflowtime.Duration.s3:ExistingObjectTag/<key>propagation: Both thes3:PutObjectRetentionands3:BypassGovernanceRetentionchecks receive the target version’s stored tags so tag-based IAM policies evaluate accurately.
Compatibility
- Standard S3 and
mcli retentionworkflows: Fully compatible. Callers that clear or shorten governance retention viamcli retention clear --governancemust hold boths3:PutObjectRetentionands3:BypassGovernanceRetention. - Active-active / site replication: Legitimate replication requests that preserve source version IDs must hold
s3:ReplicateObject(included in standard replication policies).
Regression Test Matrix
PR #234 and PR #235 lock down the full matrix in cmd/object-handlers-lock_test.go and cmd/bucket-versioning_test.go:
- All 7 rows of the
PutObjectRetentiondecision table (F1 zero-permission caller, F2PutObjectRetention-only caller, F3BypassGovernanceRetention-only caller withs3:object-lock-remaining-retention-daysconditions), - Far-future retention dates (
2400-01-01T00:00:00Z) exceedingtime.Duration’s 290-year limit, PutObject,CopyObject, andPutObjectExtractwith caller-supplied?versionId=/minio.versionId,- Directory object (
key/) andCompleteMultipartUploadin-place overwrites against lockednullversions.
Mitigation and Upgrade
Delivery Status
Both SN-2026-015 (0d3ba5d4c, PR #235) and SN-2026-016 (14042b5aa, PR #234) are shipped in SILO Server 20261018. Operators relying on S3 Object Lock WORM governance or compliance retention should upgrade to 20261018 immediately.
3 - September Security Repairs: Payload Integrity, IAM Revocation and Console Sharing
This bulletin covers three security repairs (SN-2026-012 through SN-2026-014) and adjacent dependency updates shipped in Server 20260916 and Console v2.4.1. See the security advisory ledger for the complete index.
| Finding | Fixed source | Delivery status |
|---|---|---|
SN-2026-012: presigned payload integrity |
Server c4b5e1cb4, PR #177 |
Shipped in Server 20260916 (affected in RELEASE.2026-09-03T13-18-01Z and earlier) |
SN-2026-013: durable IAM revocation |
Server PR #191 / PR #192 | Shipped in Server 20260916; requires coordinated multi-node/multi-site upgrade |
SN-2026-014: anonymous Console share proxy |
Console PR #56; Server PR #209 | Shipped in standalone Console v2.4.1 and embedded in Server 20260916 |
Presigned payload integrity — SN-2026-012
The generic authenticated handler path could verify a presigned request using a payload digest supplied via the X-Amz-Content-Sha256 header without verifying the actual request body bytes against that digest. A caller holding a presigned write URL could therefore substitute a different request body (the regression suite reproduces this against presigned PutBucketPolicy).
Commit c4b5e1cb4 in PR #177 binds body digest verification to the effective signed payload hash; any mismatch fails with XAmzContentSHA256Mismatch (400 Bad Request). An explicitly signed UNSIGNED-PAYLOAD token retains its standard meaning. The same PR also ensures IAM policy condition evaluation reads the authenticated signed values (with signed query parameters taking precedence over fallback headers) and removes the internal X-Amz-Signature-Age scratch header, complementing SN-2026-011.
Before upgrading, verify that custom SigV4 presigning clients send body payloads matching any signed X-Amz-Content-Sha256 digest.
Durable IAM revocation — SN-2026-013
Previously, delayed site-replication events, parent account recreation, or missing deletion history could resurrect identities or policy grants that an administrator had already deleted or revoked. PR #191 and PR #192 persist monotonic source revisions, deletion tombstones, and parent-revocation epochs embedded in child credentials so stale replication events and pre-revocation child tokens are rejected.
Because this change introduces persistent IAM revision and tombstone records:
- Upgrade all nodes sharing an IAM backend and all participating replication sites together; mixed-version operation and rolling downgrades are not supported.
mcli admin cluster iam exportexports active records only and omits deletion tombstones; maintain a full backend snapshot and follow the IAM upgrade and recovery guide.- See the IAM revocation design note for revision ordering, metrics, and group-membership scope.
Anonymous Console share proxy — SN-2026-014
Jiri Pejchal reported that the unauthenticated Console shared-download proxy accepted URLs pointing to the configured Server origin outside the intended presigned object-download surface.
Console PR #56 restricts scheme, host, and port to the configured S3 origin, requires a valid /{bucket}/{object} path, rejects reserved system prefixes, path traversal segments, and operation-changing query parameters, disables upstream HTTP redirects, and forwards caller context cancellation while preserving the exact signed URL bytes for valid object downloads.
Upgrade standalone Console deployments to Console v2.4.1, and upgrade SILO Server deployments (which embed Console via Server PR #209) to Server 20260916.
Adjacent dependency hardening
Server 20260916 upgrades github.com/rabbitmq/amqp091-go to v1.14.0 to address GHSA-6c5v-hqjr-5xxp (fixed upstream in v1.13.0), where a malicious AMQP broker could trigger excessive memory allocation when an AMQP bucket notification target is configured. It also adopts silo-pkg v3.14.1 with the updated JWX JSON encoding dependency.
4 - SN-2026-011: Fix and Release Status
SN-2026-011 was fixed on main starting with 123325430 on 2026-09-13 and shipped in Server 20260916. Server RELEASE.2026-09-03T13-18-01Z and earlier releases are affected (silo-pkg v3.14.0 and mcli 20260913 are client/library releases and do not patch existing Server deployments).
A holder of a signed PUT request could append an unsigned x-amz-copy-source header and turn the authorized upload into a server-side CopyObject of another object readable by the signing key. If the destination permits anonymous reads, the copied object’s contents become readable without holding the signing credentials. Both presigned URLs and Authorization-header requests were affected.
The fix verifies received x-amz-* headers against the request’s signed-header set (retaining only explicit protocol exceptions) before dispatching the operation. Follow-up request-signing and checksum coverage is documented in the signed-header design note.
Remediation
Upgrade SILO Server to Server 20260916 or later. Until all Server nodes are upgraded, scope write-signing credentials to the minimum required object prefixes and avoid granting broad read permissions to principals that issue presigned uploads into publicly readable buckets.
Reported by Oren Yomtov. See the security advisory ledger, the September 16 release hardening summary, and the component version matrix.
5 - CVE-2025-62506: Session-Policy Privilege Escalation
Status: Inherited and released
First SILO community release: RELEASE.2025-12-03T12-00-00Z
Upstream fixed release: RELEASE.2025-10-15T17-29-55Z
GitHub advisory: GHSA-jjjj-jwhf-8rgr
Upstream fix: minio/minio#21642
A service account or STS account constrained by a restricted session policy could invoke an “own account” Admin API operation to create a child service account without the session-policy restriction. Because the policy check set DenyOnly = true for own-account operations, any session policy that lacked an explicit Deny rule allowed the call, and the newly created child service account inherited the broader permissions of the parent user.
SILO did not require a separate backport. The upstream fix and its two regression test groups were already present in the Git history from which the first SILO community release (RELEASE.2025-12-03T12-00-00Z) was cut.
Commit mapping
| Role | Repository | Commit | Evidence |
|---|---|---|---|
| Upstream remediation | minio/minio |
c1a49490 |
Merge commit for minio/minio#21642 |
| SILO inheritance | pgsty/silo |
c1a49490 |
Preserves the exact Git commit object and SHA; RELEASE.2025-12-03T12-00-00Z and all subsequent SILO releases descend from it |
The identical commit SHA establishes direct source inheritance from upstream history. The Security Advisories ledger indexes the same commit mapping.
Regression evidence
Upstream PR #21642 added two regression test groups, each exercising both root and non-root parent accounts:
TestServiceAccountPrivilegeEscalationBug2_2025_10_15(cmd/admin-handlers-users_test.go) verifies that restricted service accounts cannot escalate privileges by creating unrestricted child service accounts.TestSTSPrivilegeEscalationBug2_2025_10_15(cmd/sts-handlers_test.go) verifies the same boundary for restricted STS temporary credentials.
Both test groups run as part of the standard go test ./cmd suite.
Fix and operator boundary
Whenever a session policy (sub-policy) is attached to the caller’s credential, the policy evaluator clears the DenyOnly flag before evaluating the session policy. An own-account operation can no longer pass simply because the session policy lacks an explicit Deny; the restricted session policy must explicitly grant Allow for the action.
Every SILO release contains c1a49490. Operators migrating from an upstream MinIO build older than RELEASE.2025-10-15T17-29-55Z should upgrade to SILO, audit service accounts created by restricted service or STS identities prior to the upgrade, and revoke any unauthorized child accounts.
Related advisory: CVE-2026-32285
Several vulnerability feeds later published CVE-2026-32285 with text describing github.com/buger/jsonparser while pointing to commit c1a49490. For the dependency reachability investigation and why no code change was made for that advisory, see CVE-2026-32285: The jsonparser Advisory That Required No Patch.
6 - SILO 20260903 Security Notes: SN-2026-006 through 010
This bulletin covers five security findings fixed in SILO Server 20260903 (RELEASE.2026-09-03T13-18-01Z), spanning customer-provided encryption keys (SSE-C), client-supplied replication headers, administrative status transitions, and explicit object-version deletion. SN-2026-006 through SN-2026-010 are SILO security-note identifiers grouping the pre-release audit findings inherited from the upstream minio/minio codebase.
Summary
| ID | Required attacker position | Security failure | Released behavior |
|---|---|---|---|
SN-2026-006 |
Read access to a zero-byte SSE-C object | A wrong customer key was accepted because no data block was decrypted | The object key is authenticated; wrong keys return 403 AccessDenied |
SN-2026-007 |
Read access to an SSE-C object, or a client-supplied replication marker | GetObjectAttributes returned protected metadata without authenticating the customer key |
Ordinary requests need the correct key; only an authorized replication peer uses the replica exception |
SN-2026-008 |
Any authenticated principal able to invoke the affected read/write/delete operation | Internal-looking headers granted replication-only effects without replication authorization | Exact marker plus s3:ReplicateObject or s3:ReplicateDelete is required |
SN-2026-009 |
Authenticated admin API principal holding only one status action | The enable action authorized disable, and vice versa | The requested target state selects the required admin action |
SN-2026-010 |
Authenticated S3 principal with s3:DeleteObject but not s3:DeleteObjectVersion |
A caller could permanently delete an explicit version without the AWS-required action | Explicit version deletion requires s3:DeleteObjectVersion |
SN-2026-006: zero-byte SSE-C key authentication
Affected operations: GetObject, HeadObject, CopyObject source processing, and GetObjectAttributes for zero-byte objects encrypted with SSE-C.
Tracking: issue #82.
Fixes: b73581b05 and c4fd97d0b.
SSE-C stores a sealed object key and requires the caller to supply the matching customer key on reads. Normal reads authenticate that key while preparing the encrypted payload stream. Because a zero-byte object has no payload block, the legacy fast path returned before unsealing the stored object key.
As a result, an authenticated caller with read access to a zero-byte SSE-C object could receive a successful response while supplying an incorrect customer key, or copy the object under a newly chosen key without proving knowledge of the original key.
The fix authenticates the stored SSE-C key regardless of payload length. Valid keys succeed as before; wrong keys fail with 403 AccessDenied, including null-version and key-rotation paths.
SN-2026-007: SSE-C GetObjectAttributes
Affected operation: GetObjectAttributes on SSE-C objects.
Tracking: issue #84.
Fixes: 474cd5801, 74c97d005, and 21870fa2e.
GetObjectAttributes exposes object size, ETag, checksums, storage class, and multipart part metadata. The inherited handler returned those attributes without authenticating the supplied SSE-C key, and also treated the raw X-Minio-Source-Replication-Request header as permission to skip key verification.
The fix enforces two distinct rules:
- an ordinary S3 caller must supply the valid SSE-C customer key (wrong or missing keys return
403 AccessDenied); - a replication peer may use the replica metadata path only after passing authentication and
s3:ReplicateObjectauthorization.
SN-2026-008: replication headers are not authority
Affected surface: object reads and writes, multipart completion, object and version deletion, Snowball extraction, Object Lock timestamps, checksum metadata, and bucket events.
Tracking: PR #101.
Primary fix range: 938603458 through 04b097fd9, followed by Snowball and rule-prefix hardening.
While CVE-2026-34204 blocked ordinary PUT and COPY requests from importing a subset of replication SSE metadata, a wider audit showed that several downstream handlers still treated the presence of internal replication headers as proof of a trusted replication request.
Depending on the operation, a caller holding ordinary read or write permissions could:
- trigger an SSE-C no-decryption read path without supplying the customer key;
- preserve a client-supplied source ETag or
MTime; - inject source checksum, actual-size, retention, legal-hold, or
REPLICAstatus metadata; - invoke replication-only delete semantics;
- suppress bucket notification events;
- carry replication trust across Snowball archive entries.
The fix establishes a single receiver-wide trust calculation:
- authenticate the incoming request in its original signed form;
- require a single
X-Minio-Source-Replication-Requestheader with the exact lowercase valuetrue; - authorize
s3:ReplicateObjectors3:ReplicateDeleteon the target resource; - grant
replicaTrustedonly when the authenticated replication status also equalsREPLICA; - bind the trust decision to a private
req.Context()key; - strip untrusted internal replication headers on a post-authentication request clone while preserving the trailer map for streaming checksum verification.
See No I/O Before Auth, No Privilege From Headers for the full protocol matrix, CORS interaction, black-box two-site replication verification, and rejected alternatives.
SN-2026-009: user and group status authorization
Affected operations: admin SetUserStatus and SetGroupStatus.
Tracking: PR #73 / PR #85.
Fixes: 58735ee38 and 229fe2b3c.
The inherited handlers checked admin:EnableUser or admin:EnableGroup before inspecting the requested status parameter. A delegated administrator permitted only to enable accounts could therefore disable them as well, while an administrator granted only admin:DisableUser or admin:DisableGroup was rejected.
The requested target state now selects the required admin action:
| Target | Required action |
|---|---|
| Enable user | admin:EnableUser |
| Disable user | admin:DisableUser |
| Enable group | admin:EnableGroup |
| Disable group | admin:DisableGroup |
admin:* and the built-in consoleAdmin policy remain unchanged; only custom least-privilege policies that relied on EnableUser/EnableGroup to perform both transitions require updating.
SN-2026-010: explicit object-version deletion
Affected operations: DeleteObject and each DeleteObjects entry carrying an explicit versionId.
Tracking: issue #58 and PR #104.
Fix range: 75a6734e4 through d2d47a41f.
Previously, the S3 delete handlers authorized explicit-version deletion under s3:DeleteObject and checked s3:DeleteObjectVersion only as a secondary deny filter, allowing callers with s3:DeleteObject alone to permanently delete historical versions.
Server 20260903 requires s3:DeleteObjectVersion whenever a versionId is specified. Unversioned DELETE requests continue to check s3:DeleteObject, and replication targets continue to check s3:ReplicateDelete. Callers granted only s3:DeleteObject can no longer delete explicit versions, and policies that pair Allow s3:* with Deny s3:DeleteObject to prevent permanent deletion must also deny s3:DeleteObjectVersion.
Operator actions
Before upgrading:
- identify policies and applications that delete explicit object versions and grant or deny
s3:DeleteObjectVersionexplicitly; - inspect custom admin policies for user and group enable/disable operations;
- confirm that replication service accounts hold
s3:ReplicateObject/s3:ReplicateDeleteand that ordinary application identities do not; - verify that SSE-C clients send the matching customer key on
GET,HEAD,GetObjectAttributes, andCopyObjectsource reads, including zero-byte objects.
After upgrading:
- verify both wrong-key (
403 AccessDenied) and valid-key responses on a test SSE-C object; - test object and delete replication across paired sites;
- test delegated user/group enable and disable permissions independently;
- verify explicit-version deletion with principals holding
s3:DeleteObjectalone vs.s3:DeleteObjectVersion.
Verification boundary
All five fixes are covered by unit, handler, authorization, streaming-trailer, Snowball, and site-replication regression tests. Full local acceptance on ebac0ca73 passed the cmd race suite and 174 test cases across six deployment topologies (174 PASS / 0 FAIL), followed by the RELEASE.2026-09-03T13-18-01Z release gates documented in the pre-release review.
Residual risks outside this bulletin
- Conditional deletion (
If-MatchonDeleteObjectand per-object ETag onDeleteObjects, #10) and bucket-metadata deletion convergence across site-replication peers (#77) are tracked separately; see the Server 20260903 release notes and Server 20260916 release notes.
7 - CVE-2026-32285: The jsonparser Advisory That Required No Patch
Status: Closed without a code change
GitHub issue: pgsty/silo#26
Referenced upstream commit: c1a49490 (CVE-2025-62506)
The initial assessment of CVE-2026-32285 assumed that the repository might still carry a vulnerable version of github.com/buger/jsonparser, and the team briefly considered replacing the package or maintaining a local fork. Checking the advisory’s referenced upstream commit, the resolved Go module version, and actual symbol reachability changed the conclusion:
- Public vulnerability feeds mislabeled
CVE-2026-32285as ajsonparserissue while pointing to upstream commitc1a49490, which is the session-policy privilege-escalation fix for CVE-2025-62506 already included in every SILO release sinceRELEASE.2025-12-03T12-00-00Z. - For
github.com/buger/jsonparseritself, the repository already resolvedv1.1.2, which contains the upstreamjsonparserfixes, andgovulncheckfound no reachable vulnerable symbol.
Instead of manufacturing a cosmetic dependency bump, we recorded the verification evidence and closed pgsty/silo#26.
What was wrong with the initial assumption
The issue was initially framed around the premise that jsonparser had no fixed upstream version in the tree. Acting on that premise without verifying the module graph and commit provenance would have caused real engineering harm:
- altering the dependency graph for zero security benefit;
- risking subtle behavioral regressions in JSON parsing paths for a nonexistent vulnerability;
- taking on long-term maintenance of an unnecessary dependency fork;
- misleading operators into believing that prior SILO releases were exposed when they were not.
Leaving already-remediated code unchanged is a deliberate security decision when backed by version and reachability evidence.
Investigation
The investigation verified four distinct layers:
- Commit provenance: Checked the commit linked by the CVE feed (
c1a49490) and confirmed it is the inherited session-policy fix (CVE-2025-62506). - Resolved module version: Verified
go.modandgo.sumto confirm thatgithub.com/buger/jsonparserresolves tov1.1.2, which already includes the upstreamjsonparserpatch. - Symbol reachability: Ran
govulncheckacross the tree and confirmed that no vulnerable symbol is reachable. - Feed discrepancy attribution: Traced the remaining alarm to mismatched metadata in external vulnerability feeds rather than unpatched code in the SILO tree.
When evaluating any dependency advisory, four questions must be kept separate: whether a package version was once listed as affected, whether the module is imported, whether a vulnerable symbol is reachable in the compiled call graph, and whether an external entry point can trigger that path with untrusted input.
Why there was no “just in case” upgrade
When the selected version in go.mod already contains the fix, bumping to an arbitrary commit or swapping libraries does not improve security. In a large Go module graph, unmotivated dependency churn expands the regression surface and obscures the cause of later behavioral changes.
We therefore took four concrete steps:
- closed the issue without a synthetic commit;
- recorded the commit provenance,
v1.1.2selection, andgovulncheckoutput in pgsty/silo#26; - retained automated
govulncheckandgo.modversion gates in CI to prevent accidental dependency downgrades; - indexed
CVE-2026-32285alongsideCVE-2025-62506in the Security Advisories ledger so operators auditing scanner reports can find an authoritative answer.
Verification boundary
The investigation on 2026-04-15 established that SILO’s module graph (github.com/buger/jsonparser v1.1.2) and commit history (c1a49490) required no source change for CVE-2026-32285. Any future change to module selection or jsonparser imports remains subject to the standard CI govulncheck gate.
Engineering principle
For dependency CVEs, verify in order: what commit or version the advisory actually points to, which version go.mod selects, whether any vulnerable symbol enters the call graph, and whether a runtime entry point exposes it. Only when those checks reveal an unpatched or reachable flaw should the investigation produce a code diff.
8 - CVE-2026-33322: OIDC JWT Algorithm Confusion
Status: Released
First containing release: RELEASE.2026-04-17T00-00-00Z
Affected entry points: AssumeRoleWithWebIdentity, AssumeRoleWithClientGrants
Fix commit: d24f449
GitHub issue: pgsty/silo#22
The previous OIDC implementation in internal/config/identity/openid placed the relying-party client_secret into the JWT verification keyring while also accepting HMAC signing algorithms (HS256, HS384, HS512). An attacker who obtained that client_secret could therefore sign a forged ID token with HMAC and exchange it through STS (AssumeRoleWithWebIdentity or AssumeRoleWithClientGrants) for temporary S3 credentials.
The final fix (d24f449) enforces asymmetric, JWKS-only verification and deliberately drops HS256/HS384/HS512 token support rather than keeping a configuration toggle that would reintroduce ambiguous trust semantics.
Why the vulnerability was key-role confusion, not missing signature verification
The vulnerable code did verify JWT signatures before issuing STS credentials. The failure lay in which keys the verifier accepted and whether the token’s alg header could select a signing algorithm whose trust semantics did not match the key’s intended role.
In OIDC, the client_secret authenticates the relying party (SILO) to the identity provider’s Token Endpoint. It is a shared credential between the relying party and the IdP—not an asymmetric signing key used by the IdP to assert user identities. By loading client_secret into the local token.Parse keyring alongside JWKS public keys, the verifier allowed a single credential leak to escalate into full identity forgery:
- The attacker obtained the configured OIDC
client_secret. - The attacker constructed a JWT claiming arbitrary user or role attributes and signed it with
HS256,HS384, orHS512using theclient_secret. - The verifier matched the HMAC algorithm against the
client_secretin its keyring and accepted the signature. - The forged token passed
AssumeRoleWithWebIdentityorAssumeRoleWithClientGrantsand returned valid temporary STS credentials.
A compatibility path was implemented, then deliberately removed
During development, we first implemented an explicit algorithm-allowlist and allow_hmac-style compatibility prototype (fc47fa0). On the surface, an opt-in flag appeared reasonable: enforce asymmetric verification by default while letting deployments with HMAC-based tokens opt in explicitly.
Design review overturned that approach. Keeping client_secret anywhere inside the local JWT verification keyring meant that enabling the option would silently widen the entire STS trust boundary: anyone holding the relying-party secret could again mint arbitrary user tokens. Furthermore, any future drift in the parser’s algorithm allowlist or JWKS retry path could reopen the vulnerability by default.
| Option | Benefit | Risk | Decision |
|---|---|---|---|
Keep client_secret in keyring and filter algorithms |
Minimal code diff; preserves HMAC IdPs | Still conflates Token Endpoint client auth with local JWT signature verification | Rejected |
Add an allow_hmac configuration option (fc47fa0) |
Makes HMAC compatibility explicit | Difficult for operators to reason about safely; expands parser and retry test surface | Implemented, then removed |
Enforce JWKS-only asymmetric verification (d24f449) |
Clean separation of key roles; primary parse and JWKS refresh share one parser policy | Identity providers issuing HS256/384/512 tokens must migrate |
Adopted |
Removing the completed compatibility implementation produced a much simpler and more auditable security boundary.
Final invariants
Commit d24f449 establishes four invariants in internal/config/identity/openid:
- Verifier keys are loaded exclusively from the identity provider’s JWKS endpoint (
jwks_uri). - The OIDC
client_secretnever enters the local JWT verification keyring. HS256,HS384, andHS512tokens are rejected unconditionally.- Both the initial token verification pass and the JWKS cache-refresh/retry path use the same asymmetric algorithm allowlist (
RS256,RS384,RS512,ES256,ES384,ES512).
We also kept the algorithm scope tight rather than expanding JOSE support during a security fix: PS256 and EdDSA remain unsupported.
Verification and release
The test suite in internal/config/identity/openid covers HS256 token rejection, RS256 token acceptance, and JWKS refresh/retry behavior when a key rotation occurs. All temporary compatibility helpers, configuration flags, and HMAC opt-in tests from the initial prototype were stripped from the final commit (d24f449), which shipped in RELEASE.2026-04-17T00-00-00Z.
Compatibility trade-offs
This fix introduces an intentional breaking change. Identity providers configured to issue HS256, HS384, or HS512 ID tokens must migrate to JWKS-backed RSA or ECDSA signing keys before upgrading SILO. If you suspect the OIDC client_secret was exposed prior to upgrading, rotate the secret in both the identity provider and SILO configuration.
9 - CVE-2026-33419: LDAP STS Enumeration and the Throttling Chain
Status: Released, followed by two rounds of hardening
First containing release: RELEASE.2026-04-17T00-00-00Z
Complete correction: RELEASE.2026-06-18T00-00-00Z
Fix commits: 3b950f8, 18b712d, 9e10f6d, f441108, 5e40665
GitHub issue: pgsty/silo#23
The original defect in AssumeRoleWithLDAPIdentity was straightforward: LDAP STS returned distinct errors for “user does not exist” and “password is wrong,” creating an unauthenticated username oracle. Our first patch unified the external authentication failure response and added rate-limit buckets keyed by both source IP and username.
Subsequent design reviews uncovered a deeper problem: naive success refunds, spoofable proxy headers, reservation accounting, and the shared username bucket itself turned the rate limiter into a targeted denial-of-service primitive. The final June design (5e40665) removed the shared username bucket that enabled cross-origin account lockouts, retained the per-source-IP bucket, and formalized proxy IP attribution as an explicit deployment contract.
Initial threat model
The vulnerable entry point is AssumeRoleWithLDAPIdentity. An attacker requires no existing S3 or LDAP credentials; network access to the STS endpoint is sufficient to compare the HTTP status code and error message returned for an unknown username versus a wrong password, enumerate valid directory accounts, and follow up with password spraying.
At the same time, remediation could not flatten every LDAP error into “invalid credentials.” Directory connection failures, lookup-bind credential errors, and LDAP server timeouts must still surface as HTTP 500 infrastructure errors so operators can detect and diagnose directory outages.
First round: uniform responses and a dual-key limiter
The initial patch on 2026-04-15 (3b950f8) introduced three changes:
- unknown-user lookups and bad-password binds both returned the same external
400 InvalidParameterValueSTS authentication error; - LDAP infrastructure failures continued to return HTTP
500, logging the underlying directory error on the server; - a new in-memory limiter throttled authentication attempts using buckets for both client source IP and normalized
LDAPUsername.
While this closed the response-content oracle and raised the cost of brute-force guessing, reviewing the limiter’s state machine and IP extraction immediately exposed flaws in how tokens were consumed and attributed.
Second round: success refunds, proxy attribution, and token accounting
Follow-up commits on April 16 (18b712d, 9e10f6d, f441108) fixed three defects in the initial limiter:
- Explicit reserve/commit/cancel/refund lifecycle: Successful authentication and LDAP infrastructure errors must refund their reserved token so legitimate high-frequency logins do not exhaust the failure allowance.
- Untrusted proxy headers: The limiter must default to the TCP socket peer (
RemoteAddr) and ignore client-suppliedX-Forwarded-For,X-Real-IP, andForwardedheaders unless the socket peer matches an explicit trusted-proxy allowlist. - Bounded token refunds: Cancellation and refund paths must clamp token counts to bucket capacity so concurrent cancellations cannot mint extra capacity.
Third round: why we deleted the username bucket
A second design review in June overturned our initial assumption that “throttling by source IP plus username is strictly stronger than throttling by source IP alone.”
Because the per-username bucket was shared globally across all client origins on a node, an attacker who knew a victim’s LDAPUsername could exhaust that user’s bucket from any IP address (or across rotating IPs). With only a handful of failed requests per minute, an attacker could keep a targeted user’s AssumeRoleWithLDAPIdentity calls permanently throttled before the legitimate user’s request ever reached an LDAP bind.
Commit 5e40665 (shipped in RELEASE.2026-06-18T00-00-00Z) therefore made five adjustments:
- deleted the shared per-username bucket so one origin cannot lock out a specific
LDAPUsernamefor other origins; - peeled trusted hops from
X-Forwarded-Forright-to-left and selected the first untrusted client address; - rejected catch-all trusted-proxy CIDRs (
0.0.0.0/0and::/0) that would allow any client to spoofX-Forwarded-For; - dropped RFC 7239
Forwardedfrom security-sensitive rate-limit bucketing; - accepted
X-Real-IPonly when the immediate peer is a configured trusted proxy that overwrites the header.
This proxy trust model was subsequently generalized across all S3 and Admin handlers as SN-2026-018 (MINIO_API_TRUSTED_PROXIES).
Rejected alternatives
| Option | Why it was rejected |
|---|---|
| Perform a dummy LDAP bind for unknown users | Amplifies load on the LDAP directory and adds a second bind path after the response-content oracle is already closed |
Bucket all IPv6 clients by /64 prefix |
Causes unrelated users behind the same enterprise or carrier /64 prefix to throttle one another |
Take the leftmost X-Forwarded-For value |
The leftmost entry is controlled by the client and trivially spoofed |
Fall back to the socket peer when X-Forwarded-For and X-Real-IP disagree |
An attacker can intentionally inject a conflicting header to force fallback and collapse all users behind a proxy into a single bucket |
Parse RFC 7239 Forwarded for rate limiting |
High parser complexity and ambiguity with no practical security advantage over X-Forwarded-For and X-Real-IP |
Verification and release
The unit test suite verifies the limiter’s reserve/commit/cancel/refund state machine, concurrent access, token refunds on successful binds and LDAP infrastructure errors, external response equivalence between unknown users and bad passwords, and source-IP extraction across direct RemoteAddr, spoofed headers, single- and multi-hop trusted proxies, and rejected catch-all CIDRs (0.0.0.0/0, ::/0).
Note that the end-to-end LDAP security test skips live directory binds when _MINIO_LDAP_TEST_SERVER is unset; full end-to-end verification requires pointing _MINIO_LDAP_TEST_SERVER at a live LDAP instance in addition to running the package unit tests.
Final trade-offs and operational boundaries
- Source-IP-only throttling: Removing the username bucket prevents targeted cross-IP account lockouts, at the cost of no longer enforcing a single-account failure cap across distributed botnet origins.
- Per-node memory state: The limiter is per-node and in-memory rather than a cluster-wide password defense; distributed spraying and LDAP bind timing differences still require directory-side policies or edge rate limiting.
- Proxy configuration: Deployments behind a reverse proxy must configure trusted proxy CIDRs (
MINIO_API_TRUSTED_PROXIES) so legitimate clients behind the proxy are bucketed by their client IP rather than the proxy’s socket address. - Uniform responses are the primary defense: Rate limiting slows down guessing from a single source, while the unified
400 InvalidParameterValueresponse is what closes the username enumeration oracle.
10 - CVE-2026-34204: Replication Metadata Injection
Status: Released
First containing release: RELEASE.2026-04-17T00-00-00Z
Fix commit: 56fa63b
GitHub issue: pgsty/silo#24
Before 56fa63b, ordinary PutObject and CopyObject requests could smuggle client-supplied X-Minio-Replication-* headers into internal X-Minio-Internal-* SSE and replication metadata. That mismatch between the stored metadata and the actual write path could leave the resulting object unreadable.
The fix stops accepting replication-only metadata in the default extraction path, restores those fields solely inside flows explicitly authorized for ReplicateObjectAction (s3:ReplicateObject), and sanitizes CopyObject request headers before any downstream header consumer runs.
Threat model
Exploiting this flaw required only ordinary object-write permission (s3:PutObject), not internode or replication credentials. Although the input arrived in client-controlled X-Minio-Replication-* HTTP headers, metadata extraction converted those headers into internal replication and SSE state without checking whether the caller was authorized to replicate objects.
When a subsequent GetObject or HeadObject request read the object, the server interpreted the stored object according to that forged internal state and failed to read or decrypt it. Because any authenticated writer could trigger this integrity and availability failure, every server accepting untrusted S3 writes was affected.
The underlying flaw was that client-controlled headers acquired privileged internal semantics before passing a replication authorization check.
Why we chose surgical stripping over rejecting the whole request
The most obvious fix would have been to reject any non-replication request carrying an X-Minio-Replication-* header. However, returning 400 or 403 whenever an extraneous header appears would turn previously ignored headers into hard client failures.
Instead, we designed a strip-by-default, restore-after-auth model:
- the default metadata extraction helper in
cmd/handler-utils.gonever accepts replication-only metadata; - ordinary
PutObjectandCopyObjectrequests strip those fields before building object metadata; - only code paths that have passed
ReplicateObjectActionauthorization explicitly restore replication-only metadata; - replica-status writes require the same
ReplicateObjectActionauthorization; - legitimate multipart and Snowball replication flows explicitly restore the SSE metadata they require after authorization.
This design closes the injection path while keeping wire behavior unchanged for ordinary clients that happen to pass unrecognized headers.
Why CopyObject had to sanitize headers before cleanMetadata
In CopyObject, request headers are not used solely to construct the final destination metadata map. Earlier stages in the handler inspect request headers for precondition evaluation and SSE-C source handling.
If untrusted X-Minio-Replication-* headers were stripped only inside cleanMetadata right before writing the destination object, those earlier consumers would already have acted on the forged headers. Moving header sanitization to the very beginning of CopyObject—before precondition and SSE-C helpers run—enforces a single invariant across the entire handler: untrusted replication headers never enter internal request semantics.
Implementation and verification
Commit 56fa63b updated cmd/handler-utils.go, object handlers, and multipart handlers, backed by regression tests across multiple layers:
- helper-level unit tests for trusted versus untrusted metadata extraction;
- handler-level tests for malicious
PutObjectandCopyObjectrequests carryingX-Minio-Replication-*headers; CopyObjectearly-sanitization tests verifying that precondition and SSE-C paths do not observe stripped headers;- end-to-end verification confirming that forged replication headers no longer produce unreadable objects, while authorized replication, multipart, and Snowball replication flows continue to succeed.
Trade-offs and subsequent hardening
- Default stripping: Internal replication headers sent by ordinary S3 clients are silently stripped rather than honored.
- Fail-safe default for new endpoints: Because replication-only metadata must be explicitly restored inside an authorized branch, forgetting to call the restore helper in a future replication path causes a functional test failure rather than silently reopening an untrusted injection path.
- Follow-up receiver-wide hardening:
- SN-2026-008 (
RELEASE.2026-09-03T13-18-01Z) and the CORS and replication trust design record generalized this pattern into a single post-authentication trust boundary across all internal replication headers (X-Minio-Source-Etag,X-Minio-Source-Mtime,X-Minio-Source-Replication-Request, replication SSE key headers, andX-Amz-Bucket-Replication-Status), covering SSE-C reads, source timestamps, multipart completion, object lock, deletes, and events. - Replica Metadata Normalization (
RELEASE.2026-09-16T00-00-00Z, PR #194) subsequently ensured that even authorized replicationCopyObjectrequests normalize transport-onlyaws-chunkedContent-Encodingvalues and strip GHSA-redacted metadata keys rather than re-injecting raw request headers into stored objects.
- SN-2026-008 (
11 - CVE-2026-39414: Oversized S3 Select Records and a SIMD Bypass
Status: Released; second-round fix completed in June
Initial fix release: RELEASE.2026-04-17T00-00-00Z
Complete fix release: RELEASE.2026-06-18T00-00-00Z
Fix commits: 3252d5b, 5dc705936 (fd69c89)
GitHub issue: pgsty/silo#25
Our initial April patch (3252d5b) reused the existing 1 MiB maxCharsPerRecord limit for both CSV and standard JSON Lines in S3 Select, stopping unbounded buffering while waiting for a record delimiter and returning the S3 OverMaxRecordSize error code to clients.
A follow-up call-chain audit in June uncovered a hardware-dependent gap: on x86_64 CPUs with AVX2/AVX-512 support where simdjson.SupportedCPU() returned true, JSON Lines took a separate simdjson fast path that bypassed the bounded json.PReader completely. The June fix (5dc705936 / fd69c89) routed JSON Lines through the bounded reader on every CPU architecture, while also correcting streaming error codes, parser error wrapping, and pre-error output queue flushing.
Threat model
An authenticated caller with s3:GetObject permission can issue an S3 Select (SelectObjectContent) query against an object containing an arbitrarily long single record without a newline delimiter. If the record reader buffers bytes without an upper bound until it encounters a delimiter, a single query can exhaust server memory and CPU.
Hardware-dependent dispatch makes this class of bug harder to catch: the same S3 Select query can execute different parser implementations depending on the host CPU’s instruction-set flags. Passing a test on a non-SIMD machine does not guarantee that production hosts with AVX2/AVX-512 are protected.
First round: reusing the existing 1 MiB invariant
Rather than introducing a new configuration knob, the April patch (3252d5b) applied the existing maxCharsPerRecord = 1 MiB (1048576 bytes) constant across CSV and standard line-delimited JSON:
- the CSV splitter and line-delimited JSON reader rejected records exceeding 1 MiB before buffering or parsing them further;
- the reader preserved the earliest splitter error instead of letting a subsequent partial decode overwrite it;
- the error propagated to the client as
OverMaxRecordSizerather than collapsing into a genericInternalErrorso clients and monitoring can distinguish an enforced record-size limit from a server fault.
Second round: the hardware-dependent SIMD bypass
Tracing the full S3 Select JSON dispatch chain in June revealed a second path for JSON Lines (DOCUMENT type LINES):
Whenever simdjson.SupportedCPU() returned true, JSON Lines bypassed the bounded json.PReader and called simdj.NewReader. Inside simdjson.ParseNDStream, the third-party parser continued reading across chunk boundaries until it found a newline. Wrapping the underlying io.Reader in a byte-counting adapter could not solve the problem cleanly: because ParseNDStream reads ahead in chunks, a generic wrapper cannot simultaneously preserve already-completed records in the buffer and enforce an exact 1 MiB cap on the next record.
Rather than layering a brittle byte-counting wrapper around simdjson.ParseNDStream, commits 5dc705936 and fd69c89 disabled the SIMD NDJSON path and routed JSON Lines through the bounded json.PReader on all CPUs. If a SIMD fast path is reintroduced in the future, the SIMD parser itself must enforce the 1 MiB per-record limit and pass the same CPU-independent regression suite.
Stream semantics corrected in the same round
Reviewing the S3 Select streaming pipeline during the June fix also resolved four adjacent protocol issues:
- Interface-based error matching: Used
errors.Asto pass through any error implementingSelectError, rather than matching only a single concrete struct type. - Typed JSON parser errors: Wrapped syntax failures in the JSON worker as
JSONParsingErrorinstead of returning untyped errors. - Pre-error batch flushing: Flushed completed records still waiting below the batch threshold in the output queue before emitting a terminal error event, matching S3 Select streaming semantics.
- Input-order error priority: Preserved the first error encountered in input order so a later oversized record cannot overwrite an earlier parse failure on a preceding line.
Deliberately kept out of scope
To keep the security patch focused and avoid speculative behavior changes, three adjacent topics were left out of scope:
- the historical interaction between CSV
AllowQuotedRecordDelimiterand the outer physical-newline splitter; - whether
\rinCRLFcounts toward the 1 MiB record length limit; - re-implementing a bounded SIMD NDJSON parser before having a design that enforces record boundaries inside
simdjson.
Verification and release
The regression suite in internal/s3select tests oversized CSV and JSON Lines records, OverMaxRecordSize and JSONParsingError propagation, pre-error record flushing, and parser behavior across both SIMD and non-SIMD code paths.
The initial fix shipped in RELEASE.2026-04-17T00-00-00Z (3252d5b), and the complete SIMD-independent fix shipped in RELEASE.2026-06-18T00-00-00Z (5dc705936 / fd69c89).
Final trade-offs
- Throughput vs. predictable bounds: Routing JSON Lines through
json.PReadergives up SIMD NDJSON acceleration on AVX2/AVX-512 hosts in exchange for a strict 1 MiB per-record memory bound on every CPU. - 1 MiB record ceiling: S3 Select queries against CSV or JSON Lines objects with individual records larger than 1 MiB are rejected with
OverMaxRecordSize; callers must split records exceeding 1 MiB. - Quoted multiline CSV: Quoted multiline CSV delimiter semantics remain tracked separately from this CVE.
12 - CVE-2026-40344: Snowball Auto-Extract Authentication Bypass
Status: Released
First containing release: RELEASE.2026-04-17T00-00-00Z
Fix commit: efb6e5b00
GitHub advisory: GHSA-9c4q-hq6p-c237
Snowball’s PutObjectExtractHandler (X-Amz-Meta-Snowball-Auto-Extract: true) previously omitted the authTypeStreamingUnsignedTrailer authentication case. A tar archive sent with X-Amz-Content-Sha256: STREAMING-UNSIGNED-PAYLOAD-TRAILER and a forged signature could enter untar() before authentication completed, allowing a single unauthenticated request to fan out into multiple object writes.
Commit efb6e5b00 initializes newUnsignedV4ChunkedReader(), validates the decoded content length, and completes SigV4 verification before any tar byte reaches the extractor.
Why the commit subject used a temporary identifier
When the fix was committed on 2026-04-16, the official CVE number had not yet been published by GitHub Security Advisories, so the Git commit subject used the temporary placeholder fake CVE-2026-40028. Once GHSA-9c4q-hq6p-c237 was published, the official identifier was assigned as CVE-2026-40344. We kept the immutable Git commit SHA efb6e5b00 intact and mapped it to CVE-2026-40344 in the advisory ledger.
From one missing authentication branch to bulk object writes
The affected entry point is PutObjectExtractHandler, which handles Snowball batch archive uploads (X-Amz-Meta-Snowball-Auto-Extract: true). When the client selected STREAMING-UNSIGNED-PAYLOAD-TRAILER (authTypeStreamingUnsignedTrailer), PutObjectExtractHandler lacked the switch branch that standard PutObjectHandler used to authenticate the request and wrap the chunked stream.
The impact exceeded a single unauthorized object write: as soon as the unauthenticated tar stream entered untar(), the server extracted each archive entry as a separate object write. A single missing authentication branch was therefore amplified into an arbitrary multi-object write primitive.
Final invariant: the extractor sees zero bytes on authentication failure
Design review established a strict requirement for the fix:
If authentication fails,
untar()must have read zero bytes.
That invariant rules out any design that begins streaming or extracting archive entries and attempts to roll back created objects if a later check fails. Because Snowball extraction writes objects across multiple internal paths, proving a clean rollback is far more error-prone than proving that unauthenticated bytes never cross into untar(). Authentication had to close before the extractor was invoked.
Implementation
Commit efb6e5b00 aligns PutObjectExtractHandler with standard PutObjectHandler for authTypeStreamingUnsignedTrailer:
- adds the
authTypeStreamingUnsignedTrailercase to the handler’s authentication switch; - parses and validates
X-Amz-Decoded-Content-Length; - wraps the request body with
newUnsignedV4ChunkedReader(), which enforces SigV4 signature verification before yielding payload bytes (sharing the reader boundary hardened in CVE-2026-41145); - preserves valid signed Snowball auto-extract uploads and trailing CRC32 checksum verification.
Verification
Regression tests in cmd verify the full boundary:
- rejection of a Snowball unsigned-trailer request carrying a forged SigV4 signature with zero objects created;
- rejection of anonymous Snowball auto-extract writes to non-public buckets;
- successful extraction of valid signed Snowball unsigned-trailer archives with trailing CRC32 checksums.
Compatibility and operational boundaries
- Rejected unverified combinations: Clients that sent unsigned-trailer Snowball requests with invalid or unverified query-string signatures will now fail with
AccessDeniedbefore extraction begins. - Shared reader boundary: Because Snowball auto-extract and standard
PutObjectnow sharenewUnsignedV4ChunkedReader()for unsigned-trailer streams, any future change to chunked trailer parsing must run regression tests for both paths. - Orthogonal archive surfaces: Closing pre-extraction authentication does not alter how
untar()processes archive entry paths, sizes, or object counts once an authenticated caller is inside the extractor.
13 - CVE-2026-41145: Unsigned-Trailer Query Authentication Bypass
Status: Released
First containing release: RELEASE.2026-04-17T00-00-00Z
Fix commit: f444b6f37
GitHub advisory: GHSA-hv4r-mvr4-25vw
Before f444b6f37, query-string SigV4 credentials could enter a STREAMING-UNSIGNED-PAYLOAD-TRAILER upload while the handlers verified the request signature only when an HTTP Authorization header was present. An attacker who knew a valid access-key identifier could therefore complete an object or multipart write using a forged query-string signature.
The fix moves presigned-credential rejection and SigV4 verification directly into newUnsignedV4ChunkedReader() (cmd/streaming-v4-unsigned.go), ensuring every handler that consumes an unsigned-trailer chunked stream shares a single authentication boundary.
Commit identifier note
When the patch was written on 2026-04-16, the official CVE had not yet been assigned, so the commit subject used the temporary placeholder fake CVE-2026-40027. Upon publication of GHSA-hv4r-mvr4-25vw, the vulnerability was assigned CVE-2026-41145. The historical commit SHA f444b6f37 remains unchanged.
Root cause: coupling signature verification to credential transport
The vulnerable entry points included PutObject and PutObjectPart (UploadPart). When a client sent X-Amz-Content-Sha256: STREAMING-UNSIGNED-PAYLOAD-TRAILER and supplied its SigV4 credentials (X-Amz-Credential, X-Amz-Signature, etc.) in the URL query string rather than the Authorization header, two assumptions collided:
- The handler checked whether the
Authorizationheader was non-empty to decide whether to runreqSignatureV4Verifybefore reading the stream, assuming an empty header meant an anonymous request. - Identity resolution and
newUnsignedV4ChunkedReader()still consumed the request normally while attributing the write to the access key named inX-Amz-Credential.
As a result, query-authenticated STREAMING-UNSIGNED-PAYLOAD-TRAILER requests bypassed signature verification altogether. An attacker only needed to know a valid access-key ID; the signature value in the query string was never checked.
Why we placed the check inside the shared reader instead of individual handlers
| Option | Risk | Decision |
|---|---|---|
Add header/query checks separately to PutObject and PutObjectPart |
Closes the two known handlers, but any sibling or future caller of newUnsignedV4ChunkedReader() can omit the check again (as CVE-2026-40344 demonstrated) |
Rejected |
Invent a custom presigned STREAMING-UNSIGNED-PAYLOAD-TRAILER verification flow |
Expands the SigV4 streaming state machine without an existing S3 compatibility contract | Rejected |
Reject presigned credentials and verify SigV4 inside newUnsignedV4ChunkedReader() |
Forces every caller consuming the stream through one authentication gate | Adopted |
Note that genuinely anonymous STREAMING-UNSIGNED-PAYLOAD-TRAILER uploads were not disabled. When a request carries neither header nor query credentials and the bucket policy explicitly grants anonymous s3:PutObject, the request still follows the anonymous authorization path. What is forbidden is carrying unverified credentials into the stream.
Implementation and verification
Commit f444b6f37 enforces presigned rejection and SigV4 verification at the entry of newUnsignedV4ChunkedReader() in cmd/streaming-v4-unsigned.go and removes the Authorization-header presence gates from PutObject and multipart upload handlers.
Regression tests in cmd verify:
- rejection of forged query-string
PutObjectandPutObjectPartrequests inSTREAMING-UNSIGNED-PAYLOAD-TRAILERmode; - rejection of requests mixing header and query-string SigV4 credentials;
- continued operation of valid header-authenticated unsigned-trailer uploads and anonymous uploads permitted by bucket policy.
Compatibility and operational boundaries
- Presigned unsigned-trailer is unsupported: Combining query-string SigV4 credentials with
STREAMING-UNSIGNED-PAYLOAD-TRAILERis rejected unconditionally. Clients using unsigned-trailer chunked uploads must pass SigV4 credentials via theAuthorizationheader. - Reader-level enforcement: Placing the authentication gate inside
newUnsignedV4ChunkedReader()ensures thatPutObject,PutObjectPart, andPutObjectExtract(CVE-2026-40344) cannot construct an unsigned-trailer reader without completing SigV4 verification.
14 - CVE-2026-42600: ReadMultiple Storage-REST Path Traversal
Status: Released
First containing release: RELEASE.2026-06-18T00-00-00Z
Fix commit: 73ac524
GitHub advisory: GHSA-xh8f-g2qw-gcm7
Affected scope: Distributed erasure deployments only; cluster-root / internode JWT required
The internode storage-REST route /rmpl (ReadMultiple) decoded a msgpack request body carrying Bucket, Prefix, and Files, and joined those fields directly into filesystem paths without a drive-root containment check.
Our initial fix retained the endpoint and added preflight path validation. A subsequent call-chain audit then revealed that ReadMultiple had had zero production callers since September 2024. We therefore discarded the in-place validation patch and deleted the route, handler, client wrapper, StorageAPI method, and generated msgpack types (73ac524)—eliminating roughly a thousand lines of unused privileged RPC surface.
Threat model
The /rmpl storage-REST route was registered only in distributed erasure mode; single-node deployments do not register storage-REST routes and were unaffected. Exploiting the endpoint required an internode JWT derived from the cluster root credentials, control of a peer node, or interception of unencrypted internode traffic.
Because the attacker-controlled Bucket, Prefix, and Files fields arrived inside the msgpack request body rather than in the URL or query string (r.Form), upper HTTP path-validation middleware never saw them. xlStorage.ReadMultiple joined the raw strings directly onto the drive path and read the resulting files, allowing a caller with internode credentials to escape the drive root and read any file accessible to the SILO server process.
First design: retain the API and validate every path
Our first patch hardened xlStorage.ReadMultiple in place:
- rejected absolute paths,
.and..segments, backslashes, Windows drive prefixes, and NUL bytes; - verified drive-root containment across
drive -> volume -> prefix -> file; - preserved historical edge cases for an empty
Bucketand.minio.sys/multipart; - returned an error before opening files or streaming responses.
That first draft closed the obvious traversal path, but code review immediately caught a flaw in how the loop interacted with MaxResults.
How MaxResults exposed the flaw of validate-as-you-go
The initial draft validated each entry of Files inside the file-reading loop right before opening it. When a request passed Files = [good, "../../etc/passwd"] together with MaxResults = 1, the loop read the first file, hit MaxResults == 1, and returned early without ever inspecting the second entry.
Although that specific call did not read the second file, it violated a core input-validation rule: a malformed msgpack request containing a path-traversal payload must be rejected before any partial execution or streaming begins. We moved path and length validation into a complete preflight pass over the entire request before touching the filesystem.
Final decision: why we deleted ReadMultiple instead
Before merging the preflight-validation patch, we audited every caller of ReadMultiple across the repository and git history:
- upstream had removed the last production caller in September 2024 when multipart reads migrated to
ReadParts; - the current SILO tree had zero in-tree production callers;
- upstream’s own remediation for GHSA-xh8f-g2qw-gcm7 also deleted
ReadMultiple.
| Option | Short-term diff | Long-term attack and maintenance surface | Decision |
|---|---|---|---|
| Validate paths in place | Smaller diff; preserves /rmpl |
Permanently maintains an unused, privileged multi-file read API | Abandoned |
Delete ReadMultiple |
Larger diff across interface and msgpack codegen | Removes the unused endpoint completely while preserving storageRESTVersion wire compatibility |
Adopted |
Commit 73ac524 removes the /rmpl route, storage-REST server handler, client wrapper, StorageAPI and xlStorage methods, metric definitions, and generated msgpack request/response types.
Verification and release
During the in-place validation phase, we ran unit tests covering xlStorage, the storage-REST client, msgpack encoding, path traversal vectors, and the MaxResults preflight edge case. After switching to full deletion in 73ac524 (shipped in RELEASE.2026-06-18T00-00-00Z), verification confirmed that all routes, interfaces, and generated msgpack surfaces compiled and passed go test ./cmd cleanly.
Compatibility and follow-up audit (SN-2026-002)
- External S3 API: Unchanged.
- Rolling upgrades: Keep all nodes in a distributed cluster on the same SILO release during upgrades.
- Follow-up audit across all internode endpoints: All remaining internode endpoints carrying filesystem paths inside msgpack bodies or Grid RPC frames were audited and hardened in SN-2026-002: Internode Path and Allocation Containment (shipped in
RELEASE.2026-08-04T00-00-00Z), which enforced volume and file-path containment across storage-REST, storage Grid RPC, and peer-S3 Grid RPC endpoints.
15 - SN-2026-002: Internode Path Containment Audit — Paying Off What CVE-2026-42600 Left Owing
Status: Released in Server 20260804 (release notes)
Identifier: SN-2026-002 (Security Advisories)
Fix commits: ca7baa670, b6f70ab08, 80e8eaa42, 1af351a70, a36fd8fff
Affected scope: Distributed erasure deployments only; cluster-root / internode JWT required
Prerequisite reading: CVE-2026-42600 · ReadMultiple
Following the removal of /rmpl (ReadMultiple) in CVE-2026-42600, SN-2026-002 audits every remaining internode endpoint carrying filesystem paths inside msgpack request bodies or Grid RPC frames. This advisory records that audit across all four internode protocol surfaces, the twelve inherited defects it uncovered, and the four regressions we introduced and caught while building the fix.
Conclusions first
- Twelve defects across three unprotected protocol surfaces, all inherited unchanged from upstream (verified by per-function checksum comparison across the affected storage files).
- Same architectural root cause as CVE-2026-42600:
ReadMultiplewas one endpoint on one surface; three protocol surfaces remained exposed to the same gap. - Four regressions introduced and caught during development: every regression occurred in a rule we invented from scratch (
isVolumeRootAliasandguardErasureParams), while reusing the object layer’s existinghasBadPathComponentrule produced zero regressions.
Why counting endpoints was the wrong frame
Earlier spot checks kept counting individual handlers—arriving at 19, then 21, then 22—while missing an entire protocol surface each time. Grouping internode calls by how their arguments reach xlStorage reveals four distinct surfaces:
| Protocol surface | Entries | Covered by global HTTP query middleware |
|---|---|---|
| Storage-REST HTTP query arguments | 9 | Yes — previously misclassified as unprotected |
| Storage-REST HTTP msgpack body | 4 | No; r.Form is never populated from a msgpack body |
Storage Grid RPC (/minio/grid/v1) |
18 | No; after the initial WebSocket upgrade, frames never re-enter the HTTP middleware chain |
| Peer-S3 Grid RPC | 5 | No; and it bypasses getStorage() to call xlStorage drives directly |
The first row corrects an earlier misconception: audits that grepped exclusively inside handler functions missed that the 9 query-parameter endpoints are already guarded by the global HTTP middleware (url.ParseQuery(RawQuery) in r.Form). Conversely, the fourth row (peer-S3 Grid RPC) bypasses storageRESTServer.getStorage() completely, meaning no amount of hardening limited to storage-rest-server.go could protect DeleteBucket or peer bucket metadata calls.
Three stacked layers in the storage path
Three independent design choices combined to create the vulnerability class:
- Path validation ran only on HTTP query strings: The HTTP middleware inspects
r.Form, which is populated fromurl.ParseQuery(RawQuery)and never from a request body (introduced in 2017). - Grid RPC bypassed the HTTP middleware chain:
/minio/grid/v1performs a single HTTP upgrade; all subsequent msgpack RPC frames dispatch directly to registered handlers without re-entering HTTP middleware (introduced in 2023). xlStorageassumed callers had already validated paths:getVolDirrejected a volume only when it equaled exact"",., or.., andpathJoinranpath.Cleanagainst the joined path (settled in 2018).
Each layer relied on another layer to validate paths, and none of them inspected the msgpack or Grid RPC channels in between.
One timeline interaction escalated a DoS bug from a failed request into a full process crash: ShardFileSize has contained an unguarded division by DataBlocks since 2020, but when CheckParts was wrapped inside xioutil.WithDeadline in October 2024 to bound slow disk operations, that division moved onto a bare goroutine where the HTTP server’s recover() middleware could no longer catch a divide-by-zero panic.
Vectors confirmed by execution
Every vector below was reproduced against a live xlStorage instance through the storage-REST and Grid RPC clients using planted sentinel files:
| Vector | Surface | Observed effect |
|---|---|---|
WriteAll("vol", "../../x") |
Storage Grid RPC | Arbitrary file write outside the drive root |
RenameFile(".minio.sys", "", "bucket", "x") |
Storage Grid RPC | Relocates the entire .minio.sys system volume (IAM, cluster config) into a readable S3 bucket with no .. sequence |
DeleteBucket("../victim", force) |
Peer-S3 Grid RPC | Recursive deletion of a directory tree outside the drive root |
DeleteBulk("vol", "") |
Storage-REST body | Moves an entire volume into the drive trash directory |
ReadAll(volume: "../") |
Any surface | Trailing slash bypasses getVolDir’s exact ".." check |
CheckParts with zero Erasure (DataBlocks = 0) |
Storage Grid RPC | Divide-by-zero panic in WithDeadline terminates the server process |
AppendFile declaring Content-Length: 64 GiB |
Storage-REST HTTP | Allocates 68,719,574,840 bytes for an empty request body |
DeleteVersions declaring 100,000,000 entries |
Storage-REST body | A 10-byte msgpack header allocates a 10.4 GB slice |
Part metadata with Size = -2 |
Storage Grid / AddVersion |
Truncated shard is reported healthy; healing silently skips repair |
Two vectors deserve specific attention:
RenameFilewith an empty source path (""): An emptysrcPathresolves to the volume directory itself. When pointed at.minio.sys,RenameFile(".minio.sys", "", "bucket", "x")moves the entire system metadata volume into a normal bucket, where an ordinaryGetObjectcan read cluster IAM and configuration files—without using..anywhere in the payload.- Negative part size (
Size = -2): A negative part size causesShardFileSizeto floor both terms to0. BecausecheckPartchecks onlyst.Size() < expectedSize, any existing shard file (even a 0-byte truncated file) satisfies0 < 0 == falseand is reported intact. BecauseFileInfo.IsValid()did not reject negative part sizes, poisonedxl.metametadata caused background healing to skip corrupted shards silently.
The fix: two chokepoints across 36 endpoints
The security invariant has two parts: every file path in an internode payload must resolve strictly inside its target volume, and every volume must resolve strictly inside the drive root.
Because pathJoin folds leading slashes and backslashes under volumeDir via path.Clean, only .. segments can escape a volume directory, and only volume-root aliases ("", "/", ".", and Windows trailing-space/period components) can target the volume directory itself. Commit ca7baa670 enforces those two rules at two chokepoints:
| Chokepoint | Location | Coverage |
|---|---|---|
| Volume axis | getVolDir + explicit guard in NSScanner |
Every xlStorage caller, including peer-S3 Grid RPC |
| Path axis | getStorage() decorator (guardPaths, isVolumeRootAlias) |
All 31 remote storage-REST and Storage Grid RPC methods plus nested struct slices |
Two implementation details are critical to preserve during future refactors:
- Checks must run on the raw argument before
pathJoin:pathJoincallspath.Cleanon the concatenated absolute path (/drive+volume+filePath), which silently collapses/drive/../../etcinto/etc. A check placed afterpathJoinsees a clean path with no..and allows the escape. NSScannerrequires its own volume guard:NSScanneris the onlyxlStoragemethod that constructs a volume path without callinggetVolDir, making its explicitcheckPathguard load-bearing.
We evaluated placing containment checks inside all 33 pathJoin(volumeDir, ...) call sites in xl-storage.go, which would require deciding at each of the 33 sinks whether the volume root itself is a valid target. Enforcing the invariant at getVolDir and the getStorage() decorator—backed by reflection guardrail tests on StorageAPI—covers all remote entry points without touching local erasure hot paths.
Construction log: four regressions we caused ourselves
During development and review, our own drafts introduced four regressions before shipping:
- Rejecting whitespace object keys (
" "and" "): Our first draft of the path guard treated whitespace as an invalid path character, rejecting" "and" "—which are valid S3 object keys. BecausePutObjectcommits writes across drives viaRenameData, uploading an object named" "failed on every remote drive and broke write quorum. - Rejecting backslash-only keys (
"\\") on Unix: The same draft rejected"\\". On Unix,path.Cleantreats\as an ordinary filename character rather than a directory separator. Rejecting"\\"in the internode decorator caused distributed clusters to reject a valid S3 key that single-node deployments accepted. - Windows trailing spaces and periods (
" "and"..."): The Win32 path normalizer strips trailing spaces and periods from path components, meaning a component composed solely of spaces or periods disappears and resolves to its parent directory. On Windows, both" "and"..."alias the volume root—and"..."was sitting inside our own positive test list of “valid object names” until review caught it. - Negative part sizes (
Size < 0): Our initialguardErasureParamscheck rejected zeroDataBlocksonly whenpart.Size > 0, equating “non-zero” with “positive” and missing that negative sizes floorShardFileSizeto zero even whenDataBlocksis valid.
Two false-green tests and a fuzzer blind spot
Writing the reproduction and regression tests exposed three testing pitfalls:
storageRESTClientsilently overrodeContent-Length: When testingAppendFilewith a forgedContent-Length: 64 GiBover an empty*bytes.Reader, the client library inspected the*bytes.Readertype and overwroteContent-Lengthwith0.- Go’s
http.Clientrefused mismatched body lengths: Switching to an opaqueio.Readercaused Go’s standard HTTP client transport to abort the request locally before sending it because the body closed before 64 GiB. Reproducing the server-side 64 GiB allocation required driving the handler directly viahttptest(or a raw socket). - An early return in the fuzzer hid the backslash bug: Our first fuzz property test skipped strings composed solely of
/and\via an earlyreturn. That early return blinded the fuzzer to"\\"across millions of executions until we rewrote the property test to assert strict inclusion againstIsValidObjectName.
Reused semantics vs. invented rules
| Guard component | Source of semantics | Regressions introduced during development |
|---|---|---|
guardPaths |
Reused existing hasBadPathComponent (IsValidObjectPrefix) |
0 |
getVolDir guard |
Reused existing hasBadPathComponent |
0 |
isVolumeRootAlias |
Newly written predicate | 3 |
guardErasureParams |
Newly written predicate | 1 |
hasBadPathComponent had already been exercised by production S3 traffic via IsValidObjectPrefix for years and structurally cannot reject any object key creatable through the S3 API. Every regression came from newly invented predicates where edge cases (" ", "\\", "...", Size = -2) diverged from S3 or OS semantics.
Guardrail tests
To ensure future changes to StorageAPI cannot silently reintroduce unguarded paths (avoiding the point-fix regression pattern previously seen with CVE-2026-39414), the test suite enforces three guardrails:
- Falsifiability (mutation verification): Temporarily removing the path and volume guards causes 191 subtests to fail; removing the allocation guards reproduces the 64 GiB and 10.4 GB allocations.
- Legal-traffic fuzz equivalence: Fuzz tests verify that every object key accepted by
IsValidObjectNamepassesguardPaths(1.96M executions) and every valid bucket name passesgetVolDir(810K executions). StorageAPIreflection guardrail: A reflection test inspects every method onStorageAPIand fails CI by method name if a new method accepting path or volume arguments is added without an explicit entry in the decorator guard table.
Independent review rounds
Five successive rounds of independent code review each uncovered one real flaw before release: whitespace keys -> backslash keys -> the AppendFile 64 GiB allocation -> Windows trailing spaces/periods -> negative part sizes. Separating implementation from review caught all four S3 compatibility and integrity regressions before release.
Follow-up fixes in Server 20260804
Five companion commits shipped alongside ca7baa670 in Server 20260804:
ReadFileHandlerread-length cap (b6f70ab08): Commitb6f70ab08rejects declared read lengths above 5 GiB (the maximum size of a single S3 part on this legacy whole-file bitrot path), preventing arbitrary caller-declared multi-tens-of-GiB allocations.- Negative part size rejection (
80e8eaa42): Commit80e8eaa42rejects negative part sizes at theAddVersionwrite funnel and again inCheckParts,VerifyFile, and internodeguardErasureParams, protecting both new writes and already-stored metadata. - Non-positive erasure block size rejection (
80e8eaa42): Commit80e8eaa42validatesblockSize > 0inNewErasureand guards rebalance division boundaries. ReadPartsstorage error propagation (1af351a70): Commit1af351a70fixes a pre-existing bug whereReadPartsHandlercalleddone(nil)before the storage call completed, which had caused storage errors (errFileNotFoundas well as new guard rejections) to be written as plain text into a msgpack keep-alive stream (msgp: attempted to decode type "int" with method for "map").- Panic recovery in
xioutil.WithDeadline(a36fd8fff): Commita36fd8fffrecovers panics insideWithDeadline, logspanic in deadline-bounded work: ...with a stack trace tostderr(rate-limited to 10 stack dumps per process lifetime), and fails only the in-flight request. Operators who monitor node health should add a log alert forpanic in deadline-bounded work:.
Known limitations
Two platform boundaries remain:
- Windows test environment: SILO publishes Windows binaries while automated CI runs on Linux; the Windows volume-root alias rule (
" "and"...") is derived from documented Win32 path normalization behavior. - Lexical symlink handling: Path containment is lexical (
hasBadPathComponentandisVolumeRootAlias), matching upstreamxlStoragesemantics, and does not resolve OS symlinks placed inside a drive root.
16 - SN-2026-003: s3:versionid Condition Evaluation and Request Condition Hardening
SN-2026-003 and SN-2026-005 cover a set of S3 IAM policy evaluation fixes shipped in Server 20260804 (80c2c9d03, 2f55347f7, eee05a17c, and silo-pkg v3.12.0 commit 2b087a1; pgsty/silo#40, pgsty/silo#53):
SN-2026-003(80c2c9d03):getConditionValuespopulatedargs.ConditionValues["versionid"] = []string{""}even when a request carried noversionId, causing IAMNullconditions ons3:versionidto evaluate as if the key were always present; fixing that alone would have opened aDeleteObjectsXML<VersionId>bypass without per-entry rebinding inauthorizeRequestWithTags.- Condition-source isolation (
2f55347f7): Separated query parameters, HTTP headers, internal server keys, ands3:ExistingObjectTag/<key>vs.s3:RequestObjectTag/<key>so untrusted query strings or headers cannot spoof internal condition keys. SN-2026-005(2b087a1/eee05a17c): AddedParseConfigStrictto reject degenerate bare resource prefixes ("arn:aws:s3:::"and"*arn:aws:s3:::") when writing IAM and bucket policies.
Conclusions first
- Unconditional empty slice inverted
Nullchecks: Insilo-pkg,nullFunc.evaluatetests whether a condition key is absent by checkinglen(rvalues) == 0. BecausegetConditionValuesunconditionally assignedargs.ConditionValues["versionid"] = []string{vid}(producing[]string{""}whenvid == ""),len(rvalues)was always1—makingNull: {"s3:versionid": "true"}alwaysfalseandNull: {"s3:versionid": "false"}alwaystrue. - Fixing
getConditionValuesalone breaksDeleteObjects: In multi-objectDeleteObjects(POST /?delete), the target<VersionId>is inside each XML<Object>element, not in the URL query string. IfgetConditionValuessimply omits"versionid"when the URL query has noversionId, a policy denying versioned deletes (Deny s3:DeleteObjectVersionorCondition: {"Null": {"s3:versionid": "false"}}) would fail to matchDeleteObjectsXML entries unlessauthorizeRequestWithTagsrebindsConditionValues["versionid"]fromReqInfo.VersionID. - Whitespace trimming closes a
Denybypass: Version-reading handlers calledstrings.TrimSpace(r.URL.Query().Get("versionId")), whereasgetConditionValuesdid not trim whitespace. A request with?versionId=V%20bypassedDeny StringEquals {"s3:versionid": "V"}while still deleting or reading versionV.
Why Null on s3:versionid Was Inverted
In silo-pkg/bucket/policy/condition/nullfunc.go, the IAM Null condition operator checks whether a condition key is missing from the request context:
However, in cmd/signature-v4-utils.go, getConditionValues populated "versionid" at the end of the function without checking whether vid was non-empty:
When a caller sent an unversioned request (DELETE /bucket/key with no versionId query parameter), vid was "", and args["versionid"] became []string{""} (len == 1). Consequently:
- A policy allowing only unversioned operations via
"Null": {"s3:versionid": "true"}evaluatedlen(rvalues) == 0asfalseand denied valid unversioned requests. - A policy restricting version-specific operations via
"Null": {"s3:versionid": "false"}evaluated!f.v && len(rvalues) > 0astrueand matched even when noversionIdwas supplied.
The Trap: Why Omitting Empty vid Alone Opens a DeleteObjects Bypass
The obvious one-line fix—only writing args["versionid"] = []string{vid} when vid != ""—turned out to hide a dangerous authorization bypass in DeleteMultipleObjectsHandler (POST /{bucket}?delete).
Unlike single-object DELETE /{bucket}/{object}?versionId=V, a multi-object DeleteObjects request carries no versionId in the URL query string. Instead, the request body contains an XML list of <Object><Key>...</Key><VersionId>V</VersionId></Object> entries. Inside DeleteMultipleObjectsHandler, the server loops over each XML entry, sets r.WithContext (ReqInfo.VersionID = obj.VersionID), and calls authorizeRequestWithTags for DeleteObjectAction.
Consider what happens if an administrator configures a policy that allows normal delete-marker creation (s3:DeleteObject when s3:versionid is null) or denies permanent version deletion via:
Because authorizeRequestWithTags calls getConditionValues(r, ...)—which inspects r.Form.Get("versionId") on the HTTP request URL rather than ReqInfo.VersionID—omitting empty vid in getConditionValues without propagating ReqInfo.VersionID means args["versionid"] is absent during every per-object check in DeleteObjects. Worse still, an attacker could pass a decoy query parameter on the batch URL (POST /mybucket?delete&versionId=decoy) while putting the protected <VersionId>target-v1</VersionId> inside the XML body!
The Two-Part Fix (80c2c9d03)
Commit 80c2c9d03 fixes both halves together:
- Omit
"versionid"whenvid == ""ingetConditionValues: - Rebind
"versionid"fromReqInfo.VersionIDforDeleteObjectActioninauthorizeRequestWithTags(cmd/auth-handler.go):
Why rebind conditionValues["versionid"] only when action == iampolicy.DeleteObjectAction rather than for every action? Because in CopyObjectHandler, ReqInfo.VersionID holds the destination query parameter (if any), whereas the GetObjectAction authorization check on the copy source must evaluate s3:versionid from x-amz-copy-source (?versionId=... on the source URL). Restricting the ReqInfo.VersionID override to DeleteObjectAction ensures both single-object DeleteObjectHandler and per-entry DeleteMultipleObjectsHandler evaluate the exact version being deleted—and explicitly delete(conditionValues, "versionid") when reqInfo.VersionID is empty so that a URL query decoy (POST /bucket?delete&versionId=query-level-decoy) cannot spoof an unversioned XML entry.
Closing the Whitespace Normalization Gap
While auditing every caller that reads versionId, we noticed that object handlers (GetObjectHandler, HeadObjectHandler, DeleteObjectHandler, PutObjectRetentionHandler, etc.) extract the query parameter with:
In contrast, getConditionValues read r.Form.Get("versionId") (and u.Query().Get("versionId") from x-amz-copy-source) without strings.TrimSpace.
If a policy denied access to a specific version via "StringEquals": {"s3:versionid": "v1"}, a caller requesting ?versionId=v1%20 (with a trailing space) had "versionid": ["v1 "] during policy evaluation—evading the StringEquals deny rule—after which the handler trimmed "v1 " to "v1" and operated on the protected version. Applying strings.TrimSpace to both query versionId and x-amz-copy-source versionId inside getConditionValues closes that mismatch.
Call-Chain Impact and Condition-Source Isolation (2f55347f7)
| Operation | Source of s3:versionid |
Pre-20260804 Behavior |
Server 20260804 Behavior |
|---|---|---|---|
GetObject / DeleteObject (no versionId) |
Absent | ["versionid"] = [""] (Null:true fails) |
Key omitted (Null:true matches) |
GetObject / DeleteObject (?versionId=v1%20) |
URL query versionId |
Policy sees "v1 ", handler uses "v1" |
Both policy and handler see "v1" |
CopyObject (x-amz-copy-source: /b/k?versionId=v1) |
x-amz-copy-source query |
Untrimmed vid |
Trimmed vid |
DeleteObjects (POST /?delete with <VersionId>) |
Per-entry ReqInfo.VersionID |
Ignored XML <VersionId>; read URL query |
Rebound per XML entry; URL decoy ignored |
In companion commit 2f55347f7 (pgsty/silo#53), we also hardened getConditionValues across all other request condition keys:
- Reserved internal keys: Client query parameters and HTTP headers can no longer overwrite server-derived condition keys (
username,userid,principaltype,authtype,signatureversion,tlsVersion,cipherSuite). s3:signatureAge: Computed directly from the validated SigV4 timestamp rather than trusting client-supplied values.- Tag condition separation:
s3:ExistingObjectTag/<key>is populated exclusively from stored object tags, ands3:RequestObjectTag/<key>exclusively fromx-amz-taggingon the incoming request.
SN-2026-005: Bare ARN Prefix Rejection
During the same IAM condition audit, we investigated SN-2026-005 (silo-pkg v3.12.0 commit 2b087a1, wired into Server 20260804 in eee05a17c): what happens when a policy contains a degenerate resource string with an empty bucket and object suffix, such as "arn:aws:s3:::" or "*arn:aws:s3:::"?
Because the legacy parser stripped the arn:aws:s3::: prefix and accepted an empty resource pattern "", that pattern could never match any real bucket or object. In an Allow statement, it was merely a dead rule—but in a Deny statement on Resource or an Allow statement using NotResource, a degenerate arn:aws:s3::: pattern failed open!
To prevent administrators from saving policies that silently fail open without risking startup failures on existing clusters:
silo-pkg v3.12.0addspolicy.ParseConfigStrict, which rejects degenerate bare ARN prefixes (arn:aws:s3:::and*arn:aws:s3:::).- Server
20260804(eee05a17c) callsParseConfigStricton all policy write paths (bucket policies, named IAM policies, and service-account/STS inline policies), while keeping stored policy load paths on permissiveParseConfigso existing deployments never fail to boot.
Why SN-2026-003 Is Tracked as an Advisory Rather Than a CVE
Exploiting the s3:versionid evaluation bugs required an authenticated caller operating against a policy that explicitly used s3:versionid conditions (Null, StringEquals, or StringNotEquals). Standard s3:DeleteObjectVersion action checks were unaffected unless conditioned on s3:versionid. We track the condition evaluation and DeleteObjects rebinding fixes under SN-2026-003 and the bare ARN prefix rejection under SN-2026-005.
Scope Boundaries and Related Fixes
- Multi-Delete Object Lock governance bypass (
75a6734e4/#104): While auditingDeleteMultipleObjectsHandler, we noted how per-entry headers and retention checks interact; Server 20260903 (75a6734e4,SN-2026-008) subsequently tightenedDeleteMultipleObjectsHandlersox-amz-bypass-governance-retentionis only evaluated against the specific locked entry rather than across the batch. - Snowball
PutObjectExtractminio.versionIdPAX header: Noted during ourversionIdaudit and closed in Server 20261018 (SN-2026-016,14042b5aa). - Other condition keys (
username,userid,signatureversion,authType): We intentionally scoped80c2c9d03tos3:versionidrather than changing empty-slice semantics for identity keys that are always defined on authenticated requests.
Verification
Regression tests in cmd/signature-v4-utils_test.go, cmd/auth-handler_test.go, and cmd/bucket-policy_test.go verify:
Null: {"s3:versionid": "true"}andNull: {"s3:versionid": "false"}across versioned and unversionedGetObject,DeleteObject,CopyObject, andDeleteObjectscalls,?versionId=v1%20whitespace trimming againstDeny StringEquals {"s3:versionid": "v1"},POST /bucket?delete&versionId=query-level-decoywith both versioned and unversioned XML<Object>entries,- Rejection of
arn:aws:s3:::and*arn:aws:s3:::viaParseConfigStrict.
Source References
80c2c9d03—s3:versionidcondition omission, whitespace trimming, andDeleteObjectsper-entry rebinding (SN-2026-003)2f55347f7— request condition-source isolation (#53)eee05a17c/silo-pkg@2b087a1—ParseConfigStrictbare ARN prefix rejection (SN-2026-005)
Summary
Shipped in Server 20260804, 80c2c9d03 and eee05a17c (silo-pkg v3.12.0) ensure accurate s3:versionid condition evaluation across single-object and batch DeleteObjects operations while rejecting degenerate bare S3 ARN prefixes on policy writes.
17 - SN-2026-004: Object-Scoped Policy Grants Reaching Bucket Mutations
SN-2026-004 fixes an IAM resource-matching flaw in silo-pkg v3.11.0 (3c24ad1, 1f97549, d8b1fa7) and Server 20260804 (97b7d2804; upstream reference minio/minio#20449): when evaluating bucket-level S3 requests (args.ObjectName == ""), Statement.IsAllowed synthesized "bucket/" as the target resource string, allowing object-scoped wildcard grants such as Allow s3:* on arn:aws:s3:::bucket/* to authorize sensitive bucket-level mutations including s3:PutBucketPolicy and s3:DeleteBucket.
Conclusions first
- Root cause: When
args.ObjectNamewas empty,Statement.IsAllowedappended'/'to the bucket name ("bucket/"), which matched the glob"arn:aws:s3:::bucket/*". - Privilege escalation: A tenant principal given
Allow s3:* on arn:aws:s3:::tenant/*(a common shorthand for “all object operations inside this bucket”) could callPutBucketPolicyto grantPrincipal: "*"on the bucket, or callDeleteBucket,PutReplicationConfiguration,PutBucketLifecycle,PutBucketVersioning, orPutBucketObjectLockConfiguration. - Why a targeted fix was required: Simply dropping the trailing
'/'for all empty-object checks broke 11 existing STSListBuckettests and weakenedDeny s3:* on arn:aws:s3:::bucket/*policies. - Targeted boundary: For the 12 sensitive bucket-mutation actions on
Allowstatements (!legacyBucketResourceMatch && statement.Effect == Allow && isSensitiveBucketMutation(args.Action)), the resource pattern must match both"bucket"and"bucket/". SettingMINIO_API_LEGACY_BUCKET_RESOURCE_MATCH=onrestores pre-20260804matching if a deployment temporarily relies on legacy policies.
Why arn:aws:s3:::bucket/* Matched Bucket Requests
In silo-pkg/bucket/policy/statement.go (and iam/policy/statement.go), Statement.IsAllowed(args Args) constructed the resource string to test against Statement.Resources as follows:
Because the else branch unconditionally appended "/", every bucket-level request (args.ObjectName == "") was evaluated as resource = "mybucket/". When matched against a resource pattern of "arn:aws:s3:::mybucket/*", the glob wildcard * matched the empty suffix after "/" and returned true.
Multi-Tenant Escalation Impact
In AWS IAM and S3 bucket policies, arn:aws:s3:::mybucket identifies the bucket itself, whereas arn:aws:s3:::mybucket/* identifies objects inside the bucket. Administrators routinely write policies that grant a tenant or application team full object access using a wildcard action on the object ARN:
Because s3:* includes both object actions and bucket actions, and "arn:aws:s3:::tenant-a/*" matched resource = "tenant-a/", a caller holding this object-scoped policy could execute bucket-control mutations on tenant-a:
s3:PutBucketPolicy/s3:DeleteBucketPolicy: Replace the bucket policy with"Principal": "*"to expose the bucket publicly or grant themselves explicit bucket-level permissions.s3:DeleteBucket/s3:ForceDeleteBucket: Delete the bucket outright.s3:PutReplicationConfiguration: Point bucket replication at an attacker-controlled destination to exfiltrate objects.s3:PutBucketLifecycle: Expire all objects and historical versions in the bucket.s3:PutBucketVersioning/s3:PutBucketObjectLockConfiguration: Suspend versioning or alter retention defaults.
Why We Did Not Drop the Trailing Slash Globally
Our first prototype simply removed the else { resource += "/" } branch so that args.ObjectName == "" always evaluated against "mybucket". Running the test suite and code review exposed two major regressions with a blanket change:
- Historical
ListBucketcompatibility: Eleven upstream STS and IAM tests incmd/sts-handlers_test.gointentionally granteds3:ListBucket(ors3:*) onarn:aws:s3:::mybucket/*without listing the bare bucket ARNarn:aws:s3:::mybucket. Dropping"/"for read/list actions would break existing deployments whose application policies only listarn:aws:s3:::mybucket/*. Denystatement weakening: If an administrator wroteDeny s3:* on arn:aws:s3:::mybucket/*expecting it to block all operations on that bucket, evaluatingargs.ObjectName == ""against"mybucket"instead of"mybucket/"would cause theDenystatement to stop matching bucket-level calls—turning a security tightening patch into aDenybypass!
The Fix: Action-Scoped Dual Matching
To close the escalation path without breaking ListBucket or weakening Deny statements, Statement.IsAllowed restricts the stricter check to Allow statements on sensitive bucket-level mutations (silo-pkg v3.11.0, wired into the server in 97b7d2804):
For those actions on an Allow statement, the resource set must match "mybucket" (so arn:aws:s3:::mybucket/* alone is rejected, while arn:aws:s3:::mybucket or arn:aws:s3:::* is accepted) and match "mybucket/" (see Why Monotonicity Required Matching Both).
Second-Pass Action Audit: Which Bucket Writes Are Covered
Our initial draft only gated PutBucketPolicy and DeleteBucketPolicy, overlooking DeleteBucket and lifecycle/replication mutations. In the second pass, we audited every bucket-level write action (args.ObjectName == "") and divided them into two explicit groups.
The 12 protected sensitive bucket mutations (isSensitiveBucketMutation) are:
s3:PutBucketPolicys3:DeleteBucketPolicys3:DeleteBuckets3:ForceDeleteBuckets3:PutBucketLifecycles3:PutReplicationConfigurations3:PutBucketVersionings3:PutBucketObjectLockConfigurations3:PutBucketQuotaConfigs3:PutBucketRetentionConfigs3:PutBucketCorss3:DeleteBucketCors
(Note: when Server 20260804 shipped, per-bucket CORS handlers were not yet active; when Server 20260903 implemented per-bucket CORS in #71 / #80, s3:PutBucketCors and s3:DeleteBucketCors were already protected in isSensitiveBucketMutation.)
Four lower-risk or provisioning bucket actions were intentionally kept on historical matching to avoid breaking existing automation pipelines that scope credentials to arn:aws:s3:::app-bucket/*:
s3:CreateBucket(creating the bucket itself when provisioned with a prefix-scoped policy),s3:PutBucketTagging/s3:DeleteBucketTagging,s3:PutBucketEncryption,s3:PutBucketNotification.
Why Monotonicity Required Matching Both "bucket" and "bucket/"
We wanted a strict monotonicity guarantee: when MINIO_API_LEGACY_BUCKET_RESOURCE_MATCH=off (the default), no Allow statement may authorize a request that would have been denied under legacy matching.
Proving that invariant took two iterations during code review:
- Attempt 1 (
NotResourcefailure): When we simply testedstatement.Resources.Match(args.BucketName, ...)instead of"mybucket/", a policy usingNotResource: ["arn:aws:s3:::mybucket/*"]flipped from denied (because"mybucket/"was inside the excludedNotResourceset) to allowed (because"mybucket"did not match the excluded pattern). - Attempt 2 (single-character
?wildcard failure): Even withoutNotResource, testing onlyargs.BucketNamefailed monotonicity on fixed-width wildcards! ConsiderAllow s3:PutBucketPolicy on Resource: ["arn:aws:s3:::mybucke?"]. Under legacy matching,"mybucket/"is 9 characters long, somybucke?(8 characters) did not match and the request was denied. If the new check tested only"mybucket"(8 characters),mybucke?would suddenly match and allow a request that legacy mode denied (1f97549).
Requiring statement.Resources.Match(args.BucketName, ...) and statement.Resources.Match(resource, ...) ("mybucket" and "mybucket/") guarantees mathematical monotonicity for all Resource and NotResource patterns: the new check is a pure subset (NewAllow = LegacyAllow ∧ MatchesBareBucket) of legacy Allow evaluation (d8b1fa7). Note that this means a synthetic pattern like arn:aws:s3:::mybucke? (which matches "mybucket" but not "mybucket/") will not authorize sensitive bucket mutations; administrators should write arn:aws:s3:::mybucket or arn:aws:s3:::mybucket*.
Operator Impact and Compatibility Escape Hatch
- Standard policies need no changes: Any policy that grants bucket administration on
arn:aws:s3:::mybucket,arn:aws:s3:::mybucket*, orarn:aws:s3:::*continues to work unchanged. - Object-only policies (
arn:aws:s3:::mybucket/*): Can still read/write objects and callListBucket,CreateBucket,PutBucketEncryption,PutBucketTagging, andPutBucketNotification, but will now receiveAccessDeniedon the 12 sensitive bucket mutations unlessarn:aws:s3:::mybucketis explicitly added toResource. - Escape hatch: If an existing automation workflow requires
arn:aws:s3:::mybucket/*to authorize one of the 12 sensitive bucket mutations during migration, setMINIO_API_LEGACY_BUCKET_RESOURCE_MATCH=onuntil the policy is updated.
Follow-Ups Left Open
- Startup policy audit: SILO does not rewrite stored IAM or bucket policies on upgrade; operators can inspect policies with
mcli admin policy infoto check whether any administrative role relied onarn:aws:s3:::bucket/*for bucket mutations. - Self-explaining denial diagnostics: S3
AccessDeniedresponses intentionally do not disclose which statement or ARN shape failed to match.
Summary
Shipped in Server 20260804 (5ff00073a and silo-pkg d8b1fa7 / v3.12.0), the 12 sensitive bucket-mutation actions require the policy Resource to match the bucket ARN itself unless MINIO_API_LEGACY_BUCKET_RESOURCE_MATCH=on is explicitly set.
18 - SN-2026-018: Client Source Address Trust, Forwarded Headers, and aws:SourceIp
SN-2026-018 addresses client source-IP spoofing across aws:SourceIp IAM conditions, audit logs, bucket notification events, and admin HTTP traces in silo-pkg v3.12.0 (62f326d) and Server 20260804 (00a8171cb, pgsty/silo#41): handlers.GetSourceIP previously trusted X-Forwarded-For, X-Real-IP, and RFC 7239 Forwarded from any TCP peer by default, and setting _MINIO_API_XFF_HEADER=off only disabled X-Forwarded-For while continuing to trust client-supplied X-Real-IP and Forwarded headers.
Conclusions first
_MINIO_API_XFF_HEADER=offleft two headers wide open: Setting_MINIO_API_XFF_HEADER=offskippedX-Forwarded-For, buthandlers.GetSourceIPimmediately fell through toX-Real-IPandForwarded. Any direct client could forgeX-Real-IP: 10.0.0.1to satisfyaws:SourceIppolicies.- “Put a reverse proxy in front” is often insufficient: Even behind an ingress proxy, if the proxy appends to
X-Forwarded-For(proxy_add_x_forwarded_for) while the server reads the left-most IP, or if internal pods can reach the server’s ClusterIP directly, an attacker can still spoofaws:SourceIp. MINIO_API_TRUSTED_PROXIESprovides explicit right-to-left chain verification: Server 20260804 introducesMINIO_API_TRUSTED_PROXIES(off/none, CIDR/IP list, orall/*for legacy behavior) and walksX-Forwarded-For/Forwardedfrom right to left, stopping at the first hop not in the trusted proxy set.
Every Consumer of handlers.GetSourceIP
In SILO, handlers.GetSourceIP(r) (silo-pkg/handlers/proxy.go) is the single function responsible for extracting the client IP address from an incoming *http.Request. Four security- and observability-sensitive subsystems depend on its return value:
| Consumer | Location | Security / Operational Role |
|---|---|---|
aws:SourceIp IAM condition |
cmd/signature-v4-utils.go (getConditionValues) |
Evaluates IpAddress and NotIpAddress policy conditions on bucket and IAM policies. |
Audit logging (remotehost) |
cmd/logger/audit.go |
Records the client IP in structured compliance audit logs. |
Bucket notification events (Host) |
cmd/notification.go |
Populates requestParameters.sourceIPAddress in S3 event notifications. |
Admin HTTP trace (mc admin trace) |
cmd/http-tracer.go |
Displays the client IP in live diagnostic traces. |
Note that handlers.GetSourceIPRaw returned the first matched header token without validating whether it was even a syntactically valid IP address (a unit test in upstream silo-pkg literally asserted that X-Forwarded-For: _gazonk returned "_gazonk"!). While IpAddress condition evaluation parses the string with net.ParseIP, audit logs and notification payloads received the raw string.
Why _MINIO_API_XFF_HEADER=off Did Not Stop Spoofing
In silo-pkg/handlers/proxy.go, GetSourceIPRaw inspected three groups of headers in order:
When PR minio/minio#20977 (and minio/pkg#306) added _MINIO_API_XFF_HEADER=off in February 2025, it gated only the xffHeaders loop:
- With
_MINIO_API_XFF_HEADER=off,GetSourceIPRawskippedX-Forwarded-For, - Then immediately checked
X-Real-IP(realIPHeaders) andForwarded(forwardedHeaders) unconditionally!
Thus, an operator who set _MINIO_API_XFF_HEADER=off specifically to prevent aws:SourceIp spoofing on a directly exposed SILO endpoint remained completely vulnerable to curl -H "X-Real-IP: 192.0.2.10" or curl -H "Forwarded: for=192.0.2.10".
Upstream History since 2017
This area had a long back-and-forth history:
- 2017-07 (
minio/minio#4736): Upstream briefly removedX-Forwarded-ForfromGetSourceIPafter recognizing that clients could spoofaws:SourceIp. - Subsequent refactors: Reverse-proxy deployments complained that
aws:SourceIpand audit logs only saw the load balancer’s IP, soX-Forwarded-For,X-Real-IP, andForwardedwere re-admitted—taking the first (left-most) comma-separated entry inX-Forwarded-For. - 2023-08 (
minio/minio#17878) & 2025-02 (minio/minio#20977): Operators requested a way to disableX-Forwarded-Fortrust, resulting in_MINIO_API_XFF_HEADER, which leftX-Real-IPandForwardedactive.
Why “Just Put a Reverse Proxy in Front” Is Not Enough
A common misconception is that forwarded-header spoofing only matters when SILO is exposed directly to the internet without a reverse proxy. In practice, two standard production topologies remain vulnerable even when a reverse proxy is present:
- Left-most
X-Forwarded-Forvs. proxy append (proxy_add_x_forwarded_for): Standard nginx configurations useproxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;, which appends the client’s real IP to whateverX-Forwarded-Forheader the client sent. If the client sendsX-Forwarded-For: 10.0.0.5, nginx forwardsX-Forwarded-For: 10.0.0.5, 203.0.113.99. Because legacyGetSourceIPunconditionally took the first (left-most) address (10.0.0.5), the attacker’s spoofed IP won even behind nginx! - Coexisting ingress and direct paths (Kubernetes ClusterIP): In Kubernetes, external traffic arrives through an Ingress controller, while in-cluster workloads connect to the
siloService (ClusterIP) directly. IfGetSourceIPblindly trusts forwarded headers whenever an Ingress exists, any pod inside the cluster can connect to theClusterIPdirectly and forgeX-Forwarded-FororX-Real-IPto impersonate a trusted subnet.
Design of MINIO_API_TRUSTED_PROXIES
To solve both problems cleanly, silo-pkg v3.12.0 (62f326d) and Server 20260804 (00a8171cb) introduce MINIO_API_TRUSTED_PROXIES:
| Setting | Peer Trust | Header Evaluation |
|---|---|---|
MINIO_API_TRUSTED_PROXIES=off (or none) |
Trust no proxy hops | Always uses the direct TCP peer (r.RemoteAddr) for external peers; ignores X-Forwarded-For, X-Real-IP, and Forwarded. |
MINIO_API_TRUSTED_PROXIES=10.0.1.0/24,192.168.1.10 |
Trust only listed IPs/CIDRs | If r.RemoteAddr is not in the trusted set, returns r.RemoteAddr immediately. If r.RemoteAddr is trusted, walks X-Forwarded-For (or Forwarded) from right to left and returns the first IP not in the trusted set. |
Unset, all, or * |
Legacy compatibility mode | Preserves pre-20260804 behavior (unless _MINIO_API_XFF_HEADER=off is set). |
Note that handlers.GetSourceScheme (X-Forwarded-Proto) is intentionally left untouched so TLS redirect and presigned URL scheme detection behind reverse proxies continue to work regardless of MINIO_API_TRUSTED_PROXIES.
Seven Engineering Trade-Offs
1. Default Compatibility vs. Secure-by-Configuration
Changing the unset default to off in a minor release would immediately break every reverse-proxy deployment that uses aws:SourceIp policies without having set MINIO_API_TRUSTED_PROXIES. We kept the unset default compatible and made MINIO_API_TRUSTED_PROXIES=off and CIDR lists first-class, documented controls.
2. Why We Reversed Widening _MINIO_API_XFF_HEADER=off
In our first draft, we widened _MINIO_API_XFF_HEADER=off so that it also disabled X-Real-IP and Forwarded. Running the server test suite immediately failed TestXFFDisabled in cmd/generic-handlers_test.go!
Inspecting TestXFFDisabled showed why: the test specifically modelled an nginx reverse proxy that overwrites X-Real-IP: $remote_addr (trusted single IP) while appending to X-Forwarded-For: $proxy_add_x_forwarded_for (untrusted left-most IP). Operators who set _MINIO_API_XFF_HEADER=off in that exact topology relied on SILO ignoring X-Forwarded-For so that it would fall through to X-Real-IP. We therefore preserved _MINIO_API_XFF_HEADER=off semantics for TestXFFDisabled when MINIO_API_TRUSTED_PROXIES is unset, and provided MINIO_API_TRUSTED_PROXIES=off to disable all forwarded IP headers (X-Forwarded-For, X-Real-IP, and Forwarded).
3. Why MINIO_API_TRUSTED_PROXIES Is Env-Only (No mc admin config Key)
In SILO’s startup sequence, the HTTP listener and initial request routing come up before the distributed api subsystem configuration is loaded from erasure storage. If trusted proxies were configured via mc admin config set api, there would be a startup window before config load where GetSourceIP either failed open or failed closed, and updating the setting at runtime would require atomic reloads across silo-pkg. Making MINIO_API_TRUSTED_PROXIES an environment variable configured before listener startup eliminates any race window.
4. Loopback Exemption: Peer vs. Chain Hop
SILO’s built-in FTP and SFTP servers (cmd/ftp-server.go, cmd/sftp-server.go) and local inter-node forwarding connect to the S3 layer over loopback (127.0.0.1 / ::1) and pass the external client’s IP in X-Forwarded-For. Therefore, when the immediate TCP peer (r.RemoteAddr) is loopback, SILO treats that local peer hop as trusted so FTP/SFTP audit logs and aws:SourceIp continue to see the real FTP/SFTP client IP.
Crucially, however, loopback is not implicitly trusted as an intermediate hop inside the X-Forwarded-For header chain! Otherwise, an external client behind one trusted proxy could send X-Forwarded-For: 10.0.0.1, 127.0.0.1, causing the right-to-left walker to skip the proxy (r.RemoteAddr), skip 127.0.0.1 in the header, and trust the attacker’s 10.0.0.1.
5. Header Precedence: X-Forwarded-For Before X-Real-IP
When MINIO_API_TRUSTED_PROXIES is configured with a CIDR/IP list, GetSourceIP evaluates X-Forwarded-For (right-to-left), then X-Real-IP (validated with parseIP), then Forwarded (right-to-left). Note that this differs intentionally from getSTSLDAPTrustedProxySourceIP (cmd/sts-handlers.go), which checks X-Real-IP first for single-hop LDAP STS proxies; in general multi-hop S3 proxy chains, X-Forwarded-For carries the hop-by-hop chain needed for right-to-left verification while X-Real-IP may remain frozen at an outer hop.
6. Broad CIDR Warning
If an operator sets MINIO_API_TRUSTED_PROXIES=10.0.0.0/8 when ordinary application pods also run in 10.0.0.0/8, any pod in that /8 is treated as a trusted proxy and can forge X-Forwarded-For. Operators should list only the dedicated ingress/load-balancer IPs or subnet.
7. Multi-Node Distributed Clusters
In a multi-node SILO cluster (silo server http://node{1..4}/data), a load balancer may route a client request to Node 1, which internally proxies or forwards sub-requests to Nodes 2–4:
| Topology | What MINIO_API_TRUSTED_PROXIES Must Include |
|---|---|
| Single node behind reverse proxy | Reverse proxy IPs/CIDRs only |
| Multi-node cluster behind reverse proxy | Reverse proxy IPs/CIDRs and all SILO cluster node IPs/CIDRs |
| Direct client access to multi-node cluster (no external proxy) | All SILO cluster node IPs/CIDRs (or off if clients always hit nodes directly without peer forwarding) |
Compatibility Details
- No non-IP garbage returned in trusted-proxy mode: When
MINIO_API_TRUSTED_PROXIESis active (offor CIDR list), every candidate header token is validated as a real IP address; malformed tokens like_gazonkare rejected rather than returned to callers. - Removal of unreachable IPv4-mapped prefix string hack: Legacy
proxy.gocontained a string check for"::ffff:"that was unreachable or mishandled hex forms;netip.Addr.Unmap()now normalizes IPv4-mapped IPv6 addresses (::ffff:192.0.2.1->192.0.2.1) cleanly.
Bugs Caught During Multi-Pass Code Review
Iterative code review of the patch across silo-pkg and silo caught nine subtle edge cases before release:
Second-Pass Review Findings
init()vs.MINIO_CONFIG_ENV_FILEstartup ordering: ReadingMINIO_API_TRUSTED_PROXIESand_MINIO_API_XFF_HEADERonly inside a packageinit()function meant values loaded later fromMINIO_CONFIG_ENV_FILEinserverHandleEnvVarswere ignored! Server20260804explicitly re-applies both settings inserverHandleEnvVars.- Loopback in-chain spoofing (
X-Forwarded-For: spoofed, 127.0.0.1): Caught the distinction between trusting a loopbackr.RemoteAddrpeer vs. trusting127.0.0.1inside anX-Forwarded-Forheader list (fixed as described in Trade-Off 4). - IPv6 zone identifiers (
fe80::1%eth0): Link-local IPv6 peers include a%zonesuffix inr.RemoteAddr. Usingnetip.ParseAddrand stripping the zone before CIDR matching prevents link-local peers from failing IP parsing. - Multiple
X-Forwarded-Forheader lines (r.Header.Values): HTTP proxies such as HAProxy or Envoy may append a secondX-Forwarded-Forheader line rather than comma-joining onto the first line. Callingr.Header.Get("X-Forwarded-For")read only the first (client-controlled!) header line. We switched to iteratingr.Header.Values("X-Forwarded-For")from last header line to first. - Internal
forwarder.goheader hygiene: WhenMINIO_API_TRUSTED_PROXIESrejected an untrusted peer’sX-Real-IPorForwardedheader, internal HTTP forwarding needed to avoid re-injecting untrusted headers alongside a trusted peer hop. ::ffff:0:0/96CIDR unmapping: Unmapping the prefix address of::ffff:0:0/96without checking prefix length would turn a/96IPv6 prefix into0.0.0.0and match all IPv4 addresses; we preserve CIDR prefix semantics accurately.
Third-Pass Review Findings
- Fail-closed on empty CIDR list (
MINIO_API_TRUSTED_PROXIES=","): If an operator setsMINIO_API_TRUSTED_PROXIES=","(or whitespace-only), silently treating the empty slice as “unset (trust all)” would fail open! Startup now returns an explicit configuration error ifMINIO_API_TRUSTED_PROXIESis set to an empty or unparseable value. os.LookupEnvvs.env.Get(""): Usingenv.Get(EnvAPITrustedProxies, "")could not distinguish betweenMINIO_API_TRUSTED_PROXIESbeing unset and being set to""; we check explicit presence so empty settings fail closed.- Zero-allocation backwards scan and 100-hop cap: Allocating
strings.Split(header, ",")on every request allowed a client sending a 1 MBX-Forwarded-Forheader full of commas to trigger a ~33 MB slice allocation per request. We replacedstrings.Splitwith an in-place right-to-left scanner and capped inspection at 100 hops.
Operator Recommendations
- Direct-attached SILO (no reverse proxy): Set
MINIO_API_TRUSTED_PROXIES=off. - SILO behind nginx / HAProxy / Envoy / Ingress: Set
MINIO_API_TRUSTED_PROXIESto the CIDR or IP list of the reverse proxies (and SILO node IPs in a multi-node cluster).
Out of Scope
- PROXY protocol v1/v2: TCP-layer PROXY protocol support at the listener is separate from HTTP
X-Forwarded-For/Forwardedparsing. GetSourceScheme: Left unchanged so HTTPS scheme detection behind TLS-terminating proxies continues to work independently ofMINIO_API_TRUSTED_PROXIES.ExistingObjectTag/*condition isolation: Fixed separately in2f55347f7(also shipped in20260804) so only stored object tags—never client request headers—populateExistingObjectTag/*conditions.
Severity Classification
Tracked as SN-2026-018 in the SILO advisory ledger (as with CVE-2026-42600, minio/minio is archived; severity is Moderate-to-High depending on whether policies rely on aws:SourceIp). Upgrading to Server 20260804 and setting MINIO_API_TRUSTED_PROXIES closes the spoofing vector across IAM, audit logs, notifications, and traces.
19 - SN-2026-019: Notification Keyspace Registration and Validation Redaction
SN-2026-019 covers a class of notification configuration defects fixed in Server 20260804 (162ded343 and 672c07a0f, pgsty/silo#39): notification parser and legacy-migration paths read or wrote KVS configuration keys that were missing from Default*KVS, while CheckValidKeys echoed raw key values in validation errors and FetchEnabledTargets aborted all active notification targets on the first validation failure. In Server 20260903 (f1ba68358), the discrete PostgreSQL and MySQL host/credential keys uncovered by our AST audit were retired in favor of connection_string and dsn_string.
Conclusions first
- Four uncoordinated surfaces drifted apart: In the notification configuration subsystem,
Default*KVS(valid key schema),Help*(help text and secret/sensitive classification),GetNotify*(parser), andSetNotify*(legacy config migration) were maintained by hand without a cross-check. - One invalid target disabled all notification targets:
FetchEnabledTargetsvalidates every notification subsystem sequentially and returns on the first error, leaving the global notification target listnilacross the server. - Validation errors echoed secret values: When
CheckValidKeysrejected an unknown or mistyped key, it formatted the error usingkv.String(), printingkey=value—including credentials or passwords—into logs and admin CLI responses. - AST-enforced invariant: Server
20260804registers the missing NATS and AMQP keys, redacts values fromCheckValidKeyserrors, expandsHasSensitiveKeys, and enforces a compile-time AST test ensuring every key read or written bycmd/config/notifyis registered inDefault*KVS.
A Puzzling Bug Report
The investigation started from a bizarre symptom reported by kuldeep-link11: an operator ran mc admin config set us notify_nats:FITCHECK address="..." subject="..." and received an error complaining about a completely different target (notify_nats:ONE) and two keys (nkey_seed= tls_handshake_first=off) that the operator had never passed on the command line:
Two separate mechanisms combined to produce that confusing error:
- Tokenizer absorption on
mc admin config set:kvFieldsininternal/config/parser.gosplits input tokens by matching only keys registered inDefaultKVS. When a user passes an unregistered key on the command line,kvFieldsdoes not recognize it as a key delimiter and silently absorbs"nkey_seed=..."as part of the preceding key’s value (such assubject). - Legacy migration poisoning on
notify_nats:ONE: Earlier in the deployment’s life, the legacy configuration migrator (SetNotifyNATSincmd/config/notify/legacy.go) had serializednotify_nats:ONEdirectly into the KVS struct—bypassingkvFields—and writtennkey_seedandtls_handshake_firstas discrete KVS keys. When the operator later editednotify_nats:FITCHECK,validateConfigre-validated all stored targets and tripped overnotify_nats:ONE.
One Constant Used for Two Different Namespaces
Inspecting internal/config/notify/target/nats.go immediately revealed a second bug right next to nkey_seed:
When PR minio/minio#19139 added NATS user credentials support in February 2024, NATSUserCredentials was placed inside the Env* constant block with the value "MINIO_NOTIFY_NATS_USER_CREDENTIALS", and then used as both the environment variable name and the KVS key name in DefaultNATSKVS. As a result, setting user_credentials=/path/to/creds via mc admin config set notify_nats was rejected as an unknown key, whereas the literal string MINIO_NOTIFY_NATS_USER_CREDENTIALS=/path/to/creds was what DefaultNATSKVS expected on disk.
Four Uncoordinated Surfaces
Auditing all notification targets in cmd/config/notify/ showed that every target defines four separate surfaces:
Default*KVS(cmd/config/notify/config.go): the canonical list of valid KVS keys enforced byCheckValidKeys,Help*(cmd/config/notify/help.go): user-facing help metadata andType(Sensitive/Secret),GetNotify*(cmd/config/notify/parse.go): reads KVS keys andMINIO_NOTIFY_*environment variables,SetNotify*(cmd/config/notify/legacy.go): migrates older JSON config structs into KVS pairs.
Across NATS and AMQP, four keys had drifted across those four surfaces:
| Target | Key | Introduced In | Default*KVS |
Help* |
GetNotify* |
SetNotify* |
Failure Mode |
|---|---|---|---|---|---|---|---|
notify_nats |
user_credentials |
#19139 (2024-02) |
Registered as MINIO_NOTIFY_NATS_USER_CREDENTIALS |
Missing | Read MINIO_NOTIFY_NATS_USER_CREDENTIALS |
Wrote MINIO_NOTIFY_NATS_USER_CREDENTIALS |
user_credentials rejected on mc admin config set |
notify_nats |
nkey_seed |
#21231 (2025-05) |
Missing | Missing | Read nkey_seed |
Wrote nkey_seed |
Stored config fails CheckValidKeys |
notify_nats |
tls_handshake_first |
#21008 (2025-02) |
Missing | Present | Read tls_handshake_first |
Wrote tls_handshake_first |
Stored config fails CheckValidKeys |
notify_amqp |
immediate |
Initial KVS migration | Missing | Missing | Read immediate |
Wrote cfg.Immediate into AmqpInternal! |
immediate rejected; SetNotifyAMQP overwrote internal with cfg.Immediate |
Notice the AMQP bug in the last row: SetNotifyAMQP in cmd/config/notify/legacy.go had a copy-paste typo that wrote strconv.FormatBool(cfg.Immediate) under the key target.AmqpInternal and never serialized cfg.Internal at all.
Blast-Radius Amplifier: All-or-Nothing Target Loading
Why did a broken notify_nats:ONE target matter so much? In cmd/config/notify/parse.go, FetchEnabledTargets loads all ten notification subsystems (amqp, elasticsearch, kafka, mqtt, mysql, nats, nsq, postgres, redis, webhook) in sequence. If any subsystem returns a validation error from CheckValidKeys, FetchEnabledTargets returns early before populating the active target map.
Consequently, a single invalid key in an unused notify_nats target causes startup or config reload to leave the global notification target list empty, silently disabling all bucket notifications (such as active Kafka or Webhook targets) until an administrator removes the offending config entry.
We considered changing FetchEnabledTargets to isolate errors per subsystem and keep other targets running, but declined that change: across SILO, configuration validation is intentionally all-or-nothing so that mc admin config set rejects broken configurations rather than partially applying them. Fixing key registration removes the false-positive failures without weakening config transaction semantics.
Fixing Key Registration and Classification
Commit 162ded343 fixes the NATS and AMQP keyspace defects:
- Split key constant from env constant:
target.NATSUserCredentialsis now"user_credentials", andtarget.EnvNATSUserCredentialsis"MINIO_NOTIFY_NATS_USER_CREDENTIALS". - Register all four keys in
DefaultNATSKVSandDefaultAMQPKVS:user_credentials,nkey_seed, andtls_handshake_firstare registered inDefaultNATSKVS, andimmediateis registered inDefaultAMQPKVS. - Complete
HelpNATSandHelpAMQP: Added help entries foruser_credentials,nkey_seed, andimmediate. Bothuser_credentialsandnkey_seedtake local file paths on the server filesystem (just likeclient_key), so we classified them asconfig.Sensitiverather thanconfig.Secret. - Fix
SetNotifyAMQP:SetNotifyAMQPnow writescfg.Internaltotarget.AmqpInternalandcfg.Immediatetotarget.AmqpImmediate.
Narrow Backward Tolerance for Stored NATS Configs
What if a deployment prior to 20260804 had worked around #19139 by storing the literal key MINIO_NOTIFY_NATS_USER_CREDENTIALS=/path/to/creds in its persisted cluster configuration?
Simply renaming NATSUserCredentials to "user_credentials" would cause CheckValidKeys to reject that persisted configuration on upgrade—triggering the exact FetchEnabledTargets outage described above.
While designing backward tolerance, we uncovered a subtle trap between two identically named functions in internal/config:
- The free function
config.CheckValidKeys(subSys, kvs, defaultKVS, deprecatedKeys...)accepts an optional variadic list of deprecated keys and permits them during load validation. - The method
kvs.CheckValidKeys(subSys, defaultKVS)callsCheckValidKeys(subSys, kvs, defaultKVS)without passing any deprecated keys.
We updated the NATS load path (GetNotifyNATS) to tolerate "MINIO_NOTIFY_NATS_USER_CREDENTIALS" via the deprecated-key list on config.CheckValidKeys, and implemented deterministic precedence when populating UserCredentials:
MINIO_NOTIFY_NATS_USER_CREDENTIALSenvironment variable (highest precedence),user_credentialsKVS key,- Legacy
MINIO_NOTIFY_NATS_USER_CREDENTIALSKVS key (fallback whenuser_credentialsis empty).
Redacting Values in Validation Errors (SN-2026-019)
Before 20260804, both config.CheckValidKeys and KVS.CheckValidKeys built their error string by appending kv.String() (key=value) for every unrecognized key. If an administrator mistyped a secret key name—for example, pasword=Hunter2 or auth_tokn=eyJ...—or if a legacy migrator wrote an unregistered credential key, the error message echoed the cleartext secret into the console response and server error logs.
Commit 162ded343 changes both CheckValidKeys functions to report only the offending key names (kv.Key), never kv.Value.
In commit 672c07a0f, we also audited config.HasSensitiveKeys (internal/config/config.go), which masks secrets when exporting configurations via mc admin config get or generating health-diagnostics bundles. We expanded HasSensitiveKeys to cover all credential key names used across notification, identity, and KMS subsystems (password, auth_token, secret_key, client_secret, nkey_seed, user_credentials, sasl_password, connection_string, and dsn_string).
Enforcing the Invariant with an AST Audit Test
To make sure Default*KVS, parse.go, and legacy.go can never drift apart again, we wrote an AST-based verification test in cmd/config/notify/config_audit_test.go. Using go/ast, the test parses parse.go and legacy.go, resolves every kvs.Get(...) and kvs.Set(...) call back to its string constant definition in internal/config/notify/target, and verifies that every referenced key exists in the corresponding Default*KVS.
While reviewing the audit test itself, we found and closed two blind spots in our initial test implementation:
- Reverse floor assertion: Checking only that
parse.go ⊆ Default*KVSwould pass even if the AST walker broke and extracted zero keys! We added the reverse check requiring every key registered inDefault*KVS(except documented universal keys likeenableandcomment) to be read inparse.goand asserting a minimum key-count floor per subsystem. - Untyped composite literals: In
legacy.go, helper tables constructedconfig.KVS{...}using untyped{Key, Value}composite literals rather than callingkvs.Set(). Extending the AST visitor to inspectconfig.KVScomposite literals immediately surfaced a second bug in the codebase.
What the AST Audit Caught: PostgreSQL and MySQL Discrete Keys
As soon as the AST visitor inspected config.KVS composite literals in legacy.go, it flagged SetNotifyPostgres and SetNotifyMySQL: both functions wrote five discrete keys—host, port, username, password, and database—that were read by GetNotifyPostgres / GetNotifyMySQL (parse.go) when connection_string / dsn_string was empty, yet were completely absent from DefaultPostgresKVS and DefaultMySQLKVS!
In addition, because password was not registered in DefaultPostgresKVS or DefaultMySQLKVS, passing password=... or migrating a legacy PostgreSQL/MySQL config triggered CheckValidKeys and printed the database password in the error message prior to 162ded343.
We handled this in two steps across two releases:
- Short-term fix in Server 20260804 (
672c07a0f): Registeredhost,port,username,password, anddatabaseinDefaultPostgresKVSandDefaultMySQLKVS(and markedpasswordasSecretinHelpPostgres/HelpMySQL) so that existing configurations and the new AST invariant were immediately consistent. - Architectural cleanup in Server 20260903 (
f1ba68358): Having two parallel ways to configure database connections (connection_stringvs. discretehost/port/username/password/databasekeys) causedSetNotifyPostgresandSetNotifyMySQLto overwriteconnection_stringwith empty discrete fields during config round-trips. Server20260903retired the five discrete keys acrossDefault*KVS,Help*,parse.go, andlegacy.go, standardizing exclusively onconnection_string(PostgreSQL) anddsn_string(MySQL).
Declined Changes
- Do not change
kvFieldstokenizer framing in this patch: ChangingkvFieldsto split on arbitrarykey=valuetokens rather than knownDefaultKVSkeys would alter how unquoted spaces inside values (such as SQL queries or subjects) are parsed. WithDefault*KVSnow verified by the AST audit, valid keys are always recognized bykvFields. - Do not weaken
FetchEnabledTargetsfail-fast validation: As explained in Blast-Radius Amplifier, configuration validation remains atomic across all subsystems.
Review Record
Code review verified the AST audit’s reverse floor assertions, untyped config.KVS composite literal inspection, CheckValidKeys value redaction (162ded343), and HasSensitiveKeys credential coverage (672c07a0f).
Summary
Shipped in Server 20260804 (162ded343 and 672c07a0f) with follow-up cleanup in Server 20260903 (f1ba68358), all notification KVS keys are verified against Default*KVS by AST tests and unrecognized-key errors redact values.
20 - Duplicate Part Numbers in CompleteMultipartUpload
In CompleteMultipartUpload, the S3 API requires part numbers in the completion XML to be strictly ascending. Up through Server 20260725, SILO enforced that invariant with sort.SliceIsSorted and a strict < comparator—which accepts non-decreasing sequences such as [1, 1] and duplicates the referenced part in the assembled object. Server 20260804 (pgsty/silo#49) replaces that check with an explicit adjacent-pair comparison (Parts[i-1].PartNumber >= Parts[i].PartNumber) and fixes an adjacent empty-part panic in ReadParts (2602177ef).
Conclusions first
- The bug is real and small:
CompleteMultipartUploadusedsort.SliceIsSortedwith<, which tests non-decreasing order (a[0] <= a[1] <= ...) rather than strictly increasing order (a[0] < a[1] < ...). Passing duplicate part numbers such as[1, 1]succeeded and duplicated the underlying part in the final object. - This is a data-integrity defect, not a CVE: Triggering it requires a caller who already holds
s3:PutObjectpermission on the target key and a validuploadIdfor an in-progress multipart upload. It does not cross tenant, bucket, or credential boundaries. - The fix stays at the HTTP handler boundary:
cmd/object-handlers.gorejectsParts[i-1].PartNumber >= Parts[i].PartNumberwithInvalidPartOrder(HTTP 400). Sparse part numbers ([1, 3],[5, 9],[3]) remain valid.
Why sort.SliceIsSorted Accepted Duplicates
In cmd/object-handlers.go, CompleteMultipartUploadHandler validated the parsed <CompleteMultipartUpload> part list with:
In Go’s standard library, sort.SliceIsSorted iterates i from n-1 down to 1 and calls less(i, i-1). If less(i, i-1) returns true, the slice is out of order. When two adjacent elements are equal (Parts[i].PartNumber == Parts[i-1].PartNumber), Parts[i].PartNumber < Parts[i-1].PartNumber evaluates to false, so SliceIsSorted treats them as sorted.
Replacing sort.SliceIsSorted with a direct adjacent check closes the gap:
Two Refactors Preserved the Defect
Tracing git log -S across the handler history shows that the non-decreasing check survived two separate cleanups over nine years:
| Date | PR / Commit | What Changed | Why [1, 1] Survived |
|---|---|---|---|
| 2016-08 | minio/minio#2490 |
Introduced sort.IsSorted(completedParts(complMultipartUpload.Parts)) with Less(i, j) { return a[i].PartNumber < a[j].PartNumber }. |
sort.IsSorted only checks that no element is smaller than its predecessor. |
| 2017-11 | minio/minio#5170 |
Replaced the named completedParts type with sort.SliceIsSorted using the same < closure. |
Mechanical refactor of the same comparator. |
| 2023-04 | minio/minio#17066 |
Moved ErrInvalidPartOrder validation earlier in CompleteMultipartUploadHandler before hitting the object layer. |
Moved the check block verbatim without testing duplicate part numbers. |
Every unit and mint test for ErrInvalidPartOrder tested inversions such as [2, 1], never adjacent duplicates such as [1, 1].
Observed Impact
Before the fix, uploading a single 5 MiB part (partNumber=1) and completing the upload with two <Part> entries both referencing PartNumber=1 and its ETag produced the following behavior:
| Step | Request | Pre-Fix Result | Post-Fix Result |
|---|---|---|---|
| 1 | UploadPart(partNumber=1) (5 MiB) |
200 OK, returns ETag: "e1" |
200 OK, returns ETag: "e1" |
| 2 | CompleteMultipartUpload with [(1, "e1"), (1, "e1")] |
200 OK, ETag: "...-2" |
400 Bad Request (InvalidPartOrder) |
| 3 | HeadObject / GetObject |
Content-Length: 10485760 (part 1 repeated twice) |
Object not created; upload remains open |
| 4 | Retry CompleteMultipartUpload with [(1, "e1")] |
404 NoSuchUpload (upload already consumed) |
200 OK, Content-Length: 5242880 |
Because CompleteMultipartUpload consumes the upload state on success, a buggy client that accidentally duplicated an entry in its completion XML (for example, when retrying an UploadPart call and appending to an in-memory slice instead of deduplicating by part number) silently wrote a corrupted object of the wrong size and could not retry completion without re-uploading every part.
Why This Is Not a CVE
We evaluated whether a malicious caller could weaponize duplicate part numbers for storage amplification:
- No physical disk amplification: In erasure-coded storage,
CompleteMultipartUploadrecords part metadata inxl.metareferencing the existingpart.1data files on disk rather than copying part bytes per entry. Reading the object streamspart.1twice, and background healing or copying materializes the logical size, but the caller could achieve the same result legitimately by uploading two parts or callingUploadPartCopy. - Same-caller scope: Executing
CompleteMultipartUploadrequires write access to the object and knowledge of theuploadId. No unauthenticated or read-only principal can trigger it.
The issue is therefore tracked as a correctness and S3 protocol compliance bug (#49) rather than a security advisory (SN-2026-*).
Handler vs. Object-Layer Boundary
During investigation we checked whether erasureObjects.CompleteMultipartUpload (cmd/erasure-multipart.go) also validates part ordering. It does not: the object layer iterates for i, part := range parts and matches each entry against uploadID part metadata.
We kept the check in CompleteMultipartUploadHandler (cmd/object-handlers.go) rather than duplicating it inside every ObjectLayer implementation for two reasons:
- Single untrusted entry point: Client-supplied completion lists enter only through
CompleteMultipartUploadHandler. Internal callers (batch-replicate,restore,decommission,rebalance, andPutObjectPartpathways) construct part lists directly from existingObjectInfo.Partsor sequential1..nloops. - Fail-fast before lock acquisition: Rejecting malformed XML in the HTTP handler avoids acquiring the object’s namespace lock or reading upload metadata from disk.
Preserving Sparse Part Numbers
The S3 specification requires part numbers in CompleteMultipartUpload to be strictly increasing (1 <= PartNumber <= 10,000), not contiguous and not starting at 1. A client that uploads parts 1, 2, 3 and completes using only [1, 3], or completes using [5, 9] or [3], is conforming to S3.
To make sure our fix did not over-constrain valid clients, we added explicit regression cases for:
[1, 3](sparse gap),[3](single part not starting at1),[1, 1]and[1, 2, 2, 3](adjacent duplicates, rejected withErrInvalidPartOrder),[1, 3, 2]and[2, 1](decreasing order, rejected withErrInvalidPartOrder),[1, 2, 1](non-adjacent duplicate, rejected withErrInvalidPartOrder).
Error Code Precedence for Out-of-Range Duplicates
One subtle edge case is an input that is both out of range (PartNumber < 1) and duplicated, such as [0, 0] or [-1, -1].
Because CompleteMultipartUploadHandler validates part ordering before iterating into the object layer (where individual part numbers are looked up and ErrInvalidPart is returned), inputs like [0, 0] previously passed sort.SliceIsSorted and failed later in the object layer with InvalidPart (HTTP 400). With strict ordering enforced up front, [0, 0] and [-1, -1] now fail immediately at the handler with InvalidPartOrder (HTTP 400). Both error codes map to HTTP 400 Bad Request and neither is retriable; a single out-of-range part ([0] or [-1]) still reaches the part lookup and returns InvalidPart.
Mutation Testing and a Misleading Comment
While verifying the unit tests in cmd/object-handlers-multipart_test.go, we ran two manual mutations against the handler:
- Mutating the check to require
Parts[0].PartNumber == 1: Immediately caught by the sparse-part regression tests ([3]and[5, 9]). - Deleting the
len(complMultipartUpload.Parts) == 0guard: Our draft test comment originally claimed that removing the empty-parts guard in the handler would allow an empty multipart completion to succeed. Running that mutation revealed something much more interesting: an emptyPartsslice bypassed the handler check, entered the object layer, and triggered an index-out-of-range panic inReadParts(cmd/storage-rest-client.go) inside an errgroup goroutine.
That second mutation served two purposes: we corrected the misleading comment in cmd/object-handlers-multipart_test.go, and we fixed ReadParts in commit 2602177ef (also shipped in Server 20260804) so that an empty partNumbers slice returns early instead of indexing partNumbers[0].
Declined Changes and Follow-Ups
- No retrospective repair tool: Existing objects assembled from duplicate part lists before
20260804have valid erasure metadata for their logical size; automatic truncation or deduplication would risk corrupting objects intentionally assembled from identical chunks. - No duplicate object-layer check: As noted in Handler vs. Object-Layer Boundary, internal callers construct part lists from validated metadata, and
2602177efcloses theReadPartsempty-slice boundary found during mutation testing.
Review Record
Three independent review passes verified the handler predicate, error-code precedence (InvalidPartOrder vs. InvalidPart), sparse part numbering ([3] and [5, 9]), and the ReadParts empty-slice guard (2602177ef).
Summary
Shipped in Server 20260804 (5df7b5466 and 2602177ef), CompleteMultipartUploadHandler enforces strict part-number monotonicity (PartNumber[i-1] < PartNumber[i]) and ReadParts safely rejects empty part-number slices.