No I/O Before Auth, No Privilege From Headers
This record describes the CORS hot-path and replication-request trust repair committed locally in SILO as 938603458.
Status on 2026-09-02: implementation, focused and race tests, the complete server package suite, object-lock tests, vet, build, two rounds of Fable 5 design review, repeated Opus 5 adversarial acceptance, and a real local TLS two-site replication run are complete. The candidate is pushed as pgsty/silo#101; remote CI, merge, tag, package, image, deployment, and production verification remain separate gates.
Scope: HTTP request interpretation before and inside the S3 handlers. No S3 wire field, object format, bucket metadata format, replication protocol, encryption format, or client command changes.
Security properties: pre-authentication CORS processing performs no object-layer I/O; a header never grants replication semantics by itself; SSE-C ciphertext paths and replica-only metadata require both authentication and the corresponding replication permission.
Too Long; Didn’t Read (TL;DR)
Two bugs looked unrelated:
- an
Originheader made the outermost CORS middleware treat the first URL segment as a bucket and synchronously load its metadata before authentication; X-Minio-Source-Replication-Requestmade downstream code believe a request was internal replication merely because the header existed.
They shared the same design failure: untrusted request shape was allowed to acquire expensive or privileged internal meaning before an authorization boundary.
The repair establishes two invariants:
For CORS, the outer middleware now reads only metadata already resident in memory. For replication, handlers authenticate the original signed request first, authorize the appropriate replication action, and then attach a private trust decision to the request context. Untrusted internal headers are stripped only after signature verification. The context decision—not header removal—is the authority used by option builders, encryption paths, object lock, event generation, and metadata persistence.
Failure A: pre-authentication CORS amplification
corsHandler wraps the complete server router. Any request carrying Origin reaches it before S3 authentication, request validity checks, and the normal API limiter.
The per-bucket CORS implementation originally called the normal bucket metadata getter:
When .metadata.bin did not exist, the loader intentionally searched legacy configuration files. With none found, it returned a valid empty metadata record rather than NoSuchBucket. The generic getter then inserted that record into metadataMap.
An unauthenticated client could therefore vary otherwise plausible names and obtain two effects per distinct value:
- repeated erasure/object metadata reads before the normal request limiter;
- growth of the in-memory bucket metadata map.
Name validation alone cannot repair this. An attacker can generate an effectively unbounded sequence of syntactically valid, nonexistent bucket names. Distributed deployments eventually prune stale map entries during the 15-minute metadata refresh; single-node deployments do not start that refresh loop, so their synthetic entries persist until restart.
Failure B: a marker header became authority
SILO and its MinIO-compatible clients use internal headers to preserve source state during replication. The most important marker is:
Before this repair, several paths treated header presence—or its raw string value—as proof that the request was a replication request. That affected more than metadata extraction:
GETof an SSE-C object could setNoDecryptionand return ciphertext without the customer key to a caller holding only ordinary read permission;- source ETag and modification time could replace server-generated values;
- source tagging, retention, and legal-hold timestamps could enter last-writer-wins comparisons;
- a past object-lock retention date could be accepted through a raw marker check;
- delete-marker identity and modification time could be supplied by the caller;
- successful object events could be suppressed;
- multipart actual size and encrypted checksum metadata could be injected at completion;
X-Amz-Replication-Statuscould be persisted from ordinary PUT, COPY, or POST-policy metadata extraction.
The earlier CVE-2026-34204 repair correctly stopped ordinary PUT and COPY from importing the replication SSE metadata that could make objects unreadable. It did not yet provide one authority shared by every reader of the marker, source fields, event state, object-lock exceptions, or multipart completion metadata.
Selected design
One exact marker, two trust levels
The marker is accepted only when it appears exactly once and its value is exactly lowercase true. Duplicate values, mixed case, and any other value are untrusted.
The handler then derives two related decisions:
| Decision | Requirements | Semantics it may enable |
|---|---|---|
trusted |
original request authenticated; non-anonymous principal; exact marker; s3:ReplicateObject or s3:ReplicateDelete on the addressed resource |
source ETag/MTime and source timestamps; actual size and encrypted checksum transfer; event and re-replication suppression; replication delete pool/version pinning |
replicaTrusted |
trusted, plus raw request status REPLICA or a multipart upload whose stored status is REPLICA |
replica status persistence; replication SSE sealed-key import; SSE-C ciphertext/no-decryption path; replica-only object-lock behavior |
The split is required by the real wire protocol. Not every legitimate replication request repeats X-Amz-Replication-Status: REPLICA.
The receiver follows this matrix:
| Incoming shape | Result |
|---|---|
| no marker | ordinary S3 operation |
| marker without replication permission | internal fields ignored; operation continues with ordinary semantics |
REPLICA without replication permission |
403 AccessDenied |
exact marker + replication permission, no REPLICA |
trusted only |
exact marker + replication permission + REPLICA |
trusted and replicaTrusted |
The explicit 403 for an unauthorized REPLICA request prevents a claimed replica write from being silently downgraded into a new ordinary object that may be replicated again.
Authenticate the original, then sanitize
SigV4 signs request headers. Removing an internal header before authentication would change the canonical request and turn a valid signature into SignatureDoesNotMatch.
The ordering is therefore mandatory:
The audit logger retains the original request. The effective request clone retains public S3, SSE, checksum, object-lock, copy-source, proxy, and replication-validity headers. It strips only internal source/replication controls, including source ETag/MTime/delete-marker/timestamps, replication SSE state, actual object size, encrypted checksum transfer, and the request use of X-Amz-Replication-Status.
Header stripping is defense in depth. All privileged consumers use the private context decision or an explicit Boolean; they do not infer trust by looking at the clone.
Replica status is not generic user metadata
X-Amz-Replication-Status is an S3 response header that MinIO-compatible servers also use as an internal request control. It no longer belongs to the generic supported-request-metadata list.
Ordinary PUT, COPY, multipart initiation, Snowball/PAX extraction, and POST policy cannot persist it merely by submitting the field. The receiver sets REPLICA explicitly only in a replicaTrusted branch.
This closes a subtle POST-policy path: a form field could previously store REPLICA, causing the resulting object to evade normal replication scheduling even though the POST principal never held replication permission.
Object lock receives an explicit decision
The object-lock parser used to accept past retention dates when the raw marker header was present. That package now receives allowPastRetainDate explicitly from replicaTrusted state.
The surrounding handler also uses the same decision when deciding whether an existing compliance/legal-hold version may be overwritten by a replica. This removes an internal-header dependency from the reusable object-lock package.
Actual replication wire matrix
The design was checked against the silo-go v7.3.1 emitter selected by the server’s go.mod, not inferred from comments or upstream documentation.
| Operation | Marker | REPLICA on this request |
Receiver decision |
|---|---|---|---|
regular replicated PutObject |
yes | yes | replicaTrusted |
replicated NewMultipartUpload |
yes | yes | persist trusted multipart replica provenance |
replicated PutObjectPart |
yes | no | trusted; replicaTrusted only when stored MPU status is REPLICA |
replicated CompleteMultipartUpload |
yes | no | trusted; preserve source ETag/MTime, actual size, and encrypted checksum |
| CopyObject metadata replication | yes | yes | replicaTrusted |
replicated RemoveObject |
yes | yes | replicaTrusted with s3:ReplicateDelete |
| batch replication PUT/Complete | yes | no | trusted; target credentials must hold s3:ReplicateObject |
| proxy/readiness/validity probes | separate probe headers | no marker authority | probe behavior retained; those headers are never stripped by this repair |
s3:ReplicateDelete is the trust gate, not the receiver’s only permission.
For compatibility with deployed target policies, a trusted replication delete
also requires s3:DeleteObject; an explicit deny on
s3:DeleteObjectVersion still blocks a named-version purge. Ordinary clients
do not use this compatibility path: an explicit UUID or versionId=null
requires an allow for s3:DeleteObjectVersion.
Requiring REPLICA for every trusted operation would break PutPart, multipart completion, and batch replication. Trusting every marker would recreate the vulnerability. Stored multipart provenance bridges the two requirements for encrypted raw parts.
CORS resident-only state machine
The outer CORS middleware must remain cheaper than the request it is about to route. It now calls a dedicated resident-only getter that takes one read lock and examines only in-memory state.
| Bucket metadata state | CORS result | Object-layer work |
|---|---|---|
| resident, valid per-bucket CORS | apply per-bucket rule | none |
| resident, no CORS document | use global CORS fallback | none |
| resident, invalid stored CORS | fail closed; continue without CORS headers and log once | none |
| subsystem not initialized | fail closed | none |
| known metadata load failure | fail closed | none |
internal .minio.sys namespace |
fail closed | none |
| reserved or invalid bucket-shaped path | global fallback | none |
| initialized, otherwise unknown name | global fallback | none |
loadFailed is populated only from disk-derived bucket lists during startup or refresh. It cannot grow from a client path. Successful metadata load, Set, bucket removal, stale-bucket reconciliation, and subsystem reset clear the corresponding state.
Cold-cache residual boundary
There is one accepted edge: a real bucket can be absent from both metadataMap and loadFailed if a node misses the peer metadata-load notification. Until the next bucket refresh discovers and loads it, that node treats the name as unknown and uses global CORS.
CORS remains a browser response policy, not an authorization mechanism—normal S3 authentication and bucket policy still apply—but an operator relying on a restrictive per-bucket CORS document should understand the temporary relaxation. A follow-up can mark every disk-listed-but-nonresident bucket as load-failed before attempting its refresh, preserving fail-closed behavior without adding synchronous request I/O.
Alternatives rejected
| Alternative | Why it was rejected |
|---|---|
| Validate bucket names before the old CORS getter | valid nonexistent names still provide an unbounded attacker-controlled key space and still trigger pre-auth I/O |
Call GetBucketInfo before loading CORS |
replaces eleven metadata reads with at least one unthrottled backend operation per attacker name |
| Cache every negative result with a TTL | bounds duration, not attacker cardinality or the initial I/O amplification |
| Strip replication headers before authentication | breaks SigV4 canonical-request verification |
| Reject every request carrying an internal marker | turns formerly ignored extra headers into broad client failures and breaks legitimate marker-only replication calls |
Require REPLICA on every trusted call |
breaks replicated PutPart, CompleteMultipartUpload, and batch replication wire behavior |
| Let every handler re-check raw headers independently | recreates inconsistent trust rules and leaves future consumers easy to miss |
Store a Boolean in ObjectOptions but leave events/object lock on headers |
produces two authorities that can disagree; the original bug class remains |
Implementation boundary
The selected change is intentionally layered:
- a small request-trust module defines exact marker parsing, replication authorization, private context state, and the post-authentication effective request;
- object option builders parse source fields only when their caller provides trusted state;
DecryptObjectInfo, event request parameters, multipart completion, delete options, and object lock consume the same decision;- handlers calculate trust immediately after their existing authentication path;
- multipart part handling combines current-request trust with stored MPU replica provenance;
- generic metadata extraction does not accept replica status;
- CORS middleware uses a separate resident-only metadata accessor and never calls the load-on-miss getter.
No object-layer API needs to infer HTTP trust. Programmatic internal callers that construct ObjectOptions{ReplicationRequest: true} remain unchanged.
Verification and adversarial review
Regression coverage includes:
- hundreds of distinct valid missing bucket names, both actual and preflight CORS requests, with zero metadata reads and no map growth;
- Console, reserved, invalid, startup, internal namespace, invalid stored CORS, and known load-failure paths;
- least-privilege SSE-C GET, HEAD, and GetObjectAttributes callers with correct, missing, wrong-case, and unauthorized markers;
- marker-only batch-style PUT preserving source ETag/MTime only with
s3:ReplicateObject; - unauthorized
REPLICAPUT and DELETE returning403; - POST policy unable to forge replica status;
- object-lock past-date parsing with and without replica trust;
- marker-only CopyObject with SSE-C source headers copying plaintext rather than ciphertext;
- fake marker on an ordinary SSE-C MPU failing instead of storing raw bytes;
- a real in-process SSE-C multipart replication chain: encrypted source, raw ciphertext part, trusted replica initiation, marker-only PutPart and Complete, and exact plaintext recovery with the original key.
The final local tree passed focused and race tests, the complete cmd suite, object-lock tests, vet, build, and diff checks.
A separate black-box run started two TLS-enabled SILO instances built from the candidate and enabled real site replication. It verified:
- an SSE-C 4 KiB object;
- an SSE-C 12 MiB, three-part multipart object;
- an SSE-C CopyObject result;
- a replicated delete marker.
Source and target ETag, size, version ID, SSE-C key MD5, decrypted SHA-256, and delete-marker version ID matched; targets reported REPLICA.
Two Fable 5 review rounds first corrected the trust model for marker-only batch and multipart calls, then audited the implementation. A final independent Claude Code Opus 5 review reported GO, with no P0/P1 findings, and independently reran build, vet, race, object-lock, and full cmd tests.
Compatibility and operations
- Ordinary clients: no request change. Untrusted internal headers are ignored instead of acquiring internal semantics.
- Unauthorized claimed replica writes: requests carrying
X-Amz-Replication-Status: REPLICAnow return403where some multipart subpaths previously lacked a uniform check. - Batch replication: destination credentials must include
s3:ReplicateObject, as documented in the batch replication requirements. Without it, the receiver processes marker-only writes as ordinary writes and does not preserve source ETag/MTime. - SSE-C: ordinary reads still require the customer key. Authorized replica reads may use the raw ciphertext path needed to preserve encrypted bytes.
- Events: only trusted replication suppresses replica creation/access events; a forged marker no longer silences them.
- Object lock: replica exceptions are permission-derived rather than header-derived.
- Performance: CORS removes pre-authentication backend work. Trusted writes add policy checks already required by the replication contract; no additional object pass is introduced.
- Rolling upgrade: wire and storage formats are unchanged. New receivers enforce the trust boundary; old receivers remain vulnerable to the old header semantics until upgraded. Per-bucket CORS behavior can therefore differ by node during the rolling window.
- Rollback: data written by the repaired version remains readable by the previous version, but rollback reopens both trust defects and restores pre-authentication metadata loads.
Residual risks and follow-ups
- Mark disk-listed but nonresident buckets fail-closed before refresh to narrow the cold-cache CORS window described above.
- Emit a rate-limited diagnostic when a marker-bearing request lacks replication permission; the safe ordinary fallback is otherwise easy to misdiagnose as an ETag/MTime mismatch.
- Replication validity probes retain their inherited permission-reporting behavior and should be audited separately rather than silently changed in this repair.
- This review covers the named source/replication headers. Other future internal controls must still answer the same question: which authenticated decision allowed this client value to acquire internal meaning?
Conclusion
An internal-looking header is still client input. A bucket-shaped URL segment is still attacker input. The durable repair is to stop either one from becoming authority by accident:
Before authentication, do no backend work. After authentication, derive trust once and pass the decision—not the claim—downstream.
That rule is broader than CORS or replication. It is the boundary future SILO handlers should preserve whenever inexpensive public request syntax meets expensive or privileged internal state.