Object Lock Retention Bypass and Version-Overwrite Repair Design (SN-2026-015 / SN-2026-016)
Status on 2026-10-15: The complete repair in PR #234 (14042b5aa) and PR #235 (0d3ba5d4c) first shipped in RELEASE.2026-10-15T00-00-00Z. The previously published RELEASE.2026-09-16T00-00-00Z remains affected. See the advisory and release notes for upgrade requirements and artifact acceptance.
This document records the verification, Git provenance, repair design, critical-path evaluation, and adversarial candidate trade-offs for two Object Lock (WORM) security vulnerabilities identified through external reports and an internal write-path audit: SN-2026-015 (PutObjectRetention bypass header authorization and condition-key short-circuit) and SN-2026-016 (version-addressed write overwrite bypass and write-path lock gaps).
Companion public security advisory:
1. Problem Verification and External Report Cross-Check
1.1 Two Independent External Reports and Internal Audit
This repair addresses two independent external vulnerability reports alongside an internal audit of adjacent write paths:
- Initial Report (F2 primary path): Privately reported by security researcher szopi00 via GitHub Security Advisory (GHSA-67mv-7hc9-wj88), showing that a principal holding
s3:PutObjectRetentionwithouts3:BypassGovernanceRetentioncould shorten unexpiredGOVERNANCEretention by sendingx-amz-bypass-governance-retention: true. - Independent External Report (F1 / F2 / F3): A separate external report independently reproduced three attack paths: unauthorized changes from
GOVERNANCEto multi-yearCOMPLIANCE, bypasses of request-body condition-keyDenypolicies ons3:PutObjectRetention, and shortening unexpiredGOVERNANCEretention without bypass authorization. - Internal Companion Audit (
SN-2026-016/ F4 and directorynull-version overwrites): Auditing all write paths for callers holdings3:PutObjectwithout bypass permission uncovered three related gaps: in-place overwrites via?versionId=without replication trust (putOpts), implicitnull-version overwrites on directory keys (dir/), and completion of persisted multipart uploads (CompleteMultipartUpload).
1.2 Claim-by-Claim Verification of the Independent Report
The following findings and line numbers refer to the pre-fix bcb5fc956 snapshot:
| Reported Claim | Verification | Code Evidence & Impact |
|---|---|---|
Impact 1 (Unauthorized COMPLIANCE retention): Attackers without s3:BypassGovernanceRetention (or subject to a conditional Deny) can turn GOVERNANCE into COMPLIANCE or set COMPLIANCE directly |
Confirmed | cmd/bucket-object-lock.go:205 checks only the raw header !byPassSet; cmd/auth-handler.go:773-781 skips IAM evaluation when the requested mode is COMPLIANCE, leaving byPassSet == true and returning ErrNone at if byPassSet || retSet. Under COMPLIANCE, not even the S3 root account can delete the version through the S3 API before expiry |
Impact 2 (Weakening GOVERNANCE lock): A credential without s3:BypassGovernanceRetention can shorten a GOVERNANCE lock using the raw header |
Confirmed | When the requested mode is GOVERNANCE, isPutRetentionAllowed evaluates byPassSet as false but evaluates retSet (s3:PutObjectRetention) as true; byPassSet || retSet still returns ErrNone |
Blast-radius difference between SILO and upstream MinIO on DeleteObjectVersion: SILO requires explicit s3:DeleteObjectVersion to delete a specific version; upstream MinIO checks s3:DeleteObjectVersion with DenyOnly: true |
Confirmed | SILO commit 75a6734e4 (2026-09-02) introduced deleteObjectAction requiring s3:DeleteObjectVersion, whereas upstream MinIO’s cmd/auth-handler.go still uses DenyOnly: true |
Affected versions and locations: Bug is in server cmd/, not silo-pkg; affects SILO RELEASE.2026-09-03T13-18-01Z (9b11dc94), RELEASE.2026-09-16T00-00-00Z, pre-fix SILO main (bcb5fc956), and the upstream minio/minio snapshot checked at that time |
Confirmed | silo-pkg evaluates policy Deny rules accurately; the flaw is in cmd/object-handlers.go, cmd/bucket-object-lock.go, and cmd/auth-handler.go |
Why unconditional Deny s3:PutObjectRetention cannot be used as a workaround: Unconditionally denying s3:PutObjectRetention breaks uploads using retention headers or default-retention workflows |
Confirmed | Application credentials often require s3:PutObjectRetention with condition keys (s3:object-lock-mode, s3:object-lock-remaining-retention-days) rather than an unconditional Deny |
2. Vulnerability Breakdown and Git Provenance
2.1 SN-2026-015: PutObjectRetention Authorization Short-Circuit (F1 / F2 / F3)
Before the fix, PUT /{bucket}/{object}?retention suffered from two structural defects:
- Signature-only check at handler entry:
PutObjectRetentionHandlercalledauthenticateRequest(ctx, r, policy.PutObjectRetentionAction), which verifies request authentication (signature/token) without evaluating IAM policies; - Conflated bypass check and inverted boolean logic: Inside
enforceRetentionBypassForPutandisPutRetentionAllowed, the server trusted the raw headerx-amz-bypass-governance-retention: trueand combined bypass permission with base retention permission using logical OR (byPassSet || retSet).
This produced three distinct authorization failures (F1–F3) plus a day-count arithmetic bug when clearing retention:
| ID | Attacker’s Granted Permissions | Trigger & Security Impact |
|---|---|---|
| F1 | Authenticated principal with no Object Lock permissions at all (neither s3:PutObjectRetention nor s3:BypassGovernanceRetention) |
Sending x-amz-bypass-governance-retention: true with: (1) an empty <Retention/> body clears unexpired GOVERNANCE retention to zero; or (2) <Mode>COMPLIANCE</Mode> locks the version into COMPLIANCE |
| F2 | Principal with s3:PutObjectRetention but without s3:BypassGovernanceRetention (covered by both external reports) |
Sending x-amz-bypass-governance-retention: true shortens unexpired GOVERNANCE retention or converts it to COMPLIANCE |
| F3 | Principal with s3:PutObjectRetention (subject to conditional Deny) or with s3:BypassGovernanceRetention |
(1) When the requested mode is COMPLIANCE or empty <Retention/>, the raw bypass header skips IAM evaluation in isPutRetentionAllowed, bypassing any conditional Deny on s3:PutObjectRetention; (2) when holding s3:BypassGovernanceRetention, byPassSet || retSet bypasses conditional Deny rules on s3:PutObjectRetention even for GOVERNANCE mode |
| Day-Count Arithmetic Bug | Principal subject to s3:object-lock-remaining-retention-days conditions |
Sending an empty <Retention/> body sets RetainUntilDate to zero time (0001-01-01). math.Abs(objRetention.RetainUntilDate.Sub(t).Hours()) clamped at Go’s 292-year time.Duration limit and evaluated to 106,752 days, bypassing NumericLessThan minimum-retention policies |
Exact Git Commit Provenance
- Correct initial design (Nov 2019 – Mar 2020):
ca96560d5(2019-11-20, Poorna Krishnamoorthy),f931fc7bf(2019-11-25), and #9030 (97cbac604, 2020-03-04):PutObjectRetentionHandlerenforcedcheckRequestAuthTypefors3:PutObjectRetentionat entry and evaluateds3:BypassGovernanceRetentionvia IAM before passing the authorization boolean intoenforceRetentionBypassForPut.
- Defect introduction (Apr 2020 — upstream #9259,
43a3778b4, 2020-04-06, Harshavardhana):- To support the three XML body condition keys (
s3:object-lock-mode,s3:object-lock-retain-until-date,s3:object-lock-remaining-retention-days), this commit removedcheckRequestAuthTypeat handler entry and pushed policy evaluation intoisPutRetentionAllowedinsideenforceRetentionBypassForPut, introducing three bugs at once:enforceRetentionBypassForPutchecked the raw headerif !byPassSet;isPutRetentionAllowedonly evaluated IAM whenretMode == objectlock.RetGovernance && byPassSet, leavingbyPassSet == trueforCOMPLIANCE;isPutRetentionAllowedconcluded withif byPassSet || retSet { return ErrNone }.
- To support the three XML body condition keys (
- Expansion to empty
<Retention/>clear requests (May 2020 — upstream #9677,bede525dc, 2020-05-22, Ashish Kumar Sinha):- Allowed clearing
GOVERNANCEretention via an empty<Retention/>body (Mode == ""), which also took theretMode != RetGovernancebranch and introduced the106,752-day zero-date calculation bug.
- Allowed clearing
- Partial fix in Feb 2025 and mechanical regression in Apr 2025:
- Upstream #20929 (
437dd4e32, 2025-02-12, Ramon de Klein) added an unconditionalcheckRequestAuthType(ctx, r, policy.PutObjectRetentionAction, bucket, object)at handler entry, masking F1 (while breaking condition-keyAllowpolicies and leaving F2/F3 open). - Just 50 days later, upstream #21103 (
8c7097528, 2025-04-03, Harshavardhana) mechanically replacedcheckRequestAuthTypewith signature-onlyauthenticateRequestwhile keeping the stale// Check permissions to perform this object retention operationcomment, reopening F1. SILO forked after this commit and inherited all three flaws.
- Upstream #20929 (
2.2 SN-2026-016: Version-Addressed Write Overwrite Bypass and Write-Path Lock Gaps
In standard S3 semantics, public write APIs (PutObject, CopyObject, CreateMultipartUpload, PostPolicy) never allow callers to specify a target versionId to overwrite an existing version in place. Three related write-path flaws broke this invariant:
| Path ID | Trigger | Security Impact |
|---|---|---|
F4 (Explicit target versionId on write) |
A caller with s3:PutObject passes an existing version’s versionId (UUID or null) to PutObject, CopyObject, CreateMultipartUpload, PostPolicy, or Snowball archive extraction (PAX minio.versionId) |
The storage layer overwrites that version’s payload and metadata in place. For an unexpired GOVERNANCE-locked version, its payload and retention metadata are replaced or cleared |
Companion Gap A (Implicit null version on directory keys dir/ and slow-body TOCTOU race) |
A caller sends a normal PUT /{bucket}/dir/ (without ?versionId=) to an Object Lock bucket |
(1) putOpts maps isDirObject(object) to opts.VersionID = "null", while checkPutObjectLockAllowed sees opts.VersionID == "" via getOpts, skipping the existing-lock check and allowing a normal PUT dir/ to overwrite and erase unexpired GOVERNANCE, COMPLIANCE, and LegalHoldOn locks on the directory object; (2) even with HTTP preflight fixed, preflight runs before reading r.Body, so a writer can pass preflight, pause streaming the HTTP body while an admin sets COMPLIANCE on dir/, and then finish streaming to overwrite the locked null version; (3) checkPutObjectLockAllowed only inspects locks when getObjectInfoFn returns err == nil, failing open on storage read errors |
Companion Gap B (Persisted target version in CompleteMultipartUpload) |
NewMultipartUpload persists fi.VersionID in .minio.sys/multipart/.../uploadID/xl.meta, and CompleteMultipartUpload uses completeMultipartOpts rather than putOpts |
A multipart upload initiated before the upgrade, initiated by a replication credential, or recovered from an older staging snapshot after a surviving replica has already committed and COMPLIANCE-locked that same UUID version has its target fi.VersionID persisted on disk; without an unconditional write-lock existence check during CompleteMultipartUpload, an ordinary s3:PutObject caller can complete that upload and overwrite the COMPLIANCE-locked version |
Exact Git Commit Provenance
- May 2020 —
GOVERNANCEomitted fromcheckPutObjectLockAllowed(upstream #9513,9b3b04ece, 2020-05-04):checkPutObjectLockAllowedonly blocked unexpiredCOMPLIANCEandLegalHoldOninif opts.VersionID != "" && !replicaTrusted, omittingGOVERNANCEand ignoring non-nilerrors fromgetObjectInfoFn. - Sep 2020 — Unauthenticated
?versionId=accepted inputOpts(upstream #10598,56d1b227c, 2020-09-30, Poorna Krishnamoorthy):putOptsaccepted?versionId=from the query string to support replication without checkingtrustedReplication. - May 2023 — Directory objects forced to
nullVersionID(upstream #17132,1d0211d39, 2023-05-04, Harshavardhana):putOptsanddelOptsforcedopts.VersionID = nullVersionIDforisDirObject(object)without updatingcheckPutObjectLockAllowedor the storage commit path.
3. Final Repair Plan: What Code Changes and How
The final repair combines Core Plan A (SN-2026-015) with Entry + Storage Write-Lock Defense-in-Depth + Fail-Closed Preflight (SN-2026-016) across 11 production files under cmd/:
| Issue | File | Summary of Change |
|---|---|---|
| SN-2026-015 (Core Plan A, 3 files) |
cmd/object-handlers.go |
In PutObjectRetentionHandler, evaluate isRetentionActionAllowed(PutObjectRetentionAction) immediately after ParseObjectRetention(r.Body) and before reading any bucket or object state |
cmd/bucket-object-lock.go |
Add isRetentionActionAllowed (evaluating zero/past dates as "0" days and ceiling future days via Unix second/nanosecond arithmetic); rewrite enforceRetentionBypassForPut to require both the bypass header and s3:BypassGovernanceRetention only when weakening an unexpired stored GOVERNANCE lock |
|
cmd/auth-handler.go |
Delete the flawed 45-line isPutRetentionAllowed helper (byPassSet || retSet) |
|
| SN-2026-016 (Entry & Write-Lock Guards, 8 files) |
cmd/object-api-options.go |
In putOpts (the single convergence point for HTTP write options), return errAuthentication (403 AccessDenied) whenever vid != "" && !trustedReplication |
cmd/batch-handlers.go |
In BatchJobReplicateV1.writeAsArchive, set ReplicationRequest: true on outer SnowballOptions so server-side batch replicate archives pass the outer replication marker check |
|
cmd/bucket-object-lock.go |
(1) In preflight checkPutObjectLockAllowed, resolve nullVersionID for isDirObject(object), block unexpired GOVERNANCE in addition to COMPLIANCE and LegalHoldOn, and fail closed immediately on non-NotFound read errors; (2) add fail-closed checkObjectLockedForOverwrite for null-version re-checks under the storage write lock |
|
cmd/bucket-handlers.go |
Call checkPutObjectLockAllowed for directory objects (isDirObject(object)) in PostPolicyBucketHandler |
|
cmd/object-multipart-handlers.go |
Pass trustedReplication to checkPutObjectLockAllowed in CompleteMultipartUploadHandler |
|
cmd/erasure-object.go |
Under the destination object write lock (NewNSLock) in erasureObjects.putObject and erasureObjects.CopyObject, call checkObjectLockedForOverwrite when writing to "" or nullVersionID |
|
cmd/erasure-multipart.go |
In CompleteMultipartUpload under the destination write lock, run checkObjectLockedForOverwrite for null versions and check whether any non-empty fi.VersionID already exists via getObjectInfo (or replicaObjectInfo across pools) |
|
cmd/erasure-server-pool.gocmd/erasure-sets.go |
Supply cross-pool z.replicaObjectInfo for public writes and multipart completions in multi-pool deployments, and forward versioning/replication flags in CopyObject |
3.1 SN-2026-015 Detailed Code Changes (Core Plan A)
(1) cmd/object-handlers.go: Unconditional Base Authorization at PutObjectRetentionHandler Entry
Immediately after authenticateRequest and ParseObjectRetention(r.Body)—and before GetBucketInfo, globalBucketObjectLockSys.Get, or getOpts—enforce s3:PutObjectRetention via isRetentionActionAllowed:
(2) cmd/bucket-object-lock.go: isRetentionActionAllowed and enforceRetentionBypassForPut
isRetentionActionAllowed constructs object-lock-mode, object-lock-retain-until-date, and object-lock-remaining-retention-days from the parsed request body and calls globalIAMSys.IsAllowed:
- When clearing retention (empty
<Retention/>, zeroRetainUntilDate) or for non-future dates,object-lock-remaining-retention-daysis set to"0"(fixing the106,752-day bug caused bytime.Durationclamping andmath.Abs, while keeping the key present soNumericLessThanDenypolicies match clear requests); - For future dates, ceiling days are computed from Unix seconds and nanoseconds to avoid 292-year
time.Durationsaturation.
Inside the object write lock, enforceRetentionBypassForPut evaluates only stored retention state:
(3) cmd/auth-handler.go: Remove isPutRetentionAllowed
Delete the 45-line isPutRetentionAllowed function from cmd/auth-handler.go, eliminating the byPassSet || retSet short-circuit.
3.2 SN-2026-016 Detailed Code Changes (Entry Guards + Write-Lock Re-Checks + Fail-Closed Preflight)
Why must SN-2026-016 retain storage write-lock checks in addition to HTTP entry guards and preflight checks? Four concrete scenarios cannot be prevented by entry or provenance markers alone:
- Slow-body
PUT dir/TOCTOU race (TestPlanReviewSlowDirectoryPut): InPutObjectHandler,checkPutObjectLockAllowedruns before streamingr.Bodyto disk, whereas the namespace write lock (er.NewNSLock) is acquired only afterr.Bodyfinishes streaming to a temporary file. A writer withs3:PutObjectcan initiatePUT /bucket/dir/, pass preflight whiledir/is unlocked, pause streaming the request body while an administrator appliesCOMPLIANCEretention (200 OK), and then finish sending the body. Without re-checking underer.NewNSLock, the pausedPUToverwrites thenullversion and erases the committedCOMPLIANCElock. - Pre-existing or replication-initiated persisted
uploadIdcompletion (TestPlanReviewLegacyMPUCompliance):putOptsruns only duringNewMultipartUpload;CompleteMultipartUploadusescompleteMultipartOptsand takes the targetfi.VersionIDfrom.minio.sys/multipart/.../uploadID/xl.meta. For any upload created before the upgrade (or initiated by a replication principal), completing the upload after the target UUID version is locked underCOMPLIANCErequires no concurrency and onlys3:PutObjectunlessCompleteMultipartUploadvalidates the target under the write lock. - Recovered multipart staging state colliding with a replicated
COMPLIANCEversion (TestRecoveredMultipartUploadAfterReplica): Even when a multipart upload’sfi.VersionIDwas originally generated by the server atNewMultipartUploadtime, if an older multipart staging snapshot is restored after a surviving replica has already re-synced andCOMPLIANCE-locked that completed UUID version, a provenance marker only proves where the UUID came from at initiation—not that the version does not already exist at completion time. Therefore,CompleteMultipartUploadmust unconditionally check under the destination write lock whetherfi.VersionIDalready exists. - Fail-closed read error handling (
TestPlanReviewPreflightReadFailure): Only an explicit non-existence result (ObjectNotFound,VersionNotFound, or delete markerMethodNotAllowed) may be treated as unlocked; bucket lock configuration errors or storage read quorum failures (InsufficientReadQuorum) must reject the write immediately at preflight as well as at commit time.
The four components of the SN-2026-016 repair are:
(1) Reject Untrusted versionId at the putOpts Convergence Point (cmd/object-api-options.go)
All HTTP write option builders (PutObject, CopyObject, NewMultipartUpload, PostPolicy, PutObjectExtract) converge at putOpts:
(2) Set the Outer Replication Marker on Batch Snowball Archives (cmd/batch-handlers.go)
When SILO’s batch replicate streams a Snowball archive via writeAsArchive to another SILO/MinIO target, each tar PAX entry carries minio.versionId. PutObjectExtractHandler requires X-Minio-Source-Replication-Request: true (markerExact) on the outer request before granting entryTrusted:
(3) Enforce Fail-Closed null-Version Lock Checks at Both HTTP Preflight and Storage Write Lock (cmd/bucket-object-lock.go, cmd/erasure-object.go, cmd/erasure-server-pool.go, cmd/erasure-sets.go)
- HTTP preflight (
checkPutObjectLockAllowed): ResolvenullVersionIDfor directory objects (isDirObject(object)), block unexpiredGOVERNANCEalongsideCOMPLIANCEandLegalHoldOnwhen!replicaTrusted, and fail closed immediately on any read error other thanObjectNotFound/VersionNotFound/MethodNotAllowed:
- Storage write-lock commit (
checkObjectLockedForOverwrite): Insideer.putObject,er.CopyObject, ander.CompleteMultipartUploadwhile holdinger.NewNSLock(bucket, object), any non-replication write targeting""ornullVersionIDcallscheckObjectLockedForOverwrite(usingz.replicaObjectInfoacross pools) and fails closed on config or read errors:
(4) Check Persisted fi.VersionID Under the Object Write Lock in CompleteMultipartUpload (cmd/erasure-multipart.go)
Under er.NewNSLock(bucket, object), non-replication multipart completions (!opts.ReplicationRequest && !opts.ReplicaLockReconcile && !opts.DataMovement) check the persisted fi.VersionID from uploadid/xl.meta:
- If
fi.VersionID == "" || fi.VersionID == nullVersionID: callcheckObjectLockedForOverwrite; - If
fi.VersionIDis a non-empty UUID: querygetObjectInfo(orreplicaObjectInfoacross pools) and reject witherrAuthentication(403 AccessDenied) if that version already exists on disk (including delete markers), failing closed on read errors without deleting staged parts.
4. Critical-Path and Performance Impact Analysis
| Operation Path | Execution Path & Overhead After Repair |
|---|---|
GetObject / HeadObject / ListObjects / DeleteObject |
Untouched; read and delete paths do not traverse any modified code |
PutObject / CopyObject in regular buckets (Object Lock disabled) |
putOpts checks vid != ""; under the write lock, checkObjectLockedForOverwrite checks in-memory globalBucketObjectLockSys.Get(bucket) (!retentionCfg.LockEnabled) and returns immediately with no extra disk I/O or cross-pool calls |
Regular PutObject / CopyObject in Object Lock buckets (non-/ keys) |
Object Lock buckets are always versioned; normal single-request writes generate a non-empty UUID fi.VersionID (fi.VersionID != "" && fi.VersionID != nullVersionID), so neither preflight nor write-lock commit performs an extra getObjectInfo read |
Directory object writes (PUT dir/) or null-version overwrites in Object Lock buckets |
Both preflight and the storage write lock read the null version’s metadata and fail closed on read errors, preventing slow-body TOCTOU overwrites and read-failure bypasses |
Multipart upload completions (CompleteMultipartUpload) in versioned buckets |
Performs one fail-closed getObjectInfo (or replicaObjectInfo across pools) lookup under the destination write lock during final multipart commit (NewMultipartUpload and UploadPart are unaffected). Any future optimization to skip this lookup for newly generated uploads must account for snapshot-recovery collisions and be validated separately |
PutObjectRetention (valid extensions and unauthorized requests) |
Base s3:PutObjectRetention authorization runs before reading bucket/object state, rejecting unauthorized requests in memory without disk I/O or lock contention; valid retention extensions return immediately at !weakens inside the write lock without repeating IAM evaluation |
5. Why It Works and Upgrade Notes
5.1 Three Core Security Invariants
- Invariant 1: Base permission and lock condition keys must hold before any state is read (closes F1, F3, zero-date clear bypass, and existence probing)
- All three Object Lock condition keys on
PutObjectRetentionderive solely from the XML request body and current time. EvaluatingisRetentionActionAllowed(policy.PutObjectRetentionAction, ...)immediately afterParseObjectRetentionrejects unauthorized or policy-denied requests with403 AccessDeniedbefore any bucket or object lookup.
- All three Object Lock condition keys on
- Invariant 2: Bypass is triggered strictly by weakening an unexpired stored lock and requires both header intent and IAM permission (closes F2)
enforceRetentionBypassForPutruns under the object write lock and inspects storedret.Modeandret.RetainUntilDate. If no lock exists, the lock is expired, or!weakens, no bypass is required. Whenweakens == true, storedCOMPLIANCEunconditionally returns400 ObjectLocked, and storedGOVERNANCErequires bothIsObjectLockGovernanceBypassSet(r.Header)andisRetentionActionAllowed(BypassGovernanceRetentionAction, ...).
- Invariant 3: Public writes cannot overwrite an existing UUID version or a locked
nullversion at either entry or commit, failing closed on read errors (closes F4 and companion gaps)- At entry,
putOptsrejects any untrusted targetversionId, andcheckPutObjectLockAllowedfails closed immediately on locked directorynullversions or read errors. - Under the destination write lock (
NewNSLock),checkObjectLockedForOverwritere-verifiesnull-version writes (closing the slow-body TOCTOU window), andCompleteMultipartUploadverifies that any non-emptyfi.VersionIDdoes not already exist on disk (closing legacy uploads, cross-principal completions, and post-recovery UUID collisions).
- At entry,
PutObjectRetention Decision Matrix
retOK denotes s3:PutObjectRetention allowed with the request’s lock condition keys; bypassOK denotes s3:BypassGovernanceRetention allowed with those same keys:
| Stored Version State | Requested Change | Bypass Header | retOK |
bypassOK |
Previous Behavior | Repaired Behavior |
|---|---|---|---|---|---|---|
| Any | Any | Any | No | Any | Often 200 (F1, F3) or 404/400 | Entry 403 AccessDenied |
Unexpired GOVERNANCE |
Extend retention | Any | Yes | Any | 200 | 200 OK |
Unexpired GOVERNANCE |
Shorten / clear / change mode | No | Yes | Any | 400 InvalidRequest (ObjectLocked) |
400 InvalidRequest (ObjectLocked) |
Unexpired GOVERNANCE |
Shorten / clear / change mode | Yes | Yes | No | 200 (F2 bug) | 403 AccessDenied |
Unexpired GOVERNANCE |
Shorten / clear / change mode | Yes | Yes | Yes | 200 | 200 OK |
Unexpired COMPLIANCE |
Shorten / clear / change mode | Any | Yes | Any | 400 InvalidRequest (ObjectLocked) |
400 InvalidRequest (ObjectLocked) |
| Unlocked or expired | Any | Any | Yes | Any | 200 | 200 OK |
Write-Path Invariant Coverage
| Write Path | Option Convergence & Write-Lock Guard | Post-Repair Guarantee |
|---|---|---|
PutObject (regular PUT / bucket replication / site replication / single-object batch replication) |
evaluateReplicationTrust -> putOpts; null versions run checkObjectLockedForOverwrite under write lock |
Public callers (including root) passing ?versionId= receive 403 at putOpts; trusted replication preserves source version IDs |
CopyObject (including metadata copy) |
Source uses srcOpts, destination uses copyDstOpts -> putOpts; null versions run checkObjectLockedForOverwrite under write lock |
Public callers specifying destination ?versionId= receive 403; trusted replication synchronizes target versions |
PostPolicy (browser form upload) |
putOptsFromReq -> putOpts; directory objects checked at preflight and write lock |
Target versionId receives 403; locked directory objects cannot be overwritten |
PutObjectExtract (Snowball / batch archive replication) |
Outer markerExact + per-entry s3:ReplicateObject -> entryTrusted -> putOpts; writeAsArchive sets outer marker |
Untrusted archive entries with minio.versionId receive 403; server-side batch replicate archives preserve version IDs |
NewMultipartUpload / UploadPart / UploadPartCopy |
Initiation passes through putOpts; UploadPartCopy clears replication trust |
Untrusted callers cannot initiate multipart uploads with ?versionId= (403) |
CompleteMultipartUpload |
completeMultipartOpts + write-lock check keyed on fi.VersionID |
In-flight uploads from before versioning was enabled complete normally; non-replication completions targeting any existing UUID version (including legacy, replication-initiated, or recovered uploads) receive 403; null versions run checkObjectLockedForOverwrite |
Directory object PUT / Copy (/{bucket}/dir/) |
Fail-closed preflight checkPutObjectLockAllowed + write-lock checkObjectLockedForOverwrite |
Overwriting a directory null version with unexpired GOVERNANCE, COMPLIANCE, or LegalHoldOn (including locks applied during slow body transfer) returns 400 InvalidRequest (ObjectLocked); read errors abort the write at both preflight and commit |
5.2 Upgrade and Compatibility Notes
Normal new-version writes, object reads, and authorized retention extensions are unchanged. Operators should note the following tightened behaviors:
- Policies granting
s3:BypassGovernanceRetentionwithouts3:PutObjectRetentionno longer allow retention writes (SN-2026-015)- Previous behavior: Due to
byPassSet || retSet, a principal with onlys3:BypassGovernanceRetentioncould callPutObjectRetentionby including the bypass header. - New behavior: Returns
403 AccessDenied. Per AWS S3 semantics,s3:BypassGovernanceRetentionis an additional permission when weakening governance locks;s3:PutObjectRetentionis always required.
- Previous behavior: Due to
- Unauthorized
PutObjectRetentionrequests uniformly return403instead of404or400(SN-2026-015)- Previous behavior: Returned
404 NoSuchBucketor400 InvalidBucketObjectLockConfigurationbefore checking policy. - New behavior: Policy evaluation runs before reading bucket state, returning
403 AccessDenied.
- Previous behavior: Returned
- Clearing retention (empty
<Retention/>) evaluatess3:object-lock-remaining-retention-daysas"0"(SN-2026-015)- Previous behavior: Zero-time duration clamping and
math.Absproduced106,752days, allowing clear requests to bypass minimum-retention-day policies. - New behavior: Evaluates to
"0", enforcing minimum-retention-day policies on clear requests.
- Previous behavior: Zero-time duration clamping and
- Non-replication requests (including
root) can no longer overwrite a version in place via?versionId=(SN-2026-016)- Previous behavior: Any caller with
s3:PutObjectcould pass?versionId=<id>onPUT,CopyObject, orNewMultipartUploadto overwrite that version in place. - New behavior: Returns
403 AccessDeniedatputOptsunless the request is authenticated replication.
- Previous behavior: Any caller with
batch replicateto a SILO target requiress3:ReplicateObjecton the target credentials (SN-2026-016)- Previous behavior: A target credential with only
s3:PutObjecthad internal replication headers stripped, yetputOptsstill accepted?versionId=from the query string. - New behavior: Preserving source version IDs requires target replication trust (
s3:ReplicateObject), matching bucket replication requirements.
- Previous behavior: A target credential with only
- Locked directory objects (
dir/) in Object Lock buckets cannot be overwritten until lock expiry (SN-2026-016)- Previous behavior: Directory objects shared the
nullversion and skipped existing-lock checks, allowing repeatedPUT dir/calls to overwrite and reset retention. - New behavior: Returns
400 InvalidRequest(ObjectLocked) while the directorynullversion holds an unexpiredGOVERNANCE/COMPLIANCEretention orLegalHoldOn.
- Previous behavior: Directory objects shared the
- Separately tracked adjacent item (not bundled in this patch)
PostRestoreObjectHandler(archive restore) reading a snapshot before writing back metadata viaCopyObjectis tracked separately as an archive-restore concurrency item.
6. Adversarial Evaluation Across Candidate Designs
During design review, four candidate write-path designs were evaluated against adversarial reproducers:
| Dimension | Candidate 1: HTTP Entry & Preflight Only (Rejected) |
Candidate 2: Skipping Write-Lock UUID Check via Multipart Origin Marker (Rejected) |
Candidate 3: fe3935476 Full Write-Lock Checks(Without Fail-Closed Preflight) |
Final Adopted Design: fe3935476 Full Write-Lock Checks + Fail-Closed Preflight |
|---|---|---|---|---|
SN-2026-015 Retention Authz (F1 / F2 / F3 / Zero-Date) |
Passes | Passes | Passes | Passes |
Slow-body PUT dir/ concurrent lock race (TestPlanReviewSlowDirectoryPut) |
Fails (P1): paused body stream overwrites committed COMPLIANCE lock |
Passes | Passes | Passes |
Pre-upgrade or replication-initiated uploadId overwrite (TestPlanReviewLegacyMPUCompliance) |
Fails (P1): CompleteMultipartUpload bypasses putOpts and overwrites COMPLIANCE version |
Passes | Passes | Passes |
Recovered multipart upload colliding with replicated COMPLIANCE UUID (TestRecoveredMultipartUploadAfterReplica) |
Fails (P1) | Fails (P1): origin marker only proves how the UUID was generated at initiation, not that the version is still absent at completion | Passes (unconditional fi.VersionID existence check under write lock) |
Passes (unconditional fi.VersionID existence check under write lock) |
Preflight read quorum failure behavior (TestPlanReviewPreflightReadFailure) |
Fails (P1): preflight ignores read error and overwrites object | Passes | Write lock blocks overwrite, but preflight ignores read error and streams body | Passes (preflight immediately fails closed before streaming body) |
CompleteMultipartUpload lookup behavior |
No extra lookup | Skips lookup on generated marker (introduces recovery collision flaw) | Runs getObjectInfo under write lock |
Retains strict getObjectInfo check under write lock; any future performance optimization is deferred for separate validation |
Conclusion
We adopt the fe3935476 full write-lock defense + fail-closed preflight hardening as the final implementation, and incorporate all four adversarial regression tests (TestPlanReviewSlowDirectoryPut, TestPlanReviewPreflightReadFailure, TestPlanReviewLegacyMPUCompliance, and TestRecoveredMultipartUploadAfterReplica) into cmd/object-retention-overwrite_test.go.