mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 02:45:38 -06:00
fix(skills): reject incomplete tar archive listings (#125406)
This commit is contained in:
@@ -109,7 +109,9 @@ function createCancelableBody() {
|
||||
return { stream, wasCanceled: () => canceled };
|
||||
}
|
||||
|
||||
function runCommandResult(params?: Partial<Record<"code" | "stdout" | "stderr", string | number>>) {
|
||||
function runCommandResult(
|
||||
params?: Partial<Record<"code" | "stdout" | "stderr" | "stdoutTruncatedBytes", string | number>>,
|
||||
) {
|
||||
return {
|
||||
code: 0,
|
||||
stdout: "",
|
||||
@@ -287,6 +289,51 @@ describe("installDownloadSpec extraction safety", () => {
|
||||
});
|
||||
|
||||
describe("installDownloadSpec extraction safety (tar.bz2)", () => {
|
||||
it.each(["plain", "verbose"] as const)(
|
||||
"rejects truncated %s tar listings before extraction",
|
||||
async (truncatedListing) => {
|
||||
const name = `tbz2-truncated-${truncatedListing}`;
|
||||
const entry = buildEntry(name);
|
||||
const targetDir = path.join(resolveSkillToolsRootDir(entry), "target");
|
||||
|
||||
mockArchiveResponse(new Uint8Array([1, 2, 3]));
|
||||
runCommandWithTimeoutMock.mockImplementation(async (...argv: unknown[]) => {
|
||||
const cmd = (argv[0] ?? []) as string[];
|
||||
if (cmd[0] === "tar" && cmd[1] === "tf") {
|
||||
return runCommandResult({
|
||||
stdout: "package/hello.txt\n",
|
||||
...(truncatedListing === "plain" ? { stdoutTruncatedBytes: 1 } : {}),
|
||||
});
|
||||
}
|
||||
if (cmd[0] === "tar" && cmd[1] === "tvf") {
|
||||
return runCommandResult({
|
||||
stdout: "-rw-r--r-- 0 0 0 0 Jan 1 00:00 package/hello.txt\n",
|
||||
...(truncatedListing === "verbose" ? { stdoutTruncatedBytes: 1 } : {}),
|
||||
});
|
||||
}
|
||||
if (cmd[0] === "tar" && cmd[1] === "xf") {
|
||||
throw new Error("should not extract");
|
||||
}
|
||||
return runCommandResult();
|
||||
});
|
||||
|
||||
const result = await installDownloadSkill({
|
||||
name,
|
||||
url: `https://example.invalid/${name}.tbz2`,
|
||||
archive: "tar.bz2",
|
||||
targetDir,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.stderr).toContain("tar listing output was truncated");
|
||||
expect(
|
||||
runCommandWithTimeoutMock.mock.calls.some(
|
||||
(call) => (call[0] as string[])[0] === "tar" && (call[0] as string[])[1] === "xf",
|
||||
),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("handles tar.bz2 extraction safety edge-cases", async () => {
|
||||
for (const testCase of [
|
||||
{
|
||||
|
||||
@@ -14,19 +14,21 @@ import { hasBinary } from "../loading/config.js";
|
||||
import { parseTarVerboseMetadata } from "./install-tar-verbose.js";
|
||||
|
||||
type ArchiveExtractResult = { stdout: string; stderr: string; code: number | null };
|
||||
type TarListingResult = ArchiveExtractResult & { stdoutTruncatedBytes?: number };
|
||||
type TarPreflightResult = {
|
||||
entries: string[];
|
||||
metadata: ReturnType<typeof parseTarVerboseMetadata>;
|
||||
};
|
||||
|
||||
function commandFailureResult(
|
||||
result: { stdout: string; stderr: string; code: number | null },
|
||||
result: TarListingResult,
|
||||
fallbackStderr: string,
|
||||
): ArchiveExtractResult {
|
||||
const truncated = (result.stdoutTruncatedBytes ?? 0) > 0;
|
||||
return {
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr || fallbackStderr,
|
||||
code: result.code,
|
||||
stderr: truncated ? "tar listing output was truncated; refusing to extract" : fallbackStderr,
|
||||
code: truncated ? 1 : result.code,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -49,16 +51,16 @@ async function readTarPreflight(params: {
|
||||
const listResult = await runCommandWithTimeout(["tar", "tf", params.archivePath], {
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
if (listResult.code !== 0) {
|
||||
return commandFailureResult(listResult, "tar list failed");
|
||||
if (listResult.code !== 0 || listResult.stdoutTruncatedBytes) {
|
||||
return commandFailureResult(listResult, listResult.stderr || "tar list failed");
|
||||
}
|
||||
const entries = normalizeStringEntries(listResult.stdout.split("\n"));
|
||||
|
||||
const verboseResult = await runCommandWithTimeout(["tar", "tvf", params.archivePath], {
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
if (verboseResult.code !== 0) {
|
||||
return commandFailureResult(verboseResult, "tar verbose list failed");
|
||||
if (verboseResult.code !== 0 || verboseResult.stdoutTruncatedBytes) {
|
||||
return commandFailureResult(verboseResult, verboseResult.stderr || "tar verbose list failed");
|
||||
}
|
||||
const metadata = parseTarVerboseMetadata(verboseResult.stdout);
|
||||
if (metadata.length !== entries.length) {
|
||||
|
||||
@@ -19,5 +19,8 @@ describe("parseTarVerboseMetadata", () => {
|
||||
expect(() =>
|
||||
parseTarVerboseMetadata("-rw-r--r-- user/group 9007199254740993 2026-05-28 00:00 SKILL.md"),
|
||||
).toThrow(/unable to parse tar entry size/u);
|
||||
expect(() =>
|
||||
parseTarVerboseMetadata("?rw-r--r-- user/group 1 2026-05-28 00:00 SKILL.md"),
|
||||
).toThrow(/unable to parse tar entry type/u);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,8 +34,10 @@ function mapTarVerboseTypeChar(typeChar: string): string {
|
||||
return "Socket";
|
||||
case "d":
|
||||
return "Directory";
|
||||
default:
|
||||
case "-":
|
||||
return "File";
|
||||
default:
|
||||
throw new Error(`unable to parse tar entry type: ${typeChar}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user