Skip to content

Commit 59205bd

Browse files
committed
fix(e2e): bound Telegram Bot API helper bodies
1 parent 6fd4aa8 commit 59205bd

2 files changed

Lines changed: 91 additions & 10 deletions

File tree

scripts/e2e/telegram-bot-api.ts

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ type JsonObject = Record<string, unknown>;
33
type TelegramBotApiOptions = {
44
baseUrl?: string;
55
fetchImpl?: (url: string, init: RequestInit) => Promise<Response>;
6+
maxBodyBytes?: number;
67
timeoutMs?: number;
78
};
89

@@ -12,6 +13,10 @@ const DEFAULT_TIMEOUT_MS = readPositiveInt(
1213
process.env.OPENCLAW_TELEGRAM_USER_BOT_API_TIMEOUT_MS,
1314
30000,
1415
);
16+
const DEFAULT_BODY_MAX_BYTES = readPositiveInt(
17+
process.env.OPENCLAW_TELEGRAM_USER_BOT_API_BODY_MAX_BYTES,
18+
1024 * 1024,
19+
);
1520

1621
function readPositiveInt(raw: string | undefined, fallback: number) {
1722
const parsed = Number.parseInt(raw ?? "", 10);
@@ -23,6 +28,58 @@ function optionalString(source: JsonObject, key: string) {
2328
return typeof value === "string" && value.trim() ? value.trim() : undefined;
2429
}
2530

31+
function taggedError(message: string, code: string) {
32+
return Object.assign(new Error(message), { code });
33+
}
34+
35+
async function readBoundedResponseText(
36+
response: Response,
37+
label: string,
38+
byteLimit: number,
39+
timeoutPromise: Promise<never>,
40+
) {
41+
const contentLength = response.headers.get("content-length");
42+
if (contentLength) {
43+
const parsedLength = Number(contentLength);
44+
if (Number.isSafeInteger(parsedLength) && parsedLength > byteLimit) {
45+
await response.body?.cancel().catch(() => {});
46+
throw taggedError(`${label} response body exceeded ${byteLimit} bytes`, "ETOOBIG");
47+
}
48+
}
49+
if (!response.body) {
50+
return "";
51+
}
52+
53+
const reader = response.body.getReader();
54+
const decoder = new TextDecoder();
55+
let byteCount = 0;
56+
let text = "";
57+
try {
58+
while (true) {
59+
const { done, value } = await Promise.race([reader.read(), timeoutPromise]);
60+
if (done) {
61+
return text + decoder.decode();
62+
}
63+
byteCount += value.byteLength;
64+
if (byteCount > byteLimit) {
65+
await reader.cancel().catch(() => {});
66+
throw taggedError(`${label} response body exceeded ${byteLimit} bytes`, "ETOOBIG");
67+
}
68+
text += decoder.decode(value, { stream: true });
69+
}
70+
} finally {
71+
reader.releaseLock();
72+
}
73+
}
74+
75+
function parseJsonPayload(rawPayload: string, label: string) {
76+
try {
77+
return JSON.parse(rawPayload) as JsonObject;
78+
} catch (error) {
79+
throw new Error(`${label} returned invalid JSON`, { cause: error });
80+
}
81+
}
82+
2683
export async function telegramBotApi(
2784
token: string,
2885
method: string,
@@ -31,10 +88,9 @@ export async function telegramBotApi(
3188
) {
3289
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
3390
const timeoutMs = Math.max(1, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
34-
const timeoutError = Object.assign(
35-
new Error(`Telegram Bot API ${method} timed out after ${timeoutMs}ms`),
36-
{ code: "ETIMEDOUT" },
37-
);
91+
const maxBodyBytes = Math.max(1, options.maxBodyBytes ?? DEFAULT_BODY_MAX_BYTES);
92+
const label = `Telegram Bot API ${method}`;
93+
const timeoutError = taggedError(`${label} timed out after ${timeoutMs}ms`, "ETIMEDOUT");
3894
const controller = new AbortController();
3995
let timeout: NodeJS.Timeout | undefined;
4096
const timeoutPromise = new Promise<never>((_, reject) => {
@@ -55,7 +111,8 @@ export async function telegramBotApi(
55111
}),
56112
timeoutPromise,
57113
]);
58-
const payload = (await Promise.race([response.json(), timeoutPromise])) as JsonObject;
114+
const rawPayload = await readBoundedResponseText(response, label, maxBodyBytes, timeoutPromise);
115+
const payload = parseJsonPayload(rawPayload, label);
59116
if (!response.ok || payload.ok !== true) {
60117
throw new Error(
61118
optionalString(payload, "description") ?? `${method} failed with HTTP ${response.status}`,

test/scripts/telegram-bot-api.test.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,11 @@ describe("Telegram Bot API helper", () => {
4545

4646
it("bounds stalled Bot API response bodies", async () => {
4747
vi.useFakeTimers();
48-
const fetchImpl = vi.fn().mockResolvedValue({
49-
ok: true,
50-
status: 200,
51-
json: () => new Promise(() => undefined),
52-
});
48+
const fetchImpl = vi.fn().mockResolvedValue(
49+
new Response(new ReadableStream<Uint8Array>({ start() {} }), {
50+
status: 200,
51+
}),
52+
);
5353

5454
const result = telegramBotApi(
5555
"test-token",
@@ -70,4 +70,28 @@ describe("Telegram Bot API helper", () => {
7070
await rejection;
7171
expect(fetchImpl.mock.calls[0]?.[1]?.signal.aborted).toBe(true);
7272
});
73+
74+
it("bounds oversized Bot API response bodies", async () => {
75+
const fetchImpl = vi.fn().mockResolvedValue(
76+
new Response(JSON.stringify({ ok: true, result: {}, padding: "x".repeat(128) }), {
77+
status: 200,
78+
}),
79+
);
80+
81+
await expect(
82+
telegramBotApi(
83+
"test-token",
84+
"getMe",
85+
{},
86+
{
87+
baseUrl: "https://telegram.test",
88+
fetchImpl,
89+
maxBodyBytes: 16,
90+
},
91+
),
92+
).rejects.toMatchObject({
93+
code: "ETOOBIG",
94+
message: "Telegram Bot API getMe response body exceeded 16 bytes",
95+
});
96+
});
7397
});

0 commit comments

Comments
 (0)