diff --git a/src/server.ts b/src/server.ts index 1766fb31a9..14193dfc5c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,6 +3,111 @@ */ import { isPaymentsEnabled, isSetupComplete } from "./lib/config.ts"; + +/** + * Security headers for all responses + */ +const BASE_SECURITY_HEADERS: Record = { + "x-content-type-options": "nosniff", + "referrer-policy": "strict-origin-when-cross-origin", +}; + +/** + * Get security headers for a response + * @param embeddable - Whether the page should be embeddable in iframes + */ +export const getSecurityHeaders = ( + embeddable: boolean, +): Record => { + if (embeddable) { + return { + ...BASE_SECURITY_HEADERS, + }; + } + return { + ...BASE_SECURITY_HEADERS, + "x-frame-options": "DENY", + "content-security-policy": "frame-ancestors 'none'", + }; +}; + +/** + * Check if a path is embeddable (public ticket pages only) + */ +export const isEmbeddablePath = (path: string): boolean => + /^\/ticket\/\d+$/.test(path); + +/** + * Validate origin for CORS protection on POST requests + * Returns true if the request should be allowed + */ +export const isValidOrigin = (request: Request): boolean => { + const method = request.method; + + // Only check POST requests + if (method !== "POST") { + return true; + } + + const origin = request.headers.get("origin"); + const referer = request.headers.get("referer"); + + // If no origin header, check referer (some browsers may not send origin) + const requestUrl = new URL(request.url); + const requestHost = requestUrl.host; + + // If origin is present, it must match + if (origin) { + const originUrl = new URL(origin); + return originUrl.host === requestHost; + } + + // Fallback to referer check + if (referer) { + const refererUrl = new URL(referer); + return refererUrl.host === requestHost; + } + + // If neither origin nor referer, reject (could be a direct form submission from another site) + // However, some legitimate scenarios may not have these headers (curl, etc.) + // For now, allow requests without origin/referer for backwards compatibility + // A stricter policy would return false here + return true; +}; + +/** + * Create CORS rejection response + */ +const corsRejectionResponse = (): Response => + new Response("Forbidden: Cross-origin requests not allowed", { + status: 403, + headers: { + "content-type": "text/plain", + ...getSecurityHeaders(false), + }, + }); + +/** + * Apply security headers to a response + */ +const applySecurityHeaders = ( + response: Response, + embeddable: boolean, +): Response => { + const headers = new Headers(response.headers); + const securityHeaders = getSecurityHeaders(embeddable); + + for (const [key, value] of Object.entries(securityHeaders)) { + headers.set(key, value); + } + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +}; + import { completeSetup, createAttendee, @@ -634,9 +739,9 @@ const routeMainApp = async ( }; /** - * Handle incoming requests + * Handle incoming requests (internal, without security headers) */ -export const handleRequest = async (request: Request): Promise => { +const handleRequestInternal = async (request: Request): Promise => { const url = new URL(request.url); const path = url.pathname; const method = request.method; @@ -658,3 +763,20 @@ export const handleRequest = async (request: Request): Promise => { return routeMainApp(request, path, method); }; + +/** + * Handle incoming requests with security headers and CORS protection + */ +export const handleRequest = async (request: Request): Promise => { + const url = new URL(request.url); + const path = url.pathname; + const embeddable = isEmbeddablePath(path); + + // CORS protection: reject cross-origin POST requests + if (!isValidOrigin(request)) { + return corsRejectionResponse(); + } + + const response = await handleRequestInternal(request); + return applySecurityHeaders(response, embeddable); +}; diff --git a/src/test-utils/index.ts b/src/test-utils/index.ts index 27f7af1dbe..2a8f829272 100644 --- a/src/test-utils/index.ts +++ b/src/test-utils/index.ts @@ -59,6 +59,7 @@ export const mockFormRequest = ( const body = new URLSearchParams(data).toString(); const headers: HeadersInit = { "content-type": "application/x-www-form-urlencoded", + origin: "http://localhost", }; if (cookie) { headers.cookie = cookie; @@ -70,6 +71,25 @@ export const mockFormRequest = ( }); }; +/** + * Create a mock cross-origin POST request with form data + */ +export const mockCrossOriginFormRequest = ( + path: string, + data: Record, + origin = "http://evil.com", +): Request => { + const body = new URLSearchParams(data).toString(); + return new Request(`http://localhost${path}`, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + origin, + }, + body, + }); +}; + /** * Wait for a specified number of milliseconds */ diff --git a/test/lib/server.test.ts b/test/lib/server.test.ts index 27638973e1..d3b7cc9de7 100644 --- a/test/lib/server.test.ts +++ b/test/lib/server.test.ts @@ -11,6 +11,7 @@ import { handleRequest } from "#src/server.ts"; import { createTestDb, createTestDbWithSetup, + mockCrossOriginFormRequest, mockFormRequest, mockRequest, resetDb, @@ -870,4 +871,174 @@ describe("server", () => { }); }); }); + + describe("security headers", () => { + describe("X-Frame-Options", () => { + test("home page has X-Frame-Options: DENY", async () => { + const response = await handleRequest(mockRequest("/")); + expect(response.headers.get("x-frame-options")).toBe("DENY"); + }); + + test("admin pages have X-Frame-Options: DENY", async () => { + const response = await handleRequest(mockRequest("/admin/")); + expect(response.headers.get("x-frame-options")).toBe("DENY"); + }); + + test("ticket page does NOT have X-Frame-Options (embeddable)", async () => { + await createEvent("Event", "Desc", 50, "https://example.com"); + const response = await handleRequest(mockRequest("/ticket/1")); + expect(response.headers.get("x-frame-options")).toBeNull(); + }); + + test("payment pages have X-Frame-Options: DENY", async () => { + const response = await handleRequest(mockRequest("/payment/success")); + expect(response.headers.get("x-frame-options")).toBe("DENY"); + }); + + test("setup page has X-Frame-Options: DENY", async () => { + resetDb(); + await createTestDb(); + const response = await handleRequest(mockRequest("/setup/")); + expect(response.headers.get("x-frame-options")).toBe("DENY"); + }); + }); + + describe("Content-Security-Policy frame-ancestors", () => { + test("non-embeddable pages have frame-ancestors 'none'", async () => { + const response = await handleRequest(mockRequest("/")); + expect(response.headers.get("content-security-policy")).toBe( + "frame-ancestors 'none'", + ); + }); + + test("ticket page does NOT have frame-ancestors restriction", async () => { + await createEvent("Event", "Desc", 50, "https://example.com"); + const response = await handleRequest(mockRequest("/ticket/1")); + expect(response.headers.get("content-security-policy")).toBeNull(); + }); + }); + + describe("other security headers", () => { + test("responses have X-Content-Type-Options: nosniff", async () => { + const response = await handleRequest(mockRequest("/")); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + }); + + test("responses have Referrer-Policy header", async () => { + const response = await handleRequest(mockRequest("/")); + expect(response.headers.get("referrer-policy")).toBe( + "strict-origin-when-cross-origin", + ); + }); + + test("ticket pages also have base security headers", async () => { + await createEvent("Event", "Desc", 50, "https://example.com"); + const response = await handleRequest(mockRequest("/ticket/1")); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + expect(response.headers.get("referrer-policy")).toBe( + "strict-origin-when-cross-origin", + ); + }); + }); + }); + + describe("CORS protection", () => { + test("rejects cross-origin POST requests", async () => { + await createEvent("Event", "Desc", 50, "https://example.com"); + const response = await handleRequest( + mockCrossOriginFormRequest("/ticket/1", { + name: "Attacker", + email: "attacker@evil.com", + }), + ); + expect(response.status).toBe(403); + const text = await response.text(); + expect(text).toContain("Cross-origin requests not allowed"); + }); + + test("allows same-origin POST requests", async () => { + await createEvent("Event", "Desc", 50, "https://example.com/thanks"); + const response = await handleRequest( + mockFormRequest("/ticket/1", { + name: "John Doe", + email: "john@example.com", + }), + ); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe( + "https://example.com/thanks", + ); + }); + + test("allows GET requests from any origin", async () => { + await createEvent("Event", "Desc", 50, "https://example.com"); + const response = await handleRequest( + new Request("http://localhost/ticket/1", { + headers: { origin: "http://evil.com" }, + }), + ); + expect(response.status).toBe(200); + }); + + test("rejects cross-origin admin login attempts", async () => { + const response = await handleRequest( + mockCrossOriginFormRequest("/admin/login", { + password: TEST_ADMIN_PASSWORD, + }), + ); + expect(response.status).toBe(403); + }); + + test("CORS rejection response has security headers", async () => { + const response = await handleRequest( + mockCrossOriginFormRequest("/ticket/1", { + name: "Test", + email: "test@test.com", + }), + ); + expect(response.headers.get("x-frame-options")).toBe("DENY"); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + }); + + test("allows same-origin POST with referer header only (no origin)", async () => { + await createEvent("Event", "Desc", 50, "https://example.com/thanks"); + const body = new URLSearchParams({ + name: "John Doe", + email: "john@example.com", + }).toString(); + const response = await handleRequest( + new Request("http://localhost/ticket/1", { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + referer: "http://localhost/ticket/1", + }, + body, + }), + ); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe( + "https://example.com/thanks", + ); + }); + + test("rejects cross-origin POST with referer header only", async () => { + await createEvent("Event", "Desc", 50, "https://example.com"); + const body = new URLSearchParams({ + name: "Attacker", + email: "attacker@evil.com", + }).toString(); + const response = await handleRequest( + new Request("http://localhost/ticket/1", { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + referer: "http://evil.com/phishing-page", + }, + body, + }), + ); + expect(response.status).toBe(403); + }); + }); });