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
↓ kepr-de6037e2.tar.gzatlas at this save
files changed4 files
package.json+1 −1src/issues/spam.ts+1 −14src/server/client.ts+17 −0tests/issues.test.ts+36 −10package.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": {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. */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+}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 () => {