Files
openclaw/test/scripts/resolve-openclaw-package-candidate-ip-bypass.test.ts
Jason O'Neal 7fffbf60b0 fix: harden package URL downloads (#85578)
* 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>
2026-05-23 17:28:29 +01:00

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);
});
});