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
This commit is contained in:
huangjianxiong
2026-07-01 21:57:21 +08:00
committed by GitHub
parent 733de866eb
commit 8abd5d4071
5 changed files with 110 additions and 4 deletions

View File

@@ -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<typeof resolveTlonAccount>;
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}`);
}

View File

@@ -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;

View File

@@ -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<typeof import("openclaw/plugin-sdk/ssrf-runtime")>(
"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<void>((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<number>((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<number>((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");
});
});

View File

@@ -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}` : ""}`,
);

View File

@@ -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" },