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 @@ + + +
+ + + +The page you are looking for does not exist.
+ + + 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/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); +}); 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" } }