diff --git a/content/administration/bucket-replication/bucket-replication-requirements.md b/content/administration/bucket-replication/bucket-replication-requirements.md index 907fee92..0bc59db0 100644 --- a/content/administration/bucket-replication/bucket-replication-requirements.md +++ b/content/administration/bucket-replication/bucket-replication-requirements.md @@ -133,6 +133,13 @@ The following policy provides permissions for enabling synchronization of replic - The `"EnableReplicationOnBucket"` statement grants permission for a remote target to retrieve bucket-level configuration for supporting replication operations on *all* buckets in the MinIO deployment. To restrict the policy to specific buckets, specify those buckets as an element in the `Resource` array similar to `"arn:aws:s3:::bucketName"`. - The `"EnableReplicatingDataIntoBucket"` statement grants permission for a remote target to synchronize data into *any* bucket in the MinIO deployment. To restrict the policy to specific buckets, specify those buckets as an element in the `Resource` array similar to `"arn:aws:s3:::bucketName/*"`. +Replicated deletes on a SILO target use the established receiver contract +`s3:DeleteObject` plus `s3:ReplicateDelete`. The replication credential does +not also need an allow for `s3:DeleteObjectVersion`; however, an explicit deny +on `s3:DeleteObjectVersion` still blocks the corresponding version purge. This +internal contract does not change the permission for an ordinary S3 client, +which needs `s3:DeleteObjectVersion` whenever it explicitly names a version. + The following code creates a [MinIO-managed user](/administration/identity-access-management/minio-user-management/#minio-users) with the necessary policy. Replace `TARGET` with the [alias](/reference/minio-mc/mc-alias-set/#alias) of the MinIO deployment on which you are configuring replication: ```shell diff --git a/content/administration/bucket-replication/bucket-replication-requirements.zh.md b/content/administration/bucket-replication/bucket-replication-requirements.zh.md index 25fc25e5..57d37b41 100644 --- a/content/administration/bucket-replication/bucket-replication-requirements.zh.md +++ b/content/administration/bucket-replication/bucket-replication-requirements.zh.md @@ -133,6 +133,11 @@ mc admin policy attach TARGET ReplicationAdminPolicy --user=ReplicationAdmin - `"EnableReplicationOnBucket"` 语句授予远端目标获取存储桶级配置的权限, 从而支持在 MinIO 部署中 *所有* 存储桶上执行复制操作。 如果要将策略限制到特定存储桶,请像 `"arn:aws:s3:::bucketName"` 一样, 在 `Resource` 数组中指定这些存储桶。 - `"EnableReplicatingDataIntoBucket"` 语句授予远端目标将数据同步到 MinIO 部署中 *任意* 存储桶的权限。 如果要将策略限制到特定存储桶,请像 `"arn:aws:s3:::bucketName/*"` 一样, 在 `Resource` 数组中指定这些存储桶。 +SILO 目标端的复制删除沿用 `s3:DeleteObject` 加 `s3:ReplicateDelete` 的 receiver +契约。复制凭据不需要额外获得 `s3:DeleteObjectVersion` 的 Allow;但显式 Deny +`s3:DeleteObjectVersion` 仍会阻止对应的版本清理。这个内部契约不改变普通 S3 +客户端的要求:只要请求明确指定版本,就必须具备 `s3:DeleteObjectVersion`。 + 下列代码使用所需策略创建一个 [MinIO 管理用户](/zh/administration/identity-access-management/minio-user-management/#minio-users)。 将 `TARGET` 替换为你要配置复制的 MinIO 部署的 [别名](/zh/reference/minio-mc/mc-alias-set/#alias): ```shell diff --git a/content/administration/identity-access-management/policy-based-access-control.md b/content/administration/identity-access-management/policy-based-access-control.md index e2c4c1e7..fc15c1e1 100644 --- a/content/administration/identity-access-management/policy-based-access-control.md +++ b/content/administration/identity-access-management/policy-based-access-control.md @@ -256,6 +256,11 @@ Controls access to the [ListBuckets](https://docs.aws.amazon.com/AmazonS3/latest Controls access to the [DeleteObject](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObject.html) S3 API operation. +This action authorizes a delete request that does not explicitly name a +version. On a versioned bucket, that request creates a delete marker. It does +not authorize `DELETE ?versionId=...` or a `DeleteObjects` entry containing +`VersionId`. + Supports the following additional [condition key](#minio-policy-conditions): ```shell @@ -520,6 +525,10 @@ Controls access to the [GetBucketVersioning](https://docs.aws.amazon.com/AmazonS Controls access to the [DeleteObjectVersion](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjectVersion.html) S3 API operation. +This action authorizes deletion of an explicitly named UUID or the explicit +`null` version. In `DeleteObjects`, SILO evaluates this action independently for +each entry that contains `VersionId`. + Supports the following additional [condition keys](#minio-policy-conditions): ```shell diff --git a/content/administration/identity-access-management/policy-based-access-control.zh.md b/content/administration/identity-access-management/policy-based-access-control.zh.md index 7c0a8fe0..c4fd5deb 100644 --- a/content/administration/identity-access-management/policy-based-access-control.zh.md +++ b/content/administration/identity-access-management/policy-based-access-control.zh.md @@ -256,6 +256,10 @@ MinIO 策略文档支持 IAM [S3 Action keys](https://docs.aws.amazon.com/IAM/la 控制对 [DeleteObject](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObject.html) S3 API 操作的访问。 +此操作授权未显式指定版本的删除请求。在启用版本控制的存储桶上,该请求会创建 +delete marker。它不授权 `DELETE ?versionId=...`,也不授权 `DeleteObjects` 中 +携带 `VersionId` 的条目。 + 支持以下额外[条件键](#minio-policy-conditions): ```shell @@ -520,6 +524,9 @@ s3:RequestObjectTag/ 控制对 [DeleteObjectVersion](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjectVersion.html) S3 API 操作的访问。 +此操作授权删除明确指定的 UUID 或显式 `null` 版本。在 `DeleteObjects` 中,SILO +会对每个携带 `VersionId` 的条目独立检查该权限。 + 支持以下附加 [condition keys](#minio-policy-conditions): ```shell diff --git a/content/administration/object-management/object-delete.md b/content/administration/object-management/object-delete.md index 549ae774..39bb51e5 100644 --- a/content/administration/object-management/object-delete.md +++ b/content/administration/object-management/object-delete.md @@ -26,6 +26,20 @@ Any combination of the following factors may impact how `DELETE` operations func MinIO uses a [policy based access control](/administration/identity-access-management/policy-based-access-control/#minio-policy) system for access management. The user or service account must provide the correct policy action and conditions to allow a `DELETE` for the bucket and object. +The required action is selected from the request, not merely from whether the +bucket has versioning enabled: + +| Request | Required action | +| --- | --- | +| `DELETE` without `versionId` | `s3:DeleteObject` | +| `DELETE` with a version UUID | `s3:DeleteObjectVersion` | +| `DELETE` with `versionId=null` | `s3:DeleteObjectVersion` | +| `DeleteObjects` | Evaluated independently for each XML entry | + +An explicit deny always takes precedence. Conditions using `s3:versionid` see +the effective version ID of the object being deleted; a query parameter on the +outer `DeleteObjects` request does not replace an entry's `VersionId`. + ## Unversioned Objects {#unversioned-objects} When performing a `DELETE` operation on an object in a bucket that does not have versioning enabled, the operation is straightforward. After verifying the user or service account has permission to perform the `DELETE` operation, MinIO permanently removes the object. @@ -36,7 +50,10 @@ The user or service account requesting the delete action the action must have th `DELETE` operations work differently when an object is versioned. -The user or service account must have the [`s3:DeleteObjectVersion`](/administration/identity-access-management/policy-based-access-control/#policy-action.s3-DeleteObjectVersion) action permission for the bucket and object. +Deleting a specifically named version requires +[`s3:DeleteObjectVersion`](/administration/identity-access-management/policy-based-access-control/#policy-action.s3-DeleteObjectVersion). +Deleting without a version ID still requires `s3:DeleteObject` and creates a +delete marker instead of removing an existing version. ### Delete operations on the current version {#delete-operations-on-the-current-version} diff --git a/content/administration/object-management/object-delete.zh.md b/content/administration/object-management/object-delete.zh.md index e528f081..6eb3eb1f 100644 --- a/content/administration/object-management/object-delete.zh.md +++ b/content/administration/object-management/object-delete.zh.md @@ -26,6 +26,18 @@ upstream_modified: false MinIO 使用 [基于策略的访问控制](/zh/administration/identity-access-management/policy-based-access-control/#minio-policy) 系统进行访问管理。 用户或服务账户必须提供正确的策略操作和条件,才能对该存储桶和对象执行 `DELETE`。 +所需权限由请求是否显式指定版本决定,而不是仅由存储桶是否启用版本控制决定: + +| 请求 | 所需权限 | +| --- | --- | +| 未携带 `versionId` 的 `DELETE` | `s3:DeleteObject` | +| 携带版本 UUID 的 `DELETE` | `s3:DeleteObjectVersion` | +| 携带 `versionId=null` 的 `DELETE` | `s3:DeleteObjectVersion` | +| `DeleteObjects` | 对 XML 中的每个条目独立判定 | + +显式 `Deny` 始终优先。`s3:versionid` 条件读取当前条目实际删除的版本 ID;外层 +`DeleteObjects` 请求上的查询参数不能覆盖条目自己的 `VersionId`。 + ## 未启用版本控制的对象 {#id5} 如果对未启用版本控制的存储桶中的对象执行 `DELETE` 操作,其行为比较直接。 在确认用户或服务账户具有执行 `DELETE` 操作的权限后,MinIO 会永久删除该对象。 @@ -36,7 +48,10 @@ MinIO 使用 [基于策略的访问控制](/zh/administration/identity-access-ma 启用版本控制后,`DELETE` 操作的行为会有所不同。 -用户或服务账户必须对该存储桶和对象具有 [`s3:DeleteObjectVersion`](/zh/administration/identity-access-management/policy-based-access-control/#policy-action.s3-DeleteObjectVersion) 操作权限。 +删除一个明确指定的版本需要 +[`s3:DeleteObjectVersion`](/zh/administration/identity-access-management/policy-based-access-control/#policy-action.s3-DeleteObjectVersion) +权限。未指定版本 ID 的删除仍需要 `s3:DeleteObject`,它会创建 delete marker, +而不会移除现有版本。 ### 删除当前版本 {#id7} diff --git a/content/blog/design/cors-replication-trust.md b/content/blog/design/cors-replication-trust.md new file mode 100644 index 00000000..3772b6b6 --- /dev/null +++ b/content/blog/design/cors-replication-trust.md @@ -0,0 +1,274 @@ +--- +title: "No I/O Before Auth, No Privilege From Headers" +linkTitle: "CORS & Replication Trust" +date: 2026-09-01 +lastmod: 2026-09-01 +author: "Ruohang Feng" +summary: > + A pre-authentication CORS lookup turned arbitrary path segments into metadata I/O and cache entries, while a client-controlled replication marker acquired privileges across SSE-C reads, source timestamps, checksums, object lock, events, and deletes. This record defines SILO's resident-only CORS hot path, two-level replication trust model, post-signature sanitization boundary, wire-compatibility matrix, and release evidence. +tags: [Design, Security, CORS, Replication, SSE-C, Compatibility] +weight: 12 +draft: false +url: "/blog/design/cors-replication-trust/" +--- + +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](https://github.com/pgsty/silo/pull/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) {#tldr} + +Two bugs looked unrelated: + +1. an `Origin` header made the outermost CORS middleware treat the first URL segment as a bucket and synchronously load its metadata before authentication; +2. `X-Minio-Source-Replication-Request` made 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: + +```text +before authentication: parse cheaply; never load bucket metadata +after authentication: derive one trust decision; downstream code consumes it +``` + +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 {#cors-failure} + +`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: + +```text +Origin-bearing request + -> first URL segment becomes "bucket" + -> GetCorsConfig + -> GetConfig cache miss + -> read .metadata.bin + -> probe ten legacy config paths + -> cache a default BucketMetadata record +``` + +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 {#replication-failure} + +SILO and its MinIO-compatible clients use internal headers to preserve source state during replication. The most important marker is: + +```text +X-Minio-Source-Replication-Request: true +``` + +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: + +- `GET` of an SSE-C object could set `NoDecryption` and 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-Status` could be persisted from ordinary PUT, COPY, or POST-policy metadata extraction. + +The earlier [CVE-2026-34204 repair](/blog/security/cve-2026-34204/) 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 {#design} + +### One exact marker, two trust levels {#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 {#signature-boundary} + +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: + +```text +original request + -> existing signature/authentication path + -> ordinary S3 action authorization + -> replication action authorization + -> derive trusted / replicaTrusted + -> bind decision to request context + -> clone and strip untrusted internal fields + -> option parsing, encryption, object lock, storage, events +``` + +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 {#replica-status} + +`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 {#object-lock} + +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 {#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 {#cors-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 {#cold-cache} + +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 {#alternatives} + +| 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 {#implementation} + +The selected change is intentionally layered: + +1. a small request-trust module defines exact marker parsing, replication authorization, private context state, and the post-authentication effective request; +2. object option builders parse source fields only when their caller provides trusted state; +3. `DecryptObjectInfo`, event request parameters, multipart completion, delete options, and object lock consume the same decision; +4. handlers calculate trust immediately after their existing authentication path; +5. multipart part handling combines current-request trust with stored MPU replica provenance; +6. generic metadata extraction does not accept replica status; +7. 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 {#verification} + +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 `REPLICA` PUT and DELETE returning `403`; +- 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 {#impact} + +- **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: REPLICA` now return `403` where some multipart subpaths previously lacked a uniform check. +- **Batch replication:** destination credentials must include `s3:ReplicateObject`, as documented in the [batch replication requirements](/administration/batch-framework-job-replicate/). 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 {#residual-risks} + +- 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 {#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. diff --git a/content/blog/design/cors-replication-trust.zh.md b/content/blog/design/cors-replication-trust.zh.md new file mode 100644 index 00000000..fd5a6898 --- /dev/null +++ b/content/blog/design/cors-replication-trust.zh.md @@ -0,0 +1,272 @@ +--- +title: "鉴权前不做 I/O,Header 不授予权限" +linkTitle: "CORS 与复制信任边界" +date: 2026-09-01 +lastmod: 2026-09-01 +author: "冯若航" +summary: > + CORS 预鉴权查询曾把任意 URL 路径段变成 metadata I/O 与缓存条目;客户端可控的 replication marker 又会影响 SSE-C 读取、源时间戳、checksum、对象锁、事件与删除语义。本文记录 SILO 的 resident-only CORS 热路径、两级复制信任模型、验签后清洗边界、真实 wire 兼容矩阵与发布前证据。 +tags: [设计, 安全, CORS, Replication, SSE-C, 兼容性] +weight: 12 +draft: false +url: "/zh/blog/design/cors-replication-trust/" +--- + +本文记录 SILO 本地提交 `938603458` 中的 CORS 热路径与复制请求信任边界修复。 + +> **截至 2026-09-02 的状态:** 实现、定向与 race 测试、完整服务端 package 套件、对象锁测试、vet、build、两轮 Fable 5 设计评审、多轮 Opus 5 对抗验收,以及真实本地 TLS 双站复制均已完成。候选实现已推送为 [pgsty/silo#101](https://github.com/pgsty/silo/pull/101);远端 CI、merge、tag、软件包、镜像、部署与生产验证仍是独立门槛。
+> **范围:** S3 handler 之前与内部的 HTTP 请求解释。不修改 S3 wire field、对象格式、bucket metadata 格式、复制协议、加密格式或客户端命令。
+> **安全属性:** CORS 预鉴权处理不执行对象层 I/O;header 本身永远不授予复制语义;SSE-C 密文路径与 replica-only metadata 必须同时通过身份认证与对应复制权限检查。 + +## 太长不看(TL;DR) {#tldr} + +两个问题表面上互不相干: + +1. `Origin` header 会让最外层 CORS middleware 把 URL 第一段当成桶,在鉴权前同步加载它的 metadata; +2. `X-Minio-Source-Replication-Request` 只要存在,下游代码就会把请求当作内部复制。 + +它们共享同一个设计错误:**不可信的请求形态在越过授权边界之前,就获得了昂贵或特权化的内部含义。** + +修复建立了两个不变量: + +```text +鉴权之前:只做廉价解析,绝不加载 bucket metadata +鉴权之后:只计算一次信任结论,下游只消费这个结论 +``` + +对于 CORS,外层 middleware 只读已经 resident 的内存 metadata。对于复制,handler 先认证原始签名请求,再检查对应复制权限,最后把私有信任结论写入 request context。不可信内部 header 只在验签完成后剥离。真正的权威是 context 中的决定,而不是“是否成功删掉了某个 header”;option builder、加密路径、对象锁、事件与 metadata 持久化都只读取这一决定。 + +## 故障 A:CORS 预鉴权资源放大 {#cors-failure} + +`corsHandler` 包在完整服务器 router 的最外层。任何携带 `Origin` 的请求都会在 S3 鉴权、请求有效性检查与普通 API 限流之前到达这里。 + +Per-bucket CORS 最初调用普通 bucket metadata getter: + +```text +携带 Origin 的请求 + -> URL 第一段变成“桶名” + -> GetCorsConfig + -> GetConfig cache miss + -> 读取 .metadata.bin + -> 探测十个 legacy config 路径 + -> 缓存一条默认 BucketMetadata +``` + +`.metadata.bin` 不存在时,loader 会按兼容要求继续寻找 legacy 配置;什么都没找到后,它返回一条合法的空 metadata,而不是 `NoSuchBucket`。通用 getter 随后把这条记录写入 `metadataMap`。 + +未认证客户端只需不断变化看似合理的名字,就能让每个新值产生两种代价: + +- 在普通 limiter 之前反复执行纠删码/对象 metadata 读取; +- 增长内存中的 bucket metadata map。 + +只校验桶名不能修复:攻击者可以生成近乎无限的、语法合法但不存在的桶名。分布式部署会在 15 分钟 metadata refresh 中最终清理 stale entry;单节点不会启动这条 refresh loop,因此合成条目会一直存在到重启。 + +## 故障 B:marker header 变成了权威 {#replication-failure} + +SILO 及其 MinIO-compatible 客户端使用内部 header,在复制期间保留源状态。其中最重要的 marker 是: + +```text +X-Minio-Source-Replication-Request: true +``` + +修复前,多条路径把 header 存在,或未经授权的原始字符串值,当成复制请求证明。影响远不止 metadata extraction: + +- SSE-C 对象的 `GET` 可以设置 `NoDecryption`,让只有普通读取权限、没有 customer key 的调用者取得密文; +- source ETag 与 modification time 可以覆盖服务器生成值; +- source tagging、retention、legal-hold timestamp 可以进入 last-writer-wins 比较; +- 仅凭 marker 就可以接受已经过去的 object-lock retention date; +- delete marker 的 identity 与 modification time 可以由调用者提供; +- 成功对象事件可以被抑制; +- multipart completion 可以注入 actual size 与加密 checksum metadata; +- 普通 PUT、COPY 或 POST-policy metadata extraction 可以持久化 `X-Amz-Replication-Status`。 + +此前的 [CVE-2026-34204 修复](/zh/blog/security/cve-2026-34204/) 已经正确阻止普通 PUT/COPY 导入可能让对象不可读的 replication SSE metadata。但它尚未为 marker、source field、event state、object-lock exception 与 multipart completion metadata 的所有消费者提供同一个权威。 + +## 最终设计 {#design} + +### 一个精确 marker,两级信任 {#trust-levels} + +只有 marker 恰好出现一次、且值恰好为小写 `true` 时,才承认其形态。重复值、大小写变化与任何其他值都不可信。 + +Handler 随后派生两个相关结论: + +| 决定 | 必须满足 | 可以启用的语义 | +| --- | --- | --- | +| `trusted` | 原始请求完成认证;非匿名主体;精确 marker;目标资源上具有 `s3:ReplicateObject` 或 `s3:ReplicateDelete` | source ETag/MTime 与 source timestamp;actual size 与加密 checksum 传递;event 与重复复制抑制;复制删除的 pool/version pinning | +| `replicaTrusted` | `trusted`,再加原始请求状态为 `REPLICA`,或 multipart upload 已保存 `REPLICA` 状态 | replica status 持久化;replication SSE sealed-key 导入;SSE-C 密文/NoDecryption 路径;replica-only 对象锁行为 | + +必须拆成两级,因为真实 wire 并不会在每个合法复制请求上重复 `X-Amz-Replication-Status: REPLICA`。 + +接收端遵守以下矩阵: + +| 输入形态 | 结果 | +| --- | --- | +| 没有 marker | 普通 S3 操作 | +| marker 但没有复制权限 | 忽略内部字段,按普通语义继续执行 | +| `REPLICA` 但没有复制权限 | `403 AccessDenied` | +| 精确 marker + 复制权限,没有 `REPLICA` | 只有 `trusted` | +| 精确 marker + 复制权限 + `REPLICA` | 同时获得 `trusted` 与 `replicaTrusted` | + +未授权 `REPLICA` 必须明确返回 `403`,不能静默降级成一个会再次被复制的新普通对象。 + +### 先认证原始请求,再做清洗 {#signature-boundary} + +SigV4 会签名请求 header。如果在鉴权前删除内部 header,canonical request 会发生变化,原本有效的签名将变成 `SignatureDoesNotMatch`。 + +因此顺序是硬约束: + +```text +原始请求 + -> 既有 signature/authentication path + -> 普通 S3 action 授权 + -> replication action 授权 + -> 计算 trusted / replicaTrusted + -> 把决定写入 request context + -> clone 并剥离不可信内部字段 + -> option 解析、加密、对象锁、存储、事件 +``` + +Audit logger 仍保留原始请求。Effective request clone 保留公开 S3、SSE、checksum、object-lock、copy-source、proxy 与 replication validity header;只剥离内部 source/replication control,包括 source ETag/MTime/delete-marker/timestamp、replication SSE state、actual object size、加密 checksum 传递,以及作为内部请求控制使用的 `X-Amz-Replication-Status`。 + +Header stripping 只是纵深防御。所有特权消费者都读取私有 context 决定或显式 Boolean,不会再靠检查 clone 来猜测信任。 + +### Replica status 不是普通用户 metadata {#replica-status} + +`X-Amz-Replication-Status` 是一个 S3 response header,MinIO-compatible server 同时把它用作内部请求控制。它不再属于通用 supported-request-metadata 列表。 + +普通 PUT、COPY、multipart initiation、Snowball/PAX extraction 与 POST policy 不能仅凭提交字段就持久化它。接收端只在 `replicaTrusted` 分支显式写入 `REPLICA`。 + +这同时关闭了一条隐蔽的 POST-policy 路径:form field 曾经可以写入 `REPLICA`,让对象绕开正常复制调度,而 POST principal 根本没有复制权限。 + +### 对象锁接收显式决定 {#object-lock} + +Object-lock parser 过去只要看到原始 marker header,就会接受已经过去的 retention date。现在它从 `replicaTrusted` 接收显式的 `allowPastRetainDate`。 + +外围 handler 在判断 replica 能否覆盖既有 compliance/legal-hold version 时也使用同一个决定。可复用的 object-lock package 不再依赖内部 HTTP header。 + +## 真实复制 wire 矩阵 {#wire-matrix} + +设计核对的是服务器 `go.mod` 实际选择的 silo-go v7.3.1 emitter,而不是注释或上游文档中的假设。 + +| 操作 | Marker | 本请求携带 `REPLICA` | 接收端决定 | +| --- | --- | --- | --- | +| 普通对象复制 `PutObject` | 是 | 是 | `replicaTrusted` | +| 复制 `NewMultipartUpload` | 是 | 是 | 保存可信 MPU replica provenance | +| 复制 `PutObjectPart` | 是 | 否 | `trusted`;只有已存 MPU 状态为 `REPLICA` 才获得 `replicaTrusted` | +| 复制 `CompleteMultipartUpload` | 是 | 否 | `trusted`;保留 source ETag/MTime、actual size 与加密 checksum | +| CopyObject metadata replication | 是 | 是 | `replicaTrusted` | +| 复制 `RemoveObject` | 是 | 是 | 具有 `s3:ReplicateDelete` 的 `replicaTrusted` | +| Batch replication PUT/Complete | 是 | 否 | `trusted`;目标凭据必须拥有 `s3:ReplicateObject` | +| Proxy/readiness/validity probe | 独立 probe header | marker 不授予权限 | 保持 probe 行为;本修复不会剥离这些 header | + +`s3:ReplicateDelete` 是信任闸门,但不是 receiver 的唯一权限。为了兼容已经部署的 +目标端策略,可信复制删除仍要求 `s3:DeleteObject`;显式 Deny +`s3:DeleteObjectVersion` 仍会阻止指定版本的清理。普通客户端不走这条兼容路径: +显式 UUID 或 `versionId=null` 必须获得 `s3:DeleteObjectVersion` 的 Allow。 + +如果要求所有可信请求都带 `REPLICA`,PutPart、multipart completion 与 batch replication 会立即回归;如果相信所有 marker,则漏洞会原样重现。已保存的 multipart provenance 在加密 raw part 上连接了这两个要求。 + +## CORS resident-only 状态机 {#cors-state-machine} + +最外层 CORS middleware 必须比即将进入的请求更便宜。它现在调用专用 resident-only getter,只拿一次读锁并读取内存状态。 + +| Bucket metadata 状态 | CORS 结果 | 对象层工作 | +| --- | --- | --- | +| resident,per-bucket CORS 合法 | 应用桶级规则 | 无 | +| resident,没有 CORS 文档 | 使用 global CORS fallback | 无 | +| resident,持久化 CORS 非法 | fail closed;继续处理请求但不加 CORS header,并只记一次日志 | 无 | +| subsystem 尚未初始化 | fail closed | 无 | +| 已知 metadata load failure | fail closed | 无 | +| 内部 `.minio.sys` namespace | fail closed | 无 | +| reserved 或非法桶形路径 | global fallback | 无 | +| 已初始化,除此之外的未知名字 | global fallback | 无 | + +`loadFailed` 只会从启动或 refresh 得到的 disk-derived bucket list 中写入,客户端路径无法增长它。Metadata 成功加载、`Set`、bucket removal、stale reconciliation 与 subsystem reset 都会清理相应状态。 + +### 冷缓存残余边界 {#cold-cache} + +仍有一个已接受的边缘:如果某节点漏掉 peer metadata-load 通知,一个真实桶可能同时不在 `metadataMap` 与 `loadFailed`。在下一轮 bucket refresh 发现并加载它之前,该节点会把这个名字当作 unknown,使用 global CORS。 + +CORS 是浏览器响应策略,不是授权机制;普通 S3 authentication 与 bucket policy 仍然生效。但如果运维明确依赖 restrictive per-bucket CORS document,就应该知道这段短暂放宽。后续可以在 refresh 尝试加载之前,把 disk list 中存在但不 resident 的桶标记为 load-failed;这样不增加请求同步 I/O,也能继续 fail closed。 + +## 被否决的方案 {#alternatives} + +| 方案 | 为什么否决 | +| --- | --- | +| 在旧 CORS getter 前校验桶名 | 合法但不存在的名字仍提供无限攻击空间,且继续触发预鉴权 I/O | +| 加载 CORS 前调用 `GetBucketInfo` | 只是把十一轮 metadata 读取换成每个攻击者名字至少一次未限流 backend operation | +| 给每个 negative result 做 TTL cache | 只限制持续时间,不限制攻击者 cardinality 与第一次 I/O 放大 | +| 鉴权前剥离 replication header | 破坏 SigV4 canonical request 验证 | +| 拒绝任何携带内部 marker 的请求 | 把过去被忽略的多余 header 扩大成普遍客户端失败,并破坏合法 marker-only 复制调用 | +| 要求所有可信调用都携带 `REPLICA` | 破坏复制 PutPart、CompleteMultipartUpload 与 batch replication wire | +| 每个 handler 各自重新检查 raw header | 重建不一致信任规则,未来新增消费者也极易漏掉 | +| 只在 `ObjectOptions` 放 Boolean,event/object lock 仍看 header | 产生两个可能互相矛盾的权威,原漏洞类别仍然存在 | + +## 实现边界 {#implementation} + +最终改动按层组织: + +1. 一个小型 request-trust 模块定义精确 marker 解析、复制授权、私有 context state 与鉴权后的 effective request; +2. object option builder 只有在调用方提供可信状态时才解析 source field; +3. `DecryptObjectInfo`、event request parameter、multipart completion、delete option 与 object lock 消费同一个决定; +4. handler 在既有 authentication path 之后立即计算信任; +5. multipart part 把当前请求信任与已保存 MPU replica provenance 结合; +6. 通用 metadata extraction 不再接受 replica status; +7. CORS middleware 使用独立的 resident-only accessor,永远不调用 load-on-miss getter。 + +对象层 API 无需再猜测 HTTP trust。内部程序化调用者直接构造的 `ObjectOptions{ReplicationRequest: true}` 不受影响。 + +## 验证与对抗审查 {#verification} + +回归覆盖包括: + +- 数百个不同的合法缺失桶名,actual/preflight 两类 CORS 请求,metadata read 为零且 map 不增长; +- Console、reserved、invalid、startup、内部 namespace、非法持久化 CORS 与已知 load failure; +- 最小权限 SSE-C GET、HEAD、GetObjectAttributes,对正确、缺失、大小写错误与未授权 marker 的处理; +- marker-only batch 风格 PUT 只有在具备 `s3:ReplicateObject` 时才保留 source ETag/MTime; +- 未授权 `REPLICA` PUT/DELETE 返回 `403`; +- POST policy 无法伪造 replica status; +- object-lock past-date 在有无 replica trust 时的差异; +- marker-only CopyObject 携带 SSE-C source header 时复制明文而不是密文; +- 普通 SSE-C MPU 上的伪 marker 失败,不会写入 raw byte; +- 一条真实的进程内 SSE-C multipart 复制链:加密源、raw ciphertext part、可信 replica initiation、marker-only PutPart/Complete,以及用原密钥精确恢复明文。 + +最终本地 tree 通过定向与 race 测试、完整 `cmd` suite、对象锁测试、vet、build 与 diff check。 + +另一轮黑盒测试用候选二进制启动两个 TLS-enabled SILO 实例并启用真实 site replication,验证: + +- SSE-C 4 KiB 对象; +- SSE-C 12 MiB、三个 part 的 multipart 对象; +- SSE-C CopyObject; +- replicated delete marker。 + +源/目标 ETag、size、version ID、SSE-C key MD5、解密后 SHA-256 与 delete-marker version ID 均一致,目标报告 `REPLICA`。 + +前两轮 Fable 5 评审先纠正 marker-only batch 与 multipart 调用的信任模型,再审计实现。最终独立 Claude Code Opus 5 给出 **GO**,没有 P0/P1,并独立重跑 build、vet、race、object-lock 与完整 `cmd` 测试。 + +## 兼容性与运维影响 {#impact} + +- **普通客户端:** 请求无需改变;不可信内部 header 现在会被忽略,而不是获得内部语义。 +- **未授权 replica 声明:** 携带 `X-Amz-Replication-Status: REPLICA` 的请求现在统一返回 `403`;过去部分 multipart 子路径没有这条一致检查。 +- **Batch replication:** 目标凭据必须包含 `s3:ReplicateObject`,参见 [batch replication requirements](/zh/administration/batch-framework-job-replicate/)。缺失权限时,接收端会把 marker-only write 当作普通写入,不保留 source ETag/MTime。 +- **SSE-C:** 普通读取仍需要 customer key;授权 replica read 可以使用保留加密字节所需的 raw ciphertext path。 +- **事件:** 只有可信复制才抑制 replica creation/access event;伪 marker 不再让事件静默消失。 +- **对象锁:** replica exception 来自权限决定,不再来自 header。 +- **性能:** CORS 移除了预鉴权 backend work。可信写入增加的是复制契约本来就要求的 policy check,不增加对象数据 pass。 +- **滚动升级:** wire 与 storage format 不变。新 receiver 执行信任边界;旧 receiver 在升级前仍保留旧 header 漏洞。滚动窗口中节点的 per-bucket CORS 行为可能不同。 +- **回滚:** 修复版本写入的数据仍可被旧版本读取,但 rollback 会重新打开两个信任缺陷并恢复预鉴权 metadata load。 + +## 残余风险与后续 {#residual-risks} + +- Refresh 加载之前,把 disk list 中存在但不 resident 的桶标记为 fail-closed,进一步缩小上述 CORS 冷缓存窗口。 +- 当 marker-bearing request 缺少复制权限时记录限频诊断;安全的 ordinary fallback 否则容易被误诊为 ETag/MTime 不一致。 +- Replication validity probe 保留上游继承的权限报告语义,应作为独立议题审计,而不是在本修复中静默改变。 +- 本次覆盖已命名的 source/replication header。未来任何内部控制都仍需回答同一个问题:哪一个认证后的决定允许这个客户端值获得内部含义? + +## 结论 {#conclusion} + +看起来像内部字段的 header 仍然是客户端输入;看起来像桶名的 URL 段也仍然是攻击者输入。耐久修复是阻止两者意外成为权威: + +> 鉴权之前不做 backend work;鉴权之后只派生一次 trust,并把决定而不是声明传给下游。 + +这条规则并不只属于 CORS 或 replication。当廉价的公开请求语法与昂贵或特权化的内部状态相遇时,未来 SILO handler 都应该保持这条边界。 diff --git a/content/blog/release/delete-object-version-authorization.md b/content/blog/release/delete-object-version-authorization.md new file mode 100644 index 00000000..e74913ae --- /dev/null +++ b/content/blog/release/delete-object-version-authorization.md @@ -0,0 +1,73 @@ +--- +title: "Explicit Version Deletes Now Require DeleteObjectVersion" +linkTitle: "Version Delete Authorization" +date: 2026-09-02T00:00:00+08:00 +author: "Ruohang Feng" +description: "SILO now selects DeleteObject or DeleteObjectVersion from each request's effective version ID, while preserving the established least-privilege replication target policy." +tags: [Release, Security, IAM, S3, Compatibility] +weight: 9 +draft: false +url: "/blog/release/delete-object-version-authorization/" +--- + +> **Release status:** this change is implemented in +> [pgsty/silo#104](https://github.com/pgsty/silo/pull/104), tracking +> [issue #58](https://github.com/pgsty/silo/issues/58). Publishing this note +> does not by itself mean that a server release, package, image, or deployment +> contains the change. + +SILO now maps object-delete authorization to the operation the request will +actually perform: + +| Request | Required action | +| --- | --- | +| No `versionId` | `s3:DeleteObject` | +| Version UUID | `s3:DeleteObjectVersion` | +| Explicit `versionId=null` | `s3:DeleteObjectVersion` | +| `DeleteObjects` | The mapping is applied independently to every XML entry | + +Previously, SILO required `s3:DeleteObject` for every case and used +`s3:DeleteObjectVersion` only as an explicit-deny check. A principal holding +only `DeleteObject` could therefore permanently remove a named historical +version. Conversely, a least-privilege purge principal holding only +`DeleteObjectVersion` could not perform the operation it was intended for. + +After this change, a `DeleteObject`-only principal can still perform an +unversioned delete or create a delete marker, but receives `AccessDenied` for a +named UUID or `null` version. A `DeleteObjectVersion`-only principal can remove +the named version but cannot create a delete marker. Explicit denies and +`s3:versionid` conditions retain normal policy precedence. Multi-delete uses +each entry's `VersionId`; a query-level decoy cannot change another entry's +condition value. + +## Replication compatibility {#replication-compatibility} + +Bucket and site replication target policies do **not** need to add +`s3:DeleteObjectVersion`. An authenticated request earns replication delete +trust through an exact internal marker and `s3:ReplicateDelete`; the receiver +then preserves the deployed `s3:DeleteObject + s3:ReplicateDelete` contract. +An explicit deny on `s3:DeleteObjectVersion` continues to block a replicated +version purge. + +This distinction prevents an upgraded target from silently rejecting permanent +delete replication and also avoids granting new delete capability to a +`ReplicateDelete`-only credential. A real two-site regression used a target +user with the documented minimal policy and no `DeleteObjectVersion`; both a +permanent version delete and a delete marker converged successfully. + +## Upgrade impact {#upgrade-impact} + +- Review user, service-account, OPA, and external authorization policies that + currently grant only `s3:DeleteObject` but perform `mc rm --version-id`, + `mc rm --versions`, Console delete-all-versions, or SDK deletes with + `versionId`. +- External authorization plugins now see one `s3:DeleteObjectVersion` decision + for an ordinary named-version delete instead of the former deny-only check + followed by `s3:DeleteObject`. +- `X-Minio-Force-Delete` prefix cleanup remains gated by `s3:DeleteObject`; it + is not an explicit-version S3 request. +- There is no wire or storage-format migration. Rolling back restores the old + authorization mapping but does not alter stored objects or metadata. + +See [Object Deletion](/administration/object-management/object-delete/) for the +operator-facing permission matrix. diff --git a/content/blog/release/delete-object-version-authorization.zh.md b/content/blog/release/delete-object-version-authorization.zh.md new file mode 100644 index 00000000..1b64f63a --- /dev/null +++ b/content/blog/release/delete-object-version-authorization.zh.md @@ -0,0 +1,59 @@ +--- +title: "显式版本删除现在要求 DeleteObjectVersion" +linkTitle: "版本删除授权" +date: 2026-09-02T00:00:00+08:00 +author: "冯若航" +description: "SILO 现在根据请求的实际 versionId 选择 DeleteObject 或 DeleteObjectVersion,同时保留既有的最小权限复制目标策略。" +tags: [发布, 安全, IAM, S3, 兼容性] +weight: 9 +draft: false +url: "/zh/blog/release/delete-object-version-authorization/" +--- + +> **发布状态:** 此变更已在 +> [pgsty/silo#104](https://github.com/pgsty/silo/pull/104) 中实现,对应 +> [Issue #58](https://github.com/pgsty/silo/issues/58)。本文发布本身不代表 +> 某个服务器版本、软件包、镜像或部署已经包含该变更。 + +SILO 现在按照请求实际执行的删除类型选择权限: + +| 请求 | 所需权限 | +| --- | --- | +| 未携带 `versionId` | `s3:DeleteObject` | +| 版本 UUID | `s3:DeleteObjectVersion` | +| 显式 `versionId=null` | `s3:DeleteObjectVersion` | +| `DeleteObjects` | 对每个 XML 条目独立应用映射 | + +此前所有情况都要求 `s3:DeleteObject`,`s3:DeleteObjectVersion` 只用于检查显式 Deny。 +因此只有 `DeleteObject` 的主体可以永久移除一个指定的历史版本;反过来,只有 +`DeleteObjectVersion` 的最小权限清理主体却无法执行它原本负责的操作。 + +变更后,只有 `DeleteObject` 的主体仍可执行未指定版本的删除或创建 delete marker, +但删除 UUID 或 `null` 版本会收到 `AccessDenied`。只有 `DeleteObjectVersion` 的主体 +可以移除指定版本,但不能创建 delete marker。显式 Deny 与 `s3:versionid` 条件继续遵循 +正常策略优先级。多删读取每个条目自己的 `VersionId`;外层查询参数不能污染其他条目的 +condition value。 + +## 复制兼容性 {#replication-compatibility} + +存储桶与站点复制的目标策略**不需要**新增 `s3:DeleteObjectVersion`。通过认证的请求凭借 +精确内部 marker 与 `s3:ReplicateDelete` 获得复制删除信任;receiver 继续保留已经部署的 +`s3:DeleteObject + s3:ReplicateDelete` 契约。显式 Deny +`s3:DeleteObjectVersion` 仍会阻止复制版本清理。 + +这种区分既避免升级后的目标端静默拒绝永久删除复制,也不会给只有 +`ReplicateDelete` 的凭据新增删除能力。真实两站回归使用了不含 +`DeleteObjectVersion` 的文档最小策略目标用户;永久版本删除与 delete marker 均成功收敛。 + +## 升级影响 {#upgrade-impact} + +- 检查当前只授予 `s3:DeleteObject`、但会执行 `mc rm --version-id`、 + `mc rm --versions`、Console 删除全部版本或 SDK `versionId` 删除的用户、服务账户、 + OPA 与外部授权策略。 +- 对普通的指定版本删除,外部授权插件现在只会看到一次 + `s3:DeleteObjectVersion` 判定,而不再看到旧的 deny-only 检查再加 + `s3:DeleteObject` 两次调用。 +- `X-Minio-Force-Delete` 前缀清理仍由 `s3:DeleteObject` 控制;它不是显式版本 S3 请求。 +- 不涉及 wire 或存储格式迁移。回滚会恢复旧授权映射,但不会修改已有对象或 metadata。 + +完整操作权限矩阵参见[对象删除](/zh/administration/object-management/object-delete/)。 diff --git a/content/blog/security/20260415-cve-2026-34204.md b/content/blog/security/20260415-cve-2026-34204.md index 95e3093a..fdace475 100644 --- a/content/blog/security/20260415-cve-2026-34204.md +++ b/content/blog/security/20260415-cve-2026-34204.md @@ -62,3 +62,5 @@ The public fix is [fcb8f24](https://github.com/pgsty/minio/commit/fcb8f242dee339 - The audit focused on replication headers; it does not establish that every `X-Minio-Internal-*` field has undergone the same trust review. This incident leaves a simple review question: a field that looks “internal” is not necessarily trusted. Ask where it came from and which authorization decision allowed it to acquire internal meaning. + +The later [CORS and replication trust design record](/blog/design/cors-replication-trust/) generalizes that lesson into one receiver-wide authority for SSE-C reads, source timestamps, multipart completion, object lock, deletes, events, and pre-authentication CORS work. diff --git a/content/blog/security/20260415-cve-2026-34204.zh.md b/content/blog/security/20260415-cve-2026-34204.zh.md index 049a0b8c..aeb31385 100644 --- a/content/blog/security/20260415-cve-2026-34204.zh.md +++ b/content/blog/security/20260415-cve-2026-34204.zh.md @@ -62,3 +62,5 @@ url: "/zh/blog/security/cve-2026-34204/" - 本次审计聚焦 replication header,不代表所有 `X-Minio-Internal-*` 字段都完成了同样的 trust audit。 这个事件留下的审查问题很简单:一个字段看起来像“内部字段”并不能证明它可信,必须继续追问它来自哪里,以及哪一个授权决定允许它获得内部含义。 + +后续的 [CORS 与复制信任设计记录](/zh/blog/design/cors-replication-trust/) 把这条经验推广为 receiver-wide 的统一权威,覆盖 SSE-C 读取、source timestamp、multipart completion、对象锁、删除、事件与 CORS 预鉴权工作。