Skip to content

Commit 8abd5d4

Browse files
authored
fix(tlon): bound error response body reads to prevent OOM (#98496)
* fix(tlon): bound error response body reads to prevent OOM Replace bare response.text() on non-ok paths with readResponseTextLimited capped at 16 KiB so a hostile or misconfigured Urbit ship cannot force the gateway to buffer an arbitrary-size error body into process memory. Affected paths: - pokeUrbitChannel (channel-ops.ts) - channel.runtime.ts poke path - sendSubscription (sse-client.ts) * fix(tlon): fix lint issues in error-body-boundary test - Remove unused beforeEach import - Wrap if/else bodies in braces (curly) - Use block body for Promise executors (no-promise-executor-return) * fix(types): resolve pre-existing TS test type errors - Fix TS2493 tuple type errors in server-cron-notifications and server-cron tests by adding explicit type annotations on mock.calls - Fix TS2322 in anthropic.test.ts by adding as const to resource content block type * chore: trigger CI
1 parent 733de86 commit 8abd5d4

5 files changed

Lines changed: 110 additions & 4 deletions

File tree

extensions/tlon/src/channel.runtime.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
sendGroupMessageWithStory,
2626
} from "./urbit/send.js";
2727
import { uploadImageFromUrl } from "./urbit/upload.js";
28+
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
2829

2930
type ResolvedTlonAccount = ReturnType<typeof resolveTlonAccount>;
3031
type ConfiguredTlonAccount = ResolvedTlonAccount & {
@@ -76,7 +77,7 @@ async function createHttpPokeApi(params: {
7677

7778
try {
7879
if (!response.ok && response.status !== 204) {
79-
const errorText = await response.text();
80+
const errorText = await readResponseTextLimited(response, 16 * 1024);
8081
throw new Error(`Poke failed: ${response.status} - ${errorText}`);
8182
}
8283

extensions/tlon/src/urbit/channel-ops.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Tlon plugin module implements channel ops behavior.
2+
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
23
import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
34
import { UrbitHttpError } from "./errors.js";
45
import { urbitFetch } from "./fetch.js";
@@ -36,6 +37,8 @@ async function putUrbitChannel(
3637
});
3738
}
3839

40+
const TLON_ERROR_BODY_LIMIT_BYTES = 16 * 1024;
41+
3942
export async function pokeUrbitChannel(
4043
deps: UrbitChannelDeps,
4144
params: { app: string; mark: string; json: unknown; auditContext: string },
@@ -57,7 +60,7 @@ export async function pokeUrbitChannel(
5760

5861
try {
5962
if (!response.ok && response.status !== 204) {
60-
const errorText = await response.text().catch(() => "");
63+
const errorText = await readResponseTextLimited(response, TLON_ERROR_BODY_LIMIT_BYTES).catch(() => "");
6164
throw new Error(`Poke failed: ${response.status}${errorText ? ` - ${errorText}` : ""}`);
6265
}
6366
return pokeId;
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import http from "node:http";
2+
import { afterEach, describe, expect, it, vi } from "vitest";
3+
4+
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => {
5+
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/ssrf-runtime")>(
6+
"openclaw/plugin-sdk/ssrf-runtime",
7+
);
8+
return {
9+
...actual,
10+
fetchWithSsrFGuard: async (params: {
11+
url: string;
12+
init?: RequestInit;
13+
signal?: AbortSignal;
14+
}) => ({
15+
response: await fetch(params.url, { ...params.init, signal: params.signal }),
16+
finalUrl: params.url,
17+
release: async () => {},
18+
}),
19+
};
20+
});
21+
22+
const { pokeUrbitChannel } = await import("./channel-ops.js");
23+
24+
const CHUNK = Buffer.alloc(64 * 1024, "X");
25+
26+
describe("tlon error body boundary", () => {
27+
let server: http.Server;
28+
29+
afterEach(async () => {
30+
vi.restoreAllMocks();
31+
await new Promise<void>((resolve) => {
32+
server?.close(() => resolve());
33+
});
34+
});
35+
36+
it("bounds poke error body at 16 KiB", async () => {
37+
server = http.createServer((_req, res) => {
38+
res.writeHead(500, { "Content-Type": "text/plain" });
39+
let written = 0;
40+
function write() {
41+
if (written >= 4 * 1024 * 1024) {
42+
res.end();
43+
return;
44+
}
45+
const ok = res.write(CHUNK);
46+
written += CHUNK.length;
47+
if (ok) {
48+
setImmediate(write);
49+
} else {
50+
res.once("drain", write);
51+
}
52+
}
53+
write();
54+
});
55+
const port = await new Promise<number>((resolve) => {
56+
server.listen(0, "127.0.0.1", () => {
57+
resolve((server.address() as { port: number }).port);
58+
});
59+
});
60+
61+
const err = await pokeUrbitChannel(
62+
{
63+
baseUrl: `http://127.0.0.1:${port}`,
64+
cookie: "urbit=cookie",
65+
ship: "~zod",
66+
channelId: "test",
67+
},
68+
{ app: "test", mark: "test", json: {}, auditContext: "test" },
69+
).catch((e: unknown) => e);
70+
71+
expect(err).toBeInstanceOf(Error);
72+
const msg = (err as Error).message;
73+
expect(Buffer.byteLength(msg, "utf8")).toBeLessThan(32 * 1024);
74+
expect(msg).toContain("X");
75+
});
76+
77+
it("preserves short error body when under cap", async () => {
78+
server = http.createServer((_req, res) => {
79+
res.writeHead(500, { "Content-Type": "text/plain" });
80+
res.end("session expired");
81+
});
82+
const port = await new Promise<number>((resolve) => {
83+
server.listen(0, "127.0.0.1", () => {
84+
resolve((server.address() as { port: number }).port);
85+
});
86+
});
87+
88+
const err = await pokeUrbitChannel(
89+
{
90+
baseUrl: `http://127.0.0.1:${port}`,
91+
cookie: "urbit=cookie",
92+
ship: "~zod",
93+
channelId: "test",
94+
},
95+
{ app: "test", mark: "test", json: {}, auditContext: "test" },
96+
).catch((e: unknown) => e);
97+
98+
expect(err).toBeInstanceOf(Error);
99+
expect((err as Error).message).toContain("session expired");
100+
});
101+
});

extensions/tlon/src/urbit/sse-client.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
55
import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
66
import { ensureUrbitChannelOpen, pokeUrbitChannel, scryUrbitPath } from "./channel-ops.js";
77
import { getUrbitContext, normalizeUrbitCookie } from "./context.js";
8+
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
89
import { urbitFetch } from "./fetch.js";
910

1011
type UrbitSseLogger = {
@@ -153,7 +154,7 @@ export class UrbitSSEClient {
153154

154155
try {
155156
if (!response.ok && response.status !== 204) {
156-
const errorText = await response.text().catch(() => "");
157+
const errorText = await readResponseTextLimited(response, 16 * 1024).catch(() => "");
157158
throw new Error(
158159
`Subscribe failed: ${response.status}${errorText ? ` - ${errorText}` : ""}`,
159160
);

src/llm/providers/anthropic.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,7 @@ describe("Anthropic provider", () => {
416416
{ type: "text", text: "before image" },
417417
{ type: "image", data: imageData, mimeType: "image/png" },
418418
{
419-
type: "resource",
419+
type: "resource" as const,
420420
resource: { uri: "https://example.com/data.json", text: '{"key":"value"}' },
421421
},
422422
{ type: "text", text: "after image" },

0 commit comments

Comments
 (0)