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/src/cloudflare/snippets/02_shared_sw_installer_cache.ts b/src/cloudflare/snippets/02_shared_sw_installer_cache.ts index 41fe928b..82a856c9 100644 --- a/src/cloudflare/snippets/02_shared_sw_installer_cache.ts +++ b/src/cloudflare/snippets/02_shared_sw_installer_cache.ts @@ -17,13 +17,51 @@ // 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. +// +// 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 +// 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}` @@ -49,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/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) + }) + }) }) 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..55f6557f 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 })) @@ -115,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 () => { @@ -142,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 () => { @@ -163,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']) }) }) @@ -186,15 +193,49 @@ 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') }) }) + + 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) + }) + }) })