Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 124 additions & 2 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,111 @@
*/

import { isPaymentsEnabled, isSetupComplete } from "./lib/config.ts";

/**
* Security headers for all responses
*/
const BASE_SECURITY_HEADERS: Record<string, string> = {
"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<string, string> => {
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,
Expand Down Expand Up @@ -634,9 +739,9 @@ const routeMainApp = async (
};

/**
* Handle incoming requests
* Handle incoming requests (internal, without security headers)
*/
export const handleRequest = async (request: Request): Promise<Response> => {
const handleRequestInternal = async (request: Request): Promise<Response> => {
const url = new URL(request.url);
const path = url.pathname;
const method = request.method;
Expand All @@ -658,3 +763,20 @@ export const handleRequest = async (request: Request): Promise<Response> => {

return routeMainApp(request, path, method);
};

/**
* Handle incoming requests with security headers and CORS protection
*/
export const handleRequest = async (request: Request): Promise<Response> => {
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);
};
20 changes: 20 additions & 0 deletions src/test-utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -70,6 +71,25 @@ export const mockFormRequest = (
});
};

/**
* Create a mock cross-origin POST request with form data
*/
export const mockCrossOriginFormRequest = (
path: string,
data: Record<string, string>,
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
*/
Expand Down
171 changes: 171 additions & 0 deletions test/lib/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { handleRequest } from "#src/server.ts";
import {
createTestDb,
createTestDbWithSetup,
mockCrossOriginFormRequest,
mockFormRequest,
mockRequest,
resetDb,
Expand Down Expand Up @@ -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);
});
});
});