Skip to content

fix(cf-workers): bypass Cloudflare cache for HEAD subrequests#95

Merged
alukach merged 1 commit into
mainfrom
fix/head-presigned-cache-bypass
Jun 27, 2026
Merged

fix(cf-workers): bypass Cloudflare cache for HEAD subrequests#95
alukach merged 1 commit into
mainfrom
fix/head-presigned-cache-bypass

Conversation

@alukach

@alukach alukach commented Jun 27, 2026

Copy link
Copy Markdown
Member

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 outbound HEAD into a GET. That rewritten GET then fails SigV4 against the HEAD-signed presigned URL, so the store returns 403 SignatureDoesNotMatch, which the Worker relays.

Symptom: a HEAD on a key with a cacheable extension (e.g. foo.dmg) returns 403, while a HEAD on the same object without an extension (foo) and all GETs succeed. Reproduced against AWS S3; a direct-to-AWS HEAD returns 200 (the object is healthy), confirming the rewrite happens at the Cloudflare edge.

Evidence

  • The 403 carries x-amz-request-id + Content-Type: application/xml → AWS produced it, and it received a GET (XML error bodies come from GET, not HEAD).
  • The failing extension set is exactly Cloudflare's default static-cacheable list (.csv fails; .txt/.json/.html/.xml/no-extension pass).
  • CF-Cache-Status: BYPASS on the failing path vs DYNAMIC on the passing path.

Root cause

WorkerBackend::forward already bypassed the cache (RequestCache::NoStore) — but only when a Range header was present. HEAD requests carry no Range, so they fell through to the cached path and got rewritten.

Fix

Add ForwardRequest::should_bypass_cache() (true for HEAD or any Range request) and gate the existing NoStore on it. Plain full-object GET stays cacheable so the edge can still serve public objects.

Tests

should_bypass_cache_predicate (in core, runs under host cargo test) covers HEAD, ranged GET/HEAD, plain GET, and PUT. The predicate lives on ForwardRequest in core rather than inline in cf-workers because cf-workers is wasm-only and excluded from the host cargo test/cargo check jobs.

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.
  • Post-deploy check: HEAD on a .dmg/.gif key returns 200.

🤖 Generated with Claude Code

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>
@github-actions

Copy link
Copy Markdown

🚀 Latest commit deployed to https://multistore-proxy-pr-95.development-seed.workers.dev

  • Date: 2026-06-27T05:01:15Z
  • Commit: 38c57de

@alukach alukach merged commit 973bff7 into main Jun 27, 2026
12 checks passed
@alukach alukach deleted the fix/head-presigned-cache-bypass branch June 27, 2026 05:27
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>
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