fix(clawhub): bound archive download bodies (#101176)

* fix(clawhub): bound archive download bodies

Co-authored-by: NIO <nocodet@mail.com>

* docs(changelog): note ClawHub archive bounds

* chore(changelog): defer ClawHub release note

* fix(clawhub): avoid response-limit shadowing

---------

Co-authored-by: NIO <nocodet@mail.com>
This commit is contained in:
Peter Steinberger
2026-07-06 23:29:25 +01:00
committed by GitHub
parent a464620141
commit 2655bf4dac
2 changed files with 153 additions and 6 deletions
+121
View File
@@ -8,6 +8,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { withTempDir } from "../test-helpers/temp-dir.js";
import { captureEnv, setTestEnvValue } from "../test-utils/env.js";
import {
downloadClawHubGitHubSkillArchive,
downloadClawHubPackageArchive,
downloadClawHubSkillArchive,
downloadClawHubSkillArchiveUrl,
@@ -69,6 +70,92 @@ function createStalledBodyResponse(params: {
};
}
function createOversizedArchiveResponse(
params: {
headers?: HeadersInit;
} = {},
): {
response: Response;
cancel: ReturnType<typeof vi.fn>;
} {
const cancel = vi.fn();
const body = new ReadableStream<Uint8Array>({
cancel() {
cancel();
},
});
const headers = new Headers(params.headers);
headers.set("content-type", headers.get("content-type") ?? "application/zip");
headers.set("content-length", String(256 * 1024 * 1024 + 512 * 1024));
return {
response: new Response(body, {
status: 200,
headers,
}),
cancel,
};
}
const oversizedArchiveCases: Array<{
name: string;
headers?: HeadersInit;
download: (response: Response) => Promise<unknown>;
expectedResource: string;
}> = [
{
name: "package archive",
download: (response) =>
downloadClawHubPackageArchive({
name: "@hyf/zai-external-alpha",
version: "0.0.1",
fetchImpl: async () => response,
}),
expectedResource: "package archive download for @hyf/zai-external-alpha",
},
{
name: "ClawPack artifact",
headers: { "content-type": "application/octet-stream" },
download: (response) =>
downloadClawHubPackageArchive({
name: "demo",
version: "1.2.3",
artifact: "clawpack",
fetchImpl: async () => response,
}),
expectedResource: "ClawPack download for demo@1.2.3",
},
{
name: "skill archive",
download: (response) =>
downloadClawHubSkillArchive({
slug: "agentreceipt",
version: "1.0.0",
fetchImpl: async () => response,
}),
expectedResource: "skill archive download for agentreceipt",
},
{
name: "resolver URL archive",
download: (response) =>
downloadClawHubSkillArchiveUrl({
baseUrl: "https://clawhub.ai",
url: "https://downloads.example.com/skill.zip",
fetchImpl: async () => response,
}),
expectedResource: "skill archive download at /skill.zip",
},
{
name: "GitHub source archive",
download: (response) =>
downloadClawHubGitHubSkillArchive({
repo: "owner/repo",
commit: "abc123",
fetchImpl: async () => response,
}),
expectedResource: "GitHub source archive for owner/repo@abc123",
},
];
describe("clawhub helpers", () => {
const originalEnv = captureEnv(["HOME", "XDG_CONFIG_HOME"]);
@@ -846,6 +933,40 @@ describe("clawhub helpers", () => {
).rejects.toThrow(/declared sha256/);
});
it.each(oversizedArchiveCases)(
"rejects and cancels oversized $name downloads",
async ({ headers, download, expectedResource }) => {
const oversized = createOversizedArchiveResponse({ headers });
await expect(download(oversized.response)).rejects.toThrow(
`ClawHub ${expectedResource} exceeded 268435456 bytes (268959744 bytes declared)`,
);
expect(oversized.cancel).toHaveBeenCalledTimes(1);
},
);
it("uses decoded stream bytes instead of encoded content length", async () => {
const bytes = new Uint8Array([1, 2, 3]);
const archive = await downloadClawHubPackageArchive({
name: "encoded-package",
version: "1.0.0",
fetchImpl: async () =>
new Response(bytes, {
status: 200,
headers: {
"content-encoding": "gzip",
"content-length": String(256 * 1024 * 1024 + 1),
"content-type": "application/zip",
},
}),
});
try {
await expect(fs.readFile(archive.archivePath)).resolves.toEqual(Buffer.from(bytes));
} finally {
await archive.cleanup();
}
});
it("annotates 429 errors with the reset hint and a sign-in hint when unauthenticated", async () => {
process.env.OPENCLAW_CLAWHUB_CONFIG_PATH = path.join(os.tmpdir(), "openclaw-no-clawhub-config");
await expect(
+32 -6
View File
@@ -11,7 +11,10 @@ import {
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
import { sha256Base64, sha256Hex as digestSha256Hex } from "./crypto-digest.js";
import { readResponseTextSnippet, readResponseWithLimit } from "./http-body.js";
import { parseStrictPositiveInteger } from "./parse-finite-number.js";
import {
parseStrictNonNegativeInteger,
parseStrictPositiveInteger,
} from "./parse-finite-number.js";
import { isAtLeast, parseSemver } from "./runtime-guard.js";
import { compareComparableSemver, parseComparableSemver } from "./semver-compare.js";
import { createTempDownloadTarget } from "./temp-download.js";
@@ -21,6 +24,8 @@ const DEFAULT_CLAWHUB_URL = "https://clawhub.ai";
const DEFAULT_GITHUB_CODELOAD_URL = "https://codeload.github.com";
const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
const SKILL_CARD_MAX_BYTES = 256 * 1024;
// Align with marketplace archive downloads (src/plugins/marketplace.ts).
const CLAWHUB_ARCHIVE_MAX_BYTES = 256 * 1024 * 1024;
// ClawHub is an external marketplace: bound untrusted JSON and error bodies so
// a hostile or malfunctioning host cannot exhaust memory with an endless stream.
const CLAWHUB_JSON_MAX_BYTES = 16 * 1024 * 1024;
@@ -798,17 +803,38 @@ async function readClawHubResponseBytes(params: {
resourceLabel: string;
}): Promise<Uint8Array> {
const timeoutMs = resolveClawHubRequestTimeoutMs(params.timeoutMs);
return await readResponseWithLimit(params.response, params.maxBytes ?? Number.MAX_SAFE_INTEGER, {
const maxBytes = params.maxBytes ?? CLAWHUB_ARCHIVE_MAX_BYTES;
const contentEncoding = normalizeOptionalString(params.response.headers.get("content-encoding"));
const declaredSize =
!contentEncoding || contentEncoding.toLowerCase() === "identity"
? parseStrictNonNegativeInteger(params.response.headers.get("content-length"))
: undefined;
if (declaredSize !== undefined && declaredSize > maxBytes) {
// Fetch may decode encoded bodies while retaining their wire length, so
// only identity lengths can safely short-circuit the decoded stream cap.
await params.response.body?.cancel().catch(() => undefined);
throw createClawHubBodyLimitError(params.resourceLabel, declaredSize, maxBytes, "declared");
}
return await readResponseWithLimit(params.response, maxBytes, {
chunkTimeoutMs: timeoutMs,
onOverflow: ({ size, maxBytes }) =>
new Error(
`ClawHub ${params.resourceLabel} exceeded ${maxBytes} bytes (${size} bytes received)`,
),
onOverflow: ({ size, maxBytes: limitBytes }) =>
createClawHubBodyLimitError(params.resourceLabel, size, limitBytes),
onIdleTimeout: ({ chunkTimeoutMs }) =>
new Error(`ClawHub ${params.resourceLabel} body stalled after ${chunkTimeoutMs}ms`),
});
}
function createClawHubBodyLimitError(
resourceLabel: string,
size: number,
maxBytes: number,
measurement: "declared" | "received" = "received",
): Error {
return new Error(
`ClawHub ${resourceLabel} exceeded ${maxBytes} bytes (${size} bytes ${measurement})`,
);
}
function isJsonObject(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}