mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-29 23:08:46 +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>
67 lines
2.6 KiB
TypeScript
67 lines
2.6 KiB
TypeScript
import { mkdtemp, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { downloadUrl } from "../../scripts/resolve-openclaw-package-candidate.mjs";
|
|
|
|
const tempDirs: string[] = [];
|
|
const dotted = (...parts: number[]) => parts.join(".");
|
|
|
|
type LookupAddress = { address: string; family: number };
|
|
|
|
function lookupAddresses(addresses: LookupAddress[]) {
|
|
return async () => addresses;
|
|
}
|
|
|
|
function unexpectedFetch(): never {
|
|
throw new Error("downloadUrl should reject before fetching");
|
|
}
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
});
|
|
|
|
describe("package URL IPv6 transition address blocking", () => {
|
|
it.each([
|
|
["IPv4-mapped loopback dotted", `::ffff:${dotted(127, 0, 0, 1)}`],
|
|
["IPv4-mapped RFC1918 dotted", `::ffff:${dotted(10, 0, 0, 1)}`],
|
|
["IPv4-mapped loopback hex", "::ffff:7f00:1"],
|
|
["IPv4-mapped RFC1918 hex", "::ffff:a00:1"],
|
|
["IPv4-compatible loopback dotted", `::${dotted(127, 0, 0, 1)}`],
|
|
["IPv4-compatible RFC1918 dotted", `::${dotted(10, 0, 0, 1)}`],
|
|
["IPv4-compatible loopback hex", "::7f00:1"],
|
|
["well-known NAT64 to loopback", "64:ff9b::7f00:1"],
|
|
["local-use NAT64 to RFC1918", "64:ff9b:1::a00:1"],
|
|
["6to4 embedded RFC1918", "2002:0a00:0001::"],
|
|
["Teredo embedded loopback", "2001:0:0:0:0:0:80ff:fffe"],
|
|
["ISATAP embedded RFC1918", "fe80::5efe:a00:1"],
|
|
])("rejects %s DNS result before fetch", async (_name, address) => {
|
|
const dir = await mkdtemp(path.join(tmpdir(), "openclaw-package-ip-bypass-"));
|
|
tempDirs.push(dir);
|
|
const target = path.join(dir, "openclaw.tgz");
|
|
|
|
await expect(
|
|
downloadUrl("https://packages.example/openclaw.tgz", target, {
|
|
fetchImpl: unexpectedFetch,
|
|
lookupHost: lookupAddresses([{ address, family: 6 }]),
|
|
}),
|
|
).rejects.toThrow(/private\/internal\/special-use/iu);
|
|
});
|
|
|
|
it.each([
|
|
["IPv4-mapped loopback dotted", `https://[::ffff:${dotted(127, 0, 0, 1)}]/openclaw.tgz`],
|
|
["IPv4-compatible loopback dotted", `https://[::${dotted(127, 0, 0, 1)}]/openclaw.tgz`],
|
|
])("rejects %s URL literals before fetch", async (_name, url) => {
|
|
const dir = await mkdtemp(path.join(tmpdir(), "openclaw-package-ip-bypass-"));
|
|
tempDirs.push(dir);
|
|
const target = path.join(dir, "openclaw.tgz");
|
|
|
|
await expect(
|
|
downloadUrl(url, target, {
|
|
fetchImpl: unexpectedFetch,
|
|
lookupHost: lookupAddresses([{ address: dotted(93, 184, 216, 34), family: 4 }]),
|
|
}),
|
|
).rejects.toThrow(/private\/internal\/special-use/iu);
|
|
});
|
|
});
|