From 0c81a555f1667e662b1f292efb134533a9124abe Mon Sep 17 00:00:00 2001 From: Jack McCarthy <37917934+Deicyde@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:33:19 -0400 Subject: [PATCH 1/2] wiki: key anonymous rate limits by the IPv6 /64 The anonymous limiters (flag:, csp:, mcp: and brainapi-ip:) keyed the raw CF-Connecting-IP. An IPv6 host usually holds a whole /64, so it could rotate addresses and get a fresh budget on every request. limiterIp in security.ts keys an IPv6 address by its /64 and leaves IPv4, IPv4-mapped IPv6 and anything it can't parse as they were; all four limiters use it, and each keeps its own fallback for a missing header. The /brain/api and /mcp pages and docs/BRAIN-API.md say "per /64 for IPv6" next to "per IP". --- docs/BRAIN-API.md | 4 +-- wiki/src/brain-api.ts | 7 +++-- wiki/src/env.ts | 11 ++++---- wiki/src/index.ts | 4 +-- wiki/src/mcp.ts | 6 ++-- wiki/src/security.ts | 25 +++++++++++++++-- wiki/test/limiter-ip.test.ts | 54 ++++++++++++++++++++++++++++++++++++ wiki/wrangler.jsonc | 6 ++-- 8 files changed, 97 insertions(+), 20 deletions(-) create mode 100644 wiki/test/limiter-ip.test.ts diff --git a/docs/BRAIN-API.md b/docs/BRAIN-API.md index e29669baf..441599217 100644 --- a/docs/BRAIN-API.md +++ b/docs/BRAIN-API.md @@ -661,8 +661,8 @@ top-level `note` says so. ## Rate limits & caching -- `POST /mcp`: **120 requests/min per IP** (JSON-RPC error `-32000`, HTTP 429 - when exceeded). +- `POST /mcp`: **120 requests/min per IP**, per /64 for IPv6 (JSON-RPC error `-32000`, + HTTP 429 when exceeded). - REST compatibility routes are unauthenticated and revalidation-safe (`public, max-age=0, must-revalidate`) because they resolve the mutable current selector. `/api/brain/edges` remains live and `no-store`. diff --git a/wiki/src/brain-api.ts b/wiki/src/brain-api.ts index 04fff4f45..173f658c7 100644 --- a/wiki/src/brain-api.ts +++ b/wiki/src/brain-api.ts @@ -70,6 +70,7 @@ import { type DeclPair, type SuffixBucket, } from "./decl.js"; +import { limiterIp } from "./security.js"; type Ctx = Context<{ Bindings: Env }>; @@ -2493,7 +2494,7 @@ async function withRelease( } // Same anonymous budget as /mcp (review finding: the REST twins of the MCP -// tools must not be a rate-limit bypass). Keyed by IP; MCP_LIMITER when bound, +// tools must not be a rate-limit bypass). Keyed by IP (an IPv6 /64); MCP_LIMITER when bound, // else BRAIN_API_LIMITER — distinct "brainapi-ip:" prefix avoids colliding // with the write path's "brainapi:" keys. async function rateLimitGate( @@ -2501,7 +2502,7 @@ async function rateLimitGate( next: () => Promise, ): Promise { const limiter = c.env.MCP_LIMITER ?? c.env.BRAIN_API_LIMITER; - const ip = c.req.header("CF-Connecting-IP") || "unknown"; + const ip = limiterIp(c.req.header("CF-Connecting-IP")) || "unknown"; const { success } = await limiter.limit({ key: `brainapi-ip:${ip}` }); if (!success) return c.json({ ok: false, error: "rate limited (120/min)" }, 429); await next(); @@ -2684,7 +2685,7 @@ mode) exposing nine tools: brain_bridge, brain_search, brain_snippets, brain_filter, decl_exists, brain_premises. brain_unit still answers, as an alias of brain_cell — the v2 unit -card became the cell card. Rate limit: 120 requests/min per IP. Every response echoes +card became the cell card. Rate limit: 120 requests/min per IP (per /64 for IPv6). Every response echoes release_id and snapshot:{generated_at,pin}. release_id identifies the whole release. generated_at and pin are opaque provenance values of the release's inputs: compare them for equality, never parse them, and do not read them diff --git a/wiki/src/env.ts b/wiki/src/env.ts index 647352ebc..b139d41a0 100644 --- a/wiki/src/env.ts +++ b/wiki/src/env.ts @@ -13,10 +13,11 @@ export interface Env { EDIT_LIMITER: { limit: (opts: { key: string }) => Promise<{ success: boolean }>; }; - // Anonymous flag-report limiter, keyed flag: (the flag - // endpoint has no auth, so the user-keyed EDIT_LIMITER can't cover it). It - // also caps the CSP report sink under csp: keys - // (security.ts): separate counters, shared limit and period. + // Anonymous flag-report limiter, keyed flag: (an IPv6 /64, + // limiterIp in security.ts; the flag endpoint has no auth, so the user-keyed + // EDIT_LIMITER can't cover it). It also caps the CSP report sink under + // csp: keys (security.ts): separate counters, shared limit + // and period. FLAG_LIMITER: { limit: (opts: { key: string }) => Promise<{ success: boolean }>; }; @@ -25,7 +26,7 @@ export interface Env { BRAIN_API_LIMITER: { limit: (opts: { key: string }) => Promise<{ success: boolean }>; }; - // Anonymous /mcp JSON-RPC limiter, keyed mcp:. Optional so + // Anonymous /mcp JSON-RPC limiter, keyed mcp: (an IPv6 /64). Optional so // deploys predating the binding fall back to BRAIN_API_LIMITER (mcp.ts). MCP_LIMITER?: { limit: (opts: { key: string }) => Promise<{ success: boolean }>; diff --git a/wiki/src/index.ts b/wiki/src/index.ts index 4be980e75..5def78da9 100644 --- a/wiki/src/index.ts +++ b/wiki/src/index.ts @@ -50,7 +50,7 @@ import { NATIVE_SCHEMA_VERSION, isNativeItem, validateNativeItems, publicItems, editorItems, restorePrivateMetadata, articleLocalOccurrences, repinArticleSources, type SourceContext, } from "./native-companion.js"; -import { checkOrigin, MAX_JSON_BODY_BYTES, readJsonCapped, registerSecurityHeaders } from "./security.js"; +import { checkOrigin, limiterIp, MAX_JSON_BODY_BYTES, readJsonCapped, registerSecurityHeaders } from "./security.js"; import { registerVersionRoutes } from "./version.js"; import type { Annotation } from "./engine/types.js"; import type { Env } from "./env.js"; @@ -2506,7 +2506,7 @@ const FLAG_REASON_SET = new Set(FLAG_REASONS); app.post("/api/flag/:slug", async (c) => { const originErr = checkOrigin(c); if (originErr) return originErr; - const ip = c.req.header("CF-Connecting-IP") ?? ""; + const ip = limiterIp(c.req.header("CF-Connecting-IP")) ?? ""; const { success: allowed } = await c.env.FLAG_LIMITER.limit({ key: `flag:${ip}` }); if (!allowed) return c.json({ ok: false, error: "rate limited — slow down" }, 429); const slug = c.req.param("slug"); diff --git a/wiki/src/mcp.ts b/wiki/src/mcp.ts index 3beba528d..a9fe2929b 100644 --- a/wiki/src/mcp.ts +++ b/wiki/src/mcp.ts @@ -34,7 +34,7 @@ import { SYNAPSE_KINDS_CSV, type ApiResult, } from "./brain-api.js"; -import { readBodyCapped } from "./security.js"; +import { limiterIp, readBodyCapped } from "./security.js"; type Ctx = Context<{ Bindings: Env }>; @@ -396,7 +396,7 @@ export function registerMcpRoutes(app: Hono<{ Bindings: Env }>): void { // counters separate from brainapi: writes. const limiter: Limiter = (c.env as Env & { MCP_LIMITER?: Limiter }).MCP_LIMITER ?? c.env.BRAIN_API_LIMITER; - const ip = c.req.header("CF-Connecting-IP") || "unknown"; + const ip = limiterIp(c.req.header("CF-Connecting-IP")) || "unknown"; const rl = await limiter.limit({ key: `mcp:${ip}` }); if (!rl.success) { return c.json(rpcError(null, -32000, "rate limited (120 requests/min per IP) — slow down"), 429); @@ -600,7 +600,7 @@ body; responses are plain application/json (no SSE, no sessions, no "arguments":{"q":"abelian group","direction":"informal_to_formal"}}}'

Protocol revisions 2025-06-18 and 2025-03-26; methods initialize, tools/list, tools/call, ping. -Rate limit: 120 calls/min per IP. Read-only by construction.

+Rate limit: 120 calls/min per IP (per /64 for IPv6). Read-only by construction.

The nine tools

The canonical autoformalization loop: brain_bridge (informal statement → diff --git a/wiki/src/security.ts b/wiki/src/security.ts index 0a6e5b4f4..6b95f860b 100644 --- a/wiki/src/security.ts +++ b/wiki/src/security.ts @@ -194,6 +194,25 @@ export function checkOrigin(c: Context<{ Bindings: Env }>): Response | null { return null; } +// The client part of an anonymous rate-limit key, from CF-Connecting-IP. An IPv6 +// address keys by its /64: one host usually holds a whole /64 and could otherwise +// rotate through it for a fresh budget per address. IPv4, including IPv4-mapped +// IPv6, keys by the full address; anything unparsable keys as given. +export function limiterIp(ip: string | undefined): string | undefined { + if (!ip?.includes(":")) return ip; + const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(ip); + if (mapped) return mapped[1]; + const halves = ip.split("::"); + if (halves.length > 2) return ip; + const head = halves[0] ? halves[0].split(":") : []; + const tail = halves[1] ? halves[1].split(":") : []; + const zeros = 8 - head.length - tail.length; + if (halves.length === 2 ? zeros < 1 : zeros !== 0) return ip; + const groups = [...head, ...Array(zeros).fill("0"), ...tail]; + if (!groups.every((g) => /^[0-9a-f]{1,4}$/i.test(g))) return ip; + return `${groups.slice(0, 4).map((g) => parseInt(g, 16).toString(16)).join(":")}::/64`; +} + // Reads at most `max` bytes of the body, counting bytes as they stream, so a // chunked request without Content-Length cannot buffer an unbounded body. // null = over the cap. @@ -293,10 +312,10 @@ export function registerSecurityHeaders(app: Hono<{ Bindings: Env }>): void { // Log-only sink: at most one structured line per request (Workers // observability), no storage. Unauthenticated by necessity (browsers send // the reports), so the body is byte-capped, the violations per line are - // capped, and each IP gets 5 requests a minute: FLAG_LIMITER's budget under - // its own csp: keys, checked before the body is read. + // capped, and each IP (an IPv6 /64) gets 5 requests a minute: FLAG_LIMITER's + // budget under its own csp: keys, checked before the body is read. app.post(CSP_REPORT_PATH, async (c) => { - const ip = c.req.header("CF-Connecting-IP") ?? ""; + const ip = limiterIp(c.req.header("CF-Connecting-IP")) ?? ""; const { success: allowed } = await c.env.FLAG_LIMITER.limit({ key: `csp:${ip}` }); if (!allowed) return c.json({ ok: false, error: "rate limited" }, 429); const text = await readBodyCapped(c.req.raw, MAX_REPORT_BYTES); diff --git a/wiki/test/limiter-ip.test.ts b/wiki/test/limiter-ip.test.ts new file mode 100644 index 000000000..10eecc64b --- /dev/null +++ b/wiki/test/limiter-ip.test.ts @@ -0,0 +1,54 @@ +// Anonymous rate-limit keys (limiterIp in src/security.ts): an IPv4 address keys +// whole, an IPv6 address by its /64, so one host can't rotate through its /64 for +// a fresh budget per address. Pins the parser and the key each anonymous limiter +// (flags, the CSP sink, /mcp, the Brain REST reads) receives. + +import { describe, it, expect } from "vitest"; +import { limiterIp } from "../src/security.js"; +import { setup, post, get, blockNetwork, SLUG } from "./helpers/harness.js"; + +blockNetwork(); + +describe("limiterIp", () => { + it("keeps IPv4 and IPv4-mapped IPv6 addresses whole", () => { + expect(limiterIp("203.0.113.7")).toBe("203.0.113.7"); + expect(limiterIp("::ffff:203.0.113.7")).toBe("203.0.113.7"); + expect(limiterIp("::FFFF:203.0.113.7")).toBe("203.0.113.7"); + }); + + it("keys an IPv6 address by its /64, however it is written", () => { + for (const ip of ["2001:db8:1:2:3:4:5:6", "2001:0DB8:0001:0002::ffff", "2001:db8:1:2::", "2001:db8:1:2:aaaa::1"]) { + expect(limiterIp(ip)).toBe("2001:db8:1:2::/64"); + } + expect(limiterIp("2001:db8::1")).toBe("2001:db8:0:0::/64"); + expect(limiterIp("::1")).toBe("0:0:0:0::/64"); + }); + + it("passes a missing header and anything it can't parse through unchanged", () => { + for (const ip of [undefined, "", "unknown", "2001:db8::1::2", "1:2:3:4:5:6:7:8:9", "1:2:3:4:5:6:7:8::", "2001:db8::g", "64:ff9b::192.0.2.1"]) { + expect(limiterIp(ip)).toBe(ip); + } + }); +}); + +describe("anonymous limiters", () => { + it("give every address in one IPv6 /64 the same key", async () => { + const h = setup(); + const keys: string[] = []; + const deny = { + limit: async ({ key }: { key: string }) => { + keys.push(key); + return { success: false }; + }, + }; + Object.assign(h.env, { FLAG_LIMITER: deny, BRAIN_API_LIMITER: deny }); + for (const ip of ["2001:db8:1:2::a", "2001:db8:1:2:ffff::b"]) { + expect((await post(h.env, `/api/flag/${SLUG}`, { reason: "other" }, { ip })).status).toBe(429); + expect((await post(h.env, "/api/csp-report", {}, { ip })).status).toBe(429); + expect((await post(h.env, "/mcp", { jsonrpc: "2.0", id: 1, method: "ping" }, { ip })).status).toBe(429); + expect((await get(h.env, "/api/brain/cell?id=x", { ip })).status).toBe(429); + } + const once = ["flag:", "csp:", "mcp:", "brainapi-ip:"].map((prefix) => `${prefix}2001:db8:1:2::/64`); + expect(keys).toEqual([...once, ...once]); + }); +}); diff --git a/wiki/wrangler.jsonc b/wiki/wrangler.jsonc index 5e8aaba0e..fbff989d8 100644 --- a/wiki/wrangler.jsonc +++ b/wiki/wrangler.jsonc @@ -39,7 +39,8 @@ "simple": { "limit": 30, "period": 60 } }, // Anonymous flag-report limiter: max 5 flags per minute, keyed by - // CF-Connecting-IP (the endpoint is unauthenticated by design). It also + // CF-Connecting-IP, an IPv6 address by its /64 (the endpoint is + // unauthenticated by design). It also // caps POST /api/csp-report under csp: keys; changing the limit or // the period changes both. { @@ -58,7 +59,8 @@ "simple": { "limit": 120, "period": 60 } }, // Anonymous MCP endpoint limiter: 120 JSON-RPC calls per minute, keyed - // mcp: (read-only surface; agents burst tools/call). + // mcp:, an IPv6 address by its /64 (read-only surface; + // agents burst tools/call). { "name": "MCP_LIMITER", "type": "ratelimit", From d08d709e8cca0e5d5a96c132d0556adc940890a5 Mon Sep 17 00:00:00 2001 From: Jack McCarthy <37917934+Deicyde@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:39:12 -0400 Subject: [PATCH 2/2] wiki: limiterIp keys the ::/64 block per address Loopback and the IPv4-mapped forms all sit in ::/64, so keying them by the /64 would put every such client (::ffff:cb00:7107 and friends) in one bucket. Key them by the full address, like IPv4. --- wiki/src/security.ts | 10 +++++----- wiki/test/limiter-ip.test.ts | 9 ++++----- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/wiki/src/security.ts b/wiki/src/security.ts index 6b95f860b..92bde3a53 100644 --- a/wiki/src/security.ts +++ b/wiki/src/security.ts @@ -196,12 +196,11 @@ export function checkOrigin(c: Context<{ Bindings: Env }>): Response | null { // The client part of an anonymous rate-limit key, from CF-Connecting-IP. An IPv6 // address keys by its /64: one host usually holds a whole /64 and could otherwise -// rotate through it for a fresh budget per address. IPv4, including IPv4-mapped -// IPv6, keys by the full address; anything unparsable keys as given. +// rotate through it for a fresh budget per address. Everything else keys as given: +// IPv4, the ::/64 block (loopback and the IPv4-mapped forms, one client per +// address), and anything unparsable. export function limiterIp(ip: string | undefined): string | undefined { if (!ip?.includes(":")) return ip; - const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(ip); - if (mapped) return mapped[1]; const halves = ip.split("::"); if (halves.length > 2) return ip; const head = halves[0] ? halves[0].split(":") : []; @@ -210,7 +209,8 @@ export function limiterIp(ip: string | undefined): string | undefined { if (halves.length === 2 ? zeros < 1 : zeros !== 0) return ip; const groups = [...head, ...Array(zeros).fill("0"), ...tail]; if (!groups.every((g) => /^[0-9a-f]{1,4}$/i.test(g))) return ip; - return `${groups.slice(0, 4).map((g) => parseInt(g, 16).toString(16)).join(":")}::/64`; + const prefix = groups.slice(0, 4).map((g) => parseInt(g, 16).toString(16)).join(":"); + return prefix === "0:0:0:0" ? ip : `${prefix}::/64`; } // Reads at most `max` bytes of the body, counting bytes as they stream, so a diff --git a/wiki/test/limiter-ip.test.ts b/wiki/test/limiter-ip.test.ts index 10eecc64b..a0df8baeb 100644 --- a/wiki/test/limiter-ip.test.ts +++ b/wiki/test/limiter-ip.test.ts @@ -10,10 +10,10 @@ import { setup, post, get, blockNetwork, SLUG } from "./helpers/harness.js"; blockNetwork(); describe("limiterIp", () => { - it("keeps IPv4 and IPv4-mapped IPv6 addresses whole", () => { - expect(limiterIp("203.0.113.7")).toBe("203.0.113.7"); - expect(limiterIp("::ffff:203.0.113.7")).toBe("203.0.113.7"); - expect(limiterIp("::FFFF:203.0.113.7")).toBe("203.0.113.7"); + it("keeps IPv4 and ::/64 addresses (loopback, IPv4-mapped) whole", () => { + for (const ip of ["203.0.113.7", "::ffff:203.0.113.7", "::ffff:cb00:7107", "::1"]) { + expect(limiterIp(ip)).toBe(ip); + } }); it("keys an IPv6 address by its /64, however it is written", () => { @@ -21,7 +21,6 @@ describe("limiterIp", () => { expect(limiterIp(ip)).toBe("2001:db8:1:2::/64"); } expect(limiterIp("2001:db8::1")).toBe("2001:db8:0:0::/64"); - expect(limiterIp("::1")).toBe("0:0:0:0::/64"); }); it("passes a missing header and anything it can't parse through unchanged", () => {