Skip to content

feat: forward copy-source SSE-C headers on CopyObject#123

Draft
alukach wants to merge 5 commits into
mainfrom
feat/forward-copy-source-sse-c
Draft

feat: forward copy-source SSE-C headers on CopyObject#123
alukach wants to merge 5 commits into
mainfrom
feat/forward-copy-source-sse-c

Conversation

@alukach

@alukach alukach commented Jul 22, 2026

Copy link
Copy Markdown
Member

Stacked on #121. Base is `worktree-copyobject` because `is_copy_forward_header` only exists there. Retarget to `main` once #121 merges.

What I'm changing

Same-store CopyObject (#121) forwards a client-supplied allowlist of copy-relevant headers onto the backend PUT. The allowlist matches destination SSE headers via the `x-amz-server-side-encryption*` prefix — but the headers that decrypt an SSE-C-encrypted source are named `x-amz-copy-source-server-side-encryption-customer-*`. Those start with `x-amz-copy-source-...`, so they miss that prefix and every other match arm, and get dropped before the request is signed. Copying an SSE-C source therefore 400s against real S3 (the backend can't decrypt the source without the customer key).

Surfaced as a non-blocking review observation on #121. Not a regression — the pre-#121 code rejected all copies — but worth fixing before SSE-C sources come up in anger.

How I did it

  • `crates/core/src/proxy.rs` — `is_copy_forward_header`: add `|| name.starts_with("x-amz-copy-source-server-side-encryption")` so the source SSE-C `-customer-*` headers forward and get signed. Labelled both prefixes (destination SSE vs source SSE-C) so the distinction is obvious next time.
  • `crates/core/src/proxy.rs` — test: extended `copy_object_end_to_end_sends_resigned_backend_put` to send `x-amz-copy-source-server-side-encryption-customer-algorithm` and assert it reaches the backend and is present in SignedHeaders. Confirmed it fails with the one-liner reverted and passes with it (TDD).
  • `tests/smoke/test_smoke.py` — `TestCopyObjectSseCSource`: real-S3 regression, gated by `@requires_write_bucket` (`SMOKE_WRITE_BUCKET`). Puts an SSE-C source, copies it with the `CopySourceSSECustomer*` params, reads the destination back. This lives in the smoke suite because MinIO (the integration backend) refuses SSE-C over the Docker loop's plaintext HTTP — the same "MinIO too lax to catch it" gap that already put the multipart-checksum and HEAD-rewrite regressions here.
  • `docs/reference/operations.md`: note both SSE prefixes in the forwarded copy-header list.

Test plan

  • `cargo fmt`, `cargo clippy --fix`
  • `cargo check` + `cargo check -p multistore-cf-workers --target wasm32-unknown-unknown`
  • `cargo test -p multistore copy` — 10 passed
  • TDD: unit test fails with fix reverted, passes with it
  • `python3 -m py_compile tests/smoke/test_smoke.py`
  • Live smoke run against real S3 (`SMOKE_WRITE_BUCKET=smoke-writable`) — runs in the preview workflow, not locally

🤖 Generated with Claude Code

alukach and others added 5 commits July 21, 2026 12:16
CopyObject was rejected outright (501) since 0.6.0 because the presigned
forward path drops the copy-source header and would overwrite the
destination with an empty body. Delegate the copy to the backend S3
instead: a signed PUT to the destination carries x-amz-copy-source
pointing at the source's backend bucket/key, via the same raw-signed
send_raw path batch delete uses. Native S3 copy needs one endpoint that
can read source and write destination, so this covers same-store copies
(rename, metadata edit, cross-bucket within one account); cross-store and
UploadPartCopy are rejected with a clear 501.

- types.rs: add S3Operation::CopyObject (dest bucket/key + parsed source
  bucket/key/version); PUT method, PutObject action (dest write). Source
  read is authorized separately at dispatch.
- api/request.rs: parse a copy-source PUT into CopyObject; parse_copy_source
  handles the `/bucket/key?versionId=` wire format and percent-decodes the
  key. UploadPartCopy (copy-source + uploadId) still 501s.
- proxy.rs: execute_copy resolves+authorizes the source as a read, guards
  same_backing_store (same endpoint/region/creds), builds the backend
  copy-source header (build_copy_source_header re-encodes into the source
  key space), signs and sends the PUT, and passes the CopyObjectResult XML
  straight through. Copy-relevant client headers are forwarded and signed.
- route_handler.rs: box HandlerAction::NeedsBody — the wider S3Operation
  pushed the enum past clippy's large-variant threshold.
- docs: operations.md/errors.md now describe same-store copy, not a blanket
  rejection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An empty `versionId` (`bucket/key?versionId=`) failed the `!v.is_empty()`
match guard and fell through to the fallback arm, which used the untrimmed
string — leaving `?versionId=` glued onto the source key and silently
copying from a non-existent key instead of erroring.

Always split on the path; treat an empty version as `None`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add three tests driving the full copy path through resolve_request against
the CaptureHeadersBackend, mirroring the multipart integration test:

- copy_object_end_to_end_sends_resigned_backend_put: parse → authorize
  dest+source → same-store check → build backend copy-source → re-sign →
  send_raw, asserting the exact wire request (rewritten/encoded copy-source,
  forwarded copy-relevant headers, empty-payload hash, copy headers present
  in SignedHeaders).
- copy_object_forwards_version_id_to_backend: versionId rides through.
- cross_store_copy_is_rejected_501: different backends → 501, backend never
  contacted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The `test_copy_object_rejected` test asserted the pre-implementation
behavior (all copies → 501). Same-store copy now works, so replace it with
two tests exercised live against MinIO through the Workers gateway:

- test_copy_object_same_bucket: copy within one bucket returns 200 and the
  destination has the source bytes.
- test_copy_object_cross_bucket_same_store: copy across two virtual buckets
  on the same backing store (public-data → private-uploads) is a native
  same-store copy and succeeds.

Cross-store rejection stays covered by the Rust unit test
(cross_store_copy_is_rejected_501); the integration env has a single backend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
To read an SSE-C-encrypted source, a CopyObject must carry the
x-amz-copy-source-server-side-encryption-customer-* headers so the backend
can decrypt it. These start with x-amz-copy-source-..., so they slipped past
the allowlist's x-amz-server-side-encryption* prefix (which only matches the
destination SSE headers) and were dropped before signing — an SSE-C source
would 400 against real S3.

- proxy.rs: add x-amz-copy-source-server-side-encryption prefix to
  is_copy_forward_header; label both SSE prefixes (dest vs source).
- proxy.rs: extend copy_object_end_to_end_sends_resigned_backend_put to send
  a source SSE-C header and assert it reaches the backend and is signed
  (fails without the fix).
- test_smoke.py: TestCopyObjectSseCSource — real-S3 regression, gated by
  SMOKE_WRITE_BUCKET. MinIO refuses SSE-C over the Docker loop's plaintext
  HTTP, so this can only run against real S3.
- docs: note both SSE prefixes in the forwarded copy-header list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

Claude finished @alukach's task in 1m 18s —— View job


Review checklist

  • Read the diff (git diff origin/worktree-copyobject...HEAD)
  • Checked is_copy_forward_header prefix logic for overlap/bypass bugs
  • Checked the unit test extension and smoke test for correctness
  • Checked docs update for accuracy
  • Ponytail pass for over-engineering

✅ No blocking issues — safe to merge.

The fix is a correct, minimal one-liner: x-amz-copy-source-server-side-encryption is a distinct prefix from x-amz-server-side-encryption, so the new starts_with arm in is_copy_forward_header (crates/core/src/proxy.rs:1689) only catches the three -customer-{algorithm,key,key-MD5} source-decrypt headers and can't shadow or double-match the existing destination-SSE arm.

Minor non-blocking notes:

  • crates/core/src/proxy.rs:3022-3026: the new auth.contains("x-amz-copy-source-server-side-encryption-customer-algorithm") assertion is a superstring of the existing auth.contains("x-amz-copy-source") check right above it, so that first condition is now logically redundant — harmless, just slightly duplicative. Not worth a fix.
  • Smoke test (tests/smoke/test_smoke.py) only runs against real S3 gated by SMOKE_WRITE_BUCKET, consistent with the PR description's stated MinIO limitation for SSE-C over plaintext HTTP — reasonable given the existing pattern for this repo's smoke suite.

No security issues: forwarding the client-supplied SSE-C customer key/algorithm to the backend is required for S3 SSE-C semantics (the key must reach S3 on every request touching that object), and this only forwards headers the client itself already sent — it doesn't originate or cache key material.

Simplify (ponytail): nothing to cut — this is already the smallest fix (one starts_with arm), and the test/doc additions are proportional to the change.


💰 Estimated review cost: $0.49 · 1m18s · 16 turns

@github-actions github-actions Bot added the feat label Jul 22, 2026
@github-actions

Copy link
Copy Markdown

📖 Docs preview deployed to https://multistore-docs-pr-123.development-seed.workers.dev

  • Date: 2026-07-22T05:03:39Z
  • Commit: 81cfbc7

Base automatically changed from worktree-copyobject to main July 22, 2026 05:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant