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", () => {