de6037e2c124

rate-limit issue filing by the socket peer, taking x-forwarded-for's rightmost entry only from a loopback proxy (v3.0.29)

time
2026-09-26 19:33 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
kepr 3 implementation
parent
6fcced41a42c
4 files package.json+1 −1src/issues/spam.ts+1 −14src/server/client.ts+17 −0tests/issues.test.ts+36 −10
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.28",3+	"version": "3.0.29",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/issues/spam.ts+1 −14
@@ -2,6 +2,7 @@22 import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";33 import type { Context } from "hono";44 import { getCookie, setCookie } from "hono/cookie";5+import { clientIp } from "../server/client.ts";56 import { setting } from "../server/db.ts";67 78 // §23's spam defenses, all server-side: nothing here trusts a clock or a value from the browser.@@ -22,20 +23,6 @@2223 2324 function mac(db: Database, message: string): string {2425 	return createHmac("sha256", secret(db)).update(message).digest("hex");25-}26-27-/**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- */33-function 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 ?? "";3926 }4027 4128 /** A keyed hash, so the stored value can't be reversed by hashing every IPv4 address. */

view file

src/server/client.ts+17 −0
@@ -0,0 +1,17 @@1+import type { Context } from "hono";2+3+const LOOPBACK = /^(127\.|::1$|::ffff:127\.)/;4+5+/**6+ * The client's address, for rate limits. The socket peer, unless the peer is loopback: then it is7+ * a reverse proxy on the same host (Caddy, as Kepr 2 was deployed), and the address is the8+ * rightmost `X-Forwarded-For` entry, the one that proxy appended. Anything to its left came from9+ * the client and could be forged, so it is never trusted.10+ */11+export function clientIp(c: Context): string {12+	// Bun passes its server as the env; a test passes a stand-in with the same method.13+	const server = c.env as { requestIP?: (request: Request) => { address: string } | null };14+	const peer = server?.requestIP?.(c.req.raw)?.address ?? "";15+	if (!LOOPBACK.test(peer)) return peer;16+	return c.req.header("x-forwarded-for")?.split(",").at(-1)?.trim() || peer;17+}

view file

tests/issues.test.ts+36 −10
@@ -100,13 +100,22 @@100100 		path: string,101101 		session: Session,102102 		fields: Record<string, string>,103-		ip = "203.0.113.9",103+		peer = "203.0.113.9",104+		forwardedFor = "",104105 	) =>105-		app.request(path, {106-			method: "POST",107-			headers: { cookie: session.cookie, "x-forwarded-for": `${ip}, 10.0.0.1` },108-			body: new URLSearchParams({ stamp: session.stamp, website: "", ...fields }),109-		});106+		app.request(107+			path,108+			{109+				method: "POST",110+				headers: {111+					cookie: session.cookie,112+					...(forwardedFor && { "x-forwarded-for": forwardedFor }),113+				},114+				body: new URLSearchParams({ stamp: session.stamp, website: "", ...fields }),115+			},116+			// The env Bun.serve passes: the server, whose requestIP gives the socket peer.117+			{ requestIP: () => ({ address: peer }) },118+		);110119 	const issue = {111120 		title: "Crash on <start>",112121 		body: "It <b>crashed</b>.",@@ -228,12 +237,15 @@228237 		expect(thread.match(/<span class="issue-author">Ada<\/span>/g)?.length).toBe(2);229238 	});230239 231-	test("five filings an hour per originating address, the sixth is refused", async () => {240+	test("five filings an hour per peer address, and a forged X-Forwarded-For does not help", async () => {232241 		const session = await openForm();233242 		tick(3);234-		const from = (ip: string) => post("/app/issues/new", session, issue, ip);235-		for (let i = 0; i < 5; i++) expect((await from("198.51.100.7")).status).toBe(200);236-		expect((await from("198.51.100.7")).status).toBe(429);243+		const from = (peer: string, forwardedFor = "") =>244+			post("/app/issues/new", session, issue, peer, forwardedFor);245+		for (let i = 0; i < 5; i++) {246+			expect((await from("198.51.100.7", `192.0.2.${i}`)).status).toBe(200);247+		}248+		expect((await from("198.51.100.7", "192.0.2.99")).status).toBe(429);237249 		expect((await from("198.51.100.8")).status).toBe(200);238250 		tick(3601);239251 		expect((await from("198.51.100.7")).status).toBe(200);@@ -244,6 +256,20 @@244256 			.all()245257 			.map((row) => row.ip_hash);246258 		expect(stored.every((hash) => /^[0-9a-f]{64}$/.test(hash) && hash !== plain)).toBe(true);259+	});260+261+	test("behind a proxy on loopback, the rightmost X-Forwarded-For entry is the client", async () => {262+		const session = await openForm();263+		tick(3);264+		const via = (peer: string, forwardedFor: string) =>265+			post("/app/issues/new", session, issue, peer, forwardedFor);266+		// The client forges the left entries; the proxy appended the real address last.267+		for (let i = 0; i < 5; i++) {268+			expect((await via("127.0.0.1", `10.9.9.${i}, 198.51.100.20`)).status).toBe(200);269+		}270+		expect((await via("::1", "198.51.100.20")).status).toBe(429);271+		expect((await via("::ffff:127.0.0.1", "10.9.9.9, 198.51.100.20")).status).toBe(429);272+		expect((await via("127.0.0.1", "198.51.100.20, 198.51.100.21")).status).toBe(200);247273 	});248274 249275 	test("an operator reply and a close show on the thread, marked (the console is Phase 7's)", async () => {

view file