From 564a9a3c3bd6e47bc2e5703e6b3fd32073884412 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Thu, 14 May 2026 22:22:52 +0200 Subject: [PATCH 1/3] fix: break cloudflare challenge loop at edge Cloudflare's Managed Challenge lands the visitor on a URL with a single-use __cf_chl_tk token. The SW installer reloads that URL, Cloudflare sees the replayed token, and challenges again. Strip every __cf_* param at the edge and 302 to the clean URL so reloads and shared links no longer carry challenge state. --- .../snippets/02_shared_sw_installer_cache.ts | 32 ++++++++++++++ .../02_shared_sw_installer_cache.spec.ts | 43 ++++++++++++++++++- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/cloudflare/snippets/02_shared_sw_installer_cache.ts b/src/cloudflare/snippets/02_shared_sw_installer_cache.ts index 41fe928b..e59435fe 100644 --- a/src/cloudflare/snippets/02_shared_sw_installer_cache.ts +++ b/src/cloudflare/snippets/02_shared_sw_installer_cache.ts @@ -17,13 +17,45 @@ // HTML and versioned JS/CSS share the same TTL on purpose: a stale HTML // entry referencing fresh JS (or vice versa) could mismatch and break // the installer. +// +// Strip __cf_* query params at the edge: after a visitor passes a Managed +// Challenge, Cloudflare lands them on a URL carrying a single-use token +// (__cf_chl_tk, __cf_chl_f_tk, __cf_chl_rt_tk). If that token survives +// into the SW installer reload, Cloudflare sees it replayed and issues a +// fresh challenge, looping forever. We strip every __cf_* param and 302 +// to the clean URL, so the browser address bar never carries challenge +// state. The prefix match covers today's names and any future Cloudflare +// additions in the same namespace. const EDGE_CACHE_TTL_S = 86400 // 24h +const CF_QUERY_PARAM_PREFIX = '__cf_' export default { async fetch (request: Request): Promise { const url = new URL(request.url) + // Strip __cf_* params before any cache lookup so the per-visitor URL + // never becomes a cache key and the address bar never persists a + // single-use token. + let strippedCfParam = false + for (const key of [...url.searchParams.keys()]) { + if (key.startsWith(CF_QUERY_PARAM_PREFIX)) { + url.searchParams.delete(key) + strippedCfParam = true + } + } + if (strippedCfParam) { + return new Response(null, { + status: 302, + headers: { + Location: url.toString(), + // The source URL holds a single-use, per-visitor token. Do not + // cache the redirect anywhere. + 'Cache-Control': 'no-store' + } + }) + } + if (url.pathname.startsWith('/ipfs-sw-')) { const baseDomain = url.hostname.split('.').slice(-2).join('.') const cacheKey = `https://${baseDomain}${url.pathname}` diff --git a/test/cloudflare/snippets/02_shared_sw_installer_cache.spec.ts b/test/cloudflare/snippets/02_shared_sw_installer_cache.spec.ts index 5b1f494e..a69093c2 100644 --- a/test/cloudflare/snippets/02_shared_sw_installer_cache.spec.ts +++ b/test/cloudflare/snippets/02_shared_sw_installer_cache.spec.ts @@ -3,7 +3,8 @@ // Run: node cloudflare/snippets/02_shared_sw_installer_cache.test.js // // These tests verify caching behavior: cache key normalization for SW assets, -// TTL differentiation, and Cache-Control passthrough. +// TTL differentiation, Cache-Control passthrough, and Cloudflare +// challenge-state (`__cf_*` query params) stripping at the edge. import { expect } from 'aegir/chai' import Sinon from 'sinon' @@ -12,6 +13,9 @@ import type { SinonSandbox } from 'sinon' // Captured cf options from the last fetch() call made by the snippet. let lastCfOptions = null +// True if the stubbed fetch ran. False if the snippet returned a synthetic +// redirect first. +let fetchWasCalled = false // Status code the stubbed origin response will return. let stubStatus = 200 // Headers on the stubbed origin response. @@ -20,6 +24,7 @@ let stubHeaders = {} // Helper: call the snippet handler and return { cf, response }. async function callHandler (inputUrl: string): Promise<{ cf: any, response: Response }> { lastCfOptions = null + fetchWasCalled = false const response = await handler.fetch(new Request(inputUrl)) return { cf: lastCfOptions, response } @@ -30,11 +35,13 @@ describe('02_shared_sw_installer_cache', () => { beforeEach(() => { lastCfOptions = null + fetchWasCalled = false stubStatus = 200 stubHeaders = {} sandbox = Sinon.createSandbox() sandbox.replace(globalThis, 'fetch', (requestOrUrl, init) => { + fetchWasCalled = true lastCfOptions = init?.cf ?? null const headers = new Headers(stubHeaders) return Promise.resolve(new Response(null, { status: stubStatus, headers })) @@ -197,4 +204,38 @@ describe('02_shared_sw_installer_cache', () => { expect(cf.cacheKey).to.equal('https://inbrowser.dev/__sw_installer_html') }) }) + + describe('Cloudflare challenge-state stripping', () => { + it('strips __cf_chl_tk, 302s to the clean URL, does not cache the redirect or call origin', async () => { + const { response } = await callHandler('https://bafyxxx.ipfs.inbrowser.dev/wiki/Article?__cf_chl_tk=abc123') + expect(response.status).to.equal(302) + expect(response.headers.get('Location')).to.equal('https://bafyxxx.ipfs.inbrowser.dev/wiki/Article') + expect(response.headers.get('Cache-Control')).to.equal('no-store') + expect(fetchWasCalled).to.equal(false) + }) + + it('catches any future __cf_* variant via the prefix match', async () => { + // Pins the future-proofing promise: a hypothetical Cloudflare- + // internal rename inside the __cf_* namespace still gets stripped + // with no code change. + const { response } = await callHandler('https://bafyxxx.ipfs.inbrowser.dev/?__cf_v2_tk=hypothetical') + expect(response.status).to.equal(302) + expect(response.headers.get('Location')).to.equal('https://bafyxxx.ipfs.inbrowser.dev/') + }) + + it('preserves real gateway query params on the redirect', async () => { + // ?filename=, ?download=, ?format=, ?path=, ... must survive. + const { response } = await callHandler('https://bafyxxx.ipfs.inbrowser.dev/file?__cf_chl_tk=tok&filename=foo.txt&download=true') + const location = new URL(response.headers.get('Location') ?? '') + expect(location.searchParams.get('filename')).to.equal('foo.txt') + expect(location.searchParams.get('download')).to.equal('true') + expect(location.searchParams.has('__cf_chl_tk')).to.equal(false) + }) + + it('does not redirect when no __cf_* param is present', async () => { + const { response } = await callHandler('https://bafyxxx.ipfs.inbrowser.dev/?filename=foo.txt') + expect(response.status).to.equal(200) + expect(fetchWasCalled).to.equal(true) + }) + }) }) From 668c872225facf6d731c147ab7a8fe6a9c1ba8b5 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Thu, 14 May 2026 22:24:42 +0200 Subject: [PATCH 2/3] fix: harden installer cache to 2xx only Origin returns 200 for every SPA route via the _redirects rewrite, so 3xx is anomalous. The 4xx/5xx exclusion also keeps Cloudflare's own challenge response (403 with cf-mitigated: challenge) out of the normalized cache key. --- .../snippets/02_shared_sw_installer_cache.ts | 17 ++++++++++++---- .../02_shared_sw_installer_cache.spec.ts | 20 +++++++++---------- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/src/cloudflare/snippets/02_shared_sw_installer_cache.ts b/src/cloudflare/snippets/02_shared_sw_installer_cache.ts index e59435fe..82a856c9 100644 --- a/src/cloudflare/snippets/02_shared_sw_installer_cache.ts +++ b/src/cloudflare/snippets/02_shared_sw_installer_cache.ts @@ -18,6 +18,12 @@ // entry referencing fresh JS (or vice versa) could mismatch and break // the installer. // +// Cache hardening: cache only 2xx. The origin _redirects rule rewrites +// every SPA route to a 200, so any 3xx, 4xx, or 5xx response here is +// anomalous. The 4xx/5xx exclusion also keeps Cloudflare's own Managed +// Challenge response (403 with cf-mitigated: challenge) out of the +// normalized installer cache. +// // Strip __cf_* query params at the edge: after a visitor passes a Managed // Challenge, Cloudflare lands them on a URL carrying a single-use token // (__cf_chl_tk, __cf_chl_f_tk, __cf_chl_rt_tk). If that token survives @@ -81,10 +87,13 @@ export default { cacheEverything: true, cacheKey, cacheTtlByStatus: { - '200-399': EDGE_CACHE_TTL_S, - // Do not cache errors. A transient failure must not be stuck - // in cache for the full TTL. - '400-599': 0 + '200-299': EDGE_CACHE_TTL_S, + // Cache only 2xx. Origin returns 200 for every SPA route via + // the _redirects rewrite, so 3xx here is anomalous. The + // 4xx/5xx exclusion also keeps Cloudflare's Managed Challenge + // response (403 with cf-mitigated: challenge) out of this + // cache key. + '300-599': 0 } } }) diff --git a/test/cloudflare/snippets/02_shared_sw_installer_cache.spec.ts b/test/cloudflare/snippets/02_shared_sw_installer_cache.spec.ts index a69093c2..55f6557f 100644 --- a/test/cloudflare/snippets/02_shared_sw_installer_cache.spec.ts +++ b/test/cloudflare/snippets/02_shared_sw_installer_cache.spec.ts @@ -122,14 +122,14 @@ describe('02_shared_sw_installer_cache', () => { expect(cfA.cacheKey).to.not.equal(cfB.cacheKey) }) - it('uses 24h edge TTL for 200-399', async () => { + it('uses 24h edge TTL for 2xx only', async () => { const { cf } = await callHandler('https://bafyxxx.ipfs.inbrowser.dev/page') - expect(cf.cacheTtlByStatus['200-399']).to.equal(86400) + expect(cf.cacheTtlByStatus['200-299']).to.equal(86400) }) - it('does not cache 400+ responses at edge', async () => { + it('does not cache 3xx/4xx/5xx responses at edge', async () => { const { cf } = await callHandler('https://bafyxxx.ipfs.inbrowser.dev/') - expect(cf.cacheTtlByStatus['400-599']).to.equal(0) + expect(cf.cacheTtlByStatus['300-599']).to.equal(0) }) it('does not override Cache-Control for 200 responses', async () => { @@ -149,7 +149,7 @@ describe('02_shared_sw_installer_cache', () => { it('uses per-subdomain cache key on .ipns. subdomain', async () => { const { cf } = await callHandler('https://en-wikipedia--on--ipfs-org.ipns.inbrowser.dev/wiki/') expect(cf.cacheKey).to.equal('https://en-wikipedia--on--ipfs-org.ipns.inbrowser.dev/__sw_installer_html') - expect(cf.cacheTtlByStatus['200-399']).to.equal(86400) + expect(cf.cacheTtlByStatus['200-299']).to.equal(86400) }) it('does not override Cache-Control for 301 responses', async () => { @@ -170,14 +170,14 @@ describe('02_shared_sw_installer_cache', () => { describe('cacheTtlByStatus key guards', () => { it('cacheTtlByStatus keys are defined (not undefined)', async () => { const { cf } = await callHandler('https://bafyxxx.ipfs.inbrowser.dev/page') - expect(cf.cacheTtlByStatus['200-399']).to.not.equal(undefined, 'expected 200-399 key to be defined') - expect(cf.cacheTtlByStatus['400-599']).to.not.equal(undefined, 'expected 400-599 key to be defined') + expect(cf.cacheTtlByStatus['200-299']).to.not.equal(undefined, 'expected 200-299 key to be defined') + expect(cf.cacheTtlByStatus['300-599']).to.not.equal(undefined, 'expected 300-599 key to be defined') }) it('HTML and versioned SW asset TTLs match (avoid HTML-JS skew)', async () => { const { cf: cfHtml } = await callHandler('https://bafyxxx.ipfs.inbrowser.dev/page') const { cf: cfAsset } = await callHandler('https://bafyxxx.ipfs.inbrowser.dev/ipfs-sw-main.js') - expect(cfHtml.cacheTtlByStatus['200-399']).to.equal(cfAsset.cacheTtlByStatus['200-299']) + expect(cfHtml.cacheTtlByStatus['200-299']).to.equal(cfAsset.cacheTtlByStatus['200-299']) }) }) @@ -193,14 +193,14 @@ describe('02_shared_sw_installer_cache', () => { it('works the same for .dev and .link on non-SW paths', async () => { const { cf: cfDev } = await callHandler('https://bafyxxx.ipfs.inbrowser.dev/page') const { cf: cfLink } = await callHandler('https://bafyxxx.ipfs.inbrowser.link/page') - expect(cfDev.cacheTtlByStatus['200-399']).to.equal(cfLink.cacheTtlByStatus['200-399']) + expect(cfDev.cacheTtlByStatus['200-299']).to.equal(cfLink.cacheTtlByStatus['200-299']) expect(cfDev.cacheKey).to.equal('https://bafyxxx.ipfs.inbrowser.dev/__sw_installer_html') expect(cfLink.cacheKey).to.equal('https://bafyxxx.ipfs.inbrowser.link/__sw_installer_html') }) it('base domain requests use 24h TTL and per-host installer key', async () => { const { cf } = await callHandler('https://inbrowser.dev/') - expect(cf.cacheTtlByStatus['200-399']).to.equal(86400) + expect(cf.cacheTtlByStatus['200-299']).to.equal(86400) expect(cf.cacheKey).to.equal('https://inbrowser.dev/__sw_installer_html') }) }) From f88ef894ac6d036e3f82aff5cafd6b85850a13c2 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Thu, 14 May 2026 22:26:19 +0200 Subject: [PATCH 3/3] feat: edge-cache path-to-subdomain redirects The /ipfs/ and /ipns/ to subdomain mapping is deterministic, but the synthesized 301 never hit the edge cache. Cache it via caches.default keyed by source URL so repeat hits skip CID parsing and multibase decode. Strip __cf_* params before keying the cache and the Location so per-visitor challenge tokens share one cache entry and never leak onto the subdomain (snippet 02 would otherwise strip them in a second hop). --- .../snippets/01_path_gateway_to_subdomain.ts | 50 ++++++++++++- .../01_path_gateway_to_subdomain.spec.ts | 74 ++++++++++++++++++- 2 files changed, 118 insertions(+), 6 deletions(-) diff --git a/src/cloudflare/snippets/01_path_gateway_to_subdomain.ts b/src/cloudflare/snippets/01_path_gateway_to_subdomain.ts index 3b941f8d..44b9813c 100644 --- a/src/cloudflare/snippets/01_path_gateway_to_subdomain.ts +++ b/src/cloudflare/snippets/01_path_gateway_to_subdomain.ts @@ -6,17 +6,35 @@ // /ipfs/{cid}[/path][?query] -> {base32-cidv1}.ipfs.{host}[/path][?query] // /ipns/{peerid}[/path][?query] -> {base36-cidv1}.ipns.{host}[/path][?query] // /ipns/{domain}[/path][?query] -> {dnslink-encoded}.ipns.{host}[/path][?query] +// +// Edge cache: the transformation is fully deterministic per source URL +// (CIDs are content-addressed, peer IDs and DNSLink labels are decoded and +// re-encoded by a fixed algorithm), so we cache the 301 at the Cloudflare +// edge keyed by the source URL. Subsequent hits skip CID parsing and the +// multibase work entirely. +// +// Strip __cf_* query params before computing the cache key and the redirect +// Location. Visitors that just passed a Managed Challenge arrive on a URL +// carrying a single-use challenge token; without stripping, each per-visitor +// URL becomes its own cache entry (no shared hit) and the token propagates +// onto the subdomain where snippet 02 has to clean it up with another hop. +// Prefix-matching the whole __cf_* namespace stays robust to Cloudflare- +// internal additions inside that namespace. import { toSubdomain } from './subdomain.ts' +const CF_QUERY_PARAM_PREFIX = '__cf_' + export default { /** * main fetch handler * * @param {Request} request + * @param {unknown} env + * @param {ExecutionContext} ctx * @returns {Promise} */ - async fetch (request: Request): Promise { + async fetch (request: Request, env: unknown, ctx: ExecutionContext): Promise { const url = new URL(request.url) const path = url.pathname @@ -33,7 +51,25 @@ export default { return fetch(request) } - const remainingPath = '/' + segments.slice(3).join('/') + // Strip __cf_* params before anything that touches the cache or the + // redirect target. Mirrors the hygiene applied in snippet 02. + for (const key of [...url.searchParams.keys()]) { + if (key.startsWith(CF_QUERY_PARAM_PREFIX)) { + url.searchParams.delete(key) + } + } + + // Use the stripped URL as the cache key. Default Cache API behavior keys + // by request URL, which gives unique-per-source-URL entries for free. + // `caches.default` is the Cloudflare-specific per-datacenter cache; the + // cast bridges the gap with the standard lib.dom CacheStorage shape that + // tsc resolves to first. + const cacheKey = url.toString() + const cache = (caches as unknown as { default: Cache }).default + const cached = await cache.match(cacheKey) + if (cached != null) { + return cached + } const subdomain = toSubdomain(identifier, namespace) @@ -41,14 +77,22 @@ export default { return fetch(request) } + const remainingPath = '/' + segments.slice(3).join('/') const redirectUrl = `${url.protocol}//${subdomain}.${namespace}.${url.host}${remainingPath}${url.search}` - return new Response(null, { + const response = new Response(null, { status: 301, headers: { Location: redirectUrl, 'Cache-Control': 'public, max-age=31536000, immutable' } }) + + // Cache write runs after the response is sent. The Cache API honors + // Cache-Control on the stored response, so the edge TTL matches the + // 1-year immutable browser TTL set above. + ctx.waitUntil(cache.put(cacheKey, response.clone())) + + return response } } diff --git a/test/cloudflare/snippets/01_path_gateway_to_subdomain.spec.ts b/test/cloudflare/snippets/01_path_gateway_to_subdomain.spec.ts index 3d4a8975..af8adde7 100644 --- a/test/cloudflare/snippets/01_path_gateway_to_subdomain.spec.ts +++ b/test/cloudflare/snippets/01_path_gateway_to_subdomain.spec.ts @@ -8,9 +8,15 @@ import type { SinonSandbox } from 'sinon' // The handler calls fetch(request) when it decides not to redirect. const PASSTHROUGH = Symbol('passthrough') +// In-memory caches.default stub. Keyed by cacheKey string (URL). +let edgeCache: Map +// Captured ctx for assertions. ctx.waitUntil is a no-op that resolves its +// argument immediately, matching real runtime semantics closely enough. +let ctx: ExecutionContext + // Call the real snippet handler and return the Location header or null on passthrough. async function fetchRedirect (inputUrl: string): Promise { - const resp = await handler.fetch(new Request(inputUrl)) + const resp = await handler.fetch(new Request(inputUrl), {}, ctx) // @ts-expect-error custom property added by tests if (resp[PASSTHROUGH] != null) { @@ -24,12 +30,35 @@ describe('01_path_gateway_to_subdomain', () => { let sandbox: SinonSandbox beforeEach(() => { + edgeCache = new Map() + ctx = { + waitUntil: (p: Promise) => { void p }, + passThroughOnException: () => {} + } as ExecutionContext + sandbox = Sinon.createSandbox() sandbox.replace(globalThis, 'fetch', async () => { return Object.assign(new Response(null, { status: 200 }), { [PASSTHROUGH]: true }) }) + // Stub caches.default with a Map-backed implementation. The snippet + // only uses match() and put(); other methods are unused. `caches` does + // not exist as a Node global by default, so use sandbox.define to add + // it (sandbox.replace would fail on a non-existent property). + sandbox.define(globalThis, 'caches', { + default: { + match: async (key: Request | string) => { + const k = typeof key === 'string' ? key : key.url + const cached = edgeCache.get(k) + return cached != null ? cached.clone() : undefined + }, + put: async (key: Request | string, value: Response) => { + const k = typeof key === 'string' ? key : key.url + edgeCache.set(k, value) + } + } + } as unknown as CacheStorage) }) afterEach(() => { @@ -37,7 +66,7 @@ describe('01_path_gateway_to_subdomain', () => { }) it('/ipfs/ with CIDv0 redirects to base32 CIDv1 subdomain', async () => { - const resp = await handler.fetch(new Request('https://dweb.link/ipfs/QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR/path?q=1')) + const resp = await handler.fetch(new Request('https://dweb.link/ipfs/QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR/path?q=1'), {}, ctx) expect(resp.status).to.equal(301) expect(resp.headers.get('Location')).to.equal('https://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi.ipfs.dweb.link/path?q=1') expect(resp.headers.get('Cache-Control')).to.equal('public, max-age=31536000, immutable') @@ -53,7 +82,7 @@ describe('01_path_gateway_to_subdomain', () => { it('/ipns/ with base36 key redirects to subdomain', async () => { const key = 'k51qzi5uqu5dlxjl6owpco0tn82bed1444cng351cnc48odwnr7e9pmx4nmmkh' - const resp = await handler.fetch(new Request(`https://dweb.link/ipns/${key}/file`)) + const resp = await handler.fetch(new Request(`https://dweb.link/ipns/${key}/file`), {}, ctx) expect(resp.status).to.equal(301) expect(resp.headers.get('Location')).to.equal(`https://${key}.ipns.dweb.link/file`) expect(resp.headers.get('Cache-Control')).to.equal('public, max-age=31536000, immutable') @@ -164,4 +193,43 @@ describe('01_path_gateway_to_subdomain', () => { `https://${key}.ipns.dweb.link/path` ) }) + + describe('edge cache', () => { + it('on cache hit, returns the cached response and does not re-compute', async () => { + const cid = 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi' + const url = `https://dweb.link/ipfs/${cid}/file` + + // First call: miss. Populates the cache. + const first = await handler.fetch(new Request(url), {}, ctx) + expect(first.status).to.equal(301) + expect(edgeCache.has(url)).to.equal(true) + + // Replace the cached entry with a sentinel response to prove the + // second call returns it (i.e. did not re-run the redirect logic). + const sentinel = new Response(null, { status: 301, headers: { Location: 'https://sentinel.example/' } }) + edgeCache.set(url, sentinel) + + const second = await handler.fetch(new Request(url), {}, ctx) + expect(second.headers.get('Location')).to.equal('https://sentinel.example/') + }) + + it('strips __cf_* from cache key and redirect Location', async () => { + const cid = 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi' + const resp = await handler.fetch(new Request(`https://dweb.link/ipfs/${cid}/?filename=foo.txt&__cf_chl_tk=tok`), {}, ctx) + const location = new URL(resp.headers.get('Location') ?? '') + expect(location.searchParams.has('__cf_chl_tk')).to.equal(false) + expect(location.searchParams.get('filename')).to.equal('foo.txt') + + // The cache key is the stripped URL, so per-visitor tokens collapse + // onto a single shared entry. + expect([...edgeCache.keys()]).to.deep.equal([`https://dweb.link/ipfs/${cid}/?filename=foo.txt`]) + }) + + it('pass-through cases do not touch the cache', async () => { + await handler.fetch(new Request('https://dweb.link/ipfs/not-a-cid'), {}, ctx) + await handler.fetch(new Request('https://dweb.link/ipfs/'), {}, ctx) + await handler.fetch(new Request('https://dweb.link/other/path'), {}, ctx) + expect(edgeCache.size).to.equal(0) + }) + }) })