mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-04 10:33:33 +00:00
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:
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
101
extensions/tlon/src/urbit/error-body-boundary.test.ts
Normal file
101
extensions/tlon/src/urbit/error-body-boundary.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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}` : ""}`,
|
||||
);
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user