feat(read): honor ?versionId= on GET and HEAD - #130
Conversation
|
Claude finished @alukach's task in 4m 12s —— View job Reviewing PR #130
❌ Changes requested — see findings below.
No other correctness or security issues found — authorization for the GET path is wired correctly (version reaches No ponytail (over-engineering) findings — 💰 Estimated review cost: $1.57 · 4m12s · 44 turns |
|
📖 Docs preview deployed to https://multistore-docs-pr-130.development-seed.workers.dev
|
|
🚀 Latest commit deployed to https://multistore-proxy-pr-130.development-seed.workers.dev
|
e2a2b59 to
dc04c24
Compare
dc04c24 to
47ee1fd
Compare
dbde69c to
d5f07bd
Compare
47ee1fd to
c60ac4d
Compare
A `GET`/`HEAD` carrying `?versionId=` silently returned the *current* object: the parameter was dropped at parse time and never reached the backend. Clients asking for a specific version got the wrong bytes with a 200, which is worse than an error — nothing signals the mismatch. It also left `CopyObject` as the only way to reach a non-current version, so the natural workaround was to copy an old version somewhere readable and fetch it back. - `types.rs`: `HeadObject` gains `version` to match `GetObject`. Leaving HEAD out would be a sharper inconsistency than the current uniform silence — `head_object(VersionId=…)` describing a different object than `get_object(VersionId=…)`. - `api/request.rs`: parse `versionId` for both. An empty `?versionId=` is treated as absent rather than as a version named "": S3 has no such version, so reading it as one turns a malformed request into an unsatisfiable read. - `backend/multipart.rs`: `build_backend_url` appends `?versionId=` for a version-scoped read. - `proxy.rs`: `build_versioned_read_forward` — a versioned read is signed with an `Authorization` header instead of presigned. It has to be: the query is part of a presigned request's canonical form, and the `Signer` interface presigns a bare path, so the parameter cannot be added before signing and cannot be appended after. The runtime streams the response identically. Client read headers are attached *after* signing so they stay unsigned, matching the presigned path — a runtime that normalizes a forwarded `Range` (Cloudflare may) would otherwise break a signature covering it. Non-S3 backends get a `501` rather than the current object. This inverts `plain_get_with_version_id_authorizes_an_unversioned_read` from the previous commit, which pinned the read path's version-blindness as deliberate. That reasoning held only while the read ignored versions: the invariant is that `version` describes the read that actually happens, and now it happens. The replacement test pins the new direction, and authorization sees the version. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
c60ac4d to
69aca39
Compare
What I'm changing
A
GETorHEADcarrying?versionId=silently returned the current object: the parameter was dropped at parse time and never reached the backend. A client asking for a specific version got the wrong bytes with a200— worse than an error, because nothing signals the mismatch.It also left
CopyObjectas the only operation able to reach a non-current version (the gap #129 closes on the authorization side), so the natural workaround for "read an old version" was to copy it somewhere readable and fetch it back.How I did it
crates/core/src/types.rs—HeadObjectgainsversionto matchGetObject. SupportingGETalone would be a sharper inconsistency than today's uniform silence:head_object(VersionId=…)would describe a different object thanget_object(VersionId=…)returns.crates/core/src/api/request.rs— parseversionIdfor both. An empty?versionId=is treated as absent rather than as a version named"": S3 has no such version, so reading it as one would convert a malformed request into a read that can never succeed.crates/core/src/backend/multipart.rs—build_backend_urlappends?versionId=for a version-scoped read, alongside the existing multipart query arms.crates/core/src/proxy.rs—build_versioned_read_forward: a versioned read is signed with anAuthorizationheader rather than presigned, andREAD_FORWARD_HEADERSis factored out of the two read arms.Why a versioned read can't be presigned
The query string is part of a presigned request's canonical form, so
versionIdmust be present when the signature is computed. TheSignerinterface presigns a bareobject_store::Pathwith no query, so the parameter can be neither included before signing nor appended after — appending invalidates the signature. Signing with anAuthorizationheader putsversionIdin the URL before signing instead. The runtime streams the response body identically for both kinds ofForwardRequest; this reuses the same mechanism as the existingaws-chunkedstreaming PUT re-sign.Client read headers (
Range,If-*) are attached after signing, so they travel unsigned — matching the presigned path. That is deliberate: SigV4 only requireshostandx-amz-*to be signed, and a runtime that normalizes a forwardedRange(Cloudflare may) would break a signature that covered it.Object versioning is an S3 concept, so a version-scoped read of a non-S3 backend returns
501 NotImplemented— not therequire_s3_backend400, since the request is valid S3 and it is the backend that cannot serve it.Test plan
Six tests. Verified the three that pin the dispatch change fail when the version is parsed but ignored at dispatch (the pre-change behavior):
plain_get_with_version_id_parses_as_a_versioned_read— GET and HEAD both parse a version; absent and empty both mean currentversioned_get_forwards_a_header_signed_url_carrying_the_version—versionIdin the URL,Authorizationheader present, noX-Amz-Signaturein the URLunversioned_get_still_uses_a_presigned_url— the ordinary path is untouchedversioned_read_forwards_range_unsigned—Range/If-None-Matchforwarded, absent fromSignedHeaders, and a ranged read still bypasses the full-object cacheversioned_read_of_a_non_s3_backend_is_rejected—501versioned_get_is_authorized_against_its_version— the version reaches the registry, so a version-aware policy can refuse itcargo test(full workspace, all green)cargo clippy --all-targets(clean; one pre-existing warning in an untouched test helper)cargo fmtcargo check -p multistore-cf-workers --target wasm32-unknown-unknownNotes for the reviewer
plain_get_with_version_id_authorizes_an_unversioned_read) — correct while reads ignored versions, since authorizing a version the backend would not return describes a read that never happens. The invariant is thatversionnames the read that actually occurs; this PR makes it occur, so the assertion flips. Called out because a reviewer seeing a just-added test replaced deserves the reasoning.mainif you'd rather take these independently.S3Operation::HeadObjectgains a field, asGetObjectdid in fix(copy): authorize a versioned copy-source against its version #129. Downstream custom resolvers needversion: Noneadded.?versionId=onDELETEis still ignored (documented). Versioned delete needs MFA-delete semantics and a policy story of its own.🤖 Generated with Claude Code