Skip to content

Commit 81556be

Browse files
committed
fix(kilo-chat): cap request body sizes on inbound webhook and controller proxy
Both the plugin's inbound webhook (readBody) and the controller's kilo-chat proxy routes previously buffered the full request body into memory with no size cap. A malformed or adversarial caller could send a multi-MB body and exhaust the Fly machine's memory budget, taking down other tenants sharing the instance. - Plugin webhook readBody: track cumulative bytes and throw WebhookBodyTooLargeError once the 1 MB cap is exceeded; handler maps this to HTTP 413. - Controller proxy routes: check Content-Length before c.req.text() and reject with 413 if over the cap. 1 MB for send/edit (message content can legitimately be large); 8 KB for typing / reactions / delete where payloads are tiny. Caller is already authenticated (OPENCLAW_GATEWAY_TOKEN), but this is defense-in-depth: a buggy or compromised plugin is the realistic threat, and the fix is cheap.
1 parent 7655e92 commit 81556be

4 files changed

Lines changed: 148 additions & 3 deletions

File tree

services/kiloclaw/controller/src/routes/kilo-chat.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,3 +492,65 @@ describe('DELETE /_kilo/kilo-chat/messages/:id/reactions', () => {
492492
expect(res.status).toBe(401);
493493
});
494494
});
495+
496+
describe('body size limits', () => {
497+
function makeApp(register: typeof registerKiloChatSendRoute, fetchImpl: typeof fetch) {
498+
const app = new Hono();
499+
register(app, {
500+
expectedToken: TOKEN,
501+
sandboxId: SANDBOX_ID,
502+
apiToken: 'api_token',
503+
baseUrl: 'https://chat.example.test',
504+
fetchImpl,
505+
});
506+
return app;
507+
}
508+
509+
it('send route rejects bodies larger than the 1 MB cap with 413', async () => {
510+
let upstreamCalled = false;
511+
const fetchImpl = (async () => {
512+
upstreamCalled = true;
513+
return new Response('{}', { status: 201 });
514+
}) as typeof fetch;
515+
const app = makeApp(registerKiloChatSendRoute, fetchImpl);
516+
517+
const oversizedBody = 'x'.repeat(1 * 1024 * 1024 + 10);
518+
const res = await app.fetch(
519+
new Request('http://x/_kilo/kilo-chat/send', {
520+
method: 'POST',
521+
headers: {
522+
authorization: `Bearer ${TOKEN}`,
523+
'content-type': 'application/json',
524+
'content-length': String(oversizedBody.length),
525+
},
526+
body: oversizedBody,
527+
})
528+
);
529+
expect(res.status).toBe(413);
530+
expect(upstreamCalled).toBe(false);
531+
});
532+
533+
it('typing route rejects bodies larger than the small cap with 413', async () => {
534+
let upstreamCalled = false;
535+
const fetchImpl = (async () => {
536+
upstreamCalled = true;
537+
return new Response('{}', { status: 204 });
538+
}) as typeof fetch;
539+
const app = makeApp(registerKiloChatTypingRoute, fetchImpl);
540+
541+
const oversizedBody = JSON.stringify({ conversationId: 'c1', padding: 'x'.repeat(16 * 1024) });
542+
const res = await app.fetch(
543+
new Request('http://x/_kilo/kilo-chat/typing', {
544+
method: 'POST',
545+
headers: {
546+
authorization: `Bearer ${TOKEN}`,
547+
'content-type': 'application/json',
548+
'content-length': String(oversizedBody.length),
549+
},
550+
body: oversizedBody,
551+
})
552+
);
553+
expect(res.status).toBe(413);
554+
expect(upstreamCalled).toBe(false);
555+
});
556+
});

services/kiloclaw/controller/src/routes/kilo-chat.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Hono } from 'hono';
1+
import type { Context, Hono } from 'hono';
22
import { timingSafeTokenEqual } from '../auth';
33
import { getBearerToken } from './gateway';
44

@@ -10,6 +10,31 @@ export type KiloChatRouteOptions = {
1010
fetchImpl?: typeof fetch;
1111
};
1212

13+
/**
14+
* Max accepted body for kilo-chat proxy routes. Message content is small; 1 MB
15+
* is already generous. Guards against unbounded buffering in c.req.text().
16+
*/
17+
const MAX_BODY_BYTES = 1 * 1024 * 1024;
18+
/** Reactions/typing/delete carry tiny payloads; cap tighter. */
19+
const MAX_SMALL_BODY_BYTES = 8 * 1024;
20+
21+
/**
22+
* Reject oversized bodies by Content-Length. Returns a 413 Response if the
23+
* declared body exceeds `limit`, otherwise `null` to continue. Missing/invalid
24+
* Content-Length is allowed (chunked encoding) — the underlying platform
25+
* enforces its own per-request memory ceiling.
26+
*/
27+
function guardBodySize(c: Context, limit: number): Response | null {
28+
const header = c.req.header('content-length');
29+
if (!header) return null;
30+
const n = Number(header);
31+
if (!Number.isFinite(n) || n < 0) return null;
32+
if (n > limit) {
33+
return c.json({ error: 'Payload too large' }, 413);
34+
}
35+
return null;
36+
}
37+
1338
const KILO_CHAT_SEND_PATH = '/_kilo/kilo-chat/send';
1439

1540
export function registerKiloChatSendRoute(app: Hono, options: KiloChatRouteOptions): void {
@@ -21,6 +46,9 @@ export function registerKiloChatSendRoute(app: Hono, options: KiloChatRouteOptio
2146
return c.json({ error: 'Unauthorized' }, 401);
2247
}
2348

49+
const oversized = guardBodySize(c, MAX_BODY_BYTES);
50+
if (oversized) return oversized;
51+
2452
const rawBody = await c.req.text();
2553

2654
const upstream = await fetchImpl(`${options.baseUrl}/v1/messages`, {
@@ -52,6 +80,10 @@ export function registerKiloChatEditRoute(app: Hono, options: KiloChatRouteOptio
5280
if (!token || !timingSafeTokenEqual(token, options.expectedToken)) {
5381
return c.json({ error: 'Unauthorized' }, 401);
5482
}
83+
84+
const oversized = guardBodySize(c, MAX_BODY_BYTES);
85+
if (oversized) return oversized;
86+
5587
const messageId = c.req.param('messageId');
5688
const rawBody = await c.req.text();
5789

@@ -88,6 +120,9 @@ export function registerKiloChatTypingRoute(app: Hono, options: KiloChatRouteOpt
88120
return c.json({ error: 'Unauthorized' }, 401);
89121
}
90122

123+
const oversized = guardBodySize(c, MAX_SMALL_BODY_BYTES);
124+
if (oversized) return oversized;
125+
91126
let body: { conversationId?: unknown };
92127
try {
93128
body = (await c.req.json()) as { conversationId?: unknown };
@@ -130,6 +165,10 @@ export function registerKiloChatReactionPostRoute(app: Hono, options: KiloChatRo
130165
if (!token || !timingSafeTokenEqual(token, options.expectedToken)) {
131166
return c.json({ error: 'Unauthorized' }, 401);
132167
}
168+
169+
const oversized = guardBodySize(c, MAX_SMALL_BODY_BYTES);
170+
if (oversized) return oversized;
171+
133172
const messageId = c.req.param('messageId');
134173
const rawBody = await c.req.text();
135174

@@ -166,6 +205,10 @@ export function registerKiloChatReactionDeleteRoute(
166205
if (!token || !timingSafeTokenEqual(token, options.expectedToken)) {
167206
return c.json({ error: 'Unauthorized' }, 401);
168207
}
208+
209+
const oversized = guardBodySize(c, MAX_SMALL_BODY_BYTES);
210+
if (oversized) return oversized;
211+
169212
const messageId = c.req.param('messageId');
170213
const rawBody = await c.req.text();
171214

@@ -199,6 +242,10 @@ export function registerKiloChatDeleteRoute(app: Hono, options: KiloChatRouteOpt
199242
if (!token || !timingSafeTokenEqual(token, options.expectedToken)) {
200243
return c.json({ error: 'Unauthorized' }, 401);
201244
}
245+
246+
const oversized = guardBodySize(c, MAX_SMALL_BODY_BYTES);
247+
if (oversized) return oversized;
248+
202249
const messageId = c.req.param('messageId');
203250
const rawBody = await c.req.text();
204251

services/kiloclaw/plugins/kilo-chat/src/webhook.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,16 @@ describe('createKiloChatWebhookHandler', () => {
8282
await handler(makeReq(body), res);
8383
expect(getStatus()).toBe(400);
8484
});
85+
86+
it('returns 413 when the inbound body exceeds the size cap', async () => {
87+
// 1 MB + 1 byte: well over the 1 MB cap. readBody must reject before parsing.
88+
const body = 'x'.repeat(1 * 1024 * 1024 + 1);
89+
const handler = createKiloChatWebhookHandler({ api: {} as never });
90+
const { res, getStatus, getBody } = makeRes();
91+
await handler(makeReq(body), res);
92+
expect(getStatus()).toBe(413);
93+
expect(getBody()).toContain('Payload too large');
94+
});
8595
});
8696

8797
function fakeClient(calls: { type: string; args: unknown }[]): KiloChatClient {

services/kiloclaw/plugins/kilo-chat/src/webhook.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,10 +249,25 @@ async function dispatchInbound(
249249
// HTTP body reader
250250
// ---------------------------------------------------------------------------
251251

252+
/** Max accepted inbound webhook body. Messages are small — 1 MB is already generous. */
253+
const MAX_WEBHOOK_BODY_BYTES = 1 * 1024 * 1024;
254+
255+
export class WebhookBodyTooLargeError extends Error {
256+
constructor(public readonly limit: number) {
257+
super(`kilo-chat: webhook body exceeds ${limit} bytes`);
258+
}
259+
}
260+
252261
async function readBody(req: IncomingMessage): Promise<string> {
253262
const chunks: Buffer[] = [];
263+
let total = 0;
254264
for await (const chunk of req) {
255-
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string));
265+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string);
266+
total += buf.length;
267+
if (total > MAX_WEBHOOK_BODY_BYTES) {
268+
throw new WebhookBodyTooLargeError(MAX_WEBHOOK_BODY_BYTES);
269+
}
270+
chunks.push(buf);
256271
}
257272
return Buffer.concat(chunks).toString('utf8');
258273
}
@@ -263,7 +278,18 @@ async function readBody(req: IncomingMessage): Promise<string> {
263278

264279
export function createKiloChatWebhookHandler(deps: KiloChatWebhookDeps) {
265280
return async (req: IncomingMessage, res: ServerResponse): Promise<boolean> => {
266-
const rawBody = await readBody(req);
281+
let rawBody: string;
282+
try {
283+
rawBody = await readBody(req);
284+
} catch (err) {
285+
if (err instanceof WebhookBodyTooLargeError) {
286+
res.statusCode = 413;
287+
res.setHeader('content-type', 'application/json');
288+
res.end(JSON.stringify({ error: 'Payload too large' }));
289+
return true;
290+
}
291+
throw err;
292+
}
267293

268294
let parsed: unknown;
269295
try {

0 commit comments

Comments
 (0)