Skip to content

Commit 089b49e

Browse files
committed
fix(tools-manager): add download-stream byte cap and regression tests
Add a maxBytes parameter to downloadFile that checks Content-Length before reading the body and enforces a streaming byte cap during transfer, so oversized archives are rejected before hitting disk. Extract MAX_ARCHIVE_BYTES as a module-level constant shared between downloadFile and extractArchiveSafe, ensuring both gates use the same 100 MB limit. Add two regression tests: - rejects downloads with Content-Length exceeding the archive byte cap - accepts downloads with Content-Length under the archive byte cap Ref. openclaw#98988
1 parent 74683f1 commit 089b49e

2 files changed

Lines changed: 118 additions & 7 deletions

File tree

src/agents/utils/tools-manager.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,70 @@ describe("ensureTool", () => {
135135
}),
136136
);
137137
});
138+
139+
it("rejects downloads with Content-Length exceeding archive byte cap", async () => {
140+
vi.doMock("node:os", async (importOriginal) => ({
141+
...(await importOriginal<typeof import("node:os")>()),
142+
arch: () => "x64",
143+
platform: () => "linux",
144+
}));
145+
146+
const { ensureTool } = await import("./tools-manager.js");
147+
const releaseCheckRelease = vi.fn(async () => {});
148+
const downloadRelease = vi.fn(async () => {});
149+
fetchWithSsrFGuardMock
150+
.mockResolvedValueOnce({
151+
response: new Response(JSON.stringify({ tag_name: "14.1.1" }), { status: 200 }),
152+
release: releaseCheckRelease,
153+
finalUrl: "https://api.github.com/repos/BurntSushi/ripgrep/releases/latest",
154+
})
155+
.mockResolvedValueOnce({
156+
response: new Response("oversized-body", {
157+
status: 200,
158+
headers: { "content-length": String(200 * 1024 * 1024) },
159+
}),
160+
release: downloadRelease,
161+
finalUrl: "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/archive.tar.gz",
162+
});
163+
164+
await expect(ensureTool("rg", true)).resolves.toBeUndefined();
165+
166+
expect(downloadRelease).toHaveBeenCalledOnce();
167+
});
168+
169+
it("accepts downloads with Content-Length under the archive byte cap", async () => {
170+
vi.doMock("node:os", async (importOriginal) => ({
171+
...(await importOriginal<typeof import("node:os")>()),
172+
arch: () => "x64",
173+
platform: () => "linux",
174+
}));
175+
176+
const { ensureTool } = await import("./tools-manager.js");
177+
const releaseCheckRelease = vi.fn(async () => {});
178+
const downloadRelease = vi.fn(async () => {});
179+
extractArchiveMock.mockRejectedValue(new Error("extraction error (expected)"));
180+
fetchWithSsrFGuardMock
181+
.mockResolvedValueOnce({
182+
response: new Response(JSON.stringify({ tag_name: "14.1.1" }), { status: 200 }),
183+
release: releaseCheckRelease,
184+
finalUrl: "https://api.github.com/repos/BurntSushi/ripgrep/releases/latest",
185+
})
186+
.mockResolvedValueOnce({
187+
response: new Response("small-body", {
188+
status: 200,
189+
headers: { "content-length": "10" },
190+
}),
191+
release: downloadRelease,
192+
finalUrl: "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/archive.tar.gz",
193+
});
194+
195+
await expect(ensureTool("rg", true)).resolves.toBeUndefined();
196+
197+
// Download proceeded past the Content-Length check, then extraction failed
198+
// as expected (no real archive). The important thing is we didn't reject
199+
// preemptively.
200+
expect(downloadRelease).toHaveBeenCalledOnce();
201+
});
138202
});
139203

140204
describe("getToolPath exit-status handling", () => {

src/agents/utils/tools-manager.ts

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
} from "node:fs";
1717
import { arch, platform } from "node:os";
1818
import { join } from "node:path";
19-
import { Readable } from "node:stream";
19+
import { PassThrough, Readable } from "node:stream";
2020
import { pipeline } from "node:stream/promises";
2121
import type { ReadableStream as NodeReadableStream } from "node:stream/web";
2222
import chalk from "chalk";
@@ -27,6 +27,10 @@ import { APP_NAME, getBinDir } from "../config.js";
2727
const TOOLS_DIR = getBinDir();
2828
const NETWORK_TIMEOUT_MS = 10_000;
2929
const DOWNLOAD_TIMEOUT_MS = 120_000;
30+
// Maximum compressed archive size allowed for tool downloads (100 MB).
31+
// Matches the limit enforced during extraction so oversized archives are
32+
// rejected before hitting disk, not just when extractArchiveSafe runs.
33+
const MAX_ARCHIVE_BYTES = 100 * 1024 * 1024;
3034

3135
async function cancelUnreadResponseBody(response: Response): Promise<void> {
3236
if (!response.bodyUsed) {
@@ -158,8 +162,12 @@ async function getLatestVersion(repo: string): Promise<string> {
158162
}
159163
}
160164

161-
// Download a file from URL
162-
async function downloadFile(url: string, dest: string): Promise<void> {
165+
// Download a file from URL with an optional byte cap. When maxBytes is set the
166+
// Content-Length header is checked before the body is read and a streaming byte
167+
// counter aborts the pipeline mid-transfer if the cap is exceeded. Without this
168+
// check an oversized release asset would fully download to disk before the
169+
// compression-bomb guard in extractArchiveSafe has a chance to reject it.
170+
async function downloadFile(url: string, dest: string, maxBytes?: number): Promise<void> {
163171
const guarded = await fetchWithSsrFGuard({
164172
url,
165173
timeoutMs: DOWNLOAD_TIMEOUT_MS,
@@ -177,8 +185,46 @@ async function downloadFile(url: string, dest: string): Promise<void> {
177185
throw new Error("No response body");
178186
}
179187

188+
// Check Content-Length before reading the body so oversized responses are
189+
// rejected without consuming any download bandwidth or disk space.
190+
if (maxBytes !== undefined) {
191+
const contentLength = response.headers.get("content-length");
192+
if (contentLength !== null) {
193+
const length = parseInt(contentLength, 10);
194+
if (!isNaN(length) && length > maxBytes) {
195+
await cancelUnreadResponseBody(response);
196+
throw new Error(
197+
`Download aborted: content-length ${length} exceeds ${maxBytes} byte limit`,
198+
);
199+
}
200+
}
201+
}
202+
180203
const fileStream = createWriteStream(dest);
181-
await pipeline(Readable.fromWeb(response.body as NodeReadableStream<Uint8Array>), fileStream);
204+
205+
if (maxBytes !== undefined) {
206+
// Streaming byte counter — catches oversized responses even when the
207+
// Content-Length header is missing or under-reported.
208+
let downloaded = 0;
209+
const byteCap = new PassThrough();
210+
byteCap.on("data", (chunk: Buffer) => {
211+
downloaded += chunk.length;
212+
if (downloaded > maxBytes) {
213+
byteCap.destroy(
214+
new Error(
215+
`Download aborted: exceeded ${maxBytes} byte limit after ${downloaded} bytes`,
216+
),
217+
);
218+
}
219+
});
220+
await pipeline(
221+
Readable.fromWeb(response.body as NodeReadableStream<Uint8Array>),
222+
byteCap,
223+
fileStream,
224+
);
225+
} else {
226+
await pipeline(Readable.fromWeb(response.body as NodeReadableStream<Uint8Array>), fileStream);
227+
}
182228
} finally {
183229
await guarded.release();
184230
}
@@ -223,7 +269,7 @@ async function extractArchiveSafe(
223269
destDir: extractDir,
224270
timeoutMs: 60_000,
225271
limits: {
226-
maxArchiveBytes: 100 * 1024 * 1024,
272+
maxArchiveBytes: MAX_ARCHIVE_BYTES,
227273
maxExtractedBytes: 500 * 1024 * 1024,
228274
maxEntries: 1000,
229275
},
@@ -265,8 +311,9 @@ async function downloadTool(tool: "fd" | "rg"): Promise<string> {
265311
const binaryExt = plat === "win32" ? ".exe" : "";
266312
const binaryPath = join(TOOLS_DIR, config.binaryName + binaryExt);
267313

268-
// Download
269-
await downloadFile(downloadUrl, archivePath);
314+
// Download with byte cap so oversized archives are rejected before
315+
// hitting disk, not just during extraction.
316+
await downloadFile(downloadUrl, archivePath, MAX_ARCHIVE_BYTES);
270317

271318
// Extract into a unique temp directory. fd and rg downloads can run concurrently
272319
// during startup, so sharing a fixed directory causes races.

0 commit comments

Comments
 (0)