mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-28 06:49:37 +00:00
* fix: harden package URL downloads Guard package acceptance URL downloads with HTTPS-only validation, no embedded credentials, private/special-use DNS and IP rejection, manual redirect checks, bounded timeout/size limits, pinned lookup, and atomic temp-file writes. Add tooling tests for unsafe URLs, redirect validation, size limits, and successful writes. * fix: cancel redirect response bodies before closing dispatcher ClawSweeper P2: the redirect branch in openPackageDownloadResponse cleared the timeout and awaited dispatcher.close() without first cancelling response.body. Undici's close() is graceful — it waits for in-flight requests to complete — so a malicious redirect with a slow/never-ending body could hang the hardened downloader. Fix: call response.body?.cancel() before dispatcher.close() to abort the redirect body immediately. Test: add a regression test that uses a ReadableStream with an indefinite interval to simulate a hanging body, and asserts cancel() was called. Refs: clawsweeper review on PR #85512 * test: harden redirect body cancellation race in regression test Guard the ReadableStream controller.enqueue() call with a cancelled flag and try/catch to prevent ERR_INVALID_STATE when the interval fires after cancel() closes the controller. * fix: cancel final response body before closing dispatcher in downloadUrl ClawSweeper P2: the HTTP-error and declared-oversize early-exit paths in downloadUrl threw before consuming or canceling response.body. The finally block then cleared the timeout and awaited graceful dispatcher.close() with the body still open, allowing a slow/never-ending response to hang release tooling. Fix: add response.body?.cancel() in the finally block before dispatcher.close(). Tests: add two regressions: - HTTP 500 with slow body: asserts cancel() called before dispatcher close - Declared content-length oversize with slow body: same assertion * fix: add trusted package URL source policy * fix: keep package URL resolver dependency-free * test: cover encoded IPv6 package URL bypasses * docs: sync package acceptance source overview * docs: restore release doc formatting * docs: sync package acceptance trusted-url source * test: cover dotted IPv4 embedded IPv6 package URLs * fix: parse dotted IPv4 embedded in IPv6 package URLs * test: isolate anthropic pruning defaults * test: move anthropic dated model coverage --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
463 lines
16 KiB
TypeScript
463 lines
16 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import {
|
|
downloadUrl,
|
|
loadTrustedPackageSource,
|
|
parseArgs,
|
|
readArtifactPackageCandidateMetadata,
|
|
readPackageBuildSourceSha,
|
|
validateOpenClawPackageSpec,
|
|
} from "../../scripts/resolve-openclaw-package-candidate.mjs";
|
|
|
|
const tempDirs: string[] = [];
|
|
|
|
type LookupAddress = { address: string; family: number };
|
|
|
|
function lookupAddresses(addresses: LookupAddress[]) {
|
|
return async () => addresses;
|
|
}
|
|
|
|
function unexpectedFetch(): never {
|
|
throw new Error("downloadUrl should reject before fetching");
|
|
}
|
|
|
|
async function missing(file: string): Promise<boolean> {
|
|
return await access(file).then(
|
|
() => false,
|
|
() => true,
|
|
);
|
|
}
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
});
|
|
|
|
describe("resolve-openclaw-package-candidate", () => {
|
|
it("accepts only OpenClaw release package specs for npm candidates", () => {
|
|
for (const spec of [
|
|
"openclaw@beta",
|
|
"openclaw@alpha",
|
|
"openclaw@latest",
|
|
"openclaw@2026.4.27",
|
|
"openclaw@2026.4.27-1",
|
|
"openclaw@2026.4.27-beta.2",
|
|
"openclaw@2026.4.27-alpha.2",
|
|
]) {
|
|
expect(validateOpenClawPackageSpec(spec), spec).toBeUndefined();
|
|
}
|
|
|
|
expect(() => validateOpenClawPackageSpec("@evil/openclaw@1.0.0")).toThrow(
|
|
"package_spec must be openclaw@alpha",
|
|
);
|
|
expect(() => validateOpenClawPackageSpec("openclaw@canary")).toThrow(
|
|
"package_spec must be openclaw@alpha",
|
|
);
|
|
expect(() => validateOpenClawPackageSpec("openclaw@2026.04.27")).toThrow(
|
|
"package_spec must be openclaw@alpha",
|
|
);
|
|
expect(() => validateOpenClawPackageSpec("openclaw@npm:other-package")).toThrow(
|
|
"package_spec must be openclaw@alpha",
|
|
);
|
|
expect(() => validateOpenClawPackageSpec("openclaw@file:../other-package.tgz")).toThrow(
|
|
"package_spec must be openclaw@alpha",
|
|
);
|
|
});
|
|
|
|
it("parses optional empty workflow inputs without rejecting the command line", () => {
|
|
expect(
|
|
parseArgs([
|
|
"--source",
|
|
"npm",
|
|
"--package-ref",
|
|
"release/2026.4.27",
|
|
"--package-spec",
|
|
"openclaw@beta",
|
|
"--package-url",
|
|
"",
|
|
"--package-sha256",
|
|
"",
|
|
"--artifact-dir",
|
|
".",
|
|
"--output-dir",
|
|
".artifacts/docker-e2e-package",
|
|
]),
|
|
).toEqual({
|
|
artifactDir: ".",
|
|
githubOutput: "",
|
|
metadata: "",
|
|
outputDir: ".artifacts/docker-e2e-package",
|
|
outputName: "openclaw-current.tgz",
|
|
packageSha256: "",
|
|
packageRef: "release/2026.4.27",
|
|
packageSpec: "openclaw@beta",
|
|
packageUrl: "",
|
|
source: "npm",
|
|
trustedSourceId: "",
|
|
trustedSourcePolicy: ".github/package-trusted-sources.json",
|
|
});
|
|
});
|
|
|
|
it("loads named trusted package URL source policies", async () => {
|
|
const dir = await mkdtemp(path.join(tmpdir(), "openclaw-trusted-package-source-"));
|
|
tempDirs.push(dir);
|
|
const policy = path.join(dir, "trusted-sources.json");
|
|
await writeFile(
|
|
policy,
|
|
JSON.stringify({
|
|
schemaVersion: 1,
|
|
sources: {
|
|
"enterprise-artifactory": {
|
|
allowPrivateNetwork: true,
|
|
hosts: ["packages.internal"],
|
|
pathPrefixes: ["/artifactory/openclaw/"],
|
|
ports: [443, 8443],
|
|
redirectHosts: ["packages.internal", "mirror.internal"],
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(loadTrustedPackageSource("enterprise-artifactory", policy)).resolves.toEqual({
|
|
allowPrivateNetwork: true,
|
|
auth: undefined,
|
|
hosts: ["packages.internal"],
|
|
id: "enterprise-artifactory",
|
|
pathPrefixes: ["/artifactory/openclaw/"],
|
|
ports: [443, 8443],
|
|
redirectHosts: ["packages.internal", "mirror.internal"],
|
|
});
|
|
await expect(loadTrustedPackageSource("missing", policy)).rejects.toThrow(
|
|
"Unknown trusted package source: missing",
|
|
);
|
|
});
|
|
|
|
it("rejects unsafe package_url downloads before fetching private targets", async () => {
|
|
const dir = await mkdtemp(path.join(tmpdir(), "openclaw-package-download-"));
|
|
tempDirs.push(dir);
|
|
const target = path.join(dir, "openclaw.tgz");
|
|
|
|
await expect(
|
|
downloadUrl("http://packages.example/openclaw.tgz", target, {
|
|
fetchImpl: unexpectedFetch,
|
|
lookupHost: lookupAddresses([{ address: "93.184.216.34", family: 4 }]),
|
|
}),
|
|
).rejects.toThrow("package_url must use https");
|
|
await expect(
|
|
downloadUrl("https://user@packages.example/openclaw.tgz", target, {
|
|
fetchImpl: unexpectedFetch,
|
|
lookupHost: lookupAddresses([{ address: "93.184.216.34", family: 4 }]),
|
|
}),
|
|
).rejects.toThrow("package_url must not include credentials");
|
|
await expect(
|
|
downloadUrl("https://localhost/openclaw.tgz", target, {
|
|
fetchImpl: unexpectedFetch,
|
|
lookupHost: lookupAddresses([{ address: "127.0.0.1", family: 4 }]),
|
|
}),
|
|
).rejects.toThrow(/private\/internal\/special-use/iu);
|
|
await expect(
|
|
downloadUrl("https://packages.example/openclaw.tgz", target, {
|
|
fetchImpl: unexpectedFetch,
|
|
lookupHost: lookupAddresses([{ address: "10.0.0.8", family: 4 }]),
|
|
}),
|
|
).rejects.toThrow(/resolves to private\/internal\/special-use/iu);
|
|
await expect(
|
|
downloadUrl("https://packages.example/openclaw.tgz", target, {
|
|
fetchImpl: unexpectedFetch,
|
|
lookupHost: lookupAddresses([{ address: "64:ff9b::a9fe:a9fe", family: 6 }]),
|
|
}),
|
|
).rejects.toThrow(/resolves to private\/internal\/special-use/iu);
|
|
});
|
|
|
|
it("allows private package_url downloads only through an explicit trusted source policy", async () => {
|
|
const dir = await mkdtemp(path.join(tmpdir(), "openclaw-package-download-"));
|
|
tempDirs.push(dir);
|
|
const target = path.join(dir, "openclaw.tgz");
|
|
const trustedSource = {
|
|
allowPrivateNetwork: true,
|
|
hosts: ["packages.internal"],
|
|
id: "enterprise-artifactory",
|
|
pathPrefixes: ["/artifactory/openclaw/"],
|
|
ports: [8443],
|
|
redirectHosts: ["packages.internal"],
|
|
};
|
|
const requestedUrls: string[] = [];
|
|
|
|
await downloadUrl("https://packages.internal:8443/artifactory/openclaw/openclaw.tgz", target, {
|
|
fetchImpl: async (url: URL) => {
|
|
requestedUrls.push(url.toString());
|
|
return new Response(new Uint8Array([4, 5, 6]), {
|
|
headers: { "content-length": "3" },
|
|
status: 200,
|
|
});
|
|
},
|
|
lookupHost: lookupAddresses([{ address: "10.0.0.8", family: 4 }]),
|
|
maxBytes: 3,
|
|
trustedSource,
|
|
});
|
|
|
|
expect(requestedUrls).toEqual([
|
|
"https://packages.internal:8443/artifactory/openclaw/openclaw.tgz",
|
|
]);
|
|
await expect(readFile(target)).resolves.toEqual(Buffer.from([4, 5, 6]));
|
|
|
|
await expect(
|
|
downloadUrl("https://evil.internal:8443/artifactory/openclaw/openclaw.tgz", target, {
|
|
fetchImpl: unexpectedFetch,
|
|
lookupHost: lookupAddresses([{ address: "10.0.0.9", family: 4 }]),
|
|
trustedSource,
|
|
}),
|
|
).rejects.toThrow("is not allowed by trusted package source enterprise-artifactory");
|
|
await expect(
|
|
downloadUrl("https://packages.internal:8443/other/openclaw.tgz", target, {
|
|
fetchImpl: unexpectedFetch,
|
|
lookupHost: lookupAddresses([{ address: "10.0.0.8", family: 4 }]),
|
|
trustedSource,
|
|
}),
|
|
).rejects.toThrow("path is not allowed by trusted package source enterprise-artifactory");
|
|
});
|
|
|
|
it("keeps trusted package_url redirects inside the named source policy", async () => {
|
|
const dir = await mkdtemp(path.join(tmpdir(), "openclaw-package-download-"));
|
|
tempDirs.push(dir);
|
|
const target = path.join(dir, "openclaw.tgz");
|
|
const trustedSource = {
|
|
allowPrivateNetwork: true,
|
|
hosts: ["packages.internal"],
|
|
id: "enterprise-artifactory",
|
|
pathPrefixes: ["/artifactory/openclaw/"],
|
|
ports: [8443],
|
|
redirectHosts: ["packages.internal"],
|
|
};
|
|
|
|
await expect(
|
|
downloadUrl("https://packages.internal:8443/artifactory/openclaw/openclaw.tgz", target, {
|
|
fetchImpl: async () =>
|
|
new Response(null, {
|
|
headers: { location: "https://metadata.internal:8443/artifactory/openclaw/pwn.tgz" },
|
|
status: 302,
|
|
}),
|
|
lookupHost: lookupAddresses([{ address: "10.0.0.8", family: 4 }]),
|
|
trustedSource,
|
|
}),
|
|
).rejects.toThrow("is not allowed by trusted package source enterprise-artifactory");
|
|
});
|
|
|
|
it("validates redirects for package_url downloads", async () => {
|
|
const dir = await mkdtemp(path.join(tmpdir(), "openclaw-package-download-"));
|
|
tempDirs.push(dir);
|
|
const target = path.join(dir, "openclaw.tgz");
|
|
const requestedUrls: string[] = [];
|
|
|
|
await expect(
|
|
downloadUrl("https://packages.example/openclaw.tgz", target, {
|
|
fetchImpl: async (url: URL) => {
|
|
requestedUrls.push(url.toString());
|
|
return new Response(null, {
|
|
headers: { location: "https://169.254.169.254/latest/meta-data" },
|
|
status: 302,
|
|
});
|
|
},
|
|
lookupHost: lookupAddresses([{ address: "93.184.216.34", family: 4 }]),
|
|
}),
|
|
).rejects.toThrow(/private\/internal\/special-use/iu);
|
|
expect(requestedUrls).toEqual(["https://packages.example/openclaw.tgz"]);
|
|
});
|
|
|
|
it("cancels redirect response bodies before following the next hop", async () => {
|
|
const dir = await mkdtemp(path.join(tmpdir(), "openclaw-package-download-"));
|
|
tempDirs.push(dir);
|
|
const target = path.join(dir, "openclaw.tgz");
|
|
const bodyCancelled: string[] = [];
|
|
|
|
await expect(
|
|
downloadUrl("https://packages.example/openclaw.tgz", target, {
|
|
fetchImpl: async (url: URL) => {
|
|
let cancelled = false;
|
|
const body = new ReadableStream({
|
|
start(controller) {
|
|
const timer = setInterval(() => {
|
|
if (cancelled) {
|
|
clearInterval(timer);
|
|
return;
|
|
}
|
|
try {
|
|
controller.enqueue(new Uint8Array([0]));
|
|
} catch {
|
|
// Controller may already be closed after cancel.
|
|
clearInterval(timer);
|
|
}
|
|
}, 100);
|
|
},
|
|
cancel() {
|
|
cancelled = true;
|
|
bodyCancelled.push(url.toString());
|
|
},
|
|
});
|
|
return new Response(body, {
|
|
headers: { location: "https://packages.example/redirected.tgz" },
|
|
status: 302,
|
|
});
|
|
},
|
|
lookupHost: lookupAddresses([{ address: "93.184.216.34", family: 4 }]),
|
|
timeoutMs: 5000,
|
|
}),
|
|
).rejects.toThrow();
|
|
// The redirect body must have been cancelled, not left open
|
|
expect(bodyCancelled.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("cancels response body on HTTP error before closing dispatcher", async () => {
|
|
const dir = await mkdtemp(path.join(tmpdir(), "openclaw-package-download-"));
|
|
tempDirs.push(dir);
|
|
const target = path.join(dir, "openclaw.tgz");
|
|
let bodyCancelled = false;
|
|
|
|
await expect(
|
|
downloadUrl("https://packages.example/openclaw.tgz", target, {
|
|
fetchImpl: async () => {
|
|
const body = new ReadableStream({
|
|
start(controller) {
|
|
const timer = setInterval(() => {
|
|
try {
|
|
controller.enqueue(new Uint8Array([0]));
|
|
} catch {
|
|
clearInterval(timer);
|
|
}
|
|
}, 100);
|
|
},
|
|
cancel() {
|
|
bodyCancelled = true;
|
|
},
|
|
});
|
|
return new Response(body, { status: 500 });
|
|
},
|
|
lookupHost: lookupAddresses([{ address: "93.184.216.34", family: 4 }]),
|
|
timeoutMs: 5000,
|
|
}),
|
|
).rejects.toThrow(/failed to download package_url: HTTP 500/u);
|
|
expect(bodyCancelled).toBe(true);
|
|
});
|
|
|
|
it("cancels response body on declared oversize before closing dispatcher", async () => {
|
|
const dir = await mkdtemp(path.join(tmpdir(), "openclaw-package-download-"));
|
|
tempDirs.push(dir);
|
|
const target = path.join(dir, "openclaw.tgz");
|
|
let bodyCancelled = false;
|
|
|
|
await expect(
|
|
downloadUrl("https://packages.example/openclaw.tgz", target, {
|
|
fetchImpl: async () => {
|
|
const body = new ReadableStream({
|
|
start(controller) {
|
|
const timer = setInterval(() => {
|
|
try {
|
|
controller.enqueue(new Uint8Array([0]));
|
|
} catch {
|
|
clearInterval(timer);
|
|
}
|
|
}, 100);
|
|
},
|
|
cancel() {
|
|
bodyCancelled = true;
|
|
},
|
|
});
|
|
return new Response(body, {
|
|
headers: { "content-length": String(1024 * 1024 * 100) },
|
|
status: 200,
|
|
});
|
|
},
|
|
lookupHost: lookupAddresses([{ address: "93.184.216.34", family: 4 }]),
|
|
maxBytes: 1024,
|
|
timeoutMs: 5000,
|
|
}),
|
|
).rejects.toThrow(/exceeds maximum download size/u);
|
|
expect(bodyCancelled).toBe(true);
|
|
});
|
|
|
|
it("bounds package_url downloads and writes completed files atomically", async () => {
|
|
const dir = await mkdtemp(path.join(tmpdir(), "openclaw-package-download-"));
|
|
tempDirs.push(dir);
|
|
const target = path.join(dir, "openclaw.tgz");
|
|
|
|
await expect(
|
|
downloadUrl("https://packages.example/openclaw.tgz", target, {
|
|
fetchImpl: async () =>
|
|
new Response(new Uint8Array([1, 2, 3, 4]), {
|
|
headers: { "content-length": "4" },
|
|
status: 200,
|
|
}),
|
|
lookupHost: lookupAddresses([{ address: "93.184.216.34", family: 4 }]),
|
|
maxBytes: 3,
|
|
}),
|
|
).rejects.toThrow("package_url exceeds maximum download size");
|
|
await expect(missing(target)).resolves.toBe(true);
|
|
await expect(missing(`${target}.tmp`)).resolves.toBe(true);
|
|
|
|
await downloadUrl("https://packages.example/openclaw.tgz", target, {
|
|
fetchImpl: async () =>
|
|
new Response(new Uint8Array([1, 2, 3]), {
|
|
headers: { "content-length": "3" },
|
|
status: 200,
|
|
}),
|
|
lookupHost: lookupAddresses([{ address: "93.184.216.34", family: 4 }]),
|
|
maxBytes: 3,
|
|
});
|
|
await expect(readFile(target)).resolves.toEqual(Buffer.from([1, 2, 3]));
|
|
await expect(missing(`${target}.tmp`)).resolves.toBe(true);
|
|
});
|
|
|
|
it("reads package source metadata from package artifacts", async () => {
|
|
const dir = await mkdtemp(path.join(tmpdir(), "openclaw-package-candidate-"));
|
|
tempDirs.push(dir);
|
|
await writeFile(
|
|
path.join(dir, "package-candidate.json"),
|
|
JSON.stringify(
|
|
{
|
|
packageRef: "release/2026.4.30",
|
|
packageSourceSha: "66ce632b9b7c5c7fdd3e66c739687d51638ad6e2",
|
|
packageTrustedReason: "repository-branch-history",
|
|
sha256: "a".repeat(64),
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
|
|
await expect(readArtifactPackageCandidateMetadata(dir)).resolves.toEqual({
|
|
packageRef: "release/2026.4.30",
|
|
packageSourceSha: "66ce632b9b7c5c7fdd3e66c739687d51638ad6e2",
|
|
packageTrustedReason: "repository-branch-history",
|
|
sha256: "a".repeat(64),
|
|
});
|
|
});
|
|
|
|
it("reads the source SHA from packed npm build metadata", async () => {
|
|
const dir = await mkdtemp(path.join(tmpdir(), "openclaw-package-build-info-"));
|
|
tempDirs.push(dir);
|
|
const root = path.join(dir, "package");
|
|
await mkdir(path.join(root, "dist"), { recursive: true });
|
|
await writeFile(path.join(root, "package.json"), JSON.stringify({ name: "openclaw" }));
|
|
await writeFile(
|
|
path.join(root, "dist", "build-info.json"),
|
|
JSON.stringify({ commit: "66CE632B9B7C5C7FDD3E66C739687D51638AD6E2" }),
|
|
);
|
|
const tarball = path.join(dir, "openclaw.tgz");
|
|
await new Promise<void>((resolve, reject) => {
|
|
execFile("tar", ["-czf", tarball, "-C", dir, "package"], (error) => {
|
|
if (error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
await expect(readPackageBuildSourceSha(tarball)).resolves.toBe(
|
|
"66ce632b9b7c5c7fdd3e66c739687d51638ad6e2",
|
|
);
|
|
});
|
|
});
|