From 8abd5d40712db2d9a42f5cfcf355b40cbdd6d64d Mon Sep 17 00:00:00 2001 From: huangjianxiong Date: Wed, 1 Jul 2026 21:57:21 +0800 Subject: [PATCH] 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 --- extensions/tlon/src/channel.runtime.ts | 3 +- extensions/tlon/src/urbit/channel-ops.ts | 5 +- .../src/urbit/error-body-boundary.test.ts | 101 ++++++++++++++++++ extensions/tlon/src/urbit/sse-client.ts | 3 +- src/llm/providers/anthropic.test.ts | 2 +- 5 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 extensions/tlon/src/urbit/error-body-boundary.test.ts diff --git a/extensions/tlon/src/channel.runtime.ts b/extensions/tlon/src/channel.runtime.ts index 89f1523d9057..6d12a137918e 100644 --- a/extensions/tlon/src/channel.runtime.ts +++ b/extensions/tlon/src/channel.runtime.ts @@ -25,6 +25,7 @@ import { sendGroupMessageWithStory, } from "./urbit/send.js"; import { uploadImageFromUrl } from "./urbit/upload.js"; +import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http"; type ResolvedTlonAccount = ReturnType; type ConfiguredTlonAccount = ResolvedTlonAccount & { @@ -76,7 +77,7 @@ async function createHttpPokeApi(params: { try { if (!response.ok && response.status !== 204) { - const errorText = await response.text(); + const errorText = await readResponseTextLimited(response, 16 * 1024); throw new Error(`Poke failed: ${response.status} - ${errorText}`); } diff --git a/extensions/tlon/src/urbit/channel-ops.ts b/extensions/tlon/src/urbit/channel-ops.ts index cb08dc642d23..d0ffe927ba19 100644 --- a/extensions/tlon/src/urbit/channel-ops.ts +++ b/extensions/tlon/src/urbit/channel-ops.ts @@ -1,4 +1,5 @@ // Tlon plugin module implements channel ops behavior. +import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http"; import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; import { UrbitHttpError } from "./errors.js"; import { urbitFetch } from "./fetch.js"; @@ -36,6 +37,8 @@ async function putUrbitChannel( }); } +const TLON_ERROR_BODY_LIMIT_BYTES = 16 * 1024; + export async function pokeUrbitChannel( deps: UrbitChannelDeps, params: { app: string; mark: string; json: unknown; auditContext: string }, @@ -57,7 +60,7 @@ export async function pokeUrbitChannel( try { if (!response.ok && response.status !== 204) { - const errorText = await response.text().catch(() => ""); + const errorText = await readResponseTextLimited(response, TLON_ERROR_BODY_LIMIT_BYTES).catch(() => ""); throw new Error(`Poke failed: ${response.status}${errorText ? ` - ${errorText}` : ""}`); } return pokeId; diff --git a/extensions/tlon/src/urbit/error-body-boundary.test.ts b/extensions/tlon/src/urbit/error-body-boundary.test.ts new file mode 100644 index 000000000000..09a84c32c027 --- /dev/null +++ b/extensions/tlon/src/urbit/error-body-boundary.test.ts @@ -0,0 +1,101 @@ +import http from "node:http"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/ssrf-runtime", + ); + return { + ...actual, + fetchWithSsrFGuard: async (params: { + url: string; + init?: RequestInit; + signal?: AbortSignal; + }) => ({ + response: await fetch(params.url, { ...params.init, signal: params.signal }), + finalUrl: params.url, + release: async () => {}, + }), + }; +}); + +const { pokeUrbitChannel } = await import("./channel-ops.js"); + +const CHUNK = Buffer.alloc(64 * 1024, "X"); + +describe("tlon error body boundary", () => { + let server: http.Server; + + afterEach(async () => { + vi.restoreAllMocks(); + await new Promise((resolve) => { + server?.close(() => resolve()); + }); + }); + + it("bounds poke error body at 16 KiB", async () => { + server = http.createServer((_req, res) => { + res.writeHead(500, { "Content-Type": "text/plain" }); + let written = 0; + function write() { + if (written >= 4 * 1024 * 1024) { + res.end(); + return; + } + const ok = res.write(CHUNK); + written += CHUNK.length; + if (ok) { + setImmediate(write); + } else { + res.once("drain", write); + } + } + write(); + }); + const port = await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + resolve((server.address() as { port: number }).port); + }); + }); + + const err = await pokeUrbitChannel( + { + baseUrl: `http://127.0.0.1:${port}`, + cookie: "urbit=cookie", + ship: "~zod", + channelId: "test", + }, + { app: "test", mark: "test", json: {}, auditContext: "test" }, + ).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(Error); + const msg = (err as Error).message; + expect(Buffer.byteLength(msg, "utf8")).toBeLessThan(32 * 1024); + expect(msg).toContain("X"); + }); + + it("preserves short error body when under cap", async () => { + server = http.createServer((_req, res) => { + res.writeHead(500, { "Content-Type": "text/plain" }); + res.end("session expired"); + }); + const port = await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + resolve((server.address() as { port: number }).port); + }); + }); + + const err = await pokeUrbitChannel( + { + baseUrl: `http://127.0.0.1:${port}`, + cookie: "urbit=cookie", + ship: "~zod", + channelId: "test", + }, + { app: "test", mark: "test", json: {}, auditContext: "test" }, + ).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toContain("session expired"); + }); +}); diff --git a/extensions/tlon/src/urbit/sse-client.ts b/extensions/tlon/src/urbit/sse-client.ts index 8ee6e08d8b4b..4fc8f9841b45 100644 --- a/extensions/tlon/src/urbit/sse-client.ts +++ b/extensions/tlon/src/urbit/sse-client.ts @@ -5,6 +5,7 @@ import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime"; import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; import { ensureUrbitChannelOpen, pokeUrbitChannel, scryUrbitPath } from "./channel-ops.js"; import { getUrbitContext, normalizeUrbitCookie } from "./context.js"; +import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http"; import { urbitFetch } from "./fetch.js"; type UrbitSseLogger = { @@ -153,7 +154,7 @@ export class UrbitSSEClient { try { if (!response.ok && response.status !== 204) { - const errorText = await response.text().catch(() => ""); + const errorText = await readResponseTextLimited(response, 16 * 1024).catch(() => ""); throw new Error( `Subscribe failed: ${response.status}${errorText ? ` - ${errorText}` : ""}`, ); diff --git a/src/llm/providers/anthropic.test.ts b/src/llm/providers/anthropic.test.ts index 21987cd65b2d..a18e3c00900f 100644 --- a/src/llm/providers/anthropic.test.ts +++ b/src/llm/providers/anthropic.test.ts @@ -416,7 +416,7 @@ describe("Anthropic provider", () => { { type: "text", text: "before image" }, { type: "image", data: imageData, mimeType: "image/png" }, { - type: "resource", + type: "resource" as const, resource: { uri: "https://example.com/data.json", text: '{"key":"value"}' }, }, { type: "text", text: "after image" },