Supported Operations
S3 Operations
| Operation | HTTP Method | Dispatch | Description |
|---|---|---|---|
| GetObject | GET /{bucket}/{key}[?versionId=ID] | Forward | Download a file (optionally a specific version) |
| HeadObject | HEAD /{bucket}/{key}[?versionId=ID] | Forward | Get file metadata (optionally for a specific version) |
| PutObject | PUT /{bucket}/{key} | Forward | Upload a file |
| CopyObject | PUT /{bucket}/{key} + x-amz-copy-source | Response | Server-side copy within one S3 endpoint |
| DeleteObject | DELETE /{bucket}/{key} | Forward | Delete a file |
| DeleteObjects | POST /{bucket}?delete | NeedsBody | Batch-delete up to 1000 keys (aws s3 rm --recursive, delete_objects) |
| ListBucket | GET /{bucket} | Response | List objects in a bucket (ListObjectsV1 and V2) |
| ListBuckets | GET / | Response | List all virtual buckets |
| CreateMultipartUpload | POST /{bucket}/{key}?uploads | NeedsBody | Initiate a multipart upload |
| UploadPart | PUT /{bucket}/{key}?partNumber=N&uploadId=ID | NeedsBody | Upload a part |
| CompleteMultipartUpload | POST /{bucket}/{key}?uploadId=ID | NeedsBody | Complete a multipart upload |
| AbortMultipartUpload | DELETE /{bucket}/{key}?uploadId=ID | NeedsBody | Abort a multipart upload |
Dispatch Types
- Forward — A presigned URL is generated and returned to the runtime, which executes it with its native HTTP client. Bodies stream directly between client and backend without buffering.
- Response — The handler builds a complete response (XML for LIST, error responses) and returns it. No presigned URL involved.
- NeedsBody — The runtime collects the request body, then the handler signs and sends the request via raw HTTP (
backend.send_raw()). Used by multipart and batch delete.
Batch delete authorization
DeleteObjects carries its keys in the request body, so authorization happens in two stages: the bucket-level check confirms the caller may delete something in the bucket, then each key in the body is authorized individually against the caller's allowed prefixes. Keys the caller is not permitted to delete are returned as per-key AccessDenied entries in the DeleteResult (S3's partial-result semantics) and are never forwarded to the backend; authorized keys are deleted regardless. Anonymous callers cannot batch-delete.
Server-side copy
CopyObject is a PUT /{bucket}/{key} carrying an x-amz-copy-source: /{src-bucket}/{src-key}[?versionId=…] header. The proxy resolves and authorizes both ends — the source as a read (GetObject) and the destination as a write (PutObject) — then delegates the copy to the backend: a signed PUT to the destination key carries x-amz-copy-source rewritten into the source's backend bucket/key space. The backend's CopyObjectResult XML (and any error, including S3's "error embedded in a 200 OK" case) is passed straight through. Copy-relevant client headers are forwarded and signed: x-amz-metadata-directive, x-amz-tagging-directive, x-amz-tagging, x-amz-acl, x-amz-storage-class, x-amz-website-redirect-location, x-amz-meta-*, x-amz-server-side-encryption*, and the x-amz-copy-source-if-* preconditions.
Same-endpoint only. A native S3 copy needs one endpoint that can read the source and write the destination, so source and destination must resolve to the same S3 endpoint and region (the backend bucket names may differ, so cross-bucket copies work). A copy across endpoints, or from a backend without a bucket_name, is rejected with 501 NotImplemented. UploadPartCopy (a copy-source PUT with uploadId/partNumber) is likewise not supported.
Credentials are not compared between the two ends. The copy is signed with the destination's credentials and the source contributes only its bucket name and prefix, so the source's own credentials are never used; whether the destination's credentials may read the source is an IAM question S3 answers with AccessDenied. (Comparing them would also be meaningless: a credential-injecting middleware such as AwsBackendAuth runs only against the destination config, so a source resolved from the same auth_type=oidc connection could never match.) The caller's own authorization is unaffected — the source is separately authorized as a read.
Versioned sources are authorized as versioned reads. An x-amz-copy-source may carry ?versionId=, and that version is copied. The version is carried on the synthetic GetObject used to authorize the source (S3Operation::GetObject::version), which makes the authorization action get_object_version rather than get_object — mirroring S3's own split between s3:GetObject and s3:GetObjectVersion. This is not merely advice to registry implementors: the bundled policy (auth::authorize, which multistore-static-config calls) enforces it, because a version-scoped read resolves to its own action. A caller granted get_object over a prefix therefore cannot read — or copy out — an older version of an object in it, and an anonymous caller never can; the scope must list get_object_version explicitly. Prefix-scoped policy cannot express "may read version X", so the default is to deny rather than silently permit. A registry with its own policy should draw the same distinction. The same field carries a plain read's version — see "Version-scoped reads" below.
Path-mapped deployments must map the copy-source. CopyObject names its source in a header, so a proxy that rewrites client paths onto internal bucket names must rewrite the copy-source too — otherwise the source resolves as a client-facing name the registry has never heard of and every copy fails with 404 NoSuchBucket. The client signed that header, so it must not be mutated; pass the mapped value alongside it via RequestInfo::with_copy_source, and signature verification keeps using the header as sent. PathMapping::rewrite_copy_source produces the mapped value:
let mapped = headers
.get("x-amz-copy-source")
.and_then(|v| v.to_str().ok())
.and_then(|v| mapping.rewrite_copy_source(v));
let req = RequestInfo::new(&method, &rewrite.path, query, &headers, None)
.with_signing_path(&rewrite.signing_path)
.with_copy_source(mapped.as_deref());Version-scoped reads
GET and HEAD honor ?versionId=, returning that version rather than the current object. The version is carried on the operation (version: Option<String>, None meaning current), so it reaches the registry and authorization sees the version that will actually be read.
Such a read authorizes as get_object_version, not get_object — a caller granted ordinary read access on a prefix cannot reach previous versions of objects in it without that action, and anonymous callers never can. See "Server-side copy" above and the action table in roles.
Such a read is signed with an Authorization header rather than presigned. The query string is part of a presigned request's canonical form, so versionId has to be present when the signature is computed — and the Signer interface presigns a bare path with no query, so appending it afterwards would invalidate the signature. The runtime streams the response identically either way. Client read headers (Range, If-*) are attached after signing and travel unsigned, matching the presigned path: SigV4 only requires host and x-amz-* to be signed, and a runtime that normalizes a forwarded Range would otherwise break a signature covering it.
An empty ?versionId= is treated as absent. Object versioning is an S3 concept, so a version-scoped read of a non-S3 backend returns 501 NotImplemented rather than silently serving the current object.
Writes and request headers
PutObject forwards the request body plus standard HTTP entity headers (Content-Type, Content-Disposition, Content-Encoding, Content-Language, Cache-Control, Expires, Content-MD5) and the conditional-write preconditions (If-Match, If-None-Match) to a presigned URL. S3 applies all of these even though they are not part of the (host-only) presigned signature. x-amz-* headers (user metadata x-amz-meta-*, storage class, tagging, ACLs, SSE, checksum headers such as x-amz-checksum-*, and the x-amz-copy-source-if-* copy preconditions) are not forwarded: S3 rejects unsigned x-amz-* headers on presigned requests, and the proxy presigns over host only. Supporting those headers requires a header-signing forward path — see the design note in .plans/.
Conditional writes. If-Match / If-None-Match are enforced by the backend: a write with a stale or wrong ETag fails with 412 Precondition Failed (and If-None-Match: * fails when the object already exists) instead of silently clobbering. This is the compare-and-swap that native Zarr/Icechunk writers rely on to protect refs from concurrent commits, and it holds across every write path that produces an object:
PutObject, presigned path — plain bodies; preconditions forwarded unsigned (S3 applies them anyway).PutObject,aws-chunkedstreaming path — what AWS SDKs and the CLI use by default; preconditions forwarded and signed via the header-signing re-sign.CompleteMultipartUpload— the request that materializes a multipart object; preconditions forwarded (and signed, like all headers on the raw multipart path), so large multipart writes get the same guarantee.
Either way the backend's 412 passes through unchanged.
STS Operations
Handled by an STS closure (registered on the Router via StsRouterExt).
| Operation | HTTP Method | Description |
|---|---|---|
| AssumeRoleWithWebIdentity | POST /?Action=AssumeRoleWithWebIdentity&... (params in the query string or a form-encoded body, as AWS SDKs send them) | Exchange OIDC JWT for temporary credentials |
OIDC Discovery Endpoints
Handled by OIDC discovery closures (registered on the Router via OidcRouterExt). Served when OIDC_PROVIDER_KEY and OIDC_PROVIDER_ISSUER are configured.
| Endpoint | Method | Description |
|---|---|---|
/.well-known/openid-configuration | GET | OpenID Connect discovery document |
/.well-known/jwks.json | GET | JSON Web Key Set (proxy's RSA public key) |
Limitations
WARNING
- Multipart and batch delete are S3 only — Both use raw HTTP with
S3RequestSignerand are gated tobackend_type = "s3". Non-S3 backends should use single PUT/DELETE requests. - DeleteObject does not return confirmation — The proxy forwards the DELETE and returns the backend's response status.
- Server-side copy is same-endpoint only —
CopyObjectworks when source and destination resolve to the same S3 endpoint and region (see "Server-side copy" above). A copy across endpoints, orUploadPartCopy, is rejected with501 NotImplemented. - Path-mapped proxies must map the copy-source —
CopyObjectnames its source in a header, which no URL rewrite touches. Pass the mapped value viaRequestInfo::with_copy_sourceor every copy fails with404 NoSuchBucket(see "Server-side copy" above). x-amz-*write headers are dropped — Object metadata, storage class, tagging, ACLs, SSE, and checksum headers on writes are not forwarded (see "Writes and request headers" above).- Versioned/MFA delete is not handled — A
?versionId=on a delete is ignored; the current object version is deleted. Reads (GET/HEAD) do honor it — see "Version-scoped reads" above. - Degenerate object keys are rejected — Keys containing empty,
., or..path segments (including leading/trailing slashes, e.g.dir/folder markers), or ASCII control characters, return400 InvalidRequeston every keyed operation. Real S3 accepts such keys; the proxy is deliberately stricter because they cannot be addressed consistently across its presigned and raw-signed backend paths. Batch-delete body keys are exempt, as the remediation route for legacy keys already stored under such names. - Keys are otherwise byte-faithful — All other keys (including
*,%,~,#, unicode) are stored on the backend exactly as sent. Objects written through versions before 0.6.4 via single presigned PUT with characters in object_store's rewrite set (*,%,~,#,?,[,], ...) were silently stored under percent-encoded names (a*.binasa%2A.bin); they remain addressable only by that literal mangled name and need a one-time rename to recover their logical keys.
Upload size on the Cloudflare Workers runtime
The Workers runtime is bounded by Cloudflare's request-body size limit — 100 MB on Free/Pro, 200 MB on Business, 500 MB (default) on Enterprise. This is a hard platform limit enforced at Cloudflare's edge: a request body larger than the plan limit is rejected with 413 before the proxy can act on it, and the proxy cannot raise it.
Consequences and guidance:
- A single
PutObjectcannot exceed the plan body limit. Upload larger objects as a multipart upload: eachUploadPartis a separate request, so only the part size must stay under the limit. With S3's 10,000-part maximum, 100 MB parts allow objects up to ~1 TB even on Free/Pro. - Configure clients to chunk below the limit. e.g. boto3
TransferConfig(multipart_threshold=…, multipart_chunksize=…)with a chunk size under the plan limit; aws-cli'ss3.multipart_chunksize. - Until streaming
UploadPartlands, parts on Workers are additionally capped by the 128 MB worker memory limit (parts are buffered in WASM). Keepmultipart_chunksizecomfortably below 100 MB. - The native server and Lambda runtimes have their own, generally higher, limits — this constraint is specific to Workers.
Surfacing a clean error. Configure the gateway with with_max_request_body_size so a body-bearing write (PutObject, UploadPart, or DeleteObjects) whose Content-Length exceeds the limit is rejected up front with S3's EntityTooLarge (HTTP 400) — an actionable error instead of Cloudflare's opaque 413. The Workers example reads this from the MAX_UPLOAD_BYTES environment variable; set it to your plan's request-body limit (e.g. 104857600 for 100 MB). The check requires a declared Content-Length; unknown-length streaming requests fall through to the platform limit.