Skip to content

Commit f25b2dc

Browse files
duyetduyetbot
andauthored
fix(api): rate-limit states/leases/claims/mcp coordination routes (#357)
* fix(api): rate-limit the states router Every write-heavy states route (watch, query, lease, events, PUT, GET, DELETE) attached scopedAuth per-route but never chained rateLimitMiddleware, so an authenticated key could drive unlimited D1 writes and Durable Object notify traffic. Add the limiter after auth on every route, matching the convention used by conversations/tags/etc. Refs #340 Co-Authored-By: Duyet Le <me@duyet.net> Co-Authored-By: duyetbot <bot@duyet.net> * fix(api): rate-limit the leases router POST /:id/renew and DELETE /:id had scopedAuth but no rate limiter, letting a single key drive unbounded lease renew/release traffic. Refs #340 Co-Authored-By: Duyet Le <me@duyet.net> Co-Authored-By: duyetbot <bot@duyet.net> * fix(api): rate-limit the claims router router.use("*", scopedAuth(...)) covered auth for all claim routes but no limiter followed it; chain rateLimitMiddleware right after so every claim write/read goes through the same per-key limit as other routers. Refs #340 Co-Authored-By: Duyet Le <me@duyet.net> Co-Authored-By: duyetbot <bot@duyet.net> * fix(api): rate-limit the MCP endpoint router.use("*", mcpAuth) set apiKeyHash for both API keys and capability/OAuth tokens but nothing enforced a request limit, so a leaked capability token could drive unlimited tool-call traffic. Chain rateLimitMiddleware right after mcpAuth. Refs #340, #349, #350 Co-Authored-By: Duyet Le <me@duyet.net> Co-Authored-By: duyetbot <bot@duyet.net> * test(api): cover 429s on states/leases/claims/mcp routes Seeds the rate_limits table directly at the configured limit (avoiding 10k real requests per assertion) and verifies PUT /states, POST /leases/:id/renew, POST /claims, and MCP tools/call all return the standard RATE_LIMITED envelope once exceeded, plus a states PUT still succeeds under the limit. Refs #340, #349, #350 Co-Authored-By: Duyet Le <me@duyet.net> Co-Authored-By: duyetbot <bot@duyet.net> --------- Co-authored-by: duyetbot <bot@duyet.net>
1 parent f5f811f commit f25b2dc

5 files changed

Lines changed: 270 additions & 115 deletions

File tree

packages/api/src/routes/claims/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
parseLimitParam,
77
parseOrderParam,
88
} from "../../lib/helpers";
9+
import { rateLimitMiddleware } from "../../middleware/rate-limit";
910
import { scopedAuth } from "../../middleware/scoped-auth";
1011
import {
1112
type CreateEvidenceInput,
@@ -84,6 +85,7 @@ const CreateClaimSchema = z.object({
8485
const router = new Hono<{ Bindings: Bindings; Variables: Variables }>();
8586

8687
router.use("*", scopedAuth({ scope: "claim:write" }));
88+
router.use("*", rateLimitMiddleware);
8789

8890
router.post("/", async (c) => {
8991
const { data, error } = await parseAndValidateBody(c, CreateClaimSchema);

packages/api/src/routes/leases/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Hono } from "hono";
22
import { errorResponse, parseAndValidateBody } from "../../lib/helpers";
33
import { RenewLeaseSchema } from "../../lib/validation";
4+
import { rateLimitMiddleware } from "../../middleware/rate-limit";
45
import { scopedAuth } from "../../middleware/scoped-auth";
56
import { releaseLease, renewLease } from "../../services/leases";
67
import type { Bindings, Variables } from "../../types";
@@ -13,7 +14,7 @@ const router = new Hono<{ Bindings: Bindings; Variables: Variables }>();
1314
// of being duplicated here. This router only operates on an existing lease
1415
// by id (renew/release).
1516

16-
router.post("/:id/renew", scopedAuth({ scope: "lease:write" }), async (c) => {
17+
router.post("/:id/renew", scopedAuth({ scope: "lease:write" }), rateLimitMiddleware, async (c) => {
1718
const { data, error } = await parseAndValidateBody(c, RenewLeaseSchema);
1819
if (error) return error;
1920
if (!data) return errorResponse(c, "BAD_REQUEST", "Invalid request body", 400);
@@ -26,7 +27,7 @@ router.post("/:id/renew", scopedAuth({ scope: "lease:write" }), async (c) => {
2627
return c.json(result.lease);
2728
});
2829

29-
router.delete("/:id", scopedAuth({ scope: "lease:write" }), async (c) => {
30+
router.delete("/:id", scopedAuth({ scope: "lease:write" }), rateLimitMiddleware, async (c) => {
3031
const error = await releaseLease(c.get("db"), c.get("projectId"), c.req.param("id"));
3132
if (error) return errorResponse(c, error.code, error.message, error.status);
3233
return c.body(null, 204);

packages/api/src/routes/mcp/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Hono } from "hono";
22
import { scopeSatisfies } from "../../lib/scopes";
33
import { mcpAuth } from "../../middleware/mcp-auth";
4+
import { rateLimitMiddleware } from "../../middleware/rate-limit";
45
import type { Bindings, Variables } from "../../types";
56
import { TOOLS, TOOLS_BY_NAME, type ToolContext, ToolError } from "./tools";
67

@@ -55,6 +56,7 @@ function rpcError(id: string | number | null, code: number, message: string) {
5556
}
5657

5758
router.use("*", mcpAuth);
59+
router.use("*", rateLimitMiddleware);
5860

5961
// Server-initiated SSE is not supported in stateless mode.
6062
router.get("/", (c) =>

packages/api/src/routes/states/index.ts

Lines changed: 138 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Hono } from "hono";
22
import { streamSSE } from "hono/streaming";
33
import { errorResponse, parseAndValidateBody, parseLimitParam } from "../../lib/helpers";
44
import { CreateLeaseSchema, QueryStatesSchema, UpsertStateSchema } from "../../lib/validation";
5+
import { rateLimitMiddleware } from "../../middleware/rate-limit";
56
import { scopedAuth } from "../../middleware/scoped-auth";
67
import { createLease } from "../../services/leases";
78
import {
@@ -47,45 +48,52 @@ function notifyStateEvent(c: any, event: StateEventResponse) {
4748
// GET /watch — SSE state event stream
4849
// ---------------------------------------------------------------------------
4950

50-
router.get("/watch", scopedAuth({ scope: "state:watch", allowQueryToken: true }), async (c) => {
51-
const after =
52-
parsePositiveInt(c.req.query("after")) ?? parsePositiveInt(c.req.header("Last-Event-ID")) ?? 0;
53-
const once = c.req.query("once") === "true";
54-
const hub = stateHub(c);
55-
56-
if (hub && !once) {
57-
const url = new URL("https://state-stream.local/watch");
58-
url.searchParams.set("after", String(after));
59-
return hub.fetch(
60-
new Request(url, {
61-
headers: { "X-Project-Id": c.get("projectId") },
62-
}),
63-
);
64-
}
51+
router.get(
52+
"/watch",
53+
scopedAuth({ scope: "state:watch", allowQueryToken: true }),
54+
rateLimitMiddleware,
55+
async (c) => {
56+
const after =
57+
parsePositiveInt(c.req.query("after")) ??
58+
parsePositiveInt(c.req.header("Last-Event-ID")) ??
59+
0;
60+
const once = c.req.query("once") === "true";
61+
const hub = stateHub(c);
62+
63+
if (hub && !once) {
64+
const url = new URL("https://state-stream.local/watch");
65+
url.searchParams.set("after", String(after));
66+
return hub.fetch(
67+
new Request(url, {
68+
headers: { "X-Project-Id": c.get("projectId") },
69+
}),
70+
);
71+
}
6572

66-
return streamSSE(c, async (stream) => {
67-
const events = await c
68-
.get("d1Db")
69-
.prepare(
70-
`SELECT sequence, id, state_key, agent_id, event_type, data, metadata, tags, idempotency_key, created_at
73+
return streamSSE(c, async (stream) => {
74+
const events = await c
75+
.get("d1Db")
76+
.prepare(
77+
`SELECT sequence, id, state_key, agent_id, event_type, data, metadata, tags, idempotency_key, created_at
7178
FROM state_events
7279
WHERE project_id = ? AND sequence > ?
7380
ORDER BY sequence ASC
7481
LIMIT 1000`,
75-
)
76-
.bind(c.get("projectId"), after)
77-
.all<any>();
78-
79-
for (const event of events.results ?? []) {
80-
await stream.writeSSE({
81-
id: String(event.sequence),
82-
event: `state.${event.event_type}`,
83-
data: JSON.stringify(event),
84-
});
85-
}
86-
stream.close();
87-
});
88-
});
82+
)
83+
.bind(c.get("projectId"), after)
84+
.all<any>();
85+
86+
for (const event of events.results ?? []) {
87+
await stream.writeSSE({
88+
id: String(event.sequence),
89+
event: `state.${event.event_type}`,
90+
data: JSON.stringify(event),
91+
});
92+
}
93+
stream.close();
94+
});
95+
},
96+
);
8997

9098
// ---------------------------------------------------------------------------
9199
// POST /query — Rich state query
@@ -98,7 +106,7 @@ router.get("/watch", scopedAuth({ scope: "state:watch", allowQueryToken: true })
98106
// whose `after` cursor is ascending (`sequence > after`).
99107
// ---------------------------------------------------------------------------
100108

101-
router.post("/query", scopedAuth({ scope: "state:read" }), async (c) => {
109+
router.post("/query", scopedAuth({ scope: "state:read" }), rateLimitMiddleware, async (c) => {
102110
const { data, error } = await parseAndValidateBody(c, QueryStatesSchema);
103111
if (error) return error;
104112
if (!data) return errorResponse(c, "BAD_REQUEST", "Invalid request body", 400);
@@ -122,55 +130,65 @@ router.post("/query", scopedAuth({ scope: "state:read" }), async (c) => {
122130
// POST /:state_key/lease — Acquire lease
123131
// ---------------------------------------------------------------------------
124132

125-
router.post("/:state_key/lease", scopedAuth({ scope: "lease:write" }), async (c) => {
126-
const { data, error } = await parseAndValidateBody(c, CreateLeaseSchema);
127-
if (error) return error;
128-
if (!data) return errorResponse(c, "BAD_REQUEST", "Invalid request body", 400);
129-
130-
const result = await createLease(
131-
c.get("db"),
132-
c.get("projectId"),
133-
c.req.param("state_key"),
134-
data.holder,
135-
data.ttl_ms,
136-
);
133+
router.post(
134+
"/:state_key/lease",
135+
scopedAuth({ scope: "lease:write" }),
136+
rateLimitMiddleware,
137+
async (c) => {
138+
const { data, error } = await parseAndValidateBody(c, CreateLeaseSchema);
139+
if (error) return error;
140+
if (!data) return errorResponse(c, "BAD_REQUEST", "Invalid request body", 400);
141+
142+
const result = await createLease(
143+
c.get("db"),
144+
c.get("projectId"),
145+
c.req.param("state_key"),
146+
data.holder,
147+
data.ttl_ms,
148+
);
137149

138-
if (result.error) {
139-
return errorResponse(c, result.error.code, result.error.message, result.error.status);
140-
}
150+
if (result.error) {
151+
return errorResponse(c, result.error.code, result.error.message, result.error.status);
152+
}
141153

142-
return c.json(result.lease, 201);
143-
});
154+
return c.json(result.lease, 201);
155+
},
156+
);
144157

145158
// ---------------------------------------------------------------------------
146159
// GET /:state_key/events — WAL events
147160
// ---------------------------------------------------------------------------
148161

149-
router.get("/:state_key/events", scopedAuth({ scope: "state:read" }), async (c) => {
150-
const after = parsePositiveInt(c.req.query("after")) ?? 0;
151-
const limit = parseLimitParam(c.req.query("limit"), 50, 500);
152-
const events = await listStateEvents(
153-
c.get("d1Db"),
154-
c.get("projectId"),
155-
c.req.param("state_key"),
156-
after,
157-
limit,
158-
);
162+
router.get(
163+
"/:state_key/events",
164+
scopedAuth({ scope: "state:read" }),
165+
rateLimitMiddleware,
166+
async (c) => {
167+
const after = parsePositiveInt(c.req.query("after")) ?? 0;
168+
const limit = parseLimitParam(c.req.query("limit"), 50, 500);
169+
const events = await listStateEvents(
170+
c.get("d1Db"),
171+
c.get("projectId"),
172+
c.req.param("state_key"),
173+
after,
174+
limit,
175+
);
159176

160-
return c.json({
161-
data: events,
162-
pagination: {
163-
limit: events.length,
164-
next_cursor: events.length ? String(events[events.length - 1].sequence) : null,
165-
},
166-
});
167-
});
177+
return c.json({
178+
data: events,
179+
pagination: {
180+
limit: events.length,
181+
next_cursor: events.length ? String(events[events.length - 1].sequence) : null,
182+
},
183+
});
184+
},
185+
);
168186

169187
// ---------------------------------------------------------------------------
170188
// PUT /:state_key — Upsert full state
171189
// ---------------------------------------------------------------------------
172190

173-
router.put("/:state_key", scopedAuth({ scope: "state:write" }), async (c) => {
191+
router.put("/:state_key", scopedAuth({ scope: "state:write" }), rateLimitMiddleware, async (c) => {
174192
const { data, error } = await parseAndValidateBody(c, UpsertStateSchema);
175193
if (error) return error;
176194
if (!data) return errorResponse(c, "BAD_REQUEST", "Invalid request body", 400);
@@ -219,7 +237,7 @@ router.put("/:state_key", scopedAuth({ scope: "state:write" }), async (c) => {
219237
// GET /:state_key — Latest or historical state
220238
// ---------------------------------------------------------------------------
221239

222-
router.get("/:state_key", scopedAuth({ scope: "state:read" }), async (c) => {
240+
router.get("/:state_key", scopedAuth({ scope: "state:read" }), rateLimitMiddleware, async (c) => {
223241
const atSequence = parsePositiveInt(c.req.query("at_sequence"));
224242
const atTime = parsePositiveInt(c.req.query("at_time"));
225243
const state =
@@ -241,46 +259,53 @@ router.get("/:state_key", scopedAuth({ scope: "state:read" }), async (c) => {
241259
// DELETE /:state_key — Tombstone state
242260
// ---------------------------------------------------------------------------
243261

244-
router.delete("/:state_key", scopedAuth({ scope: "state:write" }), async (c) => {
245-
const stateKey = c.req.param("state_key");
246-
const leaseId = c.req.header("X-Lease-Id") ?? c.req.query("lease_id");
247-
const idempotencyKey = c.req.header("Idempotency-Key");
248-
const requestHash = await buildIdempotencyHash("DELETE", stateKey, { lease_id: leaseId ?? null });
249-
const cached = await readIdempotency(
250-
c.get("d1Db"),
251-
c.get("projectId"),
252-
idempotencyKey,
253-
requestHash,
254-
);
255-
if (cached.error)
256-
return errorResponse(c, cached.error.code, cached.error.message, cached.error.status);
257-
if (cached.replay) return cached.replay;
258-
259-
const result = await deleteState(
260-
c.get("d1Db"),
261-
c.get("projectId"),
262-
stateKey,
263-
leaseId,
264-
idempotencyKey,
265-
requestHash,
266-
);
267-
if (result.error)
268-
return errorResponse(c, result.error.code, result.error.message, result.error.status);
269-
// A concurrent duplicate carried the same Idempotency-Key: this request's
270-
// mutation was rolled back atomically; serve the winner's stored response.
271-
if (result.replay) return result.replay;
272-
if (!result.result) return errorResponse(c, "INTERNAL_ERROR", "State delete failed", 500);
273-
274-
await storeIdempotency(
275-
c.get("d1Db"),
276-
c.get("projectId"),
277-
idempotencyKey,
278-
requestHash,
279-
result.result.status,
280-
result.result.body,
281-
);
282-
notifyStateEvent(c, result.result.event);
283-
return c.json(result.result.body, 200);
284-
});
262+
router.delete(
263+
"/:state_key",
264+
scopedAuth({ scope: "state:write" }),
265+
rateLimitMiddleware,
266+
async (c) => {
267+
const stateKey = c.req.param("state_key");
268+
const leaseId = c.req.header("X-Lease-Id") ?? c.req.query("lease_id");
269+
const idempotencyKey = c.req.header("Idempotency-Key");
270+
const requestHash = await buildIdempotencyHash("DELETE", stateKey, {
271+
lease_id: leaseId ?? null,
272+
});
273+
const cached = await readIdempotency(
274+
c.get("d1Db"),
275+
c.get("projectId"),
276+
idempotencyKey,
277+
requestHash,
278+
);
279+
if (cached.error)
280+
return errorResponse(c, cached.error.code, cached.error.message, cached.error.status);
281+
if (cached.replay) return cached.replay;
282+
283+
const result = await deleteState(
284+
c.get("d1Db"),
285+
c.get("projectId"),
286+
stateKey,
287+
leaseId,
288+
idempotencyKey,
289+
requestHash,
290+
);
291+
if (result.error)
292+
return errorResponse(c, result.error.code, result.error.message, result.error.status);
293+
// A concurrent duplicate carried the same Idempotency-Key: this request's
294+
// mutation was rolled back atomically; serve the winner's stored response.
295+
if (result.replay) return result.replay;
296+
if (!result.result) return errorResponse(c, "INTERNAL_ERROR", "State delete failed", 500);
297+
298+
await storeIdempotency(
299+
c.get("d1Db"),
300+
c.get("projectId"),
301+
idempotencyKey,
302+
requestHash,
303+
result.result.status,
304+
result.result.body,
305+
);
306+
notifyStateEvent(c, result.result.event);
307+
return c.json(result.result.body, 200);
308+
},
309+
);
285310

286311
export default router;

0 commit comments

Comments
 (0)