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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ A complete magic-link email auth flow: users enter their email, receive a login

### Security

- **CSRF protection** using the synchronizer token pattern with timing-safe comparison and origin validation
- **CSRF protection** using the synchronizer token pattern with timing-safe comparison and origin validation. A token that has aged out but still verifies against the session secret is recognised as stale rather than forged, so the form is re-rendered with a fresh token and the user's input intact instead of a dead-end 403
- **Rate limiting** middleware with configurable sliding-window limits per IP
- **Signup spam defense** on the login form — an always-on honeypot and per-IP rate limit, plus an optional first-party proof-of-work captcha (no third party, account, or extra secret; off by default, enable with `CAPTCHA_ENABLED`)
- **Session fixation prevention** — sessions are regenerated on login
Expand Down
8 changes: 8 additions & 0 deletions src/client/components/flash.css
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,11 @@
border-radius: var(--radius);
margin-bottom: 1rem;
}

.flash-warning {
border: 1px solid rgba(251, 191, 36, 0.25);
background: rgba(251, 191, 36, 0.08);
padding: 12px 16px;
border-radius: var(--radius);
margin-bottom: 1rem;
}
17 changes: 12 additions & 5 deletions src/server/components/flash.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
interface FlashProps {
type: "success" | "error";
type: "success" | "error" | "warning";
children: React.ReactNode;
}

const CLASS_NAMES = {
success: "flash-success",
error: "flash-error",
warning: "flash-warning",
} as const;

export const Flash = ({ type, children }: FlashProps) => (
// role="alert" (assertive) for errors so screen readers interrupt and announce
// them; role="status" (polite) for success so it's announced without cutting
// off the user. Both are announced when injected after a post-redirect-get.
// role="alert" (assertive) for errors and warnings so screen readers
// interrupt and announce them - both require the user to act; role="status"
// (polite) for success so it's announced without cutting off the user. Both
// are announced when injected after a post-redirect-get.
<div
className={type === "success" ? "flash-success" : "flash-error"}
className={CLASS_NAMES[type]}
role={type === "success" ? "status" : "alert"}
>
{children}
Expand Down
141 changes: 135 additions & 6 deletions src/server/controllers/app/forms.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
import {
afterAll,
afterEach,
beforeEach,
describe,
expect,
mock,
setSystemTime,
test,
} from "bun:test";
import { SQL } from "bun";
import { findOrCreateUser } from "../../services/auth";
import { createCsrfToken } from "../../services/csrf";
import { createCsrfToken, TIME_WINDOW_MINUTES } from "../../services/csrf";
import {
createAuthenticatedSession,
createGuestSession,
Expand Down Expand Up @@ -30,11 +39,24 @@ describe("Forms Controller", () => {
await cleanupTestData(db);
});

afterEach(() => {
setSystemTime();
});

afterAll(async () => {
await connection.end();
mock.restore();
});

// Mint a token as if the page had rendered several windows ago: authentic,
// but too stale to act on.
const mintStaleToken = async (sessionId: string): Promise<string> => {
setSystemTime(new Date(Date.now() - TIME_WINDOW_MINUTES * 3 * 60 * 1000));
const token = await createCsrfToken(sessionId, "POST", "/forms");
setSystemTime();
return token;
};

const createTestSession = async () => {
const user = await findOrCreateUser(randomEmail());
return createAuthenticatedSession(user.id);
Expand Down Expand Up @@ -150,7 +172,7 @@ describe("Forms Controller", () => {
expect(setCookie).toContain("Alex");
});

test("redirects without flash when name is missing", async () => {
test("redirects with a validation flash when name is missing", async () => {
const sessionId = await createGuestSession();
const cookieHeader = `session_id=${sessionId}`;
const csrfToken = await createCsrfToken(sessionId, "POST", "/forms");
Expand All @@ -174,16 +196,18 @@ describe("Forms Controller", () => {
expect(response.headers.get("location")).toBe("/forms");

const setCookie = findSetCookie(request, "flash_state");
expect(setCookie).toBeUndefined();
expect(setCookie).toBeDefined();
expect(setCookie).toContain("validation-error");
});

test("redirects without flash when name is too short", async () => {
test("preserves submitted values when name is too short", async () => {
const sessionId = await createGuestSession();
const cookieHeader = `session_id=${sessionId}`;
const csrfToken = await createCsrfToken(sessionId, "POST", "/forms");

const mockFormData = new FormData();
mockFormData.append("name", "ab");
mockFormData.append("email", "alex@example.com");
mockFormData.append("_csrf", csrfToken);

const request = createBunRequest("http://localhost:3000/forms", {
Expand All @@ -201,7 +225,10 @@ describe("Forms Controller", () => {
expect(response.headers.get("location")).toBe("/forms");

const setCookie = findSetCookie(request, "flash_state");
expect(setCookie).toBeUndefined();
expect(setCookie).toBeDefined();
expect(setCookie).toContain("validation-error");
expect(setCookie).toContain("ab");
expect(setCookie).toContain("alex@example.com");
});

test("rejects request without CSRF token", async () => {
Expand Down Expand Up @@ -301,5 +328,107 @@ describe("Forms Controller", () => {
expect(setCookie).toBeDefined();
expect(setCookie).toContain("submission-success");
});

test("preserves submitted values when the CSRF token has expired", async () => {
const sessionId = await createGuestSession();
const staleToken = await mintStaleToken(sessionId);

const mockFormData = new FormData();
mockFormData.append("name", "Alex");
mockFormData.append("email", "alex@example.com");
mockFormData.append("message", "Hello world");
mockFormData.append("_csrf", staleToken);

const request = createBunRequest("http://localhost:3000/forms", {
method: "POST",
headers: {
Origin: "http://localhost:3000",
Cookie: `session_id=${sessionId}`,
},
body: mockFormData,
});

const response = await forms.create(request);

expect(response.status).toBe(303);
expect(response.headers.get("location")).toBe("/forms");

const setCookie = findSetCookie(request, "flash_state");
expect(setCookie).toBeDefined();
expect(setCookie).toContain("csrf-expired");
expect(setCookie).toContain("Alex");
expect(setCookie).toContain("alex@example.com");
expect(setCookie).toContain("Hello world");
// The stale submission must never be recorded as a success.
expect(setCookie).not.toContain("submission-success");
});

test("still hard-fails an expired token from a foreign origin", async () => {
const sessionId = await createGuestSession();
const staleToken = await mintStaleToken(sessionId);

const mockFormData = new FormData();
mockFormData.append("name", "Alex");
mockFormData.append("_csrf", staleToken);

const request = createBunRequest("http://localhost:3000/forms", {
method: "POST",
headers: {
Origin: "http://evil.example",
Cookie: `session_id=${sessionId}`,
},
body: mockFormData,
});

const response = await forms.create(request);

expect(response.status).toBe(403);
expect(await response.text()).toBe("Invalid request origin");
expect(findSetCookie(request, "flash_state")).toBeUndefined();
});
});

describe("CSRF expiry recovery round-trip", () => {
test("re-renders the form pre-filled with a fresh token", async () => {
const sessionId = await createGuestSession();

const request = createBunRequest("http://localhost:3000/forms", {
headers: { Cookie: `session_id=${sessionId}` },
});

const { setFlash } = stateHelpers<FormsState>();
setFlash(request, {
state: "csrf-expired",
name: "Alex",
email: "alex@example.com",
message: "Hello world",
});

const response = await forms.index(request);
const html = await response.text();

expect(html).toContain("Your session timed out");
expect(html).toContain('value="Alex"');
expect(html).toContain('value="alex@example.com"');
expect(html).toContain("Hello world");
expect(html).toContain('name="_csrf"');
});

test("does not pre-fill the form after a successful submission", async () => {
const sessionId = await createGuestSession();

const request = createBunRequest("http://localhost:3000/forms", {
headers: { Cookie: `session_id=${sessionId}` },
});

const { setFlash } = stateHelpers<FormsState>();
setFlash(request, { state: "submission-success", name: "Alex" });

const response = await forms.index(request);
const html = await response.text();

expect(html).toContain("Submitted successfully");
expect(html).not.toContain('value="Alex"');
});
});
});
67 changes: 48 additions & 19 deletions src/server/controllers/app/forms.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
import type { BunRequest } from "bun";
import { getSessionContext } from "../../middleware/auth";
import { csrfProtection } from "../../middleware/csrf";
import { checkCsrf, isRecoverableCsrfFailure } from "../../middleware/csrf";
import { createCsrfToken } from "../../services/csrf";
import { setSessionCookie } from "../../services/sessions";
import type { FormsState } from "../../templates/forms";
import { Forms } from "../../templates/forms";
import { readFormValues } from "../../utils/form-data";
import { redirect, render } from "../../utils/response";
import { stateHelpers } from "../../utils/state";
import { fitFlashState, stateHelpers } from "../../utils/state";

const { getFlash, setFlash } = stateHelpers<FormsState>();

const FORM_FIELDS = ["name", "email", "message"] as const;

// Ordered longest-first: sacrifice the message before the short fields when
// the preserved values don't fit the flash cookie.
const TRIMMABLE_FIELDS = ["message", "name", "email"] as const;

export const forms = {
async index(req: BunRequest): Promise<Response> {
const ctx = await getSessionContext(req);
Expand Down Expand Up @@ -51,29 +58,51 @@ export const forms = {
return redirect("/forms");
}

const csrfResponse = await csrfProtection(req, {
method: "POST",
path: "/forms",
});
if (csrfResponse) {
return csrfResponse;
const csrf = await checkCsrf(req, { method: "POST", path: "/forms" });
if (!csrf.ok) {
// Forged, missing or cross-origin: fail hard, exactly as before.
if (!isRecoverableCsrfFailure(csrf)) {
return csrf.response;
}

// Stale but authentic. Don't run the action - hand the work back with a
// fresh token so the user can resubmit instead of losing what they typed.
const stale = await readFormValues(req, FORM_FIELDS);
setFlash(
req,
fitFlashState<FormsState>(
{
state: "csrf-expired",
name: stale.name,
email: stale.email,
message: stale.message,
},
TRIMMABLE_FIELDS,
),
);
return redirect("/forms");
}

const formData = await req.formData();
const name = formData.get("name") as string;
const email = formData.get("email") as string;
const message = formData.get("message") as string;
const { name, email, message } = await readFormValues(req, FORM_FIELDS);

if (!name || name.trim().length < 3) {
if (!name || name.length < 3) {
setFlash(
req,
fitFlashState<FormsState>(
{ state: "validation-error", name, email, message },
TRIMMABLE_FIELDS,
),
);
return redirect("/forms");
}

setFlash(req, {
state: "submission-success",
name: name.trim(),
email: email?.trim() || undefined,
message: message?.trim() || undefined,
});
setFlash(
req,
fitFlashState<FormsState>(
{ state: "submission-success", name, email, message },
TRIMMABLE_FIELDS,
),
);
return redirect("/forms");
},
};
Loading