Skip to content

Commit f4fa10c

Browse files
fix(clickclack): bound REST success JSON response reads (#96970)
* fix(clickclack): bound REST success JSON response reads * test(clickclack): harden response cap proof --------- Co-authored-by: Vincent Koc <[email protected]>
1 parent 2100ee7 commit f4fa10c

2 files changed

Lines changed: 99 additions & 2 deletions

File tree

extensions/clickclack/src/http-client.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,81 @@
1+
import { createServer, type Server } from "node:http";
12
import { describe, expect, it, vi } from "vitest";
23
import { createClickClackClient } from "./http-client.js";
34

5+
const LOOPBACK_RESPONSE_BYTES = 18 * 1024 * 1024;
6+
7+
async function listenLoopbackServer(server: Server): Promise<number> {
8+
return await new Promise((resolve, reject) => {
9+
server.once("error", reject);
10+
server.listen(0, "127.0.0.1", () => {
11+
server.off("error", reject);
12+
const address = server.address();
13+
if (!address || typeof address === "string") {
14+
reject(new Error("expected loopback TCP address"));
15+
return;
16+
}
17+
resolve(address.port);
18+
});
19+
});
20+
}
21+
22+
function createOversizedJsonServer(): { server: Server; closed: Promise<number> } {
23+
let resolveClosed: (sentBytes: number) => void = () => {};
24+
const closed = new Promise<number>((resolve) => {
25+
resolveClosed = resolve;
26+
});
27+
const server = createServer((req, res) => {
28+
let sentBytes = 0;
29+
let stopped = false;
30+
let prefixSent = false;
31+
const prefixChunk = Buffer.from('{"user":{"id":"');
32+
const bodyChunk = Buffer.alloc(64 * 1024, 0x61);
33+
const suffixChunk = Buffer.from('"}}');
34+
const writeBuffer = (buffer: Buffer) => {
35+
sentBytes += buffer.length;
36+
if (!res.write(buffer)) {
37+
res.once("drain", writeChunks);
38+
return false;
39+
}
40+
return true;
41+
};
42+
const writeChunks = () => {
43+
if (!prefixSent) {
44+
prefixSent = true;
45+
if (!writeBuffer(prefixChunk)) {
46+
return;
47+
}
48+
}
49+
while (true) {
50+
if (stopped) {
51+
return;
52+
}
53+
if (sentBytes + bodyChunk.length + suffixChunk.length >= LOOPBACK_RESPONSE_BYTES) {
54+
break;
55+
}
56+
if (!writeBuffer(bodyChunk)) {
57+
return;
58+
}
59+
}
60+
if (!stopped) {
61+
sentBytes += suffixChunk.length;
62+
res.end(suffixChunk);
63+
}
64+
};
65+
res.writeHead(200, { connection: "close", "content-type": "application/json" });
66+
res.on("close", () => {
67+
stopped = true;
68+
resolveClosed(sentBytes);
69+
});
70+
req.on("aborted", () => {
71+
stopped = true;
72+
res.destroy();
73+
});
74+
writeChunks();
75+
});
76+
return { server, closed };
77+
}
78+
479
function streamedErrorResponse(body: string, limit: number) {
580
const encoded = new TextEncoder().encode(body);
681
let readCount = 0;
@@ -39,6 +114,25 @@ function streamedErrorResponse(body: string, limit: number) {
39114
}
40115

41116
describe("ClickClack HTTP client", () => {
117+
it("bounds oversized success JSON responses and closes the stream early", async () => {
118+
const { server, closed } = createOversizedJsonServer();
119+
const port = await listenLoopbackServer(server);
120+
const client = createClickClackClient({
121+
baseUrl: `http://127.0.0.1:${port}`,
122+
token: "test-token",
123+
});
124+
125+
try {
126+
await expect(client.me()).rejects.toThrow(
127+
"ClickClack response: JSON response exceeds 16777216 bytes",
128+
);
129+
const sentBytes = await closed;
130+
expect(sentBytes).toBeLessThan(LOOPBACK_RESPONSE_BYTES);
131+
} finally {
132+
server.close();
133+
}
134+
});
135+
42136
it("bounds error response bodies without using raw response.text()", async () => {
43137
const streamed = streamedErrorResponse("x".repeat(9000), 8 * 1024);
44138
const fetchMock = vi.fn(async () => streamed.response);

extensions/clickclack/src/http-client.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
* Thin ClickClack REST/websocket client used by gateway, resolver, and outbound
33
* delivery code.
44
*/
5-
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
5+
import {
6+
readProviderJsonResponse,
7+
readResponseTextLimited,
8+
} from "openclaw/plugin-sdk/provider-http";
69
import { WebSocket } from "ws";
710
import type {
811
ClickClackChannel,
@@ -44,7 +47,7 @@ export function createClickClackClient(options: ClientOptions) {
4447
const detail = await readResponseTextLimited(response, CLICKCLACK_ERROR_BODY_LIMIT_BYTES);
4548
throw new Error(`ClickClack ${response.status}: ${detail}`);
4649
}
47-
return (await response.json()) as T;
50+
return await readProviderJsonResponse<T>(response, "ClickClack response");
4851
}
4952

5053
return {

0 commit comments

Comments
 (0)