fix: reject malformed marketplace content length

This commit is contained in:
Peter Steinberger
2026-05-28 12:11:50 -04:00
parent 03e6181f9f
commit 982e2cf0ef
2 changed files with 58 additions and 2 deletions

View File

@@ -824,6 +824,50 @@ describe("marketplace plugins", () => {
});
});
it("rejects malformed archive content-length headers before streaming", async () => {
await withTempDir("openclaw-marketplace-test-", async (rootDir) => {
const reader = {
read: vi.fn(),
cancel: vi.fn(async () => undefined),
releaseLock: vi.fn(),
};
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: {
ok: true,
status: 200,
body: {
getReader: () => reader,
} as unknown as Response["body"],
headers: new Headers({ "content-length": "1e9" }),
} as unknown as Response,
finalUrl: "https://cdn.example.com/releases/frontend-design.tgz",
release: vi.fn(async () => undefined),
});
const manifestPath = await writeMarketplaceManifest(rootDir, {
plugins: [
{
name: "frontend-design",
source: "https://example.com/frontend-design.tgz",
},
],
});
const result = await installPluginFromMarketplace({
marketplace: manifestPath,
plugin: "frontend-design",
});
expect(result).toEqual({
ok: false,
error:
"failed to download https://example.com/frontend-design.tgz: " +
"invalid content-length header: 1e9",
});
expect(reader.read).not.toHaveBeenCalled();
expect(installPluginFromPathMock).not.toHaveBeenCalled();
});
});
it("cleans up a partial download temp dir when streaming the archive fails", async () => {
await withTempDir("openclaw-marketplace-test-", async (rootDir) => {
const beforeTempDirs = await listMarketplaceDownloadTempDirs();

View File

@@ -619,6 +619,18 @@ function hasStreamingResponseBody(
);
}
function parseMarketplaceContentLength(raw: string): number {
const trimmed = raw.trim();
if (!/^\d+$/.test(trimmed)) {
throw new Error(`invalid content-length header: ${raw}`);
}
const size = Number(trimmed);
if (!Number.isSafeInteger(size)) {
throw new Error(`invalid content-length header: ${raw}`);
}
return size;
}
async function readMarketplaceChunkWithTimeout(
reader: ReadableStreamDefaultReader<Uint8Array>,
chunkTimeoutMs: number,
@@ -755,8 +767,8 @@ async function downloadUrlToTempFile(
const contentLength = response.headers.get("content-length");
if (contentLength) {
const size = Number(contentLength);
if (Number.isFinite(size) && size > MAX_MARKETPLACE_ARCHIVE_BYTES) {
const size = parseMarketplaceContentLength(contentLength);
if (size > MAX_MARKETPLACE_ARCHIVE_BYTES) {
throw new Error(
`download too large: ${size} bytes (limit: ${MAX_MARKETPLACE_ARCHIVE_BYTES} bytes)`,
);