spam.ts

3.1 KB · raw

1import type { Database } from "bun:sqlite";2import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";3import type { Context } from "hono";4import { getCookie, setCookie } from "hono/cookie";5import { setting } from "../server/db.ts";67// §23's spam defenses, all server-side: nothing here trusts a clock or a value from the browser.8export const MIN_SECONDS_ON_PAGE = 3;9const STAMP_MAX_AGE = 86400;10export const FILINGS_PER_HOUR = 5;11export const MAX_CHARS = 20_000;12const FORM_COOKIE = "kepr_form";13const HEX = /^[0-9a-f]{32,64}$/;1415/** One random key, made on first use and kept in kepr.db, for the IP hash and the form stamps. */16function secret(db: Database): string {17	db.query("INSERT OR IGNORE INTO settings (key, value) VALUES ('issue_secret', ?)").run(18		randomBytes(32).toString("hex"),19	);20	return setting(db, "issue_secret") as string;21}2223function mac(db: Database, message: string): string {24	return createHmac("sha256", secret(db)).update(message).digest("hex");25}2627/**28 * The originating address, as Kepr 2 read it: the first `X-Forwarded-For` entry. This trusts the29 * reverse proxy (Caddy) to set the header itself; Caddy replaces a client's header with the30 * connecting address unless `trusted_proxies` says otherwise. Exposed directly, a client could31 * forge it and dodge the rate limit, so without the header the socket address is used.32 */33function clientIp(c: Context): string {34	const forwarded = c.req.header("x-forwarded-for")?.split(",")[0]?.trim();35	if (forwarded) return forwarded;36	// Bun passes its server as the env; Hono's test `request()` has none.37	const server = c.env as { requestIP?: (request: Request) => { address: string } | null };38	return server?.requestIP?.(c.req.raw)?.address ?? "";39}4041/** A keyed hash, so the stored value can't be reversed by hashing every IPv4 address. */42export function ipHash(db: Database, c: Context): string {43	return mac(db, `ip:${clientIp(c)}`);44}4546/**47 * The same-site token (§25) and the time-on-page clock in one: a random cookie, SameSite=Strict so a48 * cross-site post arrives without it, and a form stamp `<unix time>.<HMAC(time, cookie)>`.49 */50export function formStamp(db: Database, c: Context, now: number): string {51	let cookie = getCookie(c, FORM_COOKIE);52	if (!cookie || !HEX.test(cookie)) {53		cookie = randomBytes(16).toString("hex");54		setCookie(c, FORM_COOKIE, cookie, {55			httpOnly: true,56			secure: true,57			sameSite: "Strict",58			path: "/",59		});60	}61	return `${now}.${mac(db, `form:${now}:${cookie}`)}`;62}6364export type StampCheck = "ok" | "invalid" | "too-fast";6566export function checkStamp(db: Database, c: Context, stamp: string, now: number): StampCheck {67	const cookie = getCookie(c, FORM_COOKIE);68	const [time, signature] = stamp.split(".");69	if (!cookie || !time || !signature || !/^\d{1,12}$/.test(time)) return "invalid";70	const expected = Buffer.from(mac(db, `form:${time}:${cookie}`));71	const given = Buffer.from(signature);72	if (given.length !== expected.length || !timingSafeEqual(given, expected)) return "invalid";73	const age = now - Number(time);74	if (age > STAMP_MAX_AGE || age < 0) return "invalid";75	return age < MIN_SECONDS_ON_PAGE ? "too-fast" : "ok";76}