From 4ec16585d5deeb996386c6e8b6911d4057239766 Mon Sep 17 00:00:00 2001 From: shigahi Date: Wed, 28 Oct 2026 11:18:13 +0100 Subject: [PATCH 1/2] Drop permissive CORS and tighten caching in the generated worker The worker answered cross-origin preflights and API responses with Access-Control-Allow-Origin: *, although the Notion client only calls the domain from the same origin and notion.site sends no CORS headers. JS chunk requests now drop the query string from the cache key and the upstream fetch, so a random query can no longer bypass the cache and force a new rewrite. Foreign page checks are also kept in the edge cache, so new isolates don't ask Notion again; failed checks are still not cached. Co-Authored-By: Claude Opus 5.5 --- src/code.ts | 82 +++++++++++++++---------------- test/generated-worker.test.mjs | 88 +++++++++++++++++++++++++++++++++- 2 files changed, 124 insertions(+), 46 deletions(-) diff --git a/src/code.ts b/src/code.ts index 644e202..9358013 100644 --- a/src/code.ts +++ b/src/code.ts @@ -483,20 +483,20 @@ ${ // Notion's JS chunks are content-hashed and the same for every visitor, so keep // the rewritten copy in the edge cache instead of rewriting it on each request. // Only a handful of chunks contain Notion domains; the rest pass through as is. + // The query string is dropped so that a random one can't bypass the cache. async function fetchJs(request, url, ctx) { const cache = caches.default; - const cacheKey = new URL(request.url); - cacheKey.searchParams.set('worknot', CACHE_VERSION); - const cached = await cache.match(cacheKey.toString()); + const cacheKey = new URL(url.pathname + '?worknot=' + CACHE_VERSION, request.url).toString(); + const cached = await cache.match(cacheKey); if (cached) return cached; - const upstream = await fetch(url.toString()); + const upstream = await fetch(url.origin + url.pathname); let body = await upstream.text(); if (body.includes('notion.site') || body.includes('www.notion.so')) body = rewriteJsBody(body); const response = new Response(body, upstream); response.headers.set('Content-Type', 'text/javascript'); response.headers.delete('Set-Cookie'); if (upstream.ok && isAssetCacheable(upstream)) { - ctx.waitUntil(cache.put(cacheKey.toString(), response.clone())); + ctx.waitUntil(cache.put(cacheKey, response.clone())); } return response; } @@ -724,30 +724,6 @@ ${ }); } - const corsHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, HEAD, POST, PUT, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', - }; - - function handleOptions(request) { - if (request.headers.get('Origin') !== null && - request.headers.get('Access-Control-Request-Method') !== null && - request.headers.get('Access-Control-Request-Headers') !== null) { - // Handle CORS pre-flight request. - return new Response(null, { - headers: corsHeaders - }); - } else { - // Handle standard OPTIONS request. - return new Response(null, { - headers: { - 'Allow': 'GET, HEAD, POST, PUT, OPTIONS', - } - }); - } - } - const NOTION_SITE_DOMAIN = (() => { try { return new URL(${str(notionUrl)}).hostname; @@ -849,8 +825,10 @@ ${ } async function fetchAndApply(request, env, ctx) { + // The Notion client only calls this domain from the same origin, so like + // notion.site, answer without CORS headers if (request.method === 'OPTIONS') { - return handleOptions(request); + return new Response(null, { headers: { 'Allow': 'GET, HEAD, POST, PUT, OPTIONS' } }); } try { return rescopeConsentCookie(await handleRequest(request, env, ctx)); @@ -934,7 +912,7 @@ ${ return notFoundResponse(url); } // The Notion client would load the page ID from the URL, so skip the custom 404 page - if (await isForeignPageNavigation(request, url)) { + if (await isForeignPageNavigation(request, url, ctx)) { return notFoundResponse(url, true); } let response; @@ -1000,7 +978,6 @@ ${ // If JSON parsing fails, continue with string replacement } response = new Response(body, response); - response.headers.set('Access-Control-Allow-Origin', '*'); response.headers.delete('Content-Security-Policy'); return response; } else if (url.pathname.startsWith('/api/')) { @@ -1019,7 +996,6 @@ ${ // Rewrite Notion space domain back to custom domain in response body = swapSpaceDomainInJson(body, NOTION_SPACE_DOMAIN, CUSTOM_SPACE_DOMAIN); response = new Response(body, response); - response.headers.set('Access-Control-Allow-Origin', '*'); response.headers.delete('Content-Security-Policy'); return response; } else if (IMAGE_OPTIMIZATION === 'resize' && /^\\/images?\\//.test(url.pathname)) { @@ -1507,10 +1483,11 @@ ${ } // Any public Notion page renders when its ID is requested on this domain, so - // check that unmapped page IDs belong to this site's workspace. Cached per isolate. + // check that unmapped page IDs belong to this site's workspace. Results are kept + // per isolate and in the edge cache, so new isolates don't ask Notion again. const pageIsForeign = new Map(); - async function isForeignPageNavigation(request, url) { + async function isForeignPageNavigation(request, url, ctx) { if (!NOTION_SITE_DOMAIN.endsWith('.notion.site')) return false; if (!isPageNavigation(request) || NON_PAGE_PATH.test(url.pathname)) return false; // The mobile client opens a database peek (?p=) as the page itself. @@ -1521,13 +1498,34 @@ ${ const match = url.pathname.match(/[0-9a-f]{32}/); if (match) pageIds.push(match[0]); for (const pageId of pageIds) { - if (/^[0-9a-f]{32}$/.test(pageId) && PAGE_TO_SLUG[pageId] === undefined && await isForeignPage(pageId)) return true; + if (/^[0-9a-f]{32}$/.test(pageId) && PAGE_TO_SLUG[pageId] === undefined && await isForeignPage(pageId, request, ctx)) return true; } return false; } - async function isForeignPage(pageId) { + async function isForeignPage(pageId, request, ctx) { if (pageIsForeign.has(pageId)) return pageIsForeign.get(pageId); + const cache = caches.default; + const cacheKey = new URL('/_worknot/foreign-page/' + pageId + '?worknot=' + CACHE_VERSION, request.url).toString(); + const cached = await cache.match(cacheKey); + let foreign; + if (cached) { + foreign = (await cached.text()) === 'true'; + } else { + foreign = await fetchPageIsForeign(pageId); + // Serve the page this time, but don't cache a transient failure + if (foreign === null) return false; + ctx.waitUntil(cache.put(cacheKey, new Response(String(foreign), { + headers: { 'Cache-Control': 'public, max-age=86400' }, + }))); + } + if (pageIsForeign.size > 1000) pageIsForeign.clear(); + pageIsForeign.set(pageId, foreign); + return foreign; + } + + // Ask Notion which workspace the page belongs to; null if that fails + async function fetchPageIsForeign(pageId) { try { const response = await fetch('https://' + NOTION_SITE_DOMAIN + '/api/v3/getPublicPageData', { method: 'POST', @@ -1542,16 +1540,12 @@ ${ }); if (!response.ok) { await response.body?.cancel(); - return false; + return null; } const data = await response.json(); - const foreign = !!data.spaceDomain && data.spaceDomain !== NOTION_SPACE_DOMAIN && data.publicDomainName !== NOTION_SPACE_DOMAIN; - if (pageIsForeign.size > 1000) pageIsForeign.clear(); - pageIsForeign.set(pageId, foreign); - return foreign; + return !!data.spaceDomain && data.spaceDomain !== NOTION_SPACE_DOMAIN && data.publicDomainName !== NOTION_SPACE_DOMAIN; } catch (e) { - // Serve the page this time, but don't cache a transient failure - return false; + return null; } } diff --git a/test/generated-worker.test.mjs b/test/generated-worker.test.mjs index c1e97ae..3edc286 100644 --- a/test/generated-worker.test.mjs +++ b/test/generated-worker.test.mjs @@ -7,12 +7,20 @@ const ROOT = '11111111111111111111111111111111'; const CHAPTER = '22222222222222222222222222222222'; const OTHER = '33333333333333333333333333333333'; const dashed = (id) => id.replace(/(.{8})(.{4})(.{4})(.{4})(.{12})/, '$1-$2-$3-$4-$5'); +let script; let worker; +let isolates = 0; +// Each import is a separate module instance, like a new isolate +async function loadWorker() { + const source = `${script}\n// isolate ${isolates++}`; + return (await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`)).default; +} + before(async () => { const vite = await createServer({ configFile: false, server: { middlewareMode: true, ws: false }, appType: 'custom', logLevel: 'error' }); try { const { default: generate } = await vite.ssrLoadModule('/src/code.ts'); - const script = generate({ + script = generate({ myDomain: 'notes.example.com', notionUrl: `https://source-workspace.notion.site/${ROOT}`, slugs: [['chapter', `https://source-workspace.notion.site/${OTHER}`]], @@ -23,12 +31,32 @@ before(async () => { rss: { enabled: false }, i18n: { enabled: false, defaultLocale: 'en' }, ogImageGeneration: { enabled: false }, }); - ({ default: worker } = await import(`data:text/javascript;base64,${Buffer.from(script).toString('base64')}`)); + worker = await loadWorker(); } finally { await vite.close(); } }); +// In-memory stand-in for caches.default, shared by every worker instance +function stubEdgeCache(t) { + const store = new Map(); + globalThis.caches = { + default: { + match: async (key) => store.get(key)?.clone(), + put: async (key, response) => { store.set(key, response); }, + }, + }; + t.after(() => { delete globalThis.caches; }); +} + +// Runs a request and waits for its ctx.waitUntil work, such as cache writes +async function run(instance, request) { + const pending = []; + const response = await instance.fetch(request, {}, { waitUntil: (promise) => pending.push(promise) }); + await Promise.all(pending); + return response; +} + async function forward(t, body, referer, extraHeaders = {}, path = '/api/v3/getPublicPageData') { const upstream = t.mock.method(globalThis, 'fetch', async () => Response.json({ spaceDomain: 'source-workspace' })); const request = new Request('https://notes.example.com' + path, { @@ -38,6 +66,7 @@ async function forward(t, body, referer, extraHeaders = {}, path = '/api/v3/getP }); const response = await worker.fetch(request, {}, { waitUntil() {} }); assert.equal(response.status, 200); + assert.equal(response.headers.get('access-control-allow-origin'), null); assert.equal(upstream.mock.calls.length, 1); const [url, init] = upstream.mock.calls[0].arguments; assert.equal(new URL(url).hostname, 'source-workspace.notion.site'); @@ -106,3 +135,58 @@ test('malformed JSON passes through without fabricated site context', async (t) const forwarded = await forward(t, '{invalid', undefined); assert.equal(forwarded.body, '{invalid'); }); + +test('cross-origin preflight requests get no CORS headers', async (t) => { + const upstream = t.mock.method(globalThis, 'fetch', async () => new Response()); + const response = await run(worker, new Request('https://notes.example.com/api/v3/getPublicPageData', { + method: 'OPTIONS', + headers: { + Origin: 'https://other.example', + 'Access-Control-Request-Method': 'POST', + 'Access-Control-Request-Headers': 'content-type', + }, + })); + assert.equal(response.headers.get('access-control-allow-origin'), null); + assert.equal(upstream.mock.calls.length, 0); +}); + +test('a query string does not bypass the JS chunk cache', async (t) => { + stubEdgeCache(t); + const upstream = t.mock.method(globalThis, 'fetch', async () => new Response('chunk', { + headers: { 'Cache-Control': 'public, max-age=31536000, immutable' }, + })); + for (const query of ['?a=1', '?b=2']) { + const response = await run(worker, new Request(`https://notes.example.com/_assets/chunk-abc.js${query}`)); + assert.equal(await response.text(), 'chunk'); + } + assert.equal(upstream.mock.calls.length, 1); + assert.equal(upstream.mock.calls[0].arguments[0], 'https://source-workspace.notion.site/_assets/chunk-abc.js'); +}); + +test('new isolates reuse the cached foreign page check', async (t) => { + stubEdgeCache(t); + const upstream = t.mock.method(globalThis, 'fetch', async () => Response.json({ spaceDomain: 'other-workspace' })); + const foreignPage = '44444444444444444444444444444444'; + for (const instance of [worker, await loadWorker()]) { + const response = await run(instance, new Request(`https://notes.example.com/${foreignPage}`, { + headers: { 'Sec-Fetch-Dest': 'document' }, + })); + assert.equal(response.status, 404); + } + assert.equal(upstream.mock.calls.length, 1); +}); + +test('a failed foreign page check is not cached', async (t) => { + stubEdgeCache(t); + const upstream = t.mock.method(globalThis, 'fetch', async () => new Response(null, { status: 503 })); + const page = '55555555555555555555555555555555'; + for (const instance of [await loadWorker(), await loadWorker()]) { + const response = await run(instance, new Request(`https://notes.example.com/${page}`, { + headers: { 'Sec-Fetch-Dest': 'document' }, + })); + // The page is served, so the upstream 503 passes through + assert.equal(response.status, 503); + } + const checks = upstream.mock.calls.filter((call) => call.arguments[0].endsWith('/api/v3/getPublicPageData')); + assert.equal(checks.length, 2); +}); From cc885f7741e35c42bff2d054646d3e7dc00a9953 Mon Sep 17 00:00:00 2001 From: shigahi Date: Wed, 28 Oct 2026 11:18:13 +0100 Subject: [PATCH 2/2] Return 404 for unknown paths and add security headers to the site The site is not a single-page app, so the SPA not-found handling served the landing page with status 200 for any path. Serve a 404 page instead, add baseline security headers via _headers, and update the compatibility date. Co-Authored-By: Claude Opus 5.5 --- public/404.html | 34 ++++++++++++++++++++++++++++++++++ public/_headers | 5 +++++ wrangler.jsonc | 4 ++-- 3 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 public/404.html create mode 100644 public/_headers diff --git a/public/404.html b/public/404.html new file mode 100644 index 0000000..3b9ef9a --- /dev/null +++ b/public/404.html @@ -0,0 +1,34 @@ + + + + + + + Page not found | Worknot + + + + +

Page not found

+

The page you are looking for does not exist.

+

Go to Worknot · 日本語

+ + diff --git a/public/_headers b/public/_headers new file mode 100644 index 0000000..693f3ea --- /dev/null +++ b/public/_headers @@ -0,0 +1,5 @@ +/* + X-Content-Type-Options: nosniff + X-Frame-Options: DENY + Referrer-Policy: strict-origin-when-cross-origin + Permissions-Policy: camera=(), microphone=(), geolocation=() diff --git a/wrangler.jsonc b/wrangler.jsonc index 8446c66..829fbd5 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -2,12 +2,12 @@ "$schema": "./node_modules/wrangler/config-schema.json", "name": "worknot", "account_id": "172411ba14a3cd8e6dce641de0152c07", - "compatibility_date": "2026-03-11", + "compatibility_date": "2026-10-28", "observability": { "enabled": true }, "assets": { "directory": "./build", - "not_found_handling": "single-page-application" + "not_found_handling": "404-page" } }