diff --git a/api/_cors.test.mjs b/api/_cors.test.mjs index 61f85c1ad..751f4de2d 100644 --- a/api/_cors.test.mjs +++ b/api/_cors.test.mjs @@ -1,6 +1,6 @@ import { strict as assert } from 'node:assert'; import test from 'node:test'; -import { getCorsHeaders, isDisallowedOrigin } from './_cors.js'; +import { getCorsHeaders, getPublicCorsHeaders, isDisallowedOrigin } from './_cors.js'; function makeRequest(origin) { const headers = new Headers(); @@ -38,3 +38,9 @@ test('requests without origin remain allowed', () => { const req = makeRequest(null); assert.equal(isDisallowedOrigin(req), false); }); + +test('getPublicCorsHeaders returns ACAO: * with no Vary', () => { + const headers = getPublicCorsHeaders(); + assert.equal(headers['Access-Control-Allow-Origin'], '*'); + assert.equal(headers['Vary'], undefined, 'Should not include Vary header'); +}); diff --git a/middleware.ts b/middleware.ts index c59fa7e84..985c17147 100644 --- a/middleware.ts +++ b/middleware.ts @@ -122,7 +122,7 @@ export default function middleware(request: Request) { if (BOT_UA.test(ua)) { return new Response('{"error":"Forbidden"}', { status: 403, - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=86400' }, }); } @@ -130,7 +130,7 @@ export default function middleware(request: Request) { if (!ua || ua.length < 10) { return new Response('{"error":"Forbidden"}', { status: 403, - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=86400' }, }); } } diff --git a/server/cors.ts b/server/cors.ts index 02ff381b8..2dbfea393 100644 --- a/server/cors.ts +++ b/server/cors.ts @@ -40,6 +40,20 @@ export function getCorsHeaders(req: Request): Record { }; } +/** + * CORS headers for public cacheable responses (no per-user variation). + * Uses ACAO: * so Vercel CDN stores ONE cache entry per URL instead of one per + * unique Origin. Eliminates Vary: Origin cache fragmentation. + */ +export function getPublicCorsHeaders(): Record { + return { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-WorldMonitor-Key, X-Widget-Key, X-Pro-Key', + 'Access-Control-Max-Age': '3600', + }; +} + export function isDisallowedOrigin(req: Request): boolean { const origin = req.headers.get('origin'); if (!origin) return false; diff --git a/server/gateway.ts b/server/gateway.ts index 799700b3a..83fe77e63 100644 --- a/server/gateway.ts +++ b/server/gateway.ts @@ -10,7 +10,7 @@ */ import { createRouter, type RouteDescriptor } from './router'; -import { getCorsHeaders, isDisallowedOrigin, isAllowedOrigin } from './cors'; +import { getCorsHeaders, getPublicCorsHeaders, isDisallowedOrigin, isAllowedOrigin } from './cors'; // @ts-expect-error — JS module, no declaration file import { validateApiKey } from '../api/_api-key.js'; import { mapErrorToResponse } from './error-mapper'; @@ -370,13 +370,18 @@ export function createDomainGateway( // bypassing auth entirely. const reqOrigin = request.headers.get('origin') || ''; const cdnCache = isAllowedOrigin(reqOrigin) ? TIER_CDN_CACHE[tier] : null; - if (cdnCache) mergedHeaders.set('CDN-Cache-Control', cdnCache); + if (cdnCache) { + mergedHeaders.set('CDN-Cache-Control', cdnCache); + // For non-premium public GET routes: use ACAO: * to eliminate + // per-origin CDN cache fragmentation. Premium routes keep per-origin + // CORS to prevent cache-level auth bypass. + if (!PREMIUM_RPC_PATHS.has(pathname)) { + const pub = getPublicCorsHeaders(); + for (const [k, v] of Object.entries(pub)) mergedHeaders.set(k, v); + mergedHeaders.delete('Vary'); + } + } mergedHeaders.set('X-Cache-Tier', tier); - - // Keep per-origin ACAO (already set from corsHeaders above) and preserve Vary: Origin. - // ACAO: * with no Vary would collapse all origins into one cache entry, bypassing - // isDisallowedOrigin() for cache hits — Vercel CDN serves s-maxage responses without - // re-invoking the function, so a disallowed origin could read a cached ACAO: * response. } mergedHeaders.delete('X-No-Cache'); if (!new URL(request.url).searchParams.has('_debug')) { diff --git a/tests/cors-public-headers.test.mts b/tests/cors-public-headers.test.mts new file mode 100644 index 000000000..6d17c9cb6 --- /dev/null +++ b/tests/cors-public-headers.test.mts @@ -0,0 +1,33 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const corsSrc = readFileSync(resolve(__dirname, '..', 'server', 'cors.ts'), 'utf-8'); + +describe('getPublicCorsHeaders', () => { + it('returns ACAO: * without Vary header', () => { + // Verify the function body contains ACAO: * and does NOT include Vary + const fnMatch = corsSrc.match(/export function getPublicCorsHeaders[\s\S]*?^}/m); + assert.ok(fnMatch, 'getPublicCorsHeaders function not found in server/cors.ts'); + const fnBody = fnMatch![0]; + assert.match(fnBody, /'Access-Control-Allow-Origin':\s*'\*'/, 'Should set ACAO: *'); + assert.doesNotMatch(fnBody, /Vary/, 'Should NOT include Vary header'); + }); + + it('includes same Allow-Methods as getCorsHeaders', () => { + const pubMethods = corsSrc.match(/getPublicCorsHeaders[\s\S]*?Allow-Methods':\s*'([^']+)'/); + const perOriginMethods = corsSrc.match(/getCorsHeaders[\s\S]*?Allow-Methods':\s*'([^']+)'/); + assert.ok(pubMethods && perOriginMethods, 'Could not extract Allow-Methods from both functions'); + assert.equal(pubMethods![1], perOriginMethods![1], 'Allow-Methods should match between public and per-origin'); + }); + + it('includes same Allow-Headers as getCorsHeaders', () => { + const pubHeaders = corsSrc.match(/getPublicCorsHeaders[\s\S]*?Allow-Headers':\s*'([^']+)'/); + const perOriginHeaders = corsSrc.match(/getCorsHeaders[\s\S]*?Allow-Headers':\s*'([^']+)'/); + assert.ok(pubHeaders && perOriginHeaders, 'Could not extract Allow-Headers from both functions'); + assert.equal(pubHeaders![1], perOriginHeaders![1], 'Allow-Headers should match between public and per-origin'); + }); +}); diff --git a/tests/gateway-cdn-cors.test.mts b/tests/gateway-cdn-cors.test.mts new file mode 100644 index 000000000..699fd17a1 --- /dev/null +++ b/tests/gateway-cdn-cors.test.mts @@ -0,0 +1,71 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const src = readFileSync(resolve(__dirname, '..', 'server', 'gateway.ts'), 'utf-8'); + +// Extract the GET 200 cache block (between "response.status === 200 && request.method === 'GET'" and the next closing brace at indentation 4) +const cacheBlock = (() => { + const start = src.indexOf("response.status === 200 && request.method === 'GET' && response.body"); + if (start === -1) return ''; + return src.slice(start, start + 3000); +})(); + +describe('gateway CDN CORS policy', () => { + it('sets ACAO: * when CDN-Cache-Control is present and route is not premium', () => { + assert.match( + cacheBlock, + /getPublicCorsHeaders/, + 'Cache block should call getPublicCorsHeaders for CDN-cached routes', + ); + assert.match( + cacheBlock, + /!PREMIUM_RPC_PATHS\.has\(pathname\)/, + 'Should guard public CORS behind premium path check', + ); + }); + + it('deletes Vary header when CDN-Cache-Control is present and route is not premium', () => { + assert.match( + cacheBlock, + /mergedHeaders\.delete\('Vary'\)/, + 'Should delete Vary header for non-premium CDN-cached routes', + ); + }); + + it('preserves per-origin ACAO for premium routes even with CDN-Cache-Control', () => { + // The ACAO: * block is guarded by !PREMIUM_RPC_PATHS.has(pathname), + // so premium routes skip it and keep per-origin CORS from corsHeaders + assert.match( + cacheBlock, + /if\s*\(!PREMIUM_RPC_PATHS\.has\(pathname\)\)/, + 'Public CORS should only apply when NOT a premium path', + ); + }); + + it('preserves per-origin ACAO for no-store tier', () => { + // no-store tier has cdnCache = null, so the ACAO: * block never runs + const tierMap = src.match(/'no-store':\s*null/); + assert.ok(tierMap, 'no-store CDN tier should be null (no CDN-Cache-Control)'); + }); + + it('preserves per-origin ACAO for POST requests', () => { + // The entire GET 200 block is guarded by request.method === 'GET' + assert.match( + cacheBlock, + /request\.method\s*===\s*'GET'/, + 'CDN cache block only applies to GET requests', + ); + }); + + it('imports getPublicCorsHeaders from cors module', () => { + assert.match( + src, + /import\s*\{[^}]*getPublicCorsHeaders[^}]*\}\s*from\s*'\.\/cors'/, + 'gateway.ts should import getPublicCorsHeaders', + ); + }); +}); diff --git a/tests/middleware-bot-cache.test.mts b/tests/middleware-bot-cache.test.mts new file mode 100644 index 000000000..969497ec5 --- /dev/null +++ b/tests/middleware-bot-cache.test.mts @@ -0,0 +1,35 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const src = readFileSync(resolve(__dirname, '..', 'middleware.ts'), 'utf-8'); + +describe('middleware bot responses', () => { + it('bot UA returns 403 with Cache-Control header', () => { + // Find the bot UA block (BOT_UA.test(ua)) followed by its Response + const botBlock = src.match(/if\s*\(BOT_UA\.test\(ua\)\)[\s\S]*?return new Response[\s\S]*?\}\);/); + assert.ok(botBlock, 'BOT_UA response block not found'); + assert.match(botBlock![0], /Cache-Control/, 'Bot 403 should include Cache-Control header'); + assert.match(botBlock![0], /max-age=86400/, 'Bot 403 should cache for 24h'); + }); + + it('short UA returns 403 with Cache-Control header', () => { + // Find the short UA block + const shortBlock = src.match(/ua\.length\s*<\s*10\)\s*\{[\s\S]*?return new Response[\s\S]*?\}\);/); + assert.ok(shortBlock, 'Short UA response block not found'); + assert.match(shortBlock![0], /Cache-Control/, 'Short UA 403 should include Cache-Control header'); + assert.match(shortBlock![0], /max-age=86400/, 'Short UA 403 should cache for 24h'); + }); + + it('social preview bots are allowed on /api/story', () => { + assert.match(src, /SOCIAL_PREVIEW_UA\.test\(ua\)/, 'Should check for social preview bots'); + assert.match(src, /SOCIAL_PREVIEW_PATHS\.has\(path\)/, 'Should allow social bots on specific paths'); + }); + + it('public API paths bypass bot filtering', () => { + assert.match(src, /PUBLIC_API_PATHS\.has\(path\)/, 'Should bypass bot filter for public paths'); + }); +});