fix(cf-workers): bypass Cloudflare cache for HEAD subrequests#95
Merged
Conversation
The Worker forwards object reads to the upstream store via a presigned URL signed for the request's HTTP method. Cloudflare's subrequest cache only operates on GET, and for a URL it treats as a cacheable static asset (a key whose extension is in CF's default list, e.g. .dmg/.tif/.zip/.gif) it rewrites an outbound HEAD into a GET. That rewritten GET fails SigV4 against the HEAD-signed presigned URL, so the store returns 403 SignatureDoesNotMatch: a HEAD on `foo.dmg` 403s while the same HEAD on `foo` (no extension) and all GETs succeed. The existing cache bypass was gated on a Range header, so HEAD requests (which carry no Range) fell through to the cached path. Add `ForwardRequest::should_bypass_cache()` (HEAD or Range) and gate the existing `RequestCache::NoStore` on it. Plain full-object GET stays cacheable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
🚀 Latest commit deployed to https://multistore-proxy-pr-95.development-seed.workers.dev
|
This was referenced Jun 27, 2026
alukach
added a commit
that referenced
this pull request
Jun 27, 2026
Guards the Cloudflare HEAD->GET cache-rewrite bug fixed in #95: for a key whose extension is in CF's cacheable static-asset set (.tif/.dmg/.zip/...), the edge rewrote the Worker's outbound HEAD into a GET, which failed SigV4 against the HEAD-signed presigned URL (403 SignatureDoesNotMatch). PUTs a `.tif` object to the writable real-S3 bucket and asserts HEAD returns 200. Only reproduces against the real Cloudflare edge, so it lives in the smoke suite (MinIO has no edge cache); gated on SMOKE_WRITE_BUCKET like the multipart-checksum regression. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
alukach
added a commit
to source-cooperative/data.source.coop
that referenced
this pull request
Jun 27, 2026
0.6.2 includes the Cloudflare HEAD subrequest cache-bypass fix (developmentseed/multistore#95), which stops the edge rewriting HEAD->GET on cacheable-extension keys (e.g. a HEAD on *.dmg returning 403 SignatureDoesNotMatch and the app rendering such files as empty directories). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The Cloudflare Workers backend forwards object reads to the upstream store via a presigned URL signed for the request's HTTP method. Cloudflare's subrequest cache only operates on
GET, and for a URL it classifies as a cacheable static asset (a key whose extension is in CF's default list —.dmg,.tif,.zip,.gif,.pdf,.csv,.js, …) it rewrites an outboundHEADinto aGET. That rewrittenGETthen fails SigV4 against theHEAD-signed presigned URL, so the store returns403 SignatureDoesNotMatch, which the Worker relays.Symptom: a
HEADon a key with a cacheable extension (e.g.foo.dmg) returns 403, while aHEADon the same object without an extension (foo) and allGETs succeed. Reproduced against AWS S3; a direct-to-AWSHEADreturns 200 (the object is healthy), confirming the rewrite happens at the Cloudflare edge.Evidence
x-amz-request-id+Content-Type: application/xml→ AWS produced it, and it received a GET (XML error bodies come from GET, not HEAD)..csvfails;.txt/.json/.html/.xml/no-extension pass).CF-Cache-Status: BYPASSon the failing path vsDYNAMICon the passing path.Root cause
WorkerBackend::forwardalready bypassed the cache (RequestCache::NoStore) — but only when aRangeheader was present.HEADrequests carry noRange, so they fell through to the cached path and got rewritten.Fix
Add
ForwardRequest::should_bypass_cache()(true forHEADor anyRangerequest) and gate the existingNoStoreon it. Plain full-objectGETstays cacheable so the edge can still serve public objects.Tests
should_bypass_cache_predicate(incore, runs under hostcargo test) covers HEAD, ranged GET/HEAD, plain GET, and PUT. The predicate lives onForwardRequestincorerather than inline incf-workersbecausecf-workersis wasm-only and excluded from the hostcargo test/cargo checkjobs.Verification
cargo test -p multistore— predicate tests pass.cargo check -p multistore-cf-workers --target wasm32-unknown-unknown— compiles.cargo fmt --check,cargo clippy -p multistore -- -D warnings— clean.HEADon a.dmg/.gifkey returns 200.🤖 Generated with Claude Code