diff --git a/.github/workflows/page.yaml b/.github/workflows/page.yaml index 5fa0fc95..0786b9ac 100644 --- a/.github/workflows/page.yaml +++ b/.github/workflows/page.yaml @@ -25,7 +25,7 @@ jobs: build: runs-on: ubuntu-latest env: - HUGO_VERSION: 0.161.0 + HUGO_VERSION: 0.165.0 steps: - name: Checkout uses: actions/checkout@v5 @@ -36,7 +36,8 @@ jobs: - name: Setup Go uses: actions/setup-go@v6 with: - go-version: '1.26.6' + go-version-file: go.mod + cache: true - name: Setup Hugo run: | diff --git a/content/blog/design/bucket-cors-replication.md b/content/blog/design/bucket-cors-replication.md new file mode 100644 index 00000000..96fd7880 --- /dev/null +++ b/content/blog/design/bucket-cors-replication.md @@ -0,0 +1,389 @@ +--- +title: "Per-Bucket CORS: Making Deletes and Recovery Converge" +linkTitle: "Bucket CORS Replication" +date: 2026-08-28 +lastmod: 2026-08-28 +author: "Ruohang Feng" +summary: > + SILO accepted per-bucket CORS through PR #71, then found that a missed or reordered site-replication event could restore a deleted browser-origin rule. This record explains the problem in plain language, the merge decision, two rounds of adversarial review, the source-timestamp and tombstone repair, S3 response cleanup, costs, rejected alternatives, historical follow-ups, and the release gate tracked by issue #75. +tags: [Design, S3, CORS, Replication, Compatibility] +weight: 32 +draft: false +url: "/blog/design/bucket-cors-replication/" +--- + +This document records the problem, review, merge decision, adversarial debate, and final implementation contract for [SILO PR #71](https://github.com/pgsty/silo/pull/71) and the release-hardening work tracked by [SILO #75](https://github.com/pgsty/silo/issues/75). + +> **Status:** PR #71 merged as [`e4e3007da`](https://github.com/pgsty/silo/commit/e4e3007da6d7d1198a6a050e34f84566d40a9654). The B2 convergence repair is commit [`724f8703d`](https://github.com/pgsty/silo/commit/724f8703d83f4c51859c7650b7f1da2c2a55548c) in [PR #80](https://github.com/pgsty/silo/pull/80), whose initial eight DCO, CI, race, cross-compile, and vulnerability checks passed. The final B2+B3 code is signed commit `0eebc928f`; combined Opus findings are fixed, full tagged/race/build/vet/lint/compatibility gates pass, and a real local two-site offline-DELETE/heal/restart test plus raw SigV4 B3 probes pass. The EN/ZH records pass a warning-fatal Hugo build, rendered link checking, and local browser QA. Issue #75 remains a release gate: nothing is merged, tagged, packaged, published as an image, deployed, or production-verified.
+> **Owner:** [`pgsty/silo`](https://github.com/pgsty/silo) owns the server changes. This public design record belongs to [`pgsty/silo.pgsty.com`](https://github.com/pgsty/silo.pgsty.com). Console UI remains a separate deliverable.
+> **Decision:** accept the useful feature, preserve the contributor's work, and block release until site-replication deletes, recovery, wildcard responses, and the narrow protocol follow-ups converge correctly. + +## The problem and solution in plain language {#plain-language} + +Before PR #71, SILO could set CORS only for the whole cluster. A browser application could not say, “allow this website to use bucket A, but not bucket B.” Standard S3 calls for reading, writing, and deleting a bucket's CORS configuration existed as stubs and returned `NotImplemented`. + +PR #71 added the missing feature. A bucket can now store its own allowed websites, HTTP methods, request headers, exposed response headers, and preflight cache time. Standard S3 clients can manage the configuration, and buckets without one keep the old global behavior. + +The core feature works. The remaining problem appears when the same bucket is replicated between sites. + +Imagine an administrator allows `https://old.example.com`, then removes that permission. SILO correctly deletes the rule on the first site. If another site temporarily misses that delete, the recovery process must later learn that “deleted at 10:05” is newer than “configured at 10:00.” The current code sometimes forgets the deletion time or replaces the source time with the time at which a peer received the message. Recovery can then mistake the old live rule for the newest state and restore it. + +The fix is not a new replication system. A deleted configuration is represented by the data SILO already has: + +```text +configuration = absent +updated time = time of DELETE +``` + +That pair is a deletion **tombstone**. The repair preserves the source timestamp for both PUT and DELETE, carries the timestamp even when no XML remains, and makes recovery use the same CORS apply path as normal peer delivery. Older events can no longer revive a newer deletion. + +The direct cost is bounded: a CORS-specific distributed namespace lock and monotone state transition, focused ObjectLayer tests, strict wire validation, response-compatibility fixes, and operational documentation. There is no new storage field, dependency, feature flag, distributed clock, or general replication framework. The continuing cost is that SILO owns these tests and the bucket-CORS compatibility contract. Site replication still relies on synchronized wall clocks, as it already did. + +CORS is not IAM authorization. A stale CORS rule does not grant an S3 permission that a principal lacks. It can, however, let a browser origin continue reading an already-authorized cross-origin response after an administrator intended to revoke that browser access. That is why convergence is a release blocker rather than cosmetic polish. + +## What PR #71 added {#feature} + +PR #71 replaced the inherited Bucket CORS stubs with a cohesive feature: + +- standard `PutBucketCors`, `GetBucketCors`, and `DeleteBucketCors` APIs; +- Content-MD5 or supported checksum validation on PUT; +- XML parsing and validation for origins, methods, allowed headers, exposed headers, rule IDs, and max age; +- raw XML persistence in `BucketMetadata` with a CORS update timestamp; +- per-bucket OPTIONS preflight handling and actual-response CORS headers; +- the existing global CORS policy as a fallback only for buckets without a per-bucket configuration; +- normal site-replication send, receive, initial-sync, status, and heal wiring; +- unit, handler, middleware, metadata, and transport tests. + +The local review rebased the feature onto the then-current `main`, built it, ran focused normal and race tests, full `cmd` tests, pinned lint, generated-file checks, compatibility checks, and a real `minio-go` smoke test. PUT, GET, DELETE, allowed and rejected preflights, `Vary`, and actual response headers all worked in the single-site path. + +This evidence justified accepting the feature. It did not prove every failure-recovery path. + +## The reproduced convergence failures {#reproductions} + +Review-only tests against the real ObjectLayer reproduced the three failures +below. A later release review also proved that payload-only status comparison +kept different source-time barriers hidden, and that equal-timestamp conflicts +depended on arrival and map-iteration order. + +### A newer DELETE can be ignored {#ignored-delete} + +The peer handler used the ordinary metadata `Update` and `Delete` methods. Those methods assign `UTCNow()` on the receiving site. If an older PUT arrives late, its locally generated arrival time can appear newer than a later source DELETE, so the DELETE is discarded. + +### An older PUT can revive a deletion {#resurrected-put} + +`GetCorsConfig` returns not-found and a zero timestamp once the live config is nil. The deletion time still exists in raw bucket metadata, but the handler cannot see it through that getter. A stale PUT therefore passes the staleness check and restores the rule. + +### Heal can select the stale live rule {#heal-resurrection} + +`SiteReplicationMetaInfo` currently exports `CorsConfigUpdatedAt` only when CORS XML is present. After DELETE, the site reports nil configuration and zero time. A peer that still has the old XML reports a non-zero older time. Heal selects that old rule as “latest” and writes it back. + +These are the same failure expressed at three seams: peer apply, metadata status, and recovery. + +## The intended state model {#state-model} + +Per-bucket CORS needs only the state already present in `BucketMetadata`: + +| Logical state | XML | Timestamp | Meaning | +| --- | --- | --- | --- | +| Never configured | nil | zero | baseline; it is never transmitted or selected as a winner | +| Configured | non-nil | source PUT time | live per-bucket rule | +| Deleted | nil | source DELETE time | tombstone; newer than any earlier live rule | + +The selected register uses a deterministic total order: + +```text +1. source UpdatedAt +2. baseline < live < tombstone +3. equal-time live/live: lexicographic decoded payload bytes +``` + +Peer apply is a monotone join: it applies only a strictly greater state, so +retry and duplicate delivery are idempotent. A tombstone wins an equal-time +PUT/DELETE conflict, while two live values choose the same bytewise winner at +every site. `CreatedAt` is not the baseline marker; it is only the bucket-lineage +floor that rejects an event from an older bucket incarnation and emits a +bucket-scoped diagnostic. + +## How the decision was made {#decision-history} + +### Initial review {#initial-review} + +The first review agreed that the need was real and the single-site architecture was reasonable, but found that site replication emitted CORS events without completing every receive, status, and recovery path. The contributor added the missing wiring, checksum validation, wildcard/ID limits, cache variation, and focused tests. + +A second runtime review confirmed the normal single-site and direct replication paths, then reproduced the tombstone and source-time failures above. The feature was close enough to accept, but not safe enough to release as complete. + +### Merge versus release {#merge-versus-release} + +The maintainer chose to merge PR #71 and own the remaining hardening. This separated two decisions that are often confused: + +1. Is the contribution valuable and structurally sound enough to accept? **Yes.** +2. Is the resulting feature ready to tag, package, publish, and deploy? **Not until #75 closes.** + +The merge triggered full `main` CI, which passed. Release and Docker publication remain manual, independent gates. + +### Self-adversarial plan review {#self-review} + +The first follow-up plan was intentionally comprehensive, then reviewed against four failure modes: overdesign, new problems introduced by the fix, failure to reuse existing infrastructure, and disproportionate maintenance cost. + +That review removed or deferred: + +- a new general metadata-apply abstraction; +- a custom wildcard matcher; +- broad policy/tag/SSE/quota refactoring; +- a multi-process site-replication test lab; +- method-case, Unicode-ID, and trailing-XML strictness without differential evidence; +- a no-Origin hot-path optimization that could alter existing `Vary` behavior; +- vector clocks, a new tombstone field, and a global timestamp redesign. + +### Independent Claude Opus 5 reviews {#claude-review} + +Four read-only local Claude Code reviews used canonical `claude-opus-5` at +maximum effort. They moved the design from a timestamp-only patch to the final +zero-baseline, deterministic C-prime register; required the distributed CORS +lock and monotonic local barrier; made status and heal compare full state; and +closed strict base64, semantic validation, cache, `Vary`, wildcard credentials, +and initial-sync tombstone gaps. + +The combined B2+B3 review then checked the strict parser, exact method and +Unicode-ID contract, MaxAge presence, Origin-null forwarding marker, checksum +classification, and replication/restart behavior together. It found a test +helper conflict and the upgrade risk that a document accepted by a lenient +development build could make all bucket metadata unavailable. The helper was +corrected. Legacy-invalid CORS now leaves other bucket metadata readable, +fails browser behavior closed, rejects new invalid saves, and remains +repairable by a valid CORS PUT or DELETE. + +## Design goals and non-goals {#scope} + +### Goals {#goals} + +- make CORS PUT and DELETE converge under duplicate, delayed, reordered, and missed events; +- preserve exact source timestamps on peer apply and heal; +- let a newer nil tombstone beat an older live config; +- avoid widening a configured bucket to global CORS on metadata failure; +- align literal wildcard, credentials, exposed headers, and cache variation with S3 behavior; +- correct the new CORS status count and the narrow validation gaps proven by existing matcher behavior; +- keep the repair independently reviewable and reversible. + +### Non-goals {#non-goals} + +- redesign every bucket metadata replication handler; +- solve distributed clock skew or same-timestamp multi-writer conflicts globally; +- add a new metadata schema, event log, queue, general metadata lock, or feature flag; +- build a permanent multi-site process lab; +- tighten unrelated XML or validation paths without evidence; +- add Console UI; +- mix historical Object Lock, tag, SSE, policy, quota, or versioning repairs into this branch. + +## Final repair design {#design} + +### Commit 1: preserve tombstones and source order {#commit-1} + +The CORS replication handler remains the single place for explicit peer CORS events. + +Under a CORS-specific distributed namespace lock it will: + +1. require a non-empty bucket and non-zero source timestamp; +2. require existing bucket metadata rather than fabricating it; +3. read raw `CorsConfigUpdatedAt`, including a timestamp whose live config is nil; +4. reject an event before the bucket lineage and ignore any state not strictly greater under the total order; +5. strictly decode and validate a non-nil CORS payload or treat nil as DELETE; +6. set `CorsConfigXML` and `CorsConfigUpdatedAt` directly from the source event, preserving the exact source barrier; +7. persist through `BucketMetadataSys.save`, preserving the existing disk, cache, notification, and peer-node refresh path. + +The legacy/default multi-field path may carry a non-nil CORS snapshot, so it +uses the same lock, strict validation, and join; typed deletes continue through +the CORS-specific handler. `SiteReplicationMetaInfo` always exports the source +timestamp and encodes XML only when present. Status compares kind, decoded +payload, and timestamp. Heal chooses the deterministic maximum and pushes it +through the same transition, including when only the timestamp differs. + +The zero baseline is deliberately not defaulted to bucket creation. Initial +sync sends live and tombstone states but omits baseline. Local PUT and DELETE +choose a timestamp strictly after `max(UTCNow, CreatedAt, current barrier)`. + +### Commit 2: fail closed and match S3 responses {#commit-2} + +The middleware currently falls back to global CORS for every `GetCorsConfig` error. The repair distinguishes two cases: + +- true no-config: use the global policy, preserving existing behavior; +- another metadata error on a request with `Origin`: log once and call the underlying S3 handler without global CORS headers. + +This is fail-closed for browsers without converting a metadata problem into a new server-wide 500 contract. A failed preflight reaches the router's ordinary non-CORS error response. Requests without `Origin` keep the existing middleware path; there is no speculative hot-path optimization. + +Successful preflights also return configured `Access-Control-Expose-Headers`, which the [S3 OPTIONS contract](https://docs.aws.amazon.com/AmazonS3/latest/developerguide/RESTOPTIONSobject.html) lists explicitly. + +Origin matching will return the actual matched pattern. Response behavior is: + +| Matched origin element | `Access-Control-Allow-Origin` | `Access-Control-Allow-Credentials` | +| --- | --- | --- | +| `*` | `*` | omitted | +| exact origin | request origin | `true` | +| pattern such as `https://*` | request origin | `true` | + +This matters when a rule contains both a specific origin and `*`: response semantics follow the first origin element that actually matched, rather than merely noticing that the rule contains a wildcard somewhere. + +The three cache dimensions are set before the preflight match result, so both 200 and 403 responses vary by Origin, requested method, and requested headers. + +### Commit 3: narrow validation and status cleanup {#commit-3} + +The site summary increments `TotalCorsConfigCount` from the current site's `s.CorsConfig != nil`, not from a cumulative count that may already include an earlier site. + +Validation rejects: + +- an empty allowed origin; +- `?`, because the reused generic matcher treats it as a wildcard while S3 documents only a single `*` wildcard. + +The implementation keeps the existing matcher and at-most-one-`*` rule. It does not change method case handling, ID character counting, or trailing XML behavior. + +The handler test suite adds missing and mismatched Content-MD5 cases. That test exposed another concrete bug: the handler wrapped the checksum reader in an exact-`ContentLength` `LimitReader`, which returned EOF before the checksum wrapper could report a mismatched digest. After the existing positive and 64 KiB ContentLength guards, the handler now reads the wrapped request body to EOF directly. This preserves the shared `validateLengthAndChecksum` implementation and makes `BadDigest` observable without adding a second checksum path. + +### Commit 4: operator notes {#commit-4} + +The in-repository note records: + +- bucket CORS overrides rather than merges with global CORS; +- DELETE restores global fallback; +- an older binary does not enforce the new configuration; +- an older binary rewriting bucket metadata may drop the unknown CORS fields; +- an older peer harmlessly no-ops an unknown CORS event, then converges through heal after upgrade; +- site replication still depends on synchronized clocks. + +Public operational documentation remains a separate documentation-repository deliverable, represented by this record and any later task-oriented reference updates. + +## Test design {#tests} + +The tests exercise real state transitions without creating a permanent multi-process lab. + +| Test seam | Required cases | +| --- | --- | +| Peer apply with ObjectLayer | delayed PUT then newer DELETE; stale PUT after tombstone; duplicate delivery; exact source timestamps; missing metadata returns an error and creates no record | +| Transport-to-apply | retain the existing JSON nil/non-nil round trip; feed at least one JSON-decoded event into the real peer handler | +| SiteReplicationMetaInfo | nil config still carries the DELETE timestamp; pre-feature zero timestamp defaults to Created | +| Heal | newer nil tombstone beats older live XML; local state becomes nil with the exact tombstone time | +| Middleware errors | no-config uses global fallback; another metadata error gives actual and preflight responses no global CORS headers | +| Origin responses | exact, literal `*`, patterned, and mixed-origin rules; credentials only when permitted | +| Preflight | expose headers; allowed headers; max age; three `Vary` fields on success and rejection | +| Validation and handler | empty origin, `?`, missing Content-MD5, mismatched Content-MD5 | + +The full admin-auth dispatch is not given its own integration fixture. It is a two-line switch already covered by compilation and review; the wire and real handler seams carry the meaningful state-machine risk. Startup's concrete `errBucketMetadataNotInitialized` value is not frozen in a dedicated test; a representative non-not-found metadata error covers the middleware decision. + +## Rejected alternatives {#rejected} + +### Put CORS tombstone logic in the generic metadata merger {#reject-generic-handler} + +Rejected because it would give one field special nil, staleness, and early-return semantics inside a seven-field merge function. The CORS-specific handler already exists and is the smaller boundary. + +### Add a new timestamp-aware metadata abstraction {#reject-helper} + +Rejected until at least two metadata types demonstrate identical requirements. A general helper today would encode assumptions about delete semantics that differ across policy, tag, SSE, object lock, quota, and versioning. + +### Add a physical tombstone field or event journal {#reject-schema} + +Rejected because `(nil config, DELETE timestamp)` already represents the required state. A new schema increases downgrade and migration cost without adding information. + +### Replace wall clocks with a distributed ordering system {#reject-clock-redesign} + +Rejected as disproportionate and inconsistent with existing site replication. Correctly preserving source time restores the current contract; it does not solve global clock skew. + +### Build a full multi-site test lab {#reject-lab} + +Rejected because the failures are local state-machine defects and every important seam is directly testable in process. A lab would be slower, more brittle, and harder to diagnose. + +### Write a custom CORS wildcard matcher {#reject-matcher} + +Rejected because input validation can constrain the existing matcher to the S3-supported single-`*` language. Reimplementing matching creates more boundary cases than it removes. + +### Tighten every validation edge now {#reject-strictness} + +Rejected because uppercase-only methods, Unicode ID counting, and trailing-document rejection could change accepted inputs without evidence that they affect security or real client compatibility. + +### Fix every neighboring replication issue in the same branch {#reject-scope-expansion} + +Rejected because shared-looking code does not prove shared semantics. Historical problems receive their own reproduction, issue, review, and release boundary. + +## Costs, benefits, and remaining risks {#tradeoffs} + +### Benefits {#benefits} + +- standard S3 Bucket CORS works for browser applications and common SDKs; +- buckets can use narrower origin policies than the cluster-wide fallback; +- normal delivery, missed events, reorder, retry, and heal converge on the same state; +- a revoked browser origin cannot be restored merely because a peer missed DELETE; +- error handling cannot silently widen a configured bucket to global CORS; +- wildcard and credentials behavior matches the established S3 client expectations. + +### Implementation and maintenance cost {#costs} + +The production changes remain local to bucket metadata timestamps, CORS peer apply/heal/status, CORS middleware, and CORS validation. The largest addition is regression coverage, because state convergence must be proven on both supported ObjectLayer test backends. + +No new dependency, service, configuration key, storage field, background worker, or cross-repository server dependency is introduced. The ongoing cost is maintaining the S3 compatibility matrix, source-timestamp tests, and documentation. + +### Remaining risks accepted by design {#remaining-risks} + +- wall-clock ordering assumes synchronized site clocks; +- equal timestamps use a CORS-local deterministic tie-breaker rather than a global replication redesign; +- mixed-version operation is unsupported for CORS writes; all sites must upgrade before the feature is enabled; +- an old binary may ignore or later drop CORS metadata during rollback writes; +- full Console management remains absent; +- inherited replication defects outside CORS remain separate work. + +These are visible constraints, not hidden claims of perfect parity. + +## Historical follow-ups kept separate {#follow-ups} + +Adversarial review confirmed one unrelated defect in the existing initial-sync path: an Object Lock event is constructed with `SRBucketMetaTypeObjectLockConfig` but stores its payload in `Tags` instead of `ObjectLockConfig`. That requires a dedicated issue and fix. + +Neighboring site summaries also use cumulative counters, and policy/tag/SSE/quota/versioning peer handlers may share source-time or tombstone weaknesses. The follow-up policy is: + +1. reproduce each behavior independently; +2. open a focused issue with the affected metadata contract; +3. do not modify it in the CORS branch; +4. consider a shared helper only after at least two types require the same semantics. + +This keeps historical cleanup honest without turning a bounded CORS repair into a site-replication rewrite. + +An event earlier than local `CreatedAt` is ignored as belonging to an older +bucket incarnation and logged once with a bucket-scoped key. Status keeps the +mismatch visible; removing the floor would risk applying an old CORS grant to +a newly recreated bucket. + +## Compatibility impact {#compatibility} + +| Existing user or deployment | Expected impact | +| --- | --- | +| No bucket CORS configured | existing global CORS behavior remains | +| Single-site bucket CORS | standard control plane and enforcement remain; response fidelity improves | +| Site replication without bucket CORS | no behavioral change | +| Site replication with bucket CORS | source ordering, DELETE, retry, and heal become reliable | +| Raw PUT caller | must send the S3-required Content-MD5 or supported checksum | +| Older peer | no-ops unknown CORS events until upgrade; heal converges afterwards | +| Downgrade | bucket CORS is not enforced; metadata may be lost if an old binary rewrites the record | +| Console-only operator | no CORS editor yet; use SDK, CLI, or S3 API | + +The stricter empty-origin and `?` validation lands before any SILO release containing PR #71, so there is no released SILO bucket-CORS configuration population to migrate across that change. + +## Verification and release gates {#release-gates} + +The repair is complete only when all of the following are independently true: + +1. focused replication, middleware, validation, and handler tests pass; +2. focused race tests pass; +3. full `cmd` tests pass; +4. `go build ./...`, pinned lint, generated-file checks, and compatibility checks pass; +5. the standard `minio-go` PUT/GET/DELETE and preflight smoke test passes; +6. an independent adversarial review finds no unresolved blocker; +7. the follow-up server PR is committed, pushed, reviewed, and merged; +8. its PR CI and the resulting `main` CI are green; +9. the documentation build and bilingual link checks pass; +10. release tag, packages, image publication, deployment, and production verification are completed as separate gates. + +Until then, issue #75 remains open and no release or Docker image should advertise per-bucket CORS as release-ready. + +## Conclusion {#conclusion} + +Per-bucket CORS solves a real compatibility and browser-isolation problem, and PR #71's core implementation was worth accepting. The remaining defect is not a reason to discard the feature; it is a reason to state the replication model precisely and finish it before release. + +The final design preserves the source timestamp and nil tombstone through normal peer apply, status, and heal; fails closed without turning CORS metadata errors into a new S3 outage; fixes literal wildcard and cache behavior; and keeps validation changes evidence-based. It reuses the existing CORS handler, bucket metadata, save path, matcher, and ObjectLayer tests. It adds no general framework and does not pull unrelated historical repairs into the branch. + +That is the minimum complexity needed to make the merged feature sufficient, safe, and maintainable. diff --git a/content/blog/design/bucket-cors-replication.zh.md b/content/blog/design/bucket-cors-replication.zh.md new file mode 100644 index 00000000..fd500ab7 --- /dev/null +++ b/content/blog/design/bucket-cors-replication.zh.md @@ -0,0 +1,359 @@ +--- +title: "桶级 CORS:让删除与恢复正确收敛" +linkTitle: "桶级 CORS 复制" +date: 2026-08-28 +lastmod: 2026-08-28 +author: "冯若航" +summary: > + SILO 通过 PR #71 接纳了桶级 CORS,随后发现遗漏或乱序的站点复制事件可能恢复已经删除的浏览器来源规则。本文先用浅显语言说明问题、收益与代价,再完整记录合并决策、两轮对抗评审、来源时间戳与删除墓碑修复、S3 响应收尾、被否决方案、历史后续项,以及 Issue #75 定义的发布门槛。 +tags: [设计, S3, CORS, 复制, 兼容性] +weight: 32 +draft: false +url: "/zh/blog/design/bucket-cors-replication/" +--- + +本文完整记录 [SILO PR #71](https://github.com/pgsty/silo/pull/71) 与发布善后任务 [SILO #75](https://github.com/pgsty/silo/issues/75) 的问题、评审、合并决策、对抗辩论和最终实现契约。 + +> **状态:** PR #71 已合并为 [`e4e3007da`](https://github.com/pgsty/silo/commit/e4e3007da6d7d1198a6a050e34f84566d40a9654)。B2 收敛修复是 [PR #80](https://github.com/pgsty/silo/pull/80) 中的 [`724f8703d`](https://github.com/pgsty/silo/commit/724f8703d83f4c51859c7650b7f1da2c2a55548c),其首轮 DCO、CI、race、cross-compile 与漏洞检查共八项全部通过。最终 B2+B3 代码是 signed commit `0eebc928f`;组合 Opus finding 已修复,全量 tagged/race/build/vet/lint/compatibility 门禁、真实本地双站点离线 DELETE/heal/restart 与 raw SigV4 B3 探针均通过。EN/ZH 记录通过 warning-fatal Hugo 构建、渲染链接检查和本地浏览器 QA。Issue #75 仍是发布阻断:尚未 merge、tag、打包、发布镜像、部署或完成生产验证。
+> **归属:** [`pgsty/silo`](https://github.com/pgsty/silo) 负责服务端修改;这份公共设计记录归 [`pgsty/silo.pgsty.com`](https://github.com/pgsty/silo.pgsty.com) 所有;Console UI 仍是独立交付。
+> **决策:** 接受有价值的功能并保留贡献者成果,但在站点复制删除、恢复、通配符响应和窄幅协议善后正确收敛前,不允许发布。 + +## 用浅显语言说明问题与方案 {#plain-language} + +PR #71 之前,SILO 只能为整个集群设置一份 CORS。浏览器应用无法表达:“允许这个网站使用桶 A,但不允许它使用桶 B。”标准 S3 的桶级 CORS 读取、写入和删除接口虽然有路由,却只是返回 `NotImplemented` 的占位实现。 + +PR #71 补上了这项能力。每个桶现在都可以保存自己的允许网站、HTTP 方法、请求头、开放响应头和预检缓存时间。标准 S3 客户端可以管理配置;没有专属配置的桶继续使用原来的全局行为。 + +核心功能已经可以工作。剩余问题出现在同一个桶跨站点复制时。 + +假设管理员先允许 `https://old.example.com`,随后撤销这个权限。第一个站点正确删除了规则。如果另一个站点临时漏收 DELETE,恢复过程必须知道“10:05 的删除”比“10:00 的配置”更新。当前代码有时会忘掉删除时间,或把来源时间替换成对端收到消息的时间。恢复过程于是可能误把仍然存活的旧规则当成最新状态,再把它写回来。 + +修复不需要新的复制系统。删除后的配置可以用 SILO 已有的数据表示: + +```text +配置内容 = 不存在 +更新时间 = DELETE 发生的时间 +``` + +这对状态就是删除**墓碑(tombstone)**。修复会为 PUT 和 DELETE 保留来源时间;即使 XML 已经不存在,也传播这个时间;并让 heal 与普通 peer 交付复用同一条 CORS 应用路径。旧事件因此不能再复活更新的删除。 + +直接代价有清晰边界:一个 CORS 专用分布式 namespace lock 与单调状态转换、穿透真实 ObjectLayer 的测试、严格 wire 校验、响应兼容收尾和运维文档。它不增加存储字段、依赖、功能开关、分布式时钟或通用复制框架。长期代价是 SILO 需要维护这些测试和桶级 CORS 兼容契约;站点复制仍像原来一样依赖时钟同步。 + +CORS 不是 IAM 鉴权。过期 CORS 规则不会让无权访问 S3 的主体获得权限,但它可能让管理员本想撤销的浏览器来源继续读取原本已获授权的跨域响应。因此复制收敛是发布阻断,而不是外观问题。 + +## PR #71 增加了什么 {#feature} + +PR #71 把继承而来的 Bucket CORS 占位实现替换成了一项完整功能: + +- 标准 `PutBucketCors`、`GetBucketCors`、`DeleteBucketCors` API; +- PUT 的 Content-MD5 或受支持 checksum 校验; +- Origin、Method、AllowedHeader、ExposeHeader、规则 ID 和 MaxAge 的 XML 解析与校验; +- 原始 XML 与 CORS 更新时间写入 `BucketMetadata`; +- 桶级 OPTIONS 预检与实际响应 CORS 头; +- 仅在桶没有专属配置时继续使用现有全局 CORS; +- 站点复制的正常发送、接收、初始同步、状态和 heal 接线; +- 单元、Handler、Middleware、Metadata 与传输测试。 + +本地评审把功能合入当时最新的 `main`,执行了构建、定向普通与 race 测试、完整 `cmd`、固定版本 lint、生成文件检查、兼容性检查,以及真实 `minio-go` 冒烟测试。单站路径上的 PUT、GET、DELETE、允许与拒绝预检、`Vary` 和实际响应头全部正常。 + +这些证据足以接纳功能,但不能证明所有失败恢复路径。 + +## 已复现的收敛错误 {#reproductions} + +评审使用真实 ObjectLayer 的临时测试稳定复现了下面三个问题。后续发布审查还证明:只比较 payload 的 status 会隐藏不同来源时间屏障;同时间戳冲突则依赖事件到达与 map 迭代顺序。 + +### 更新的 DELETE 可能被忽略 {#ignored-delete} + +Peer handler 使用普通 metadata `Update` 与 `Delete`。这些方法会在接收站点无条件写入 `UTCNow()`。如果旧 PUT 延迟到达,它生成的本地接收时间可能看起来比后续来源 DELETE 更晚,结果 DELETE 被当成旧事件丢弃。 + +### 旧 PUT 可能复活删除 {#resurrected-put} + +活跃配置为 nil 后,`GetCorsConfig` 会返回 not-found 与零时间戳。删除时间仍保存在原始 bucket metadata 中,但 handler 通过这个 getter 看不到。旧 PUT 因此通过 staleness 判断并恢复规则。 + +### Heal 可能选择仍存活的旧规则 {#heal-resurrection} + +当前 `SiteReplicationMetaInfo` 只有在 CORS XML 非空时才输出 `CorsConfigUpdatedAt`。DELETE 后,站点报告 nil 配置与零时间;仍保留旧 XML 的 peer 则报告非零旧时间。Heal 于是把旧规则选成“最新”,重新写回。 + +这三个现象其实是同一个缺陷在三个边界上的表现:peer apply、metadata status 与 recovery。 + +## 预期状态模型 {#state-model} + +桶级 CORS 只需要 `BucketMetadata` 中已经存在的状态: + +| 逻辑状态 | XML | 时间戳 | 含义 | +| --- | --- | --- | --- | +| 从未配置 | nil | 零时间 | baseline;既不发送,也不作为 winner | +| 已配置 | 非 nil | 来源 PUT 时间 | 生效中的桶级规则 | +| 已删除 | nil | 来源 DELETE 时间 | tombstone;比任何更早的活跃规则更新 | + +选定 register 使用确定性总序: + +```text +1. 来源 UpdatedAt +2. baseline < live < tombstone +3. 同时间 live/live:按解码后的 payload 字节字典序 +``` + +Peer apply 是单调 join:只有严格更大的状态才会落盘,因此 retry 与重复交付天然幂等。同时间 PUT/DELETE 由 tombstone 胜出;两个 live 值则在每个站点选择相同的字节 winner。`CreatedAt` 不再表示 baseline,只作为 bucket lineage 下界,拒绝旧 bucket incarnation 的事件并输出按桶去重的诊断。 + +## 决策是如何形成的 {#decision-history} + +### 初始评审 {#initial-review} + +第一次评审确认需求真实、单站架构合理,但发现站点复制虽然发出了 CORS 事件,却没有完成所有接收、状态和恢复路径。贡献者随后补齐接线、checksum 校验、通配符/ID 限制、缓存变化维度与定向测试。 + +第二次运行时评审确认单站和正常直接复制路径,同时复现了上述 tombstone 与来源时间问题。功能已经足够接近,可以接纳,但还不够安全,不能当作完成状态发布。 + +### 合并不等于发布 {#merge-versus-release} + +维护者决定合并 PR #71,并接手剩余加固。这个决策明确分开了两个经常被混淆的问题: + +1. 贡献是否有价值、结构是否足够合理,可以接纳?**可以。** +2. 结果是否已经可以打 tag、打包、发布镜像和部署?**必须等 #75 关闭。** + +合并触发的完整 `main` CI 已经通过;正式发布和 Docker 发布仍是手工、独立门槛。 + +### 自我对抗性方案评审 {#self-review} + +最初的善后方案刻意写得很全面,随后按四种失败模式自审:是否过度设计、是否为修一个问题引入更多问题、是否没有复用现有基础设施,以及维护成本是否不成比例。 + +这次自审删除或延后了: + +- 新的通用 metadata apply 抽象; +- 自定义 wildcard matcher; +- Policy/Tag/SSE/Quota 的广泛重构; +- 多进程站点复制测试实验室; +- 没有差分证据的方法大小写、Unicode ID、trailing XML 严格化; +- 可能改变现有 `Vary` 行为的无 Origin 热路径优化; +- vector clock、新 tombstone 字段和全局时间戳重构。 + +### 独立 Claude Opus 5 评审 {#claude-review} + +四轮只读本地 Claude Code 评审使用 canonical `claude-opus-5` 与 maximum effort。评审把方案从 timestamp-only 补丁推进成最终 zero-baseline、确定性 C-prime register;要求分布式 CORS lock 与单调本地 barrier;让 status/heal 比较完整状态;并关闭 strict base64、语义校验、缓存、`Vary`、wildcard credentials 与 initial-sync tombstone 缺口。 + +B2+B3 组合终审又同时检查 strict parser、精确 Method 与 Unicode ID 契约、MaxAge presence、Origin-null forwarding marker、checksum 分类以及复制/重启行为。它发现一处测试 helper 冲突,以及宽松开发版本接受的文档可能导致整份 bucket metadata 不可用的升级风险。Helper 已修;legacy-invalid CORS 现在保持其他 bucket metadata 可读,对浏览器 fail closed,拒绝新 invalid save,并允许通过有效 CORS PUT/DELETE 修复。 + +## 设计目标与非目标 {#scope} + +### 设计目标 {#goals} + +- 让 CORS PUT/DELETE 在重复、延迟、乱序、漏发后正确收敛; +- Peer apply 与 heal 都保留精确来源时间; +- 更新的 nil tombstone 能打败更旧的活跃配置; +- metadata 故障不能把已配置桶静默放宽到全局 CORS; +- 字面通配符、credentials、expose headers 和缓存变化符合 S3 行为; +- 修正新加入的 CORS 状态计数,以及现有 matcher 已证明的窄幅校验缺口; +- 保持修复可独立评审、可单独回滚。 + +### 非目标 {#non-goals} + +- 重构所有 bucket metadata 复制 handler; +- 全局解决分布式时钟偏差或同时间戳多写者冲突; +- 新增 metadata schema、事件日志、队列、通用 metadata lock 或功能开关; +- 建设长期多站点进程实验室; +- 在没有证据时收紧无关 XML 或校验路径; +- 增加 Console UI; +- 把历史 Object Lock、Tag、SSE、Policy、Quota 或 Versioning 修复混入本分支。 + +## 最终修复设计 {#design} + +### Commit 1:保留 tombstone 与来源顺序 {#commit-1} + +CORS 复制 handler 继续作为显式 peer CORS 事件的唯一应用边界。 + +它在 CORS 专用分布式 namespace lock 内执行: + +1. 要求非空 bucket 与非零来源时间; +2. 要求 bucket metadata 已经存在,而不是凭空创建; +3. 读取原始 `CorsConfigUpdatedAt`,包括活跃配置为 nil 时保留的时间; +4. 拒绝早于 bucket lineage 的事件,并忽略总序中不严格大于本地状态的事件; +5. 严格解码并校验非 nil CORS payload,或把 nil 解释为 DELETE; +6. 从来源事件设置 `CorsConfigXML` 与 `CorsConfigUpdatedAt`,保留精确来源屏障; +7. 通过 `BucketMetadataSys.save` 持久化,保留现有磁盘、缓存、通知和 peer-node 刷新路径。 + +Legacy/default multi-field 路径可能携带非 nil CORS snapshot,因此也使用同一把锁、严格校验与 join;typed delete 继续走 CORS 专用 handler。`SiteReplicationMetaInfo` 无条件输出来源时间,只在 XML 存在时编码内容。Status 比较 kind、解码 payload 与时间;heal 选择确定性最大状态并通过同一 transition 传播,包括只差时间戳的场景。 + +Zero baseline 明确不默认成 bucket 创建时间。Initial sync 发送 live 与 tombstone,但不发送 baseline。本地 PUT/DELETE 生成严格晚于 `max(UTCNow, CreatedAt, current barrier)` 的时间。 + +### Commit 2:Fail closed,并对齐 S3 响应 {#commit-2} + +当前 middleware 会在所有 `GetCorsConfig` 错误上回退到全局 CORS。修复区分两种情况: + +- 确实没有配置:继续使用全局策略,保持现有行为; +- 请求带 `Origin` 且出现其他 metadata 错误:log once,然后调用底层 S3 handler,不附加全局 CORS 头。 + +这样对浏览器 fail closed,又不会把 metadata 问题变成新的全局 500 契约。失败预检会进入 router 的普通非 CORS 错误响应。无 Origin 请求保持现有 middleware 路径,不做投机热路径优化。 + +成功预检还会返回配置的 `Access-Control-Expose-Headers`;[S3 OPTIONS 契约](https://docs.aws.amazon.com/AmazonS3/latest/developerguide/RESTOPTIONSobject.html)明确列出了这个响应头。 + +Origin 匹配将返回真正命中的 pattern。响应行为是: + +| 命中的 Origin 元素 | `Access-Control-Allow-Origin` | `Access-Control-Allow-Credentials` | +| --- | --- | --- | +| `*` | `*` | 不发送 | +| 精确 Origin | 请求 Origin | `true` | +| `https://*` 等模式 | 请求 Origin | `true` | + +当同一规则同时包含具体 Origin 和 `*` 时,响应语义跟随第一个实际命中的 Origin 元素,而不是事后发现规则里某处存在 wildcard 就统一处理。 + +三个缓存变化维度会在预检匹配前设置,因此 200 和 403 都按 Origin、请求方法和请求头区分缓存。 + +### Commit 3:窄幅校验与状态善后 {#commit-3} + +站点摘要使用当前站点的 `s.CorsConfig != nil` 增加 `TotalCorsConfigCount`,而不是使用可能已经累计早先站点的总数。 + +校验拒绝: + +- 空 AllowedOrigin; +- `?`,因为复用的通用 matcher 会把它当成 wildcard,而 S3 只定义单个 `*` 通配符。 + +实现保留现有 matcher 与最多一个 `*` 的限制;不修改方法大小写、ID 字符计数或 trailing XML 行为。 + +Handler 测试补上缺失与不匹配的 Content-MD5。这个测试又暴露了一个真实问题:handler 在 checksum reader 外套了长度恰好等于 `ContentLength` 的 `LimitReader`,后者会在 checksum wrapper 报摘要不匹配前先返回 EOF。完成既有正数与 64 KiB ContentLength guard 后,handler 现在直接把包装后的请求体读到 EOF。这样既继续复用共享 `validateLengthAndChecksum`,又能真正返回 `BadDigest`,不增加第二条 checksum 路径。 + +### Commit 4:运维说明 {#commit-4} + +仓库内说明记录: + +- 桶级 CORS 覆盖而不是合并全局 CORS; +- DELETE 恢复全局 fallback; +- 老版本不会执行新配置; +- 老版本重写 bucket metadata 时可能丢弃未知 CORS 字段; +- 老 peer 对未知 CORS 事件会无害 no-op,升级后通过 heal 收敛; +- 站点复制继续依赖时钟同步。 + +公共运维文档仍是独立文档仓库交付;本文以及后续面向任务的参考更新共同承担这个边界。 + +## 测试设计 {#tests} + +测试验证真实状态转换,但不建立长期多进程实验室。 + +| 测试边界 | 必须覆盖的场景 | +| --- | --- | +| Peer apply + ObjectLayer | 延迟 PUT 后较新 DELETE;tombstone 后旧 PUT;重复交付;精确来源时间;metadata 不存在时返回错误且不创建记录 | +| Transport 到 apply | 保留现有 nil/非 nil JSON 往返;至少把一个 JSON 解码事件送入真实 peer handler | +| SiteReplicationMetaInfo | nil 配置仍携带 DELETE 时间;功能前的零时间默认成 Created | +| Heal | 更新的 nil tombstone 打败旧活跃 XML;本地变为 nil,时间精确等于 tombstone | +| Middleware 错误 | 无配置使用全局 fallback;其他 metadata 错误的 actual/preflight 都不附加全局 CORS | +| Origin 响应 | 精确、字面量 `*`、模式和混合规则;仅在允许时发送 credentials | +| Preflight | ExposeHeader、AllowedHeader、MaxAge;成功与拒绝都携带三个 `Vary` | +| 校验与 Handler | 空 Origin、`?`、缺失 Content-MD5、不匹配 Content-MD5 | + +完整 admin-auth dispatch 不新增独立 fixture。它只是一个两行 switch,编译与评审已经覆盖;wire 与真实 handler 才承载有意义的状态机风险。也不固定 startup 的具体 `errBucketMetadataNotInitialized` 值;一个代表性非 not-found metadata 错误足以覆盖 middleware 决策。 + +## 被否决的方案 {#rejected} + +### 把 CORS tombstone 逻辑塞进通用 metadata merger {#reject-generic-handler} + +否决,因为这会让一个七字段 merge 函数中的单个字段拥有特殊 nil、staleness 和提前返回语义。CORS 专用 handler 已经存在,是更小的边界。 + +### 新建 timestamp-aware metadata 抽象 {#reject-helper} + +在至少两个 metadata 类型证明需求完全一致前否决。今天创建通用 helper 会提前编码 Policy、Tag、SSE、Object Lock、Quota、Versioning 之间并不相同的删除语义。 + +### 新增物理 tombstone 字段或事件日志 {#reject-schema} + +否决,因为 `(nil 配置, DELETE 时间戳)` 已经表达全部所需信息。新 schema 只会增加降级与迁移成本。 + +### 用分布式顺序系统替换 wall clock {#reject-clock-redesign} + +否决,因为代价不成比例,也与现有 Site Replication 不一致。正确保留来源时间可以恢复现有契约,但不能全局解决时钟偏差。 + +### 建设完整多站点测试实验室 {#reject-lab} + +否决,因为问题属于本地状态机缺陷,所有重要边界都可进程内直接测试。实验室更慢、更脆弱,也更难诊断。 + +### 编写自定义 CORS wildcard matcher {#reject-matcher} + +否决,因为输入校验可以把现有 matcher 限制到 S3 支持的单 `*` 语言。重新实现匹配只会制造更多边缘。 + +### 现在收紧所有校验 {#reject-strictness} + +否决,因为强制方法大写、Unicode ID 计数与 trailing document 拒绝可能改变已接受输入,却没有安全或真实客户端兼容证据。 + +### 在同一分支修复所有相邻复制问题 {#reject-scope-expansion} + +否决,因为代码看起来相似不代表语义相同。每个历史问题都必须有独立复现、Issue、评审和发布边界。 + +## 成本、收益与剩余风险 {#tradeoffs} + +### 收益 {#benefits} + +- 浏览器应用与常见 SDK 获得标准 S3 Bucket CORS; +- 每个桶可使用比集群全局 fallback 更窄的 Origin 策略; +- 正常交付、漏发、乱序、重试和 heal 最终收敛到同一状态; +- Peer 漏收 DELETE 不会导致已撤销浏览器来源被恢复; +- metadata 错误不能把已配置桶静默扩大到全局 CORS; +- wildcard 与 credentials 行为符合既有 S3 客户端预期。 + +### 实现与维护代价 {#costs} + +生产修改只涉及 bucket metadata 时间戳、CORS peer apply/heal/status、CORS middleware 和 CORS 校验。最大的新增量是回归测试,因为必须在两个受支持 ObjectLayer 测试后端证明状态收敛。 + +不增加依赖、服务、配置键、存储字段、后台 worker 或跨仓库服务端依赖。长期成本是维护 S3 兼容矩阵、来源时间测试与文档。 + +### 设计上接受的剩余风险 {#remaining-risks} + +- wall-clock 顺序依赖站点时钟同步; +- 同时间戳使用 CORS 局部确定性 tie-breaker,而不是全局复制重构; +- CORS 写入不支持混合版本运行;启用功能前必须升级所有站点; +- 降级写入时老版本可能忽略或丢弃 CORS metadata; +- 完整 Console 管理仍不存在; +- CORS 之外继承的复制缺陷仍是独立工作。 + +这些是可见约束,不是隐藏的“完全一致”宣传。 + +## 保持独立的历史善后 {#follow-ups} + +对抗评审确认了现有初始同步路径中的一个无关缺陷:Object Lock 事件虽然使用 `SRBucketMetaTypeObjectLockConfig`,却把 payload 放进 `Tags` 而不是 `ObjectLockConfig`。它需要独立 Issue 与修复。 + +相邻站点摘要也存在累计计数模式,Policy/Tag/SSE/Quota/Versioning peer handler 还可能共享来源时间或 tombstone 弱点。后续规则是: + +1. 分别复现每个行为; +2. 按受影响的 metadata 契约建立聚焦 Issue; +3. 不在 CORS 分支修改; +4. 只有至少两个类型证明语义完全相同后,才考虑共享 helper。 + +这样既诚实清理历史问题,又不会把有边界的 CORS 修复变成 Site Replication 大重构。 + +早于本地 `CreatedAt` 的事件被视为旧 bucket incarnation 并忽略,同时使用按桶去重的日志记录。Status 会继续暴露 mismatch;删除这个 floor 反而可能把旧 CORS grant 安装到同名新 bucket。 + +## 兼容性影响 {#compatibility} + +| 现有用户或部署 | 预期影响 | +| --- | --- | +| 没有配置 Bucket CORS | 继续使用现有全局 CORS | +| 单站 Bucket CORS | 标准控制面与执行行为保留;响应一致性改善 | +| 启用 Site Replication,但不使用 Bucket CORS | 无行为变化 | +| Site Replication + Bucket CORS | 来源顺序、DELETE、重试和 heal 变得可靠 | +| 原始 PUT 调用者 | 必须发送 S3 要求的 Content-MD5 或受支持 checksum | +| 老 peer | 升级前 no-op 未知 CORS 事件;升级后由 heal 收敛 | +| 降级 | 不执行 Bucket CORS;老版本重写记录时可能丢失 metadata | +| 只使用 Console 的运维者 | 暂无 CORS 编辑器;需使用 SDK、CLI 或 S3 API | + +空 Origin 与 `?` 的严格化发生在任何包含 PR #71 的 SILO 正式版本之前,因此不存在已经发布的 SILO Bucket CORS 配置需要迁移。 + +## 验证与发布门槛 {#release-gates} + +只有以下条件分别成立,修复才算完成: + +1. 定向复制、middleware、校验和 handler 测试通过; +2. 定向 race 测试通过; +3. 完整 `cmd` 测试通过; +4. `go build ./...`、固定版本 lint、生成文件和兼容性检查通过; +5. 标准 `minio-go` PUT/GET/DELETE 与预检冒烟通过; +6. 独立对抗评审没有未解决 blocker; +7. 跟进服务端 PR 已提交、推送、评审并合并; +8. PR CI 与合并后的 `main` CI 全绿; +9. 文档构建和双语链接检查通过; +10. release tag、软件包、镜像发布、部署与生产验证分别完成。 + +在此之前,Issue #75 保持 open,任何 release 或 Docker 镜像都不能把桶级 CORS 宣称为可发布完成状态。 + +## 结论 {#conclusion} + +桶级 CORS 解决了真实的兼容性和浏览器隔离问题,PR #71 的核心实现值得接纳。剩余缺陷不是丢弃功能的理由,而是要求在发布前精确定义并完成复制状态模型的理由。 + +最终设计让正常 peer apply、status 与 heal 都保留来源时间和 nil tombstone;对 metadata 错误 fail closed,又不把它升级成新的 S3 可用性故障;修正字面 wildcard 与缓存行为;校验变化坚持证据边界。它复用现有 CORS handler、bucket metadata、save 路径、matcher 和 ObjectLayer 测试,不增加通用框架,也不把无关历史修复塞进分支。 + +这就是让已经合并的功能达到充分、安全、可维护所需的最小复杂度。 diff --git a/content/blog/design/bucket-cors-wire-contract.md b/content/blog/design/bucket-cors-wire-contract.md new file mode 100644 index 00000000..b37f5825 --- /dev/null +++ b/content/blog/design/bucket-cors-wire-contract.md @@ -0,0 +1,269 @@ +--- +title: "Per-Bucket CORS Wire Contract: Strict XML, Checksums, and Browser Responses" +linkTitle: "Bucket CORS Wire Contract" +date: 2026-08-29 +lastmod: 2026-08-29 +author: "Ruohang Feng" +summary: > + SILO's first per-bucket CORS implementation accepted a second XML root, counted UTF-8 bytes instead of characters in rule IDs, and normalized lowercase methods outside the S3 enum. This B3 design record fixes the wire/parser/validation contract, documents the AWS and browser evidence, records the rejected alternatives, and keeps site-replication work explicitly out of scope. +tags: [Design, S3, CORS, XML, Compatibility] +weight: 33 +draft: false +url: "/blog/design/bucket-cors-wire-contract/" +--- + +This document records the B3 protocol-hardening decision for per-bucket CORS after [SILO PR #71](https://github.com/pgsty/silo/pull/71) merged as [`e4e3007da`](https://github.com/pgsty/silo/commit/e4e3007da6d7d1198a6a050e34f84566d40a9654). It covers only the S3 request body, validation, checksum, matching, and browser-response contract. Site-replication ordering, tombstones, heal, status counters, and generic metadata refactoring remain separate work under [SILO #75](https://github.com/pgsty/silo/issues/75). + +> **Status:** the standalone B3 implementation is local commit `ae879f6cc`; the final B2+B3 integration is signed commit `0eebc928f` on the issue-75 branch. The combined Opus 5 Max review preserved the strict parser, validation, checksum, MaxAge, wildcard, `Origin: null`, and fail-closed replication contracts, then identified and fixed a conflict-helper typo and a legacy-invalid metadata recovery risk. Full combined local gates, raw SigV4 B3 probes, and real two-site CORS replication/recovery pass; the EN/ZH records pass a warning-fatal build, rendered link checks, and local browser QA. The combined change remains unmerged, untagged, unpublished, undeployed, and not production-verified.
+> **Decision:** implement the strict B3 wire contract before the first SILO release containing per-bucket CORS. Do not normalize invalid input into validity, do not broaden the patch into site replication, and do not claim an overall release GO from local B3 evidence. + +## Why this is a release blocker {#problem} + +Bucket CORS is a standard S3 control plane. Its input is not merely configuration-shaped text: raw clients sign an exact XML request body, modern AWS SDKs attach a required payload checksum, SILO stores the accepted bytes verbatim, and `GetBucketCors` later returns those bytes to strict XML clients. + +Three adversarial cases exposed holes in the merged implementation: + +1. a valid `` followed by a second XML root was accepted and stored; +2. an ID containing exactly 255 Unicode characters was rejected because Go's `len(string)` counted UTF-8 bytes; +3. `get` was accepted because validation uppercased the value before checking the S3 enum. + +These are server-side wire problems. The official AWS SDK models do not fully validate rule IDs or method strings on the client, and raw signed clients can always bypass typed SDK construction. The service must enforce the contract. + +The second-root case is especially damaging. SILO stored the whole body, not just the first decoded element. A successful PUT could therefore make a later GET return a document with two roots, which standards-compliant XML clients reject. + +## Authoritative contract {#contract} + +The implementation uses current AWS documentation and generated SDK models as the protocol baseline: + +- [PutBucketCors](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketCors.html) defines the XML root, the 64 KB document limit, Content-MD5 and SDK checksum headers, up to 100 rules, and the rule-match conditions: origin, method, and every requested header must all match. +- [CORSRule](https://docs.aws.amazon.com/AmazonS3/latest/API/API_CORSRule.html) defines the uppercase method values and the inclusive 255-character ID limit. +- [Elements of a CORS configuration](https://docs.aws.amazon.com/AmazonS3/latest/userguide/ManageCorsUsing.html) permits at most one `*` in each allowed origin or allowed header. +- [Testing CORS](https://docs.aws.amazon.com/AmazonS3/latest/userguide/testing-cors.html) shows a successful preflight returning the matched rule's full method list, requested allowed headers, exposed headers, credentials, and cache-variation headers. +- [S3 error responses](https://docs.aws.amazon.com/AmazonS3/latest/developerguide/ErrorResponses.html) defines `MalformedXML` for XML that does not validate against the S3 schema and `BadDigest` for a mismatched Content-MD5 or checksum value. +- The generated [AWS SDK for Go v2 PutBucketCors operation](https://github.com/aws/aws-sdk-go-v2/blob/main/service/s3/api_op_PutBucketCors.go) marks the request checksum as required. Its [CORS types](https://github.com/aws/aws-sdk-go-v2/blob/main/service/s3/types/types.go) use an `int32` MaxAgeSeconds and leave most semantic validation to the server. +- The [WHATWG Fetch Standard](https://fetch.spec.whatwg.org/) forbids sharing a credentialed response when `Access-Control-Allow-Origin` is `*`. + +A read-only OPTIONS request to the public AWS `landsat-pds` bucket independently confirmed the current response behavior: a wildcard rule returned `Access-Control-Allow-Origin: *`, the full `GET, HEAD` method list, and no `Access-Control-Allow-Credentials` header. + +## Reproduction classification {#classification} + +| Behavior | Result | Evidence and decision | +| --- | --- | --- | +| second XML root accepted | **REAL** | parser, signed in-process handler, and real TCP SigV4 all accepted it before the fix | +| 255 Unicode-character ID rejected | **REAL** | parser/Validate, signed handler, and real boto3 request reproduced SILO's rejection; accepting 255 code points is based on AWS's character wording and SDK model, not authenticated AWS PUT | +| lowercase method accepted | **REAL** | parser/Validate, signed handler, and real boto3 request all reproduced it | +| 64 KiB boundary | **NOT REAL** | 65,536 bytes already passed and 65,537 failed; keep regression coverage | +| 100-rule boundary | **NOT REAL** | exactly 100 already passed and 101 failed; keep regression coverage | +| first fully matching rule | **NOT REAL** | matching already fell through an earlier header-restrictive rule; preserve that behavior | +| checksum EOF bypass | **CONDITIONAL** | an in-memory reader could hide EOF from the checksum wrapper, while real TCP already rejected bad digests; remove the reader-dependent behavior anyway | +| empty and unknown XML members | **CONDITIONAL, resolved strictly** | AWS schema/error documentation supports rejection, but no authenticated AWS PUT black-box result was available | +| wildcard origin plus credentials | **REAL** | merged SILO echoed the origin and enabled credentials for `*`; live AWS and Fetch require `*` without credentials | +| `Origin: null` rewritten to wildcard | **REAL, found in final review** | inner forwarding middleware rewrote an explicitly matched `null` origin to `*` while retaining credentials; B3 now marks its response so the legacy rewrite skips it | +| negative MaxAge rejection | **CONDITIONAL, pre-existing** | retained because browser max-age is non-negative; no authenticated AWS PUT differential was available | + +## Goals and non-goals {#scope} + +### Goals {#goals} + +- accept exactly one S3 CORS document element and only XML Misc after it; +- enforce the documented 64 KiB, 100-rule, ID, method, wildcard, and MaxAge contracts; +- verify Content-MD5 and modern SDK checksums independent of reader chunking behavior; +- retain the first fully matching rule semantics; +- return S3-compatible successful preflight and actual-request headers; +- make every changed behavior reviewable through parser, Validate, signed handler, and real-client tests; +- keep the exported compatibility manifest unchanged. + +### Non-goals {#non-goals} + +- change site-replication delivery, tombstones, heal, or status accounting; +- redesign global CORS fallback or metadata-error handling; +- add Console UI; +- introduce a general XML-schema framework; +- validate arbitrary XML attributes or require one namespace spelling; +- enforce cross-rule ID uniqueness without stronger current evidence; +- refactor unrelated lifecycle, tagging, policy, SSE, quota, or versioning parsers; +- commit, push, tag, publish an image, deploy, or claim production parity in this work item. + +## Alternatives considered {#alternatives} + +### A. Patch only the three reported lines {#alternative-three-lines} + +This would add an EOF check, use a rune count, and remove method uppercasing. It is attractive but incomplete: it leaves unknown elements, duplicate singleton fields, empty numeric values, int32 overflow, the generic `?` wildcard, reader-dependent checksum verification, and incorrect wildcard/preflight responses. + +**Rejected:** too narrow for the explicitly reviewed B3 contract. + +### B. Normalize input into a canonical configuration {#alternative-normalize} + +The server could uppercase methods, trim values, discard unknown elements, and keep only the first XML root. This is convenient for friendly clients but changes invalid signed wire input into a different valid configuration. It also preserves bytes that do not round-trip through `GetBucketCors` cleanly. + +**Rejected:** S3 compatibility requires validation, not silent repair. + +### C. Add a strict, B3-specific wire representation {#alternative-strict-wire} + +Decode into private XML wire structs that capture direct text, unknown elements, repeated singleton fields, and MaxAge presence. Convert into the existing public `Config` and `Rule` types only after the XML shape is valid. Keep semantic checks in `Validate` and matching helpers. + +**Selected:** it is strict where evidence exists, order-independent, namespace-tolerant, local to CORS, and adds no exported compatibility symbol. + +### D. Validate every possible XML and header detail {#alternative-full-schema} + +This would enforce namespace URIs, reject every unknown attribute, validate every response header as an RFC token, and add cross-rule ID uniqueness. + +**Rejected for now:** these constraints lack sufficient differential evidence and risk unnecessary incompatibility. + +## Final design {#design} + +### 1. XML wire parser {#parser} + +`ParseBucketCorsConfig` decodes into private wire-only types: + +- `CORSConfiguration` is the only root; +- root and rule levels reject non-whitespace direct character data; +- unknown root, rule, and nested leaf elements are rejected; +- `ID` and `MaxAgeSeconds` may occur at most once per rule; +- list members remain repeatable and order-independent; +- MaxAge text must parse as a signed 32-bit integer; +- after the root closes, whitespace, comments, and processing instructions are allowed; another root, text, directive, or malformed token is rejected. + +Namespace prefixes and the standard namespace declaration remain accepted because matching uses XML local names. Unknown attributes are not newly rejected. The existing `Config` and `Rule` XML tags remain for serialization compatibility, but production request and metadata parsing uses `ParseBucketCorsConfig`. + +This parser also runs when stored bucket metadata is loaded. That is a deliberate pre-release choice: no SILO tag postdates PR #71, so there is no released per-bucket CORS population to migrate. A development build that previously stored malformed CORS XML makes the bucket's entire metadata record unloadable—not only its CORS view—until the stored CORS document is replaced or deleted. + +### 2. Semantic validation {#validation} + +`Validate` enforces: + +- one through 100 rules; +- valid UTF-8 and no more than 255 Unicode code points in an ID; +- at least one non-empty allowed origin and one method per rule; +- methods exactly equal to `GET`, `PUT`, `HEAD`, `POST`, or `DELETE`; +- no `?` in an allowed origin or allowed header, because the inherited matcher treats it as a wildcard while S3 documents only `*`; +- at most one `*` in each allowed origin and allowed header; +- non-empty allowed and exposed header elements; +- MaxAgeSeconds from zero through `2^31-1`. + +An empty ID remains allowed because ID itself is optional and current AWS documentation publishes no non-empty constraint. Cross-rule ID uniqueness remains outside this patch. + +### 3. Matching {#matching} + +The generic matcher is replaced on this path by a small single-`*` matcher: + +```text +no * -> exact match +one * -> prefix and suffix must both match; * may match zero bytes +``` + +Allowed-header matching remains case-insensitive, while the requested header spelling is preserved in the response. Method matching is case-sensitive after the S3 PUT path validates canonical stored values. Direct site-replication and heal writes in the merged base bypass that validation; they remain a separate integration requirement and can otherwise store a method that the B3 matcher will not execute. + +`MatchPreflight` continues past a rule that matches origin and method but rejects one requested header. The selected rule is therefore the first rule that matches all three documented conditions. It also returns the exact origin element that matched and whether MaxAgeSeconds was present, preserving the difference between absent and explicit zero. + +### 4. Request size and checksums {#checksums} + +The handler keeps the existing positive Content-Length and 64 KiB guards. `validateLengthAndChecksum` still wraps the body with the shared checker, but the CORS handler now reads that wrapped body to EOF instead of placing another exact-length `LimitReader` outside it. + +This makes checksum verification independent of whether the underlying reader returns the final bytes with or without `io.EOF` in the same call. A well-formed but mismatched Content-MD5 or full-header SDK checksum returns `BadDigest`; missing checksum material still returns the existing required-checksum error. The shared helper can classify malformed checksum syntax as missing, and this small-body path does not implement aws-chunked trailing-checksum decoding; those fidelity gaps remain outside B3. No second checksum implementation is added. + +Modern boto3 traffic is a material compatibility gate because current botocore sends `x-amz-sdk-checksum-algorithm: CRC32` plus `x-amz-checksum-crc32`, not Content-MD5, for this required-checksum operation. + +### 5. Browser responses {#responses} + +The matched origin element controls the response: + +| Matched element | `Access-Control-Allow-Origin` | `Access-Control-Allow-Credentials` | +| --- | --- | --- | +| `*` | `*` | omitted | +| `null` | `null` | `true` | +| exact origin | request origin | `true` | +| pattern such as `https://*` | request origin | `true` | + +A successful preflight returns: + +- the matched rule's complete `AllowedMethods` list; +- only the requested headers that the rule permits; +- configured `ExposeHeaders`; +- MaxAgeSeconds, including explicit zero; +- the existing three successful-preflight `Vary` dimensions. + +Actual requests keep their existing continue-through behavior and receive origin, credentials, expose, and `Vary: Origin` headers when a rule matches. A request-context marker prevents the inner legacy forwarding middleware from rewriting an explicitly allowed `null` origin to `*`; unmarked global responses retain their historical workaround. Because `null` is shared by sandboxed documents and `file://` origins, operators should configure it only when credentialed access from all such contexts is intentional. + +Allowed-origin elements are evaluated in document order. If a rule contains both a specific origin and `*`, place the specific origin first when that origin must retain reflected-origin credentials semantics. + +The rejected-preflight body remains the existing bare 403 in this B3 patch. Producing the full AWS `AccessForbidden` XML shape and changing rejected-response cache/audit behavior require a separate wire decision rather than being smuggled into parser hardening. + +## Implementation map {#implementation} + +| Area | Files | Responsibility | +| --- | --- | --- | +| parser and validation | `internal/bucket/cors/cors.go` | private wire structs, strict trailing-token check, rune/enum/wildcard/MaxAge validation, matching | +| parser tests | `internal/bucket/cors/cors_test.go`, `cors_adversarial_test.go` | roots, XML Misc, unknown/nested/duplicate members, boundaries, matching | +| PUT handler | `cmd/bucket-cors-handlers.go` | size/checksum gates, EOF consumption, S3 error mapping | +| signed handler tests | `cmd/bucket-cors-adversarial_test.go` | three reported cases, 64 KiB, 100 rules, MD5 and CRC32 positive/negative cases | +| browser responses | `cmd/api-router.go`, `cmd/generic-handlers.go` | matched origin semantics, `null` marker, full methods, expose, explicit zero max age | +| response tests | `cmd/bucket-cors-middleware_test.go` | exact/pattern/wildcard/`null` origins, first full rule, headers, methods, expose, max age, credentials | + +No site-replication source file belongs to this implementation boundary. + +## Test and evidence matrix {#tests} + +| Layer | Required evidence | +| --- | --- | +| parser | second root/text/dangling close rejected; trailing whitespace/comment/PI accepted; unknown/nested/duplicate rejected | +| Validate | 255 Unicode characters accepted, 256 rejected; lowercase and unsupported methods rejected; wildcard and empty-value cases | +| boundary | exactly 64 KiB and 100 rules accepted; one byte/rule over rejected; MaxAge absent/zero/negative/int32 overflow | +| signed handler | raw SigV4 PUT for all three reported failures; missing/bad MD5; valid/bad SDK CRC32 | +| middleware | first fully matching rule; wildcard, pattern, and `null` credentials; legacy unmarked `null` rewrite; full methods; requested headers; expose; explicit max age zero | +| focused race | CORS package and CORS handler/middleware tests under the race detector | +| full local | untagged and `kqueue,dev` full `cmd`; build; vet; pinned lint; generated/rebrand; diff check | +| real clients | `minio-go` v7.3.1 PUT/GET/preflight/DELETE and boto3/botocore CRC32 PUT/GET/preflight/DELETE plus adversarial rejects | +| external behavior | read-only OPTIONS against a public AWS bucket for wildcard, methods, credentials, and Vary | + +## Adversarial review resolution {#review} + +Claude Code Opus 5 ran at max effort against the evidence, implementation, and then this bilingual design plus the final code. The earlier implementation review returned **GO**. The publication review returned **GO WITH FIXES**, with no P0 or P1 and five P2 findings. After the accepted code and documentation changes, the same session returned **GO** with no P0–P2 finding. + +Its non-blocking findings were independently adjudicated: + +- the one behavioral P2 was accepted: an explicitly allowed actual `Origin: null` now survives the inner legacy forwarding middleware; +- the metadata-load blast radius and replication-validation exception are now stated precisely; +- returning an interior MaxAge pointer is read-only and the selected rule was already an interior pointer; no new mutation occurs; +- `BadDigest` is retained because the current AWS S3 error reference explicitly applies it to Content-MD5 or checksum mismatch; +- malformed checksum syntax, trailing-checksum decoding, no-match `Vary`, full `AccessForbidden` XML, and outer-middleware audit behavior are recorded but remain outside B3; +- non-UTF-8 XML declarations are not enabled because the S3 request syntax is UTF-8 and current SDKs emit UTF-8; +- method whitespace remains invalid while integer whitespace is accepted according to their different XML lexical domains; +- validating every exposed header as an RFC token is deferred without AWS differential evidence. + +## Compatibility and rollout {#compatibility} + +| Existing use | Effect | +| --- | --- | +| typed `minio-go` or boto3 CORS | valid configurations continue to round-trip; modern CRC32 requests are verified | +| raw valid XML | accepted up to the same size and rule limits | +| lowercase method | now rejected instead of normalized | +| 255 non-ASCII ID characters | now accepted; more than 255 rejected | +| second root, unknown element, duplicate singleton, empty/overflow MaxAge | now rejected as malformed XML | +| literal wildcard origin | now returns `*` without credentials | +| patterned origin | concrete request origin remains reflected with credentials | +| old development metadata containing malformed CORS XML | the entire bucket metadata record may be unloadable until the CORS XML is replaced or deleted | +| site replication | no B3 code change; its own convergence repair and tests remain separate | + +The strictness change lands before any tagged SILO version contains per-bucket CORS. That timing is the compatibility window. Once released, this wire contract becomes stable and future relaxations or tightenings require their own differential evidence. + +## Verification result and remaining gates {#gates} + +The final local implementation passed: + +- focused parser, Validate, signed handler, and middleware tests; +- focused race tests for `internal/bucket/cors` and CORS `cmd` paths; +- `go test ./cmd -count=1` and the full `kqueue,dev` `cmd` lane; +- `go build ./...` and `go vet ./...`; +- golangci-lint 2.13.1 with zero issues; +- generated-file, compatibility/rebrand, entrypoint, and diff checks; +- real boto3/botocore 1.43.58 and `minio-go` v7.3.1 regressions against a freshly built local server; +- final Claude Code Opus 5 max-effort review. + +These results establish **B3 IMPLEMENTATION GO** only. Overall release remains blocked until the separate replication work is integrated, the server and documentation changes are committed and pushed, PR and merged-main CI pass, a release artifact is built, and deployment/production checks complete independently. + +## Conclusion {#conclusion} + +The final B3 design treats Bucket CORS as a signed S3 wire contract rather than a forgiving configuration file. It rejects malformed or noncanonical input before persistence, counts IDs as characters, validates modern SDK checksums, preserves documented first-full-rule selection, and emits browser-safe S3 responses. + +The patch stays local to the CORS parser, validator, handler, matcher, response code, and tests. It adds no new service, schema, dependency, exported compatibility symbol, or site-replication refactor. That is the smallest complete solution supported by the protocol and live evidence. diff --git a/content/blog/design/bucket-cors-wire-contract.zh.md b/content/blog/design/bucket-cors-wire-contract.zh.md new file mode 100644 index 00000000..7b73b055 --- /dev/null +++ b/content/blog/design/bucket-cors-wire-contract.zh.md @@ -0,0 +1,269 @@ +--- +title: "桶级 CORS Wire 契约:严格 XML、Checksum 与浏览器响应" +linkTitle: "桶级 CORS Wire 契约" +date: 2026-08-29 +lastmod: 2026-08-29 +author: "冯若航" +summary: > + SILO 第一版桶级 CORS 会接受第二个 XML root、把 UTF-8 字节数当作规则 ID 字符数,并把 S3 枚举之外的小写方法规范化成有效值。本文记录 B3 对 wire/parser/validation 契约的最终修复、AWS 与浏览器证据、被否决方案,以及与 site-replication 明确分离的实现边界。 +tags: [设计, S3, CORS, XML, 兼容性] +weight: 33 +draft: false +url: "/zh/blog/design/bucket-cors-wire-contract/" +--- + +本文记录 [SILO PR #71](https://github.com/pgsty/silo/pull/71) 以 [`e4e3007da`](https://github.com/pgsty/silo/commit/e4e3007da6d7d1198a6a050e34f84566d40a9654) 合并后,桶级 CORS 的 B3 协议加固决策。范围只包括 S3 请求体、校验、checksum、匹配和浏览器响应契约。站点复制顺序、tombstone、heal、状态计数与通用 metadata 重构仍是 [SILO #75](https://github.com/pgsty/silo/issues/75) 下的独立工作。 + +> **状态:** 独立 B3 实现是本地 commit `ae879f6cc`,最终 B2+B3 整合是 issue-75 分支上的 signed commit `0eebc928f`。组合 Opus 5 Max 评审保留了 strict parser、Validate、checksum、MaxAge、wildcard、`Origin: null` 与 fail-closed replication 契约,并发现、修正了一处冲突 helper 拼接错误和 legacy-invalid metadata 恢复风险。组合全量本地门禁、raw SigV4 B3 探针与真实双站点 CORS 复制/恢复均通过;EN/ZH 记录通过 warning-fatal 构建、渲染链接检查和本地浏览器 QA。组合修改仍未 merge、tag、发布、部署或完成生产验证。
+> **决策:** 在第一个包含桶级 CORS 的 SILO 正式版本之前完成严格 B3 wire 契约。不能把无效输入规范化成有效配置,不能把补丁扩大成 site replication 重构,也不能用本地 B3 证据宣称整体发布 GO。 + +## 为什么这是发布阻断 {#problem} + +Bucket CORS 是标准 S3 控制面。输入不只是“看起来像配置的文本”:原始客户端会对精确 XML 请求体签名,现代 AWS SDK 会附带必需的 payload checksum,SILO 会原样保存接受的字节,`GetBucketCors` 随后又会把这些字节返回给严格 XML 客户端。 + +三个对抗用例暴露了合并实现的缺口: + +1. 合法 `` 后追加第二个 XML root 仍被接受并保存; +2. 恰好包含 255 个 Unicode 字符的 ID 会被拒绝,因为 Go `len(string)` 统计 UTF-8 字节; +3. `get` 会被接受,因为校验先把它转成大写再检查 S3 枚举。 + +这些是服务端 wire 问题。AWS 官方 SDK 模型不会在客户端完整校验规则 ID 或方法字符串,原始签名客户端也始终可以绕过 typed SDK 构造。因此必须由服务端执行契约。 + +第二个 root 的后果尤其严重。SILO 保存的是完整 body,而不是第一个已解码元素。一次成功 PUT 因此可能让后续 GET 返回含两个 root 的文档,标准 XML 客户端会直接拒绝它。 + +## 权威契约 {#contract} + +实现以当前 AWS 文档与生成 SDK 模型作为协议基线: + +- [PutBucketCors](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketCors.html) 定义 XML root、64 KB 文档上限、Content-MD5 与 SDK checksum header、最多 100 条规则,以及 Origin、Method、所有请求 Header 必须同时匹配的规则条件。 +- [CORSRule](https://docs.aws.amazon.com/AmazonS3/latest/API/API_CORSRule.html) 定义大写方法值与包含端点的 255 字符 ID 上限。 +- [CORS 配置元素](https://docs.aws.amazon.com/AmazonS3/latest/userguide/ManageCorsUsing.html)规定每个 AllowedOrigin 或 AllowedHeader 最多包含一个 `*`。 +- [测试 CORS](https://docs.aws.amazon.com/AmazonS3/latest/userguide/testing-cors.html)展示成功预检返回命中规则的完整方法列表、请求且允许的 Header、ExposeHeader、credentials 与缓存变化 Header。 +- [S3 错误响应](https://docs.aws.amazon.com/AmazonS3/latest/developerguide/ErrorResponses.html)把不符合 S3 schema 的 XML 定义为 `MalformedXML`,把 Content-MD5 或 checksum 不匹配定义为 `BadDigest`。 +- 生成的 [AWS SDK for Go v2 PutBucketCors 操作](https://github.com/aws/aws-sdk-go-v2/blob/main/service/s3/api_op_PutBucketCors.go)把请求 checksum 标记为 required;其 [CORS 类型](https://github.com/aws/aws-sdk-go-v2/blob/main/service/s3/types/types.go)使用 `int32` MaxAgeSeconds,并把大多数语义校验留给服务端。 +- [WHATWG Fetch Standard](https://fetch.spec.whatwg.org/)禁止在 `Access-Control-Allow-Origin` 为 `*` 时共享带 credentials 的响应。 + +对公开 AWS `landsat-pds` bucket 的只读 OPTIONS 请求又独立确认了当前响应行为:字面 wildcard 规则返回 `Access-Control-Allow-Origin: *`、完整 `GET, HEAD` 方法列表,并且不返回 `Access-Control-Allow-Credentials`。 + +## 复现分类 {#classification} + +| 行为 | 结论 | 证据与决策 | +| --- | --- | --- | +| 接受第二个 XML root | **REAL** | 修复前 Parser、进程内签名 Handler 与真实 TCP SigV4 均接受 | +| 拒绝 255 个 Unicode 字符的 ID | **REAL** | Parser/Validate、签名 Handler 与真实 boto3 请求复现了 SILO 的拒绝;接受 255 个 code point 依据 AWS 的字符表述与 SDK 模型,而不是 AWS 授权 PUT | +| 接受小写 Method | **REAL** | Parser/Validate、签名 Handler 与真实 boto3 请求均复现 | +| 64 KiB 边界 | **NOT REAL** | 65,536 字节原本就通过,65,537 失败;保留回归测试 | +| 100-rule 边界 | **NOT REAL** | 恰好 100 原本就通过,101 失败;保留回归测试 | +| 第一条完全匹配规则 | **NOT REAL** | 现有匹配会越过 Header 受限的较早规则;保持该行为 | +| Checksum EOF 绕过 | **CONDITIONAL** | 内存 Reader 可能让 checksum wrapper 看不到 EOF,真实 TCP 原本已拒绝坏摘要;仍消除 reader-dependent 行为 | +| 空与未知 XML member | **CONDITIONAL,按 strict 解决** | AWS schema/error 文档支持拒绝,但没有可用的 AWS 授权 PUT 黑盒结果 | +| wildcard Origin + credentials | **REAL** | 合并版 SILO 会对 `*` 回显 Origin 并开启 credentials;真实 AWS 与 Fetch 要求 `*` 且不带 credentials | +| `Origin: null` 被改写成 wildcard | **REAL,终审发现** | 内层 forwarding middleware 把明确命中的 `null` 改成 `*`,却保留 credentials;B3 现在标记专属响应,让旧 rewrite 跳过它 | +| 拒绝负 MaxAge | **CONDITIONAL,原有行为** | 因浏览器 max-age 非负而保留;没有可用的 AWS 授权 PUT 差分结果 | + +## 目标与非目标 {#scope} + +### 目标 {#goals} + +- 只接受一个 S3 CORS 文档元素,之后仅允许 XML Misc; +- 执行文档定义的 64 KiB、100-rule、ID、Method、wildcard 与 MaxAge 契约; +- 让 Content-MD5 与现代 SDK checksum 校验不依赖 Reader 分块方式; +- 保留第一条完全匹配规则的语义; +- 返回兼容 S3 且对浏览器安全的成功预检与实际请求 Header; +- 通过 Parser、Validate、签名 Handler 与真实客户端测试让每个变化可审阅; +- 保持 exported compatibility manifest 不变。 + +### 非目标 {#non-goals} + +- 修改 site-replication 交付、tombstone、heal 或状态计数; +- 重构全局 CORS fallback 或 metadata 错误处理; +- 增加 Console UI; +- 引入通用 XML schema 框架; +- 校验任意 XML 属性或强制唯一 namespace 写法; +- 在缺少更强当前证据时强制跨规则 ID 唯一; +- 重构无关 Lifecycle、Tagging、Policy、SSE、Quota 或 Versioning parser; +- 在本工作项中 commit、push、tag、发布镜像、部署或宣称生产一致。 + +## 方案比较 {#alternatives} + +### A. 只修报告的三行 {#alternative-three-lines} + +可以补 EOF 检查、改用 rune count、删除 Method 大写转换。这个方案很诱人,但并不完整:Unknown Element、重复 singleton、空数值、int32 overflow、通用 `?` wildcard、依赖 Reader 的 checksum 校验,以及错误的 wildcard/preflight 响应仍然存在。 + +**否决:** 对已经明确要求核验的 B3 契约过窄。 + +### B. 把输入规范化成 canonical 配置 {#alternative-normalize} + +服务端可以把 Method 转成大写、trim value、丢弃 unknown element,只保留第一个 XML root。对友好客户端很方便,却会把无效的已签名 wire 输入变成另一份有效配置,也会保留无法通过 `GetBucketCors` 干净 round-trip 的字节。 + +**否决:** S3 兼容要求校验,而不是静默修复。 + +### C. 增加 B3 专用 strict wire 表示 {#alternative-strict-wire} + +解码到私有 XML wire struct,捕获直接文本、unknown element、重复 singleton 与 MaxAge presence。只有 XML shape 合法后才转换成现有公开 `Config` 与 `Rule`;语义检查继续留在 `Validate` 和 matcher。 + +**采用:** 在有证据处严格、元素顺序无关、namespace 宽容、局限于 CORS,且不增加 exported compatibility symbol。 + +### D. 校验所有可能 XML 与 Header 细节 {#alternative-full-schema} + +这会强制 namespace URI、拒绝所有 unknown attribute、按 RFC token 校验所有响应 Header,并强制跨规则 ID 唯一。 + +**暂不采用:** 这些约束缺少足够差分证据,可能制造无必要不兼容。 + +## 最终设计 {#design} + +### 1. XML wire parser {#parser} + +`ParseBucketCorsConfig` 解码到私有 wire-only 类型: + +- `CORSConfiguration` 是唯一 root; +- root 与 rule 层拒绝非空白直接字符数据; +- 拒绝 unknown root、rule 与 leaf 内嵌元素; +- 每条规则最多一个 `ID` 与 `MaxAgeSeconds`; +- 列表成员仍可重复,元素顺序不受限制; +- MaxAge 文本必须能解析为有符号 32 位整数; +- root 关闭后允许空白、Comment 与 Processing Instruction;拒绝另一个 root、文本、Directive 或 malformed token。 + +Namespace prefix 与标准 namespace 声明仍然可用,因为匹配使用 XML local name;本次不新增 unknown attribute 拒绝。现有 `Config` 与 `Rule` XML tag 为序列化兼容继续保留,但生产请求和 metadata 解析使用 `ParseBucketCorsConfig`。 + +这个 parser 也用于加载已保存 bucket metadata。这是明确的发布前选择:PR #71 之后没有 SILO tag,因此不存在已经发布的桶级 CORS 配置群需要迁移。曾经保存 malformed CORS XML 的开发版本会让整个 bucket metadata record 无法加载,而不只是 CORS view;必须先替换或删除已保存的 CORS 文档。 + +### 2. 语义校验 {#validation} + +`Validate` 执行: + +- 1 到 100 条规则; +- ID 是有效 UTF-8,且不超过 255 个 Unicode code point; +- 每条规则至少一个非空 AllowedOrigin 和一个 Method; +- Method 必须精确等于 `GET`、`PUT`、`HEAD`、`POST` 或 `DELETE`; +- AllowedOrigin 与 AllowedHeader 不得包含 `?`,因为继承 matcher 会把它当 wildcard,而 S3 只定义 `*`; +- 每个 AllowedOrigin 与 AllowedHeader 最多一个 `*`; +- AllowedHeader 与 ExposeHeader 元素非空; +- MaxAgeSeconds 位于 0 到 `2^31-1`。 + +空 ID 继续允许,因为 ID 本身可选,当前 AWS 文档也没有发布非空约束;跨规则 ID 唯一仍不在本补丁范围。 + +### 3. 匹配 {#matching} + +本路径用小型单 `*` matcher 替代通用 matcher: + +```text +没有 * -> 精确匹配 +一个 * -> Prefix 与 Suffix 都必须匹配;* 可以匹配零字节 +``` + +AllowedHeader 匹配继续忽略大小写,响应保留请求 Header 原始拼写。S3 PUT 路径会校验 canonical 保存值,因此 Method 匹配按大小写精确进行;合并基线中的直接 site-replication 与 heal 写入会绕过该校验,它们仍是独立集成要求,否则可能保存一条 B3 matcher 不会执行的方法。 + +`MatchPreflight` 会越过 Origin 与 Method 匹配、但拒绝某个请求 Header 的规则;最终选中的因此是满足三项文档条件的第一条规则。它还返回真正命中的 Origin 元素与 MaxAgeSeconds 是否出现,从而保留“缺失”与“显式 0”的差别。 + +### 4. 请求大小与 checksum {#checksums} + +Handler 保留现有正 Content-Length 与 64 KiB guard。`validateLengthAndChecksum` 继续用共享 checker 包装 body,但 CORS handler 现在把包装后的 body 读到 EOF,不再在外面套另一个长度恰好的 `LimitReader`。 + +这样无论底层 Reader 是在同一次调用中返回最后字节与 `io.EOF`,还是下一次调用才返回 EOF,checksum 校验都一致。格式合法但内容不匹配的 Content-MD5 或 full-header SDK checksum 返回 `BadDigest`;缺少 checksum material 继续返回现有 required-checksum 错误。共享 helper 可能把 malformed checksum syntax 归类为 missing,本小 body 路径也不实现 aws-chunked trailing-checksum 解码;这些 fidelity 缺口保持在 B3 之外。不新增第二套 checksum 实现。 + +现代 boto3 流量是实质兼容门,因为当前 botocore 对这个 required-checksum 操作发送的是 `x-amz-sdk-checksum-algorithm: CRC32` 与 `x-amz-checksum-crc32`,而不是 Content-MD5。 + +### 5. 浏览器响应 {#responses} + +命中的 Origin 元素决定响应: + +| 命中元素 | `Access-Control-Allow-Origin` | `Access-Control-Allow-Credentials` | +| --- | --- | --- | +| `*` | `*` | 不发送 | +| `null` | `null` | `true` | +| 精确 Origin | 请求 Origin | `true` | +| `https://*` 等 pattern | 请求 Origin | `true` | + +成功预检返回: + +- 命中规则的完整 `AllowedMethods` 列表; +- 规则允许且请求实际提出的 Header; +- 配置的 `ExposeHeaders`; +- MaxAgeSeconds,包括显式 0; +- 现有成功预检的三个 `Vary` 维度。 + +实际请求保持现有 continue-through 行为;规则匹配时增加 Origin、credentials、Expose 与 `Vary: Origin` Header。请求 context marker 会阻止内层旧 forwarding middleware 把明确允许的 `null` Origin 改写成 `*`;没有 marker 的全局响应继续保留历史 workaround。由于 sandbox document 与 `file://` Origin 共享 `null`,只有在确实要允许所有这些上下文携带 credentials 时才应配置它。 + +AllowedOrigin 元素按文档顺序求值。如果同一规则同时包含具体 Origin 与 `*`,而具体 Origin 需要保留反射 Origin + credentials 语义,应把具体 Origin 放在前面。 + +拒绝预检在 B3 中继续返回现有空 body 403。生成完整 AWS `AccessForbidden` XML,以及调整拒绝响应缓存/audit 行为,需要独立 wire 决策,不能偷偷混入 parser 加固。 + +## 实现映射 {#implementation} + +| 区域 | 文件 | 职责 | +| --- | --- | --- | +| Parser 与 Validate | `internal/bucket/cors/cors.go` | 私有 wire struct、严格 trailing token、rune/enum/wildcard/MaxAge 校验、匹配 | +| Parser 测试 | `internal/bucket/cors/cors_test.go`、`cors_adversarial_test.go` | Root、XML Misc、unknown/nested/duplicate、边界与匹配 | +| PUT Handler | `cmd/bucket-cors-handlers.go` | 大小/checksum guard、EOF 消费、S3 error mapping | +| 签名 Handler 测试 | `cmd/bucket-cors-adversarial_test.go` | 三个报告用例、64 KiB、100 rules、MD5/CRC32 正反例 | +| 浏览器响应 | `cmd/api-router.go`、`cmd/generic-handlers.go` | 命中 Origin 语义、`null` marker、完整 Method、Expose、显式 MaxAge 0 | +| 响应测试 | `cmd/bucket-cors-middleware_test.go` | 精确/pattern/wildcard/`null` Origin、第一条完全匹配规则、Header、Method、Expose、MaxAge、credentials | + +任何 site-replication 源文件都不属于本实现边界。 + +## 测试与证据矩阵 {#tests} + +| 层次 | 必须证据 | +| --- | --- | +| Parser | 拒绝第二 root/文本/悬空关闭;接受 trailing 空白/Comment/PI;拒绝 unknown/nested/duplicate | +| Validate | 255 个 Unicode 字符接受、256 拒绝;拒绝小写与不支持 Method;wildcard 与空值用例 | +| 边界 | 恰好 64 KiB 与 100 rules 接受;多一个字节/规则拒绝;MaxAge 缺失/0/负数/int32 overflow | +| 签名 Handler | 三个报告失败的原始 SigV4 PUT;缺失/错误 MD5;有效/错误 SDK CRC32 | +| Middleware | 第一条完全匹配规则;wildcard、pattern 与 `null` credentials;无 marker 的旧 `null` rewrite;完整 Method;请求 Header;Expose;显式 MaxAge 0 | +| 定向 race | race detector 下的 CORS package 与 CORS Handler/Middleware 测试 | +| 完整本地门 | 无 tag 与 `kqueue,dev` 完整 `cmd`;build;vet;固定 lint;generated/rebrand;diff check | +| 真实客户端 | `minio-go` v7.3.1 PUT/GET/preflight/DELETE;boto3/botocore CRC32 PUT/GET/preflight/DELETE 与对抗拒绝 | +| 外部行为 | 对公开 AWS bucket 的只读 OPTIONS,确认 wildcard、Methods、credentials 与 Vary | + +## 对抗评审裁决 {#review} + +Claude Code Opus 5 以 max effort 依次评审证据、实现,以及这份双语设计与最终代码。较早的实现评审结论为 **GO**;发布前终审结论为 **GO WITH FIXES**,没有 P0/P1,共有五项 P2 finding。纳入接受的代码与文档修改后,同一会话给出 **GO**,没有 P0–P2 finding。 + +其非阻断意见经过独立裁决: + +- 唯一行为 P2 已接受:实际请求中明确允许的 `Origin: null` 现在能穿过内层旧 forwarding middleware; +- metadata load 影响范围与 replication 校验例外现在已精确说明; +- 返回内部 MaxAge pointer 仅供读取,且选中 Rule 原本就是内部 pointer;没有新增写入; +- 保留 `BadDigest`,因为当前 AWS S3 错误参考明确把它用于 Content-MD5 或 checksum 不匹配; +- malformed checksum syntax、trailing-checksum 解码、no-match `Vary`、完整 `AccessForbidden` XML 与外层 Middleware audit 行为被记录,但保持在 B3 之外; +- 不启用非 UTF-8 XML declaration,因为 S3 请求语法是 UTF-8,当前 SDK 也生成 UTF-8; +- Method 空白保持无效,Integer 空白继续接受,符合不同 XML lexical domain; +- 在缺少 AWS 差分证据时,延后用 RFC token 校验每个 ExposeHeader。 + +## 兼容性与上线 {#compatibility} + +| 现有用法 | 影响 | +| --- | --- | +| Typed `minio-go` 或 boto3 CORS | 合法配置继续 round-trip;现代 CRC32 请求得到校验 | +| 原始合法 XML | 在相同大小与规则上限内继续接受 | +| 小写 Method | 不再规范化,直接拒绝 | +| 255 个非 ASCII ID 字符 | 现在接受;超过 255 拒绝 | +| 第二 root、unknown element、重复 singleton、空/overflow MaxAge | 作为 malformed XML 拒绝 | +| 字面 wildcard Origin | 现在返回 `*` 且不带 credentials | +| Pattern Origin | 继续反射具体请求 Origin 并带 credentials | +| 含 malformed CORS XML 的旧开发 metadata | 整个 bucket metadata record 可能无法加载,直到替换或删除 CORS XML | +| Site replication | B3 不改代码;其收敛修复与测试保持独立 | + +严格化发生在任何带桶级 CORS 的 SILO tag 之前,这就是兼容窗口。一旦发布,该 wire 契约即成为稳定接口;今后放宽或收紧都必须另有差分证据。 + +## 验证结果与剩余门槛 {#gates} + +最终本地实现通过: + +- 定向 Parser、Validate、签名 Handler 与 Middleware 测试; +- `internal/bucket/cors` 与 CORS `cmd` 路径的定向 race; +- `go test ./cmd -count=1` 与完整 `kqueue,dev` `cmd` lane; +- `go build ./...` 与 `go vet ./...`; +- golangci-lint 2.13.1,零 issue; +- generated、compatibility/rebrand、entrypoint 与 diff check; +- 对新构建本地 server 执行真实 boto3/botocore 1.43.58 与 `minio-go` v7.3.1 回归; +- Claude Code Opus 5 max effort 最终复审。 + +这些结果只建立 **B3 IMPLEMENTATION GO**。整体发布仍被独立 replication 工作、服务端/文档 commit 与 push、PR 与 merged-main CI、release artifact、部署和生产验证分别阻断。 + +## 结论 {#conclusion} + +最终 B3 设计把 Bucket CORS 当作已签名 S3 wire 契约,而不是宽容的配置文件。它在持久化前拒绝 malformed 或非 canonical 输入,按字符统计 ID,校验现代 SDK checksum,保留文档定义的第一条完全匹配规则,并生成对浏览器安全的 S3 响应。 + +补丁只涉及 CORS Parser、Validate、Handler、Matcher、响应代码与测试。不增加新服务、schema、依赖、exported compatibility symbol 或 site-replication 重构。这是协议和真实证据支持的最小完整方案。