Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/BRAIN-API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
7 changes: 4 additions & 3 deletions wiki/src/brain-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import {
type DeclPair,
type SuffixBucket,
} from "./decl.js";
import { limiterIp } from "./security.js";

type Ctx = Context<{ Bindings: Env }>;

Expand Down Expand Up @@ -2493,15 +2494,15 @@ 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:<user.id>" keys.
async function rateLimitGate(
c: Ctx,
next: () => Promise<void>,
): Promise<Response | void> {
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();
Expand Down Expand Up @@ -2684,7 +2685,7 @@ mode) exposing nine tools: <code>brain_bridge</code>, <code>brain_search</code>,
<code>brain_snippets</code>, <code>brain_filter</code>, <code>decl_exists</code>,
<code>brain_premises</code>.
<code>brain_unit</code> still answers, as an alias of <code>brain_cell</code> — the v2 unit
card <em>became</em> the cell card. Rate limit: 120 requests/min per IP. Every response echoes
card <em>became</em> the cell card. Rate limit: 120 requests/min per IP (per /64 for IPv6). Every response echoes
<code>release_id</code> and <code>snapshot:{generated_at,pin}</code>. <code>release_id</code>
identifies the whole release. <code>generated_at</code> and <code>pin</code> are opaque provenance
values of the release's inputs: compare them for equality, never parse them, and do not read them
Expand Down
11 changes: 6 additions & 5 deletions wiki/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ export interface Env {
EDIT_LIMITER: {
limit: (opts: { key: string }) => Promise<{ success: boolean }>;
};
// Anonymous flag-report limiter, keyed flag:<CF-Connecting-IP> (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:<CF-Connecting-IP> keys
// (security.ts): separate counters, shared limit and period.
// Anonymous flag-report limiter, keyed flag:<CF-Connecting-IP> (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:<CF-Connecting-IP> keys (security.ts): separate counters, shared limit
// and period.
FLAG_LIMITER: {
limit: (opts: { key: string }) => Promise<{ success: boolean }>;
};
Expand All @@ -25,7 +26,7 @@ export interface Env {
BRAIN_API_LIMITER: {
limit: (opts: { key: string }) => Promise<{ success: boolean }>;
};
// Anonymous /mcp JSON-RPC limiter, keyed mcp:<CF-Connecting-IP>. Optional so
// Anonymous /mcp JSON-RPC limiter, keyed mcp:<CF-Connecting-IP> (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 }>;
Expand Down
4 changes: 2 additions & 2 deletions wiki/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -2506,7 +2506,7 @@ const FLAG_REASON_SET = new Set<string>(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");
Expand Down
6 changes: 3 additions & 3 deletions wiki/src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>;

Expand Down Expand Up @@ -396,7 +396,7 @@ export function registerMcpRoutes(app: Hono<{ Bindings: Env }>): void {
// counters separate from brainapi:<user.id> 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);
Expand Down Expand Up @@ -600,7 +600,7 @@ body; responses are plain <code>application/json</code> (no SSE, no sessions, no
"arguments":{"q":"abelian group","direction":"informal_to_formal"}}}'</code></pre>
<p class="muted">Protocol revisions 2025-06-18 and 2025-03-26; methods
<code>initialize</code>, <code>tools/list</code>, <code>tools/call</code>, <code>ping</code>.
Rate limit: 120 calls/min per IP. Read-only by construction.</p>
Rate limit: 120 calls/min per IP (per /64 for IPv6). Read-only by construction.</p>

<h2>The nine tools</h2>
<p>The canonical autoformalization loop: <code>brain_bridge</code> (informal statement &rarr;
Expand Down
25 changes: 22 additions & 3 deletions wiki/src/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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 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<string>(zeros).fill("0"), ...tail];
if (!groups.every((g) => /^[0-9a-f]{1,4}$/i.test(g))) return ip;
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
// chunked request without Content-Length cannot buffer an unbounded body.
// null = over the cap.
Expand Down Expand Up @@ -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:<ip> 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:<ip> 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);
Expand Down
53 changes: 53 additions & 0 deletions wiki/test/limiter-ip.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// 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 ::/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", () => {
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");
});

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]);
});
});
6 changes: 4 additions & 2 deletions wiki/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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:<ip> keys; changing the limit or
// the period changes both.
{
Expand All @@ -58,7 +59,8 @@
"simple": { "limit": 120, "period": 60 }
},
// Anonymous MCP endpoint limiter: 120 JSON-RPC calls per minute, keyed
// mcp:<CF-Connecting-IP> (read-only surface; agents burst tools/call).
// mcp:<CF-Connecting-IP>, an IPv6 address by its /64 (read-only surface;
// agents burst tools/call).
{
"name": "MCP_LIMITER",
"type": "ratelimit",
Expand Down
Loading