diff --git a/SECURITY.md b/SECURITY.md index ce0199d..e4cf57e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -18,10 +18,10 @@ Pinned by `integration/better-auth.test.ts`: `email policy: secret-keyed codes, - **FAIL IF** a state-changing POST is accepted without an exact `Origin` match, a signature-verified CSRF cookie, a matching `X-CSRF-Token` and an `application/json` body; inspect `protectAuth` in `better-auth-security.ts`. - **FAIL IF** an upstream Better Auth route outside the read/write allowlist answers with anything but 404, or a provider callback accepts a method other than GET and Apple's `form_post` relay; inspect `protectAuth`. -- **FAIL IF** a response leaves without `Cache-Control: no-store`, `Referrer-Policy: no-referrer`, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY` and a CSP denying framing, inline script and third-party sources; inspect `protectAuth`. +- **FAIL IF** a response, including a Workers setup or teardown failure, leaves without `Cache-Control: no-store`, `Referrer-Policy: no-referrer`, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY` and a CSP denying framing, inline script and third-party sources; inspect `authSecurityHeaders`, `protectAuth` and `createBetterAuthWorker`. - **FAIL IF** the app accepts a non-canonical origin, a secret under 32 characters, or a success/error path off the application origin; inspect `authOptions` in `better-auth.ts`. -Pinned by `integration/better-auth.test.ts`: `auth surface: explicit CSRF, exact origin, security headers and disabled unused endpoints`, `auth surface: an allowlisted write with a non-JSON body answers 415`, `auth options reject a non-canonical origin, a short secret and off-origin redirect paths`, `Better Auth email rejects expired codes and cross-origin sign-in`; `integration/better-auth-oauth.test.ts`: `Better Auth OAuth: Apple form_post relay, wrong browser, mismatched provider and expired state`, `Better Auth OAuth: callbacks refuse every method but GET and Apple's form-encoded POST`. +Pinned by `integration/better-auth.test.ts`: `auth surface: explicit CSRF, exact origin, security headers and disabled unused endpoints`, `auth surface: an allowlisted write with a non-JSON body answers 415`, `auth options reject a non-canonical origin, a short secret and off-origin redirect paths`, `Better Auth email rejects expired codes and cross-origin sign-in`; `integration/better-auth-oauth.test.ts`: `Better Auth OAuth: Apple form_post relay, wrong browser, mismatched provider and expired state`, `Better Auth OAuth: callbacks refuse every method but GET and Apple's form-encoded POST`; `integration/better-auth-workers.test.ts`: `Workers auth setup failures return a safe 503 with security headers`. ## Email codes diff --git a/packages/auth/src/better-auth-security.ts b/packages/auth/src/better-auth-security.ts index e379502..a5939d7 100644 --- a/packages/auth/src/better-auth-security.ts +++ b/packages/auth/src/better-auth-security.ts @@ -1,6 +1,6 @@ import { createHmac, timingSafeEqual } from 'node:crypto'; import { sql } from 'kysely'; -import type { Hono } from 'hono'; +import type { Hono, MiddlewareHandler } from 'hono'; import { isIdentityEmail } from './better-auth-email.ts'; import { bodyLimit } from 'hono/body-limit'; import type { connectDatabase } from 'pgstencil/postgres'; @@ -131,6 +131,20 @@ async function consume( return result.rows.length === 1; } +/** Apply the same policy to auth responses and the Workers adapter's fallbacks. */ +export const authSecurityHeaders: MiddlewareHandler = async (c, next) => { + await next(); + // Apply to the final response, including upstream immutable redirects. + c.header('Cache-Control', 'no-store'); + c.header('Referrer-Policy', 'no-referrer'); + c.header('X-Content-Type-Options', 'nosniff'); + c.header('X-Frame-Options', 'DENY'); + c.header( + 'Content-Security-Policy', + "default-src 'none'; script-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'", + ); +}; + export function protectAuth( app: Hono, options: { @@ -170,18 +184,7 @@ export function protectAuth( c.req.raw = new Request(c.req.raw, { headers }); await next(); }); - app.use('*', async (c, next) => { - await next(); - // Apply to the final response, including upstream immutable redirects. - c.header('Cache-Control', 'no-store'); - c.header('Referrer-Policy', 'no-referrer'); - c.header('X-Content-Type-Options', 'nosniff'); - c.header('X-Frame-Options', 'DENY'); - c.header( - 'Content-Security-Policy', - "default-src 'none'; script-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'", - ); - }); + app.use('*', authSecurityHeaders); app.use('/api/auth/*', bodyLimit({ maxSize: 16 * 1024 })); app.get('/api/auth/csrf', (c) => { let value = cookies(c.req.raw).get(cookieName); diff --git a/packages/auth/src/better-auth-workers.ts b/packages/auth/src/better-auth-workers.ts index 9f73cc5..2a2fd38 100644 --- a/packages/auth/src/better-auth-workers.ts +++ b/packages/auth/src/better-auth-workers.ts @@ -6,6 +6,7 @@ import { import { Hono } from 'hono'; import type { EmailSender } from 'pgstencil'; import { createAuthApp, type AuthAppOptions } from './better-auth.ts'; +import { authSecurityHeaders } from './better-auth-security.ts'; import { oauthFromEnvironment, providers, @@ -41,6 +42,8 @@ export function createBetterAuthWorker( }, ) { const app = new Hono<{ Bindings: E }>(); + // Setup, teardown and diagnostics can return errors outside the inner app. + app.use('*', authSecurityHeaders); app.all('*', async (c) => { const handle = async () => { if (new URL(c.env.APP_ORIGIN).protocol !== 'https:') diff --git a/tests/integration/better-auth-workers.test.ts b/tests/integration/better-auth-workers.test.ts index 580fed3..5efc26b 100644 --- a/tests/integration/better-auth-workers.test.ts +++ b/tests/integration/better-auth-workers.test.ts @@ -180,6 +180,34 @@ async function login(f: Fixture, address = 'worker@example.test') { }; } +test('Workers auth setup failures return a safe 503 with security headers', async ({ + onTestFinished, +}) => { + const bundle = await bundles[0]!; + const worker = new Miniflare( + convertV4MiniflareOptions({ + modules: true, + script: bundle.outputFiles[0]!.text, + compatibilityDate: '2026-09-08', + compatibilityFlags: ['nodejs_compat'], + bindings: { APP_ORIGIN: 'http://misconfigured.example.test' }, + }), + ); + onTestFinished(() => worker.dispose()); + const response = await worker.dispatchFetch(origin + '/api/auth/get-session'); + expect(response.status).toBe(503); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(response.headers.get('referrer-policy')).toBe('no-referrer'); + expect(response.headers.get('x-content-type-options')).toBe('nosniff'); + expect(response.headers.get('x-frame-options')).toBe('DENY'); + expect(response.headers.get('content-security-policy')).toBe( + "default-src 'none'; script-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'", + ); + expect(await response.json()).toEqual({ + message: 'Sign-in is temporarily unavailable. Please try again.', + }); +}); + test('Better Auth in workerd: deterministic replay, separate clocks, shared database rate limits', async ({ onTestFinished, }) => { diff --git a/tests/unit/better-auth-workers.test.ts b/tests/unit/better-auth-workers.test.ts new file mode 100644 index 0000000..8fbdc33 --- /dev/null +++ b/tests/unit/better-auth-workers.test.ts @@ -0,0 +1,51 @@ +import { test, expect } from 'vitest'; +import { createBetterAuthWorker } from '../../packages/auth/src/better-auth-workers.ts'; +import type { DiagnosticRecord } from '../../packages/pgstencil/src/diagnostics.ts'; + +for (const diagnostics of [false, true]) + for (const failure of ['insecure origin', 'email factory']) + test(`Workers auth errors keep security headers: ${failure}, diagnostics ${diagnostics}`, async () => { + const records: DiagnosticRecord[] = []; + const privateError = 'private-email@example.test secret-token'; + const app = createBetterAuthWorker({ + ...(diagnostics + ? { + diagnostics: { + sink: (record: DiagnosticRecord) => records.push(record), + }, + } + : {}), + email: () => { + if (failure === 'email factory') throw new Error(privateError); + return { send: async () => {} }; + }, + }); + const response = await app.fetch( + new Request('https://auth.example.test/api/auth/get-session'), + { + APP_ORIGIN: `${failure === 'insecure origin' ? 'http' : 'https'}://auth.example.test`, + AUTH_SECRET: 'worker-test-secret-at-least-32-characters', + HYPERDRIVE: { connectionString: 'postgres://unused' }, + }, + ); + expect(response.status).toBe(503); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(response.headers.get('referrer-policy')).toBe('no-referrer'); + expect(response.headers.get('x-content-type-options')).toBe('nosniff'); + expect(response.headers.get('x-frame-options')).toBe('DENY'); + expect(response.headers.get('content-security-policy')).toBe( + "default-src 'none'; script-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'", + ); + expect(await response.json()).toEqual({ + message: diagnostics + ? 'Temporarily unavailable. Please try again.' + : 'Sign-in is temporarily unavailable. Please try again.', + }); + if (diagnostics) { + expect(response.headers.get('x-request-id')).toBeTruthy(); + expect( + records.some((record) => record.event === 'request.failed'), + ).toBe(true); + expect(JSON.stringify(records)).not.toContain(privateError); + } + });