diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index 79c5d73..097fa2b 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -100,6 +100,13 @@ function createSchema(database: DatabaseSync) { createdAt INTEGER NOT NULL ); + CREATE TABLE IF NOT EXISTS verification_request_limits ( + identifier TEXT PRIMARY KEY, + requestCount INTEGER NOT NULL DEFAULT 0, + windowStartedAt INTEGER NOT NULL, + blockedUntil INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_notes_topic ON notes(topicId); CREATE INDEX IF NOT EXISTS idx_notes_unit ON notes(unitId); CREATE INDEX IF NOT EXISTS idx_notes_created ON notes(createdAt); diff --git a/src/lib/server/verificationRateLimit.spec.ts b/src/lib/server/verificationRateLimit.spec.ts new file mode 100644 index 0000000..8031bc3 --- /dev/null +++ b/src/lib/server/verificationRateLimit.spec.ts @@ -0,0 +1,61 @@ +import { DatabaseSync } from "node:sqlite"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + VERIFICATION_BAN_MS, + VerificationRateLimitError, + recordVerificationRequest, +} from "./verificationRateLimit"; + +describe("verification request rate limit", () => { + let db: DatabaseSync; + + beforeEach(() => { + db = new DatabaseSync(":memory:"); + db.exec(` + CREATE TABLE verification_request_limits ( + identifier TEXT PRIMARY KEY, + requestCount INTEGER NOT NULL DEFAULT 0, + windowStartedAt INTEGER NOT NULL, + blockedUntil INTEGER NOT NULL DEFAULT 0 + ) + `); + }); + + afterEach(() => { + db.close(); + }); + + it("blocks the IP and browser on the fifth request", () => { + const now = 1_000; + for (let attempt = 0; attempt < 4; attempt++) { + expect(() => + recordVerificationRequest(db, "192.0.2.1", "browser-a", now), + ).not.toThrow(); + } + + expect(() => recordVerificationRequest(db, "192.0.2.1", "browser-a", now)).toThrow( + VerificationRateLimitError, + ); + expect(() => recordVerificationRequest(db, "192.0.2.1", "browser-b", now)).toThrow( + VerificationRateLimitError, + ); + expect(() => recordVerificationRequest(db, "198.51.100.1", "browser-a", now)).toThrow( + VerificationRateLimitError, + ); + }); + + it("allows requests again after one day", () => { + const now = 1_000; + for (let attempt = 0; attempt < 5; attempt++) { + try { + recordVerificationRequest(db, "192.0.2.1", "browser-a", now); + } catch (error) { + expect(error).toBeInstanceOf(VerificationRateLimitError); + } + } + + expect(() => + recordVerificationRequest(db, "192.0.2.1", "browser-a", now + VERIFICATION_BAN_MS), + ).not.toThrow(); + }); +}); diff --git a/src/lib/server/verificationRateLimit.ts b/src/lib/server/verificationRateLimit.ts new file mode 100644 index 0000000..350639b --- /dev/null +++ b/src/lib/server/verificationRateLimit.ts @@ -0,0 +1,93 @@ +import { createHash } from "node:crypto"; +import type { DatabaseSync } from "node:sqlite"; + +export const VERIFICATION_REQUEST_LIMIT = 5; +export const VERIFICATION_BAN_MS = 24 * 60 * 60 * 1000; + +type LimitRow = { + requestCount: number; + windowStartedAt: number; + blockedUntil: number; +}; + +export class VerificationRateLimitError extends Error { + constructor(public readonly blockedUntil: number) { + super("Too many verification requests. Try again in 24 hours."); + this.name = "VerificationRateLimitError"; + } +} + +function identifier(type: "ip" | "client", value: string): string { + return createHash("sha256").update(`${type}:${value}`).digest("hex"); +} + +/** + * Records a verification-email request for both the IP and browser. The fifth + * request starts a one-day block and is rejected. + */ +export function recordVerificationRequest( + db: DatabaseSync, + ip: string, + clientId: string, + now = Date.now(), +): void { + const identifiers = [identifier("ip", ip), identifier("client", clientId)]; + const select = db.prepare( + "SELECT requestCount, windowStartedAt, blockedUntil FROM verification_request_limits WHERE identifier = ?", + ); + const upsert = db.prepare(` + INSERT INTO verification_request_limits + (identifier, requestCount, windowStartedAt, blockedUntil) + VALUES (?, ?, ?, ?) + ON CONFLICT(identifier) DO UPDATE SET + requestCount = excluded.requestCount, + windowStartedAt = excluded.windowStartedAt, + blockedUntil = excluded.blockedUntil + `); + + db.exec("BEGIN IMMEDIATE"); + try { + const rows = identifiers.map((key) => select.get(key) as LimitRow | undefined); + const activeBlock = Math.max( + 0, + ...rows.map((row) => (row && row.blockedUntil > now ? row.blockedUntil : 0)), + ); + + if (activeBlock > now) { + for (let index = 0; index < identifiers.length; index++) { + const row = rows[index]; + upsert.run( + identifiers[index], + Math.max(row?.requestCount ?? 0, VERIFICATION_REQUEST_LIMIT), + row?.windowStartedAt ?? now, + activeBlock, + ); + } + db.exec("COMMIT"); + throw new VerificationRateLimitError(activeBlock); + } + + const counts = rows.map((row) => + row && now - row.windowStartedAt < VERIFICATION_BAN_MS ? row.requestCount + 1 : 1, + ); + const shouldBlock = counts.some((count) => count >= VERIFICATION_REQUEST_LIMIT); + const blockedUntil = shouldBlock ? now + VERIFICATION_BAN_MS : 0; + + for (let index = 0; index < identifiers.length; index++) { + const row = rows[index]; + const windowStartedAt = + row && now - row.windowStartedAt < VERIFICATION_BAN_MS ? row.windowStartedAt : now; + upsert.run(identifiers[index], counts[index], windowStartedAt, blockedUntil); + } + db.exec("COMMIT"); + + if (shouldBlock) { + throw new VerificationRateLimitError(blockedUntil); + } + } catch (error) { + if (!(error instanceof VerificationRateLimitError)) { + db.exec("ROLLBACK"); + } + throw error; + } +} diff --git a/src/routes/api/+server.ts b/src/routes/api/+server.ts index a87279e..7142c88 100644 --- a/src/routes/api/+server.ts +++ b/src/routes/api/+server.ts @@ -1,8 +1,17 @@ import { json } from "@sveltejs/kit"; import type { RequestHandler } from "./$types"; import { call } from "$lib/server/api"; +import { getDb } from "$lib/server/db"; +import { + VERIFICATION_BAN_MS, + VerificationRateLimitError, + recordVerificationRequest, +} from "$lib/server/verificationRateLimit"; -export const POST: RequestHandler = async ({ request }) => { +const VERIFICATION_REQUEST_FUNCTIONS = new Set(["auth:requestCode", "admin:requestCode"]); +const VERIFICATION_CLIENT_COOKIE = "dsec_verification_client"; + +export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => { let fn: string; let args: Record; @@ -15,11 +24,28 @@ export const POST: RequestHandler = async ({ request }) => { } try { + if (VERIFICATION_REQUEST_FUNCTIONS.has(fn)) { + const clientId = cookies.get(VERIFICATION_CLIENT_COOKIE) ?? crypto.randomUUID(); + cookies.set(VERIFICATION_CLIENT_COOKIE, clientId, { + path: "/", + httpOnly: true, + maxAge: VERIFICATION_BAN_MS / 1000, + sameSite: "lax", + secure: new URL(request.url).protocol === "https:", + }); + recordVerificationRequest(getDb(), getClientAddress(), clientId); + } + const result = await call(fn, args as Record); return json({ ok: true, result }); } catch (err: any) { const message = err?.message ?? "Internal error"; - const status = message === "Not authenticated" || message === "Not authorized" ? 401 : 400; + const status = + err instanceof VerificationRateLimitError + ? 429 + : message === "Not authenticated" || message === "Not authorized" + ? 401 + : 400; return json({ ok: false, error: message }, { status }); } };