Skip to content

Commit d75b9ea

Browse files
committed
fix(signal): bound container REST reads so a hostile signal-cli-rest-api host cannot exhaust memory
1 parent 4a4657a commit d75b9ea

2 files changed

Lines changed: 137 additions & 21 deletions

File tree

extensions/signal/src/client-container.test.ts

Lines changed: 126 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,23 @@ import {
1717

1818
// spyOn approach works with vitest forks pool for cross-directory imports
1919
const mockFetch = vi.fn();
20+
21+
// Build a Response-like `body` stream from a string so the production code exercises the
22+
// bounded body readers (readProviderTextResponse / readResponseTextLimited) instead of an
23+
// unbounded res.text(). Kept local to this file to avoid touching shared HTTP test mocks.
24+
function bodyStream(text: string): { body: ReadableStream<Uint8Array> } {
25+
const bytes = new TextEncoder().encode(text);
26+
return {
27+
body: new ReadableStream<Uint8Array>({
28+
start(controller) {
29+
if (bytes.byteLength > 0) {
30+
controller.enqueue(bytes);
31+
}
32+
controller.close();
33+
},
34+
}),
35+
};
36+
}
2037
const wsMockState = vi.hoisted(() => ({
2138
behavior: "close" as "close" | "open" | "error" | "unexpected-response",
2239
urls: [] as string[],
@@ -223,7 +240,7 @@ describe("containerRestRequest", () => {
223240
mockFetch.mockResolvedValue({
224241
ok: true,
225242
status: 200,
226-
text: async () => JSON.stringify({ version: "1.0" }),
243+
...bodyStream(JSON.stringify({ version: "1.0" })),
227244
});
228245

229246
const result = await containerRestRequest("/v1/about", { baseUrl: "http://localhost:8080" });
@@ -236,7 +253,7 @@ describe("containerRestRequest", () => {
236253
mockFetch.mockResolvedValue({
237254
ok: true,
238255
status: 201,
239-
text: async () => "",
256+
...bodyStream(""),
240257
});
241258

242259
await containerRestRequest("/v2/send", { baseUrl: "http://localhost:8080" }, "POST", {
@@ -259,7 +276,7 @@ describe("containerRestRequest", () => {
259276
mockFetch.mockResolvedValue({
260277
ok: true,
261278
status: 201,
262-
text: async () => JSON.stringify({ timestamp: 1700000000000 }),
279+
...bodyStream(JSON.stringify({ timestamp: 1700000000000 })),
263280
});
264281

265282
const result = await containerRestRequest(
@@ -289,7 +306,7 @@ describe("containerRestRequest", () => {
289306
ok: false,
290307
status: 500,
291308
statusText: "Internal Server Error",
292-
text: async () => "Server error details",
309+
...bodyStream("Server error details"),
293310
});
294311

295312
await expect(
@@ -301,7 +318,7 @@ describe("containerRestRequest", () => {
301318
mockFetch.mockResolvedValue({
302319
ok: true,
303320
status: 200,
304-
text: async () => "",
321+
...bodyStream(""),
305322
});
306323

307324
const result = await containerRestRequest("/v1/about", { baseUrl: "http://localhost:8080" });
@@ -312,7 +329,7 @@ describe("containerRestRequest", () => {
312329
mockFetch.mockResolvedValue({
313330
ok: true,
314331
status: 200,
315-
text: async () => "{}",
332+
...bodyStream("{}"),
316333
});
317334

318335
await containerRestRequest("/v1/about", { baseUrl: "http://localhost:8080", timeoutMs: 5000 });
@@ -332,7 +349,7 @@ describe("containerRestRequest", () => {
332349
mockFetch.mockResolvedValue({
333350
ok: true,
334351
status: 200,
335-
text: async () => "{}",
352+
...bodyStream("{}"),
336353
});
337354

338355
await containerRestRequest("/v1/about", {
@@ -357,7 +374,7 @@ describe("containerSendMessage", () => {
357374
mockFetch.mockResolvedValue({
358375
ok: true,
359376
status: 200,
360-
text: async () => JSON.stringify({ timestamp: "1700000000000" }),
377+
...bodyStream(JSON.stringify({ timestamp: "1700000000000" })),
361378
});
362379

363380
const result = await containerSendMessage({
@@ -382,7 +399,7 @@ describe("containerSendMessage", () => {
382399
mockFetch.mockResolvedValue({
383400
ok: true,
384401
status: 200,
385-
text: async () => JSON.stringify({ timestamp: "not-a-number" }),
402+
...bodyStream(JSON.stringify({ timestamp: "not-a-number" })),
386403
});
387404

388405
await expect(
@@ -401,7 +418,7 @@ describe("containerSendMessage", () => {
401418
mockFetch.mockResolvedValue({
402419
ok: true,
403420
status: 200,
404-
text: async () => JSON.stringify({ timestamp }),
421+
...bodyStream(JSON.stringify({ timestamp })),
405422
});
406423

407424
await expect(
@@ -419,7 +436,7 @@ describe("containerSendMessage", () => {
419436
mockFetch.mockResolvedValue({
420437
ok: true,
421438
status: 200,
422-
text: async () => JSON.stringify({}),
439+
...bodyStream(JSON.stringify({})),
423440
});
424441

425442
await containerSendMessage({
@@ -440,7 +457,7 @@ describe("containerSendMessage", () => {
440457
mockFetch.mockResolvedValue({
441458
ok: true,
442459
status: 200,
443-
text: async () => JSON.stringify({}),
460+
...bodyStream(JSON.stringify({})),
444461
});
445462

446463
await containerSendMessage({
@@ -459,7 +476,7 @@ describe("containerSendMessage", () => {
459476
mockFetch.mockResolvedValue({
460477
ok: true,
461478
status: 200,
462-
text: async () => JSON.stringify({}),
479+
...bodyStream(JSON.stringify({})),
463480
});
464481

465482
await containerSendMessage({
@@ -488,7 +505,7 @@ describe("containerSendMessage", () => {
488505
mockFetch.mockResolvedValue({
489506
ok: true,
490507
status: 200,
491-
text: async () => JSON.stringify({}),
508+
...bodyStream(JSON.stringify({})),
492509
});
493510

494511
await containerSendMessage({
@@ -799,7 +816,7 @@ describe("containerRestRequest edge cases", () => {
799816
ok: false,
800817
status: 500,
801818
statusText: "Internal Server Error",
802-
text: async () => "",
819+
...bodyStream(""),
803820
});
804821

805822
await expect(
@@ -811,13 +828,103 @@ describe("containerRestRequest edge cases", () => {
811828
mockFetch.mockResolvedValue({
812829
ok: true,
813830
status: 200,
814-
text: async () => "not-valid-json",
831+
...bodyStream("not-valid-json"),
815832
});
816833

817834
await expect(
818835
containerRestRequest("/v1/about", { baseUrl: "http://localhost:8080" }),
819836
).rejects.toThrow();
820837
});
838+
839+
it("fails closed when the success body exceeds the response size cap", async () => {
840+
// Drive the real bounded reader with a >16 MiB stream. Pull lazily so the cap
841+
// (16 MiB) trips and cancels the stream long before 20 MiB is materialized.
842+
const ONE_MIB = new Uint8Array(1024 * 1024);
843+
let emitted = 0;
844+
const stream = new ReadableStream<Uint8Array>({
845+
pull(controller) {
846+
if (emitted >= 20) {
847+
controller.close();
848+
return;
849+
}
850+
emitted += 1;
851+
controller.enqueue(ONE_MIB);
852+
},
853+
});
854+
mockFetch.mockResolvedValue({
855+
ok: true,
856+
status: 200,
857+
headers: new Headers(),
858+
body: stream,
859+
});
860+
861+
await expect(
862+
containerRestRequest("/v1/about", { baseUrl: "http://localhost:8080" }),
863+
).rejects.toThrow(/exceeds \d+ bytes/);
864+
// The stream must have been cancelled at the cap, not drained to completion.
865+
expect(emitted).toBeLessThan(20);
866+
});
867+
868+
it("bounds the error body so a huge failure response cannot be buffered whole", async () => {
869+
const HUGE = "x".repeat(1024 * 1024); // 1 MiB of error text
870+
mockFetch.mockResolvedValue({
871+
ok: false,
872+
status: 500,
873+
statusText: "Internal Server Error",
874+
...bodyStream(HUGE),
875+
});
876+
877+
await containerRestRequest("/v2/send", { baseUrl: "http://localhost:8080" }, "POST").then(
878+
() => {
879+
throw new Error("expected containerRestRequest to reject");
880+
},
881+
(err: unknown) => {
882+
const message = err instanceof Error ? err.message : String(err);
883+
expect(message.startsWith("Signal REST 500:")).toBe(true);
884+
// readResponseTextLimited truncates the diagnostic body well below the 1 MiB payload.
885+
expect(message.length).toBeLessThan(64 * 1024);
886+
},
887+
);
888+
});
889+
890+
it("parses a large but under-cap success body without truncation", async () => {
891+
// Regression guard: a legitimate multi-MiB JSON response (well under the 16 MiB
892+
// cap) must still be read in full and parsed intact — the bound must not clip
893+
// valid container payloads. Build ~4 MiB of real JSON.
894+
const items = Array.from({ length: 50_000 }, (_, i) => ({
895+
id: i,
896+
note: "signal-container-payload-entry",
897+
}));
898+
const payload = JSON.stringify({ items });
899+
expect(payload.length).toBeGreaterThan(2 * 1024 * 1024);
900+
expect(payload.length).toBeLessThan(16 * 1024 * 1024);
901+
mockFetch.mockResolvedValue({
902+
ok: true,
903+
status: 200,
904+
...bodyStream(payload),
905+
});
906+
907+
const result = await containerRestRequest<{ items: Array<{ id: number }> }>("/v1/about", {
908+
baseUrl: "http://localhost:8080",
909+
});
910+
// Full body round-trips: first and last entries survive, count is exact.
911+
expect(result.items).toHaveLength(50_000);
912+
expect(result.items[0]?.id).toBe(0);
913+
expect(result.items[49_999]?.id).toBe(49_999);
914+
});
915+
916+
it("returns undefined for an empty success body via the bounded reader", async () => {
917+
// The bounded reader must preserve the existing empty-body -> undefined contract
918+
// (no spurious JSON.parse("") throw) so well-behaved 200/empty responses are unchanged.
919+
mockFetch.mockResolvedValue({
920+
ok: true,
921+
status: 200,
922+
...bodyStream(""),
923+
});
924+
925+
const result = await containerRestRequest("/v1/about", { baseUrl: "http://localhost:8080" });
926+
expect(result).toBeUndefined();
927+
});
821928
});
822929

823930
describe("streamContainerEvents", () => {
@@ -869,7 +976,7 @@ describe("containerSendReaction", () => {
869976
mockFetch.mockResolvedValue({
870977
ok: true,
871978
status: 200,
872-
text: async () => JSON.stringify({ timestamp: 1700000000000 }),
979+
...bodyStream(JSON.stringify({ timestamp: 1700000000000 })),
873980
});
874981

875982
const result = await containerSendReaction({
@@ -905,7 +1012,7 @@ describe("containerRpcRequest reactions", () => {
9051012
mockFetch.mockResolvedValue({
9061013
ok: true,
9071014
status: 200,
908-
text: async () => JSON.stringify({}),
1015+
...bodyStream(JSON.stringify({})),
9091016
});
9101017

9111018
await containerRpcRequest(
@@ -937,7 +1044,7 @@ describe("containerRemoveReaction", () => {
9371044
mockFetch.mockResolvedValue({
9381045
ok: true,
9391046
status: 200,
940-
text: async () => JSON.stringify({ timestamp: 1700000000000 }),
1047+
...bodyStream(JSON.stringify({ timestamp: 1700000000000 })),
9411048
});
9421049

9431050
const result = await containerRemoveReaction({

extensions/signal/src/client-container.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ import {
1414
parseStrictNonNegativeInteger,
1515
resolveTimerTimeoutMs,
1616
} from "openclaw/plugin-sdk/number-runtime";
17+
import {
18+
readProviderTextResponse,
19+
readResponseTextLimited,
20+
} from "openclaw/plugin-sdk/provider-http";
1721
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
1822
import WebSocket from "ws";
1923

@@ -241,11 +245,16 @@ export async function containerRestRequest<T = unknown>(
241245
}
242246

243247
if (!res.ok) {
244-
const errorText = await res.text().catch(() => "");
248+
// Bound the error body: signal-cli-rest-api is an untrusted external container,
249+
// and a hostile/buggy response must not let an error path buffer an unbounded body.
250+
const errorText = await readResponseTextLimited(res).catch(() => "");
245251
throw new Error(`Signal REST ${res.status}: ${errorText || res.statusText}`);
246252
}
247253

248-
const text = await res.text();
254+
// Bound the success body under the shared 16 MiB provider cap before JSON.parse so a
255+
// malicious/runaway container response cannot OOM the runtime (send/typing/version all
256+
// funnel through here). Reuse the same bounded reader family as the attachment path.
257+
const text = await readProviderTextResponse(res, "Signal REST");
249258
if (!text) {
250259
return undefined as T;
251260
}

0 commit comments

Comments
 (0)