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.