From 894c6f91f73fdcbadce8d03b5ec76f55bafd0228 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 2 Nov 2026 00:50:25 +0100 Subject: [PATCH 1/3] fix(routing): apply authored headers without overwriting auth cache policy Fixes #579 --- docs/guide/edge/headers.md | 3 +- packages/void/src/router/compile.ts | 7 +- .../void/src/runtime/better-auth-shared.ts | 7 +- packages/void/src/runtime/routing.ts | 8 +- .../integration/auth-isr-cache-policy.test.ts | 102 +++++++++++++++++- .../native-asset-cache-policy.test.ts | 40 ++++++- .../void/test/unit/auth-middleware.test.ts | 78 +++++++++++++- packages/void/test/unit/compile-auth.test.ts | 22 ++++ .../void/test/unit/routing-runtime.test.ts | 73 ++++++++++++- 9 files changed, 321 insertions(+), 19 deletions(-) diff --git a/docs/guide/edge/headers.md b/docs/guide/edge/headers.md index e7dc43d84..2fffbcf34 100644 --- a/docs/guide/edge/headers.md +++ b/docs/guide/edge/headers.md @@ -23,7 +23,8 @@ Define custom response headers in [`void.config.ts`](../../reference/config) usi - **Pattern keys** start with `/`. `*` matches any characters including `/`. - **Header values** use `Name: value` format. The value may contain colons. - All matching rules are merged. When multiple rules set the same header name, the **last match wins**. -- User-defined `Cache-Control` overrides the built-in default. The default still applies when no rule matches. +- User-defined `Cache-Control` overrides the built-in default. The default still applies when no rule matches. Authentication can still require private or no-store caching for credentialed responses, auth endpoints, and responses that set cookies. +- Rules apply to matching error responses too, including 404 responses. An immutable cache rule can therefore cache a missing file. Use immutable caching only for URLs that change when their content changes. ## Scope diff --git a/packages/void/src/router/compile.ts b/packages/void/src/router/compile.ts index 611786510..ec71a59b0 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -294,9 +294,6 @@ export function compileRouteTable( if (emitEdgeRules && options.redirectRules?.length) { compileRoutingRulesMiddleware(lines, options.redirectRules, options.dev === true); } - if (emitEdgeRules && options.headerRules?.length) { - compileHeaderMiddleware(lines, options.headerRules); - } // After routing rules, which ignore the action key as platform dispatch does, // and before auth and user middleware, so their c.redirect() drops it too. @@ -314,6 +311,10 @@ export function compileRouteTable( lines.push(''); } + if (emitEdgeRules && options.headerRules?.length) { + compileHeaderMiddleware(lines, options.headerRules); + } + // Register user middleware for (let i = 0; i < scan.middleware.length; i++) { lines.push(`app.use(middleware${i});`); diff --git a/packages/void/src/runtime/better-auth-shared.ts b/packages/void/src/runtime/better-auth-shared.ts index e28111fe9..5da46dd8d 100644 --- a/packages/void/src/runtime/better-auth-shared.ts +++ b/packages/void/src/runtime/better-auth-shared.ts @@ -629,7 +629,12 @@ export function createVoidAuthRuntime( appendAuthHeaders( c, result.headers, - state !== null || credentialed || result.headers?.has('set-cookie') === true, + state !== null || + credentialed || + result.headers?.has('set-cookie') === true || + c.res.headers.has('set-cookie') || + c.req.path === '/api/auth' || + c.req.path.startsWith('/api/auth/'), isUnchangedStaticAsset(c), ); } diff --git a/packages/void/src/runtime/routing.ts b/packages/void/src/runtime/routing.ts index 46c143bab..dde1a73f7 100644 --- a/packages/void/src/runtime/routing.ts +++ b/packages/void/src/runtime/routing.ts @@ -238,15 +238,9 @@ export function createRoutingRuntime( if (!matched.length) { return; } - const headers = new Headers(c.res.headers); for (const [name, value] of matched) { - headers.set(name, value); + c.header(name, value); } - c.res = new Response(c.res.body, { - headers, - status: c.res.status, - statusText: c.res.statusText, - }); }, applyFallback: (c, rules) => fallbackHandled.has(c.req.raw) ? Promise.resolve(undefined) : applyRules(c, rules, true), diff --git a/packages/void/test/integration/auth-isr-cache-policy.test.ts b/packages/void/test/integration/auth-isr-cache-policy.test.ts index 711d5f868..e25806d00 100644 --- a/packages/void/test/integration/auth-isr-cache-policy.test.ts +++ b/packages/void/test/integration/auth-isr-cache-policy.test.ts @@ -10,7 +10,7 @@ import { createPagesFallback, createSsrFallback, } from '../../src/runtime/routing-pages.ts'; -import { createRoutingRuntime } from '../../src/runtime/routing.ts'; +import { createRoutingRuntime, type RoutingContext } from '../../src/runtime/routing.ts'; const env = { BETTER_AUTH_SECRET: 'local-test-secret-with-at-least-32-characters' }; const options = { dialect: 'sqlite' as const, providers: null }; @@ -100,6 +100,106 @@ describe('real anonymous Better Auth lookup cache policy', () => { }); }); +describe('auth cache policy with authored headers', () => { + function headerFixture() { + const database = memoryAdapter({ user: [], session: [], account: [], verification: [] }); + const runtime = createVoidAuthRuntime('sqlite', async () => {}); + const app = new Hono(); + const routing = createRoutingRuntime(app, { nodeTarget: true }); + app.use(routing.contextMiddleware); + app.use(async (c, next) => runtime.runVoidAuthMiddleware(c, next, options, database)); + app.use( + routing.headerMiddleware([ + { + pattern: '/*', + headers: [ + ['Cache-Control', 'public, max-age=31536000, immutable'], + ['X-Frame-Options', 'DENY'], + ], + }, + ]), + ); + app.on(['GET', 'POST'], '/api/auth/*', (c) => + runtime.handleVoidAuthRequest(c, options, database), + ); + return { app, routing }; + } + + it('retains anonymous auth endpoint cache protection under a public rule', async () => { + const { app } = headerFixture(); + const response = await app.request('https://app.test/api/auth/get-session', undefined, env); + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(response.headers.get('pragma')).toBe('no-cache'); + expect(response.headers.get('x-frame-options')).toBe('DENY'); + await expect(response.json()).resolves.toBeNull(); + }); + + it.each(['/api/auth/sign-in/email', '/login', '/fallback-login'])( + 'keeps an anonymous body-credential sign-in private through %s', + async (path) => { + const { app, routing } = headerFixture(); + const email = 'auth-headers@example.test'; + const password = 'correct-horse-battery-staple'; + app.post('/login', (c) => (c as RoutingContext).rewrite('/api/auth/sign-in/email')); + app.notFound( + async (c) => + (await routing.applyFallback(c, [ + { source: '/fallback-login', destination: '/api/auth/sign-in/email', status: 200 }, + ])) ?? c.text('not found', 404), + ); + const signUp = await app.request( + 'https://app.test/api/auth/sign-up/email', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Auth headers', email, password }), + }, + env, + ); + expect(signUp.status).toBe(200); + await signUp.arrayBuffer(); + const response = await app.request( + 'https://app.test' + path, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email, password }), + }, + env, + ); + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(response.headers.get('pragma')).toBe('no-cache'); + expect(response.headers.get('x-frame-options')).toBe('DENY'); + const cookie = response.headers + .getSetCookie() + .map((value) => value.split(';')[0]) + .join('; '); + expect(cookie).toContain('better-auth.session_token='); + await expect(response.json()).resolves.toMatchObject({ user: { email } }); + const session = await app.request( + 'https://app.test/api/auth/get-session', + { + headers: { cookie }, + }, + env, + ); + await expect(session.json()).resolves.toMatchObject({ user: { email } }); + }, + ); + + it('keeps authored headers on a user middleware redirect', async () => { + const { app } = headerFixture(); + app.use('/private', async (c) => c.redirect('/login')); + const response = await app.request('https://app.test/private', undefined, env); + expect(response.status).toBe(302); + expect(response.headers.get('location')).toBe('/login'); + expect(response.headers.get('x-frame-options')).toBe('DENY'); + await response.arrayBuffer(); + }); +}); + describe('real authenticated Better Auth session cache policy', () => { async function signedInApp( routeHeaders: Record, diff --git a/packages/void/test/integration/native-asset-cache-policy.test.ts b/packages/void/test/integration/native-asset-cache-policy.test.ts index 0d34072df..d95a7ad6f 100644 --- a/packages/void/test/integration/native-asset-cache-policy.test.ts +++ b/packages/void/test/integration/native-asset-cache-policy.test.ts @@ -149,10 +149,17 @@ beforeAll(async () => { import { createRoutingRuntime } from ${JSON.stringify(runtimeSource('routing.ts'))}; import { createErrorRenderer } from ${JSON.stringify(runtimeSource('routing-errors.ts'))}; const apps = {}; - for (const mode of ['pages', 'ssr', 'static']) { + for (const mode of ['pages', 'ssr', 'static', 'headers']) { const app = new Hono(); const routing = createRoutingRuntime(app, {}); app.use(routing.contextMiddleware); + if (mode === 'headers') { + app.use(routing.headerMiddleware([{ pattern: '/static/*', headers: [ + ['Cache-Control', 'public, max-age=31536000, immutable'], + ['X-Void-Header', 'configured'], + ] }])); + app.get('/health', (c) => c.text('healthy', 200, { 'cache-control': 'private, no-store' })); + } app.notFound(mode === 'pages' ? createPagesFallback({ routing, assetsDir: 'static', errors: createErrorRenderer({ projectRoot: '/fixture', homeDir: '/home' }) }) : mode === 'ssr' @@ -270,6 +277,37 @@ describe('native asset cache policy in real Workers', () => { }, ); + it.each(['css', 'javascript'])( + 'overrides an existing ASSETS cache policy for %s', + async (kind) => { + const file = kind === 'css' ? cssAsset : uncachedAsset; + const response = await runtime!.dispatchFetch(`https://app.test/${file}?mode=headers`); + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('public, max-age=31536000, immutable'); + expect(response.headers.get('x-void-header')).toBe('configured'); + expect(response.headers.get('content-type')).toContain( + kind === 'css' ? 'text/css' : 'javascript', + ); + expect(await response.text()).toContain(kind === 'css' ? 'color' : 'uncached'); + }, + ); + + it('applies matching headers to real asset misses and preserves unmatched responses', async () => { + const missing = await runtime!.dispatchFetch( + 'https://app.test/static/missing-abcdefgh.js?mode=headers', + ); + expect(missing.status).toBe(404); + expect(missing.headers.get('cache-control')).toBe('public, max-age=31536000, immutable'); + expect(missing.headers.get('x-void-header')).toBe('configured'); + await missing.arrayBuffer(); + + const unmatched = await runtime!.dispatchFetch('https://app.test/health?mode=headers'); + expect(unmatched.status).toBe(200); + expect(unmatched.headers.get('cache-control')).toBe('private, no-store'); + expect(unmatched.headers.has('x-void-header')).toBe(false); + await expect(unmatched.text()).resolves.toBe('healthy'); + }); + it('wraps an immutable Cache API response without mutating its headers or consuming its body', async () => { const response = await runtime!.dispatchFetch('https://app.test/cache-proof'); expect(response.status).toBe(200); diff --git a/packages/void/test/unit/auth-middleware.test.ts b/packages/void/test/unit/auth-middleware.test.ts index 296b1f90c..cb0a38329 100644 --- a/packages/void/test/unit/auth-middleware.test.ts +++ b/packages/void/test/unit/auth-middleware.test.ts @@ -16,6 +16,7 @@ import { privateCachePolicy, type VoidAuthRuntimeOptions, } from '../../src/runtime/better-auth-shared.ts'; +import { createRoutingRuntime } from '../../src/runtime/routing.ts'; import { markStaticAsset } from '../../src/runtime/static-asset-marker.ts'; const authRuntimeOptions: VoidAuthRuntimeOptions = { @@ -443,6 +444,74 @@ describe('runVoidAuthMiddleware static asset cache policy', () => { ); } + it.each([ + { + name: 'anonymous override', + cookie: false, + policy: 'public, max-age=60', + refresh: false, + expected: 'public, max-age=60', + }, + { + name: 'credentialed override', + cookie: true, + policy: 'public, max-age=60', + refresh: false, + expected: 'no-store', + }, + { + name: 'unchanged credentialed policy', + cookie: true, + policy: immutable, + refresh: false, + expected: 'private, max-age=31536000, immutable', + }, + { + name: 'session refresh', + cookie: true, + policy: immutable, + refresh: true, + expected: 'private, no-store', + }, + ])( + 'finalizes authored asset headers safely for $name', + async ({ cookie, policy, refresh, expected }) => { + const headers = sessionLookup(); + if (refresh) { + headers.append('set-cookie', 'session=rotated; Path=/; HttpOnly'); + } + const app = createApp({ response: null, headers }); + const routing = createRoutingRuntime(app, { nodeTarget: true }); + app.use( + routing.headerMiddleware([ + { + pattern: '/assets/*', + headers: [ + ['Cache-Control', policy], + ['X-Frame-Options', 'DENY'], + ], + }, + ]), + ); + app.get('/assets/client-abcdefgh.js', (c) => + markStaticAsset(c, new Response('export {}', { headers: { 'cache-control': immutable } })), + ); + const response = await app.request( + 'http://localhost/assets/client-abcdefgh.js', + { + headers: cookie ? { cookie: 'session=current' } : {}, + }, + env, + ); + expect(response.headers.get('cache-control')).toBe(expected); + expect(response.headers.get('x-frame-options')).toBe('DENY'); + expect(response.headers.getSetCookie()).toEqual( + refresh ? ['session=rotated; Path=/; HttpOnly'] : [], + ); + await expect(response.text()).resolves.toBe('export {}'); + }, + ); + it('makes the file policy private for a credentialed request', async () => { const response = await withCookie(assetApp(sessionLookup())); expect(response.headers.get('cache-control')).toBe('private, max-age=31536000, immutable'); @@ -500,10 +569,11 @@ describe('runVoidAuthMiddleware static asset cache policy', () => { it('keeps the file policy when later middleware only adds a header', async () => { const app = createApp({ response: null, headers: sessionLookup() }); - app.use(async (c, next) => { - await next(); - c.header('x-frame-options', 'DENY'); - }); + app.use( + createRoutingRuntime(app, { nodeTarget: true }).headerMiddleware([ + { pattern: '/assets/*', headers: [['X-Frame-Options', 'DENY']] }, + ]), + ); app.get('/assets/client-abcdefgh.js', (c) => markStaticAsset(c, new Response('export {}', { headers: { 'cache-control': immutable } })), ); diff --git a/packages/void/test/unit/compile-auth.test.ts b/packages/void/test/unit/compile-auth.test.ts index 83126adae..bd15914cc 100644 --- a/packages/void/test/unit/compile-auth.test.ts +++ b/packages/void/test/unit/compile-auth.test.ts @@ -50,6 +50,28 @@ describe('compileRouteTable auth integration', () => { expect(code).not.toContain('"/auth/login"'); }); + it('finalizes auth caching after header rules and user middleware', () => { + const code = compileRouteTable( + { ...emptyScan, middleware: [{ order: 1, filePath: '01.auth.ts' }] }, + '/routes', + '/middleware', + '/crons', + '/queues', + null, + { + ...baseOptions, + auth: sqliteAuth, + headerRules: [{ pattern: '/*', headers: [['Cache-Control', 'public, max-age=60']] }], + }, + ); + const auth = code.indexOf('return runVoidAuthMiddleware(c, next,'); + const headers = code.indexOf('app.use("*", __routingRuntime.headerMiddleware('); + const user = code.indexOf('app.use(middleware0)'); + expect(auth).toBeGreaterThan(-1); + expect(headers).toBeGreaterThan(auth); + expect(user).toBeGreaterThan(headers); + }); + it('mounts Better Auth middleware and routes when auth is enabled', () => { const code = compileRouteTable(emptyScan, '/routes', '/middleware', '/crons', '/queues', null, { ...baseOptions, diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts index 744c1ed1d..b2d12db85 100644 --- a/packages/void/test/unit/routing-runtime.test.ts +++ b/packages/void/test/unit/routing-runtime.test.ts @@ -120,6 +120,77 @@ describe('typed HTTP execution', () => { dev ? 'c.rewrite -> /new (middleware)' : null, ); }); + it.each([200, 404])( + 'overrides an existing response header with the last matching rule on %s', + async (status) => { + const app = new Hono(); + const runtime = createRoutingRuntime(app, { nodeTarget: true }); + app.use( + runtime.headerMiddleware([ + { pattern: '/assets/*', headers: [['Cache-Control', 'no-cache']] }, + { + pattern: '/assets/*', + headers: [['Cache-Control', 'public, max-age=31536000, immutable']], + }, + ]), + ); + app.get( + '/assets/app.css', + () => + new Response('body{}', { + status, + headers: { + 'Content-Type': 'text/css', + 'Cache-Control': 'public, max-age=0, must-revalidate', + }, + }), + ); + const response = await app.request('https://app.test/assets/app.css'); + expect(response.headers.get('cache-control')).toBe('public, max-age=31536000, immutable'); + expect(response.status).toBe(status); + expect(response.headers.get('content-type')).toBe('text/css'); + expect(await response.text()).toBe('body{}'); + }, + ); + it('updates immutable response headers and preserves unrelated cookies and metadata', async () => { + const app = new Hono(); + const runtime = createRoutingRuntime(app, { nodeTarget: true }); + app.use( + runtime.headerMiddleware([ + { + pattern: '/assets/*', + headers: [ + ['Location', '/new'], + ['X-Robots-Tag', 'noindex'], + ], + }, + ]), + ); + app.get('/assets/redirect', () => Response.redirect('https://old.test/', 302)); + app.get('/assets/cookies', () => { + const headers = new Headers({ 'Content-Type': 'text/css' }); + headers.append('Set-Cookie', 'one=1; Path=/'); + headers.append('Set-Cookie', 'two=2; Path=/'); + return new Response('body{}', { status: 202, statusText: 'Queued', headers }); + }); + app.get( + '/other', + () => new Response('other', { headers: { 'Cache-Control': 'private, no-store' } }), + ); + const redirect = await app.request('https://app.test/assets/redirect'); + expect(redirect.status).toBe(302); + expect(redirect.headers.get('location')).toBe('/new'); + const response = await app.request('https://app.test/assets/cookies'); + expect(response.status).toBe(202); + expect(response.statusText).toBe('Queued'); + expect(response.headers.get('content-type')).toBe('text/css'); + expect(response.headers.get('x-robots-tag')).toBe('noindex'); + expect(response.headers.getSetCookie()).toEqual(['one=1; Path=/', 'two=2; Path=/']); + expect(await response.text()).toBe('body{}'); + const other = await app.request('https://app.test/other'); + expect(other.headers.get('cache-control')).toBe('private, no-store'); + expect(other.headers.has('x-robots-tag')).toBe(false); + }); it('merges header rules in order after recursive fallback and stops fallback cycles', async () => { const app = new Hono(); const runtime = createRoutingRuntime(app, { nodeTarget: true, dev: true }); @@ -139,7 +210,7 @@ describe('typed HTTP execution', () => { ); const response = await app.fetch(new Request('https://app.test/a')); expect(response.status).toBe(404); - expect(response.headers.get('x-rule')).toBe('one'); + expect(response.headers.get('x-rule')).toBe('two'); expect(response.headers.get('X-Void-Routing')).toBe('fallback[/a] -> /b (unknown)'); expect(response.headers.get('X-Void-No-Match')).toBe('1'); }); From e406dff0d9dc216cca7d2582e57e43cbcd67d427 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 3 Nov 2026 15:04:31 +0800 Subject: [PATCH 2/3] fix(routing): keep asset, cookie and fallback behaviour with authored headers - Refresh the static-asset mark after a rule sets Cache-Control, so auth makes the authored file policy private for credentialed requests instead of replacing it with no-store. - Append Set-Cookie rules so the response's own cookies, including the Better Auth session, are kept. - Only count Better Auth cookies when deciding that a response needs the auth cache policy; app cookies keep the route's own policy. - Skip the outer header pass for fallback answers, so only the destination's rules apply, as in @void/edge and platform dispatch. - Docs: say where auth keeps private caching and what a platform does. Co-Authored-By: Claude Opus 5.5 --- docs/guide/edge/headers.md | 3 +- .../void/src/runtime/better-auth-shared.ts | 32 ++++++++++++- packages/void/src/runtime/routing.ts | 17 ++++++- .../void/test/unit/auth-middleware.test.ts | 28 +++++++++++- .../void/test/unit/routing-runtime.test.ts | 45 ++++++++++++++++++- 5 files changed, 120 insertions(+), 5 deletions(-) diff --git a/docs/guide/edge/headers.md b/docs/guide/edge/headers.md index e776ffa55..701e28b3e 100644 --- a/docs/guide/edge/headers.md +++ b/docs/guide/edge/headers.md @@ -23,7 +23,8 @@ Define custom response headers in [`void.config.ts`](../../reference/config) usi - **Pattern keys** start with `/`. `*` matches any characters including `/`. - **Header values** use `Name: value` format. The value may contain colons. - All matching rules are merged. When multiple rules set the same header name, the **last match wins**. -- User-defined `Cache-Control` overrides the built-in default. The default still applies when no rule matches. Authentication can still require private or no-store caching for credentialed responses, auth endpoints, and responses that set cookies. +- User-defined `Cache-Control` overrides the built-in default. The default still applies when no rule matches. +- With [authentication](../auth.md), responses to requests that send cookies or credentials stay private, and auth endpoints stay uncached, during local development, with `void deploy --platform cloudflare`, and on [other runtimes](../deployment.md#other-runtimes). On a [Void platform](../deployment.md#deploy-to-a-team-platform), a `Cache-Control` rule replaces the app's value, so do not match signed-in pages or `/api/auth/*` with a public cache rule. - Rules apply to matching error responses too, including 404 responses. An immutable cache rule can therefore cache a missing file. Use immutable caching only for URLs that change when their content changes. ## Scope diff --git a/packages/void/src/runtime/better-auth-shared.ts b/packages/void/src/runtime/better-auth-shared.ts index 5da46dd8d..bd10617d4 100644 --- a/packages/void/src/runtime/better-auth-shared.ts +++ b/packages/void/src/runtime/better-auth-shared.ts @@ -333,6 +333,36 @@ function bareCacheDirectives(policy: string | null): Set { * `s-maxage` and a field-scoped `private` give way to a leading `private`. * A policy that is already private or no-store stays as it is. */ +/** + * True when the response sets a Better Auth cookie, such as the session from a + * sign-in reached through a rewrite or fallback. A cookie the app sets itself + * keeps the route's own cache policy. + */ +function setsAuthCookie( + headers: Headers, + advanced: + | { cookiePrefix?: string; cookies?: Record } + | undefined, +): boolean { + const prefix = `${advanced?.cookiePrefix || 'better-auth'}.`; + const custom = new Set(); + for (const cookie of Object.values(advanced?.cookies ?? {})) { + if (cookie?.name) { + custom.add(cookie.name); + } + } + return headers.getSetCookie().some((value) => { + const equals = value.indexOf('='); + let name = (equals === -1 ? value : value.slice(0, equals)).trim(); + for (const secure of ['__Secure-', '__Host-']) { + if (name.startsWith(secure)) { + name = name.slice(secure.length); + } + } + return name.startsWith(prefix) || custom.has(name); + }); +} + export function privateCachePolicy(policy: string | null): string { const directives = splitCacheDirectives(policy ?? ''); if (directives.length === 0) { @@ -632,7 +662,7 @@ export function createVoidAuthRuntime( state !== null || credentialed || result.headers?.has('set-cookie') === true || - c.res.headers.has('set-cookie') || + setsAuthCookie(c.res.headers, auth.options.advanced) || c.req.path === '/api/auth' || c.req.path.startsWith('/api/auth/'), isUnchangedStaticAsset(c), diff --git a/packages/void/src/runtime/routing.ts b/packages/void/src/runtime/routing.ts index 7aa99b668..49ba57741 100644 --- a/packages/void/src/runtime/routing.ts +++ b/packages/void/src/runtime/routing.ts @@ -21,6 +21,7 @@ import { convertReturnValue } from './response.ts'; import { toUrlString } from './route-url.ts'; import { ASSET_EXTENSIONS } from './routing-assets.ts'; import { markRouteHandler } from './routing-pages.ts'; +import { isUnchangedStaticAsset, markStaticAsset } from './static-asset-marker.ts'; export { createErrorRenderer } from './routing-errors.ts'; export type RoutingRule = { @@ -64,6 +65,9 @@ export function createRoutingRuntime( ): RoutingRuntime { const originalUrls = new WeakMap(); const fallbackHandled = new WeakSet(); + // Outer requests a fallback answered. The fallback already applied the + // destination's header rules, as edge routing and platform dispatch do. + const fallbackServed = new WeakSet(); const warnedPatterns = new Set(); const requestContext = new AsyncLocalStorage(); const assetExtensions = options.blockedAssetExtensions ?? ASSET_EXTENSIONS; @@ -135,6 +139,7 @@ export function createRoutingRuntime( keepActionKey(c.req.raw, request, search); if (fallback) { fallbackHandled.add(request); + fallbackServed.add(c.req.raw); } const response = await dispatch(request, c); const kind = rule.force === true ? 'rewrite' : 'fallback'; @@ -231,6 +236,9 @@ export function createRoutingRuntime( }, headerMiddleware: (rules) => async (c, next) => { await next(); + if (fallbackServed.has(c.req.raw)) { + return; + } const pathname = new URL(c.req.url).pathname; const matched: Array<[string, string]> = []; for (const rule of rules) { @@ -247,8 +255,15 @@ export function createRoutingRuntime( if (!matched.length) { return; } + const asset = isUnchangedStaticAsset(c); for (const [name, value] of matched) { - c.header(name, value); + // A Set-Cookie rule adds its cookie and keeps the response's own cookies. + c.header(name, value, { append: name.toLowerCase() === 'set-cookie' }); + } + if (asset) { + // A configured Cache-Control is the file's policy from here on, so auth + // still treats the response as a static file and only makes it private. + markStaticAsset(c, c.res); } }, applyFallback: (c, rules) => diff --git a/packages/void/test/unit/auth-middleware.test.ts b/packages/void/test/unit/auth-middleware.test.ts index cb0a38329..ebf97136b 100644 --- a/packages/void/test/unit/auth-middleware.test.ts +++ b/packages/void/test/unit/auth-middleware.test.ts @@ -30,6 +30,7 @@ function createApp(sessionResult: { response: AuthState | null; headers: Headers mocks.getSession.mockResolvedValue(sessionResult); mocks.betterAuth.mockReturnValue({ api: { getSession: mocks.getSession }, + options: {}, }); const app = new Hono(); @@ -457,7 +458,7 @@ describe('runVoidAuthMiddleware static asset cache policy', () => { cookie: true, policy: 'public, max-age=60', refresh: false, - expected: 'no-store', + expected: 'private, max-age=60', }, { name: 'unchanged credentialed policy', @@ -504,6 +505,7 @@ describe('runVoidAuthMiddleware static asset cache policy', () => { env, ); expect(response.headers.get('cache-control')).toBe(expected); + expect(response.headers.get('pragma')).toBeNull(); expect(response.headers.get('x-frame-options')).toBe('DENY'); expect(response.headers.getSetCookie()).toEqual( refresh ? ['session=rotated; Path=/; HttpOnly'] : [], @@ -512,6 +514,30 @@ describe('runVoidAuthMiddleware static asset cache policy', () => { }, ); + it.each([ + { name: 'an app cookie', cookie: 'theme=dark; Path=/', expected: 'private, max-age=300' }, + { + name: 'a Better Auth cookie', + cookie: '__Secure-better-auth.session_token=abc; Path=/; HttpOnly', + expected: 'no-store', + }, + ])( + 'decides the cache policy of an anonymous response that sets $name', + async ({ cookie, expected }) => { + const app = createApp({ response: null, headers: sessionLookup() }); + app.get( + '/prefs', + () => + new Response('ok', { + headers: { 'cache-control': 'private, max-age=300', 'set-cookie': cookie }, + }), + ); + const response = await app.request('http://localhost/prefs', {}, env); + expect(response.headers.get('cache-control')).toBe(expected); + expect(response.headers.getSetCookie()).toEqual([cookie]); + }, + ); + it('makes the file policy private for a credentialed request', async () => { const response = await withCookie(assetApp(sessionLookup())); expect(response.headers.get('cache-control')).toBe('private, max-age=31536000, immutable'); diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts index da7a7deea..b753ae46d 100644 --- a/packages/void/test/unit/routing-runtime.test.ts +++ b/packages/void/test/unit/routing-runtime.test.ts @@ -251,6 +251,48 @@ describe('typed HTTP execution', () => { expect(other.headers.get('cache-control')).toBe('private, no-store'); expect(other.headers.has('x-robots-tag')).toBe(false); }); + it('adds a Set-Cookie rule next to the response cookies', async () => { + const app = new Hono(); + const runtime = createRoutingRuntime(app, { nodeTarget: true }); + app.use( + runtime.headerMiddleware([{ pattern: '/*', headers: [['Set-Cookie', 'consent=1; Path=/']] }]), + ); + app.get('/sign-in', (c) => { + c.header('Set-Cookie', 'session=abc; Path=/; HttpOnly', { append: true }); + return c.text('ok'); + }); + const response = await app.request('https://app.test/sign-in'); + expect(response.headers.getSetCookie()).toEqual([ + 'session=abc; Path=/; HttpOnly', + 'consent=1; Path=/', + ]); + }); + it('does not give a fallback page the rules of the missing path', async () => { + const app = new Hono(); + const runtime = createRoutingRuntime(app, { nodeTarget: true }); + app.use(runtime.contextMiddleware); + app.use( + runtime.headerMiddleware([ + { + pattern: '/assets/*', + headers: [['Cache-Control', 'public, max-age=31536000, immutable']], + }, + ]), + ); + app.get( + '/index.html', + (c) => new Response('', { headers: { 'Cache-Control': 'no-cache' } }), + ); + app.notFound( + async (c) => + (await runtime.applyFallback(c, [ + { source: '/*', destination: '/index.html', status: 200 }, + ])) ?? c.text('missing', 404), + ); + const response = await app.fetch(new Request('https://app.test/assets/old-chunk.js')); + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('no-cache'); + }); it('merges header rules in order after recursive fallback and stops fallback cycles', async () => { const app = new Hono(); const runtime = createRoutingRuntime(app, { nodeTarget: true, dev: true }); @@ -270,7 +312,8 @@ describe('typed HTTP execution', () => { ); const response = await app.fetch(new Request('https://app.test/a')); expect(response.status).toBe(404); - expect(response.headers.get('x-rule')).toBe('two'); + // Only the destination's rules apply, as in edge routing and platform dispatch. + expect(response.headers.get('x-rule')).toBe('one'); expect(response.headers.get('X-Void-Routing')).toBe('fallback[/a] -> /b (unknown)'); expect(response.headers.get('X-Void-No-Match')).toBe('1'); }); From 8910d96a40be76152002ae8926fc28bbeb1996cd Mon Sep 17 00:00:00 2001 From: David Date: Tue, 3 Nov 2026 18:44:20 +0100 Subject: [PATCH 3/3] fix(routing): protect auth rewrites and error headers in #669 --- packages/void/src/router/compile.ts | 17 ++-- packages/void/src/runtime/auth-response.ts | 2 + .../void/src/runtime/better-auth-shared.ts | 9 +- packages/void/src/runtime/routing.ts | 65 +++++++++--- .../integration/auth-isr-cache-policy.test.ts | 98 ++++++++++++++++--- packages/void/test/unit/compile-auth.test.ts | 8 +- 6 files changed, 161 insertions(+), 38 deletions(-) create mode 100644 packages/void/src/runtime/auth-response.ts diff --git a/packages/void/src/router/compile.ts b/packages/void/src/router/compile.ts index ec71a59b0..02dd6e96a 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -304,15 +304,22 @@ export function compileRouteTable( lines.push('app.use(__createNamedActionMarker(__matchedRoutes));'); } + const headerRules = emitEdgeRules ? options.headerRules : undefined; + if (headerRules?.length) { + lines.push(`const __headerRules = ${JSON.stringify(headerRules)};`); + } if (auth) { + if (headerRules?.length) { + lines.push('app.use("*", __routingRuntime.headerMiddleware(__headerRules, "auth-error"));'); + } lines.push('app.use(async (c, next) => {'); lines.push(' return runVoidAuthMiddleware(c, next, __voidAuthOptions, __voidAuthDatabase);'); lines.push('});'); lines.push(''); } - if (emitEdgeRules && options.headerRules?.length) { - compileHeaderMiddleware(lines, options.headerRules); + if (headerRules?.length) { + lines.push('app.use("*", __routingRuntime.headerMiddleware(__headerRules));'); } // Register user middleware @@ -1487,12 +1494,6 @@ function compileRoutingRulesMiddleware( lines.push('app.use("*", __routingRuntime.routingMiddleware(__routingRules));'); } -function compileHeaderMiddleware(lines: Array, rules: Array): void { - lines.push('// Header rules (applied to responses)'); - lines.push(`const __headerRules = ${JSON.stringify(rules)};`); - lines.push('app.use("*", __routingRuntime.headerMiddleware(__headerRules));'); -} - /** Generate the server-side fetch client, which calls `app.fetch()` with the current request's credentials. */ export function compileWorkerClient(authClientPath: string, nodeTarget = false): string { const envImport = nodeTarget ? '' : 'import { env } from "cloudflare:workers";\n'; diff --git a/packages/void/src/runtime/auth-response.ts b/packages/void/src/runtime/auth-response.ts new file mode 100644 index 000000000..433be71b3 --- /dev/null +++ b/packages/void/src/runtime/auth-response.ts @@ -0,0 +1,2 @@ +// Requests whose response depends on auth, including parent requests after a rewrite. +export const authDependentRequests: WeakSet = new WeakSet(); diff --git a/packages/void/src/runtime/better-auth-shared.ts b/packages/void/src/runtime/better-auth-shared.ts index bd10617d4..92ef5928d 100644 --- a/packages/void/src/runtime/better-auth-shared.ts +++ b/packages/void/src/runtime/better-auth-shared.ts @@ -10,6 +10,7 @@ import { type VoidAuthProvider, type VoidSocialAuthProvider, } from '../auth/providers.ts'; +import { authDependentRequests } from './auth-response.ts'; import { authContext, type AuthState, type VoidAuthConfig } from './auth.ts'; import { isUnchangedStaticAsset } from './static-asset-marker.ts'; @@ -649,6 +650,11 @@ export function createVoidAuthRuntime( c.set('user', state?.user ?? null); c.set('session', state?.session ?? null); + const authEndpoint = c.req.path === '/api/auth' || c.req.path.startsWith('/api/auth/'); + if (authEndpoint) { + authDependentRequests.add(c.req.raw); + } + await authContext.run({ user: state?.user ?? null, state }, async () => { await next(); }); @@ -663,8 +669,7 @@ export function createVoidAuthRuntime( credentialed || result.headers?.has('set-cookie') === true || setsAuthCookie(c.res.headers, auth.options.advanced) || - c.req.path === '/api/auth' || - c.req.path.startsWith('/api/auth/'), + authEndpoint, isUnchangedStaticAsset(c), ); } diff --git a/packages/void/src/runtime/routing.ts b/packages/void/src/runtime/routing.ts index 49ba57741..2f94c5a6a 100644 --- a/packages/void/src/runtime/routing.ts +++ b/packages/void/src/runtime/routing.ts @@ -16,6 +16,7 @@ import { markActionKey, searchWithoutActionKey, } from '../pages/action-key.ts'; +import { authDependentRequests } from './auth-response.ts'; import { VoidAssetRewriteError } from './handler.ts'; import { convertReturnValue } from './response.ts'; import { toUrlString } from './route-url.ts'; @@ -24,6 +25,13 @@ import { markRouteHandler } from './routing-pages.ts'; import { isUnchangedStaticAsset, markStaticAsset } from './static-asset-marker.ts'; export { createErrorRenderer } from './routing-errors.ts'; +// These targeted policies can take precedence over Cache-Control at a CDN. +const sharedCacheHeaders = [ + 'cdn-cache-control', + 'cloudflare-cdn-cache-control', + 'surrogate-control', +]; + export type RoutingRule = { source: string; destination: string; @@ -48,7 +56,7 @@ export type RoutingRuntime = { contextMiddleware: MiddlewareHandler; requestMiddleware: MiddlewareHandler; routingMiddleware: (rules: Array) => MiddlewareHandler; - headerMiddleware: (rules: Array) => MiddlewareHandler; + headerMiddleware: (rules: Array, phase?: 'auth-error') => MiddlewareHandler; applyFallback: (context: Context, rules: Array) => Promise; markNoMatch: (request: Request, response: Response) => Response; }; @@ -68,6 +76,7 @@ export function createRoutingRuntime( // Outer requests a fallback answered. The fallback already applied the // destination's header rules, as edge routing and platform dispatch do. const fallbackServed = new WeakSet(); + const headerEntered = new WeakSet(); const warnedPatterns = new Set(); const requestContext = new AsyncLocalStorage(); const assetExtensions = options.blockedAssetExtensions ?? ASSET_EXTENSIONS; @@ -86,10 +95,19 @@ export function createRoutingRuntime( headers, }); }; - const dispatch = (request: Request, c: Context): Promise => - Promise.resolve( - options.nodeTarget ? app.fetch(request, c.env) : app.fetch(request, c.env, c.executionCtx), + const dispatch = async (request: Request, c: Context, trace: string): Promise => { + const response = annotate( + await (options.nodeTarget + ? app.fetch(request, c.env) + : app.fetch(request, c.env, c.executionCtx)), + trace, ); + if (authDependentRequests.has(request)) { + // The enclosing response may depend on auth even if middleware clones or replaces it. + authDependentRequests.add(c.req.raw); + } + return response; + }; const rememberOriginal = (request: Request, c: Context): void => { originalUrls.set(request, originalUrls.get(c.req.raw) ?? new URL(c.req.url)); }; @@ -141,10 +159,10 @@ export function createRoutingRuntime( fallbackHandled.add(request); fallbackServed.add(c.req.raw); } - const response = await dispatch(request, c); const kind = rule.force === true ? 'rewrite' : 'fallback'; - return annotate( - response, + return dispatch( + request, + c, `${kind}[${rule.source}] -> ${destination} (${rule.origin || 'unknown'})`, ); } @@ -190,9 +208,7 @@ export function createRoutingRuntime( const request = new Request(url.toString(), c.req.raw); rememberOriginal(request, c); keepActionKey(c.req.raw, request, search); - return dispatch(request, c).then((response) => - annotate(response, `c.rewrite -> ${path} (middleware)`), - ); + return dispatch(request, c, `c.rewrite -> ${path} (middleware)`); }; const redirect = context.redirect.bind(context); c.redirect = (destination, status) => { @@ -234,8 +250,26 @@ export function createRoutingRuntime( } } }, - headerMiddleware: (rules) => async (c, next) => { + headerMiddleware: (rules, phase) => async (c, next) => { + const authErrorsOnly = phase === 'auth-error'; + if (!authErrorsOnly) { + headerEntered.add(c); + } await next(); + if (authErrorsOnly && (!c.error || headerEntered.has(c))) { + return; + } + const protectCache = authErrorsOnly || authDependentRequests.has(c.req.raw); + if (protectCache) { + authDependentRequests.add(c.req.raw); + c.header('Cache-Control', 'no-store'); + c.header('Pragma', 'no-cache'); + for (const name of sharedCacheHeaders) { + if (c.res.headers.has(name)) { + c.header(name, undefined); + } + } + } if (fallbackServed.has(c.req.raw)) { return; } @@ -257,8 +291,15 @@ export function createRoutingRuntime( } const asset = isUnchangedStaticAsset(c); for (const [name, value] of matched) { + const key = name.toLowerCase(); + if ( + protectCache && + (key === 'cache-control' || key === 'pragma' || sharedCacheHeaders.includes(key)) + ) { + continue; + } // A Set-Cookie rule adds its cookie and keeps the response's own cookies. - c.header(name, value, { append: name.toLowerCase() === 'set-cookie' }); + c.header(name, value, { append: key === 'set-cookie' }); } if (asset) { // A configured Cache-Control is the file's policy from here on, so auth diff --git a/packages/void/test/integration/auth-isr-cache-policy.test.ts b/packages/void/test/integration/auth-isr-cache-policy.test.ts index e25806d00..a0e634e19 100644 --- a/packages/void/test/integration/auth-isr-cache-policy.test.ts +++ b/packages/void/test/integration/auth-isr-cache-policy.test.ts @@ -10,7 +10,11 @@ import { createPagesFallback, createSsrFallback, } from '../../src/runtime/routing-pages.ts'; -import { createRoutingRuntime, type RoutingContext } from '../../src/runtime/routing.ts'; +import { + createRoutingRuntime, + type RoutingContext, + type RoutingHeaderRule, +} from '../../src/runtime/routing.ts'; const env = { BETTER_AUTH_SECRET: 'local-test-secret-with-at-least-32-characters' }; const options = { dialect: 'sqlite' as const, providers: null }; @@ -101,24 +105,27 @@ describe('real anonymous Better Auth lookup cache policy', () => { }); describe('auth cache policy with authored headers', () => { - function headerFixture() { + const cdnHeaders = ['CDN-Cache-Control', 'Cloudflare-CDN-Cache-Control', 'Surrogate-Control']; + function headerFixture(dev = false) { const database = memoryAdapter({ user: [], session: [], account: [], verification: [] }); const runtime = createVoidAuthRuntime('sqlite', async () => {}); const app = new Hono(); - const routing = createRoutingRuntime(app, { nodeTarget: true }); + const routing = createRoutingRuntime(app, { nodeTarget: true, dev }); + const rules: Array = [ + { + pattern: '/*', + headers: [ + ['Cache-Control', 'public, max-age=31536000, immutable'], + ...cdnHeaders.map((name): [string, string] => [name, 'max-age=600']), + ['X-Frame-Options', 'DENY'], + ['Access-Control-Allow-Origin', 'https://client.test'], + ], + }, + ]; app.use(routing.contextMiddleware); + app.use(routing.headerMiddleware(rules, 'auth-error')); app.use(async (c, next) => runtime.runVoidAuthMiddleware(c, next, options, database)); - app.use( - routing.headerMiddleware([ - { - pattern: '/*', - headers: [ - ['Cache-Control', 'public, max-age=31536000, immutable'], - ['X-Frame-Options', 'DENY'], - ], - }, - ]), - ); + app.use(routing.headerMiddleware(rules)); app.on(['GET', 'POST'], '/api/auth/*', (c) => runtime.handleVoidAuthRequest(c, options, database), ); @@ -132,9 +139,69 @@ describe('auth cache policy with authored headers', () => { expect(response.headers.get('cache-control')).toBe('no-store'); expect(response.headers.get('pragma')).toBe('no-cache'); expect(response.headers.get('x-frame-options')).toBe('DENY'); + for (const name of cdnHeaders) { + expect(response.headers.has(name)).toBe(false); + } await expect(response.json()).resolves.toBeNull(); }); + it.each([false, true])('keeps a nested session rewrite private (dev=%s)', async (dev) => { + const { app } = headerFixture(dev); + app.get('/session', async (c) => + (await (c as RoutingContext).rewrite('/session-alias')).clone(), + ); + app.get('/session-alias', (c) => (c as RoutingContext).rewrite('/api/auth/get-session')); + + const response = await app.request('https://app.test/session', undefined, env); + + expect(response.status).toBe(200); + expect(response.headers.has('set-cookie')).toBe(false); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(response.headers.get('pragma')).toBe('no-cache'); + expect(response.headers.get('x-frame-options')).toBe('DENY'); + for (const name of cdnHeaders) { + expect(response.headers.has(name)).toBe(false); + } + await expect(response.json()).resolves.toBeNull(); + }); + + it('protects a HEAD session rewrite when Hono merges response headers', async () => { + const { app } = headerFixture(); + app.get('/session', (c) => { + c.res.headers.set('cache-control', 'public, max-age=60'); + c.res.headers.set('cdn-cache-control', 'max-age=90'); + return (c as RoutingContext).rewrite('/api/auth/get-session'); + }); + + const method = { method: 'HEAD' }; + const direct = await app.request('https://app.test/api/auth/get-session', method, env); + const response = await app.request('https://app.test/session', method, env); + + expect(response.status).toBe(direct.status); + expect(direct.headers.get('cache-control')).toBe('no-store'); + expect(response.body).toBeNull(); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(response.headers.has('cdn-cache-control')).toBe(false); + }); + + it('keeps authored headers when auth fails before routing', async () => { + const { app } = headerFixture(); + app.onError( + createErrorRenderer({ dev: false, projectRoot: process.cwd(), homeDir: process.cwd() }) + .onError, + ); + app.get('/page', (c) => c.text('page')); + + const response = await app.request('https://app.test/page', undefined, {}); + + expect(response.status).toBe(500); + expect(response.headers.get('access-control-allow-origin')).toBe('https://client.test'); + expect(response.headers.get('cache-control')).toBe('no-store'); + for (const name of cdnHeaders) { + expect(response.headers.has(name)).toBe(false); + } + }); + it.each(['/api/auth/sign-in/email', '/login', '/fallback-login'])( 'keeps an anonymous body-credential sign-in private through %s', async (path) => { @@ -193,6 +260,9 @@ describe('auth cache policy with authored headers', () => { const { app } = headerFixture(); app.use('/private', async (c) => c.redirect('/login')); const response = await app.request('https://app.test/private', undefined, env); + for (const name of cdnHeaders) { + expect(response.headers.get(name)).toBe('max-age=600'); + } expect(response.status).toBe(302); expect(response.headers.get('location')).toBe('/login'); expect(response.headers.get('x-frame-options')).toBe('DENY'); diff --git a/packages/void/test/unit/compile-auth.test.ts b/packages/void/test/unit/compile-auth.test.ts index bd15914cc..02862273f 100644 --- a/packages/void/test/unit/compile-auth.test.ts +++ b/packages/void/test/unit/compile-auth.test.ts @@ -65,9 +65,13 @@ describe('compileRouteTable auth integration', () => { }, ); const auth = code.indexOf('return runVoidAuthMiddleware(c, next,'); - const headers = code.indexOf('app.use("*", __routingRuntime.headerMiddleware('); + const errorHeaders = code.indexOf( + '__routingRuntime.headerMiddleware(__headerRules, "auth-error")', + ); + const headers = code.indexOf('__routingRuntime.headerMiddleware(__headerRules)'); const user = code.indexOf('app.use(middleware0)'); - expect(auth).toBeGreaterThan(-1); + expect(errorHeaders).toBeGreaterThan(-1); + expect(auth).toBeGreaterThan(errorHeaders); expect(headers).toBeGreaterThan(auth); expect(user).toBeGreaterThan(headers); });