fix(core): byte-faithful, consistently validated object keys across all backend paths#108
Conversation
Multipart operations (CreateMultipartUpload, UploadPart, Complete, Abort) build their backend URL by splicing the decoded object key into a string. For key characters that url::Url leaves literal in paths but that are outside the RFC 3986 unreserved set (`=`, `!`, `(`, `)`, `:`, `@`, ...), the request went out — and was signed — with the literal byte, while S3/MinIO reconstruct the SigV4 canonical URI by strict-encoding the decoded path. The signatures never matched, so multipart uploads to Hive-style partition keys (`country_iso=ETH/...`) failed with 403 SignatureDoesNotMatch at CreateMultipartUpload. Encode the assembled prefix+key with the SigV4 strict set (unreserved chars, `/` kept as separator) before splicing. The URL string is both the signing input and the wire bytes, so the two stay byte-identical on any backend. Presigned CRUD ops already encode this way via object_store's STRICT_ENCODE_SET, which is why single PUT/GET on such keys worked. Also syncs Cargo.lock with the 0.6.3 release version bump. Reported downstream as source-cooperative/data.source.coop#180. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comments described the pre-fix state ('already work', 'pre-existing');
reword to describe the invariant instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Path::from percent-encodes characters object_store deems unsafe (*, %, ~, #, ...) into the logical path, so presigned CRUD silently renamed such objects on the backend (a*.bin stored as a%2A.bin) while the raw-signed multipart path stores the true key. Consequences: multipart- written keys were unreadable through GET, listings showed names that 404 on fetch, and 100%.txt / 100%25.txt could alias to one backend object — serving wrong content with a 200. Use Path::parse in build_object_path: byte-faithful, with object_store encoding the wire URL exactly once. Keys with empty or relative segments (a//b, a/../b), which Path::from silently collapsed to a different key, now return 400 InvalidRequest. Objects stored under mangled names by earlier versions keep their mangled backend names and must be addressed accordingly (or renamed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
🚀 Latest commit deployed to https://multistore-proxy-pr-108.development-seed.workers.dev
|
… URLs (#109) Add a contract test asserting the three backend URL builders — the authenticated presigned signer, the anonymous UnsignedUrlSigner, and the raw-signed build_backend_url — emit byte-identical wire paths that percent-decode back to the logical key, over a corpus covering every character class that has diverged before (=, spaces, *, %, ~, #, unicode, literal %3D). If an object_store upgrade shifts its path encoding, this is the loud alarm. The test immediately caught a third instance of the #105 bug class: UnsignedUrlSigner spliced the raw key with no encoding, so anonymous URLs carried literal key bytes — a key holding %3D decodes to = on the backend, and a # truncates the path as a URL fragment. Encode with the same strict set as the signed builders. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
- drop dead `let key = key.as_str()` rebinding in UnsignedUrlSigner - decode one builder in the contract's decode test; byte-equality test pins the other builders to it - fix stale multipart-matrix comment claiming the presigned path still rewrites INVALID-set chars (this PR removes that behavior) - doc build_object_path's residual leading/trailing-slash stripping Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reject keys with empty, `.`, or `..` path segments (including leading/trailing slashes) or ASCII control characters with 400 InvalidRequest at operation-parse time (build_s3_operation), for every keyed operation. The presigned path already rejected interior degenerate segments via Path::parse but silently stripped leading/trailing slashes (a DELETE of `dir/` deleted `dir`); the raw-signed multipart path accepted all of them — writing objects the presigned path can't address, breaking listings that cover them (object_store fails parsing listed keys with empty segments), and letting a literal `..` reach URL normalization, which on non-WHATWG-normalizing runtimes retargets the signed backend request across buckets. build_backend_url enforces the same rule as a backstop for hand-built operations. Batch-delete body keys stay exempt: they never enter a URL path, and permissiveness there is the remediation route for legacy degenerate keys. Documented in docs/reference/operations.md alongside the byte-faithfulness guarantee and the pre-0.6.4 mangled-name migration note. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
📖 Docs preview deployed to https://multistore-docs-pr-108.development-seed.workers.dev
|
|
Claude finished @alukach's task in 3m 22s —— View job ✅ No blocking issues — safe to merge. Reviewed the full diff ( Traced the full key lifecycle: decode at the edge (
No correctness bugs, security issues, or best-practice problems found. Simplify (ponytail)
💰 Estimated review cost: $1.31 · 3m21s · 35 turns |
Problem
The proxy builds backend request paths in three places, and they disagreed on what a logical key means:
build_object_path) usedobject_store::path::Path::from, which percent-encodes characters object_store deems unsafe (*,%,~,#,?,[,], ...) into the logical path, silently renaming objects on the backend. Verified live against MinIO:star*.txtcreates backend objectstar%2A.txt— a silent rename invisible to the uploader (self-round-trip works) but visible in listings, to direct bucket consumers, and to any list-then-fetch client (404).mp*.txt(true key, post-fix(core): percent-encode object keys in raw-signed backend URLs #105) is unreadable and undeletable through the presigned path (GET → NoSuchKey, DELETE → silent 204 no-op).100%.txtand100%25.txtcan alias to the same backend object: a GET then returns the wrong file's contents with a 200.UnsignedUrlSigner, used for credential-less backends) spliced the raw key into a URL string with no encoding: a key holding a literal%3Ddecoded to=on the backend, and once keys are byte-faithful,#/?would truncate the wire path at the fragment/query boundary.build_backend_url) accepted degenerate keys the other paths can't address:a//b.txtwritten via multipart is unreadable and undeletable via presigned and breaks every listing page that covers it (object_store fails parsing listed keys with empty segments), and a literal..segment survives tourl::Url::parsenormalization — on runtimes that don't WHATWG-normalize request paths (e.g. the axum server example; CF Workers normalizes and is safe), that retargets the signed backend request across buckets.Fix
build_object_path:Path::from→Path::parse. Byte-faithful; object_store's URL builder then percent-encodes the raw path exactly once at the wire boundary.UnsignedUrlSigner: percent-encode the key with the same strict set the signed builders use (sharedS3_PATH_ENCODE_SET).validate_key, called once inbuild_s3_operationfor every keyed operation: keys with empty,., or..segments (including leading/trailing slashes, e.g.dir/folder markers) or ASCII control characters return400 InvalidRequestinstead of being silently rewritten (Path::fromcollapseda//b→a/b;Path::parsestripsdir/→dir, so a DELETE ofa/deleteda) or written where other paths can't address them.build_backend_urlenforces the same rule as a backstop for hand-built operations. Real S3 accepts such keys; the proxy is deliberately stricter — documented indocs/reference/operations.md. Batch-delete body keys are exempt: they never enter a URL path, and permissiveness there is the remediation route for legacy degenerate keys.All three builders now emit byte-identical wire paths that percent-decode back to exactly the logical key — and agree on the rejected set.
Tests
crates/core/tests/key_encoding_contract.rs): for a corpus covering every character class that has diverged before (=from fix(core): percent-encode object keys in raw-signed backend URLs #105, spaces,*/%/~/#, unicode, literal%3D), all three builders emit byte-identical wire paths that decode back to the logical key. Loud alarm if an object_store upgrade changes its encoding.InvalidRequestfor every keyed operation; hand-built degenerate operations can't reach a backend URL.100%.txtvs100%25.txtstay distinct objects; multipart write + presigned GET agree on a*key;a//banddir/→ 400 on both single PUT and multipart create. (..segments are collapsed by WHATWG URL parsing at the CF Workers edge before the proxy sees them; the unit tests cover those.)star%2A.txt) keep those literal backend names: after this change they're addressable by that literal name rather than the original logical one. If any production data contains keys with the affected characters, a one-time rename sweep is needed. The affected character set is rare in real keys, and multipart uploads never produced mangled names (pre-fix(core): percent-encode object keys in raw-signed backend URLs #105 they failed outright for every affected character except~, whose keys were stored byte-faithfully), so the exposure is bounded to presigned single PUTs.., or..segments, leading/trailing slashes (dir/folder markers), or control characters now return400 InvalidRequeston every keyed operation. Real S3 accepts these keys; previously the proxy either silently rewrote them to a different key or wrote objects its other paths couldn't address. Batch delete still accepts them in its body, as the remediation path.🤖 Generated with Claude Code