mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 11:41:24 +00:00
fix(security): harden archive extraction (#16203)
* fix(browser): confine upload paths for file chooser * fix(browser): sanitize suggested download filenames * chore(lint): avoid control regex in download sanitizer * test(browser): cover absolute escape paths * docs(browser): update upload example path * refactor(browser): centralize upload path confinement * fix(infra): harden tmp dir selection * fix(security): harden archive extraction * fix(infra): harden tar extraction filter
This commit is contained in:
committed by
GitHub
parent
9a134c8a10
commit
3aa94afcfd
@@ -1,6 +1,11 @@
|
||||
import JSZip from "jszip";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import * as tar from "tar";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ReleaseAsset } from "./signal-install.js";
|
||||
import { looksLikeArchive, pickAsset } from "./signal-install.js";
|
||||
import { extractSignalCliArchive, looksLikeArchive, pickAsset } from "./signal-install.js";
|
||||
|
||||
// Realistic asset list modelled after an actual signal-cli GitHub release.
|
||||
const SAMPLE_ASSETS: ReleaseAsset[] = [
|
||||
@@ -126,3 +131,44 @@ describe("pickAsset", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractSignalCliArchive", () => {
|
||||
it("rejects zip slip path traversal", async () => {
|
||||
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-signal-install-"));
|
||||
try {
|
||||
const archivePath = path.join(workDir, "bad.zip");
|
||||
const extractDir = path.join(workDir, "extract");
|
||||
await fs.mkdir(extractDir, { recursive: true });
|
||||
|
||||
const zip = new JSZip();
|
||||
zip.file("../pwned.txt", "pwnd");
|
||||
await fs.writeFile(archivePath, await zip.generateAsync({ type: "nodebuffer" }));
|
||||
|
||||
await expect(extractSignalCliArchive(archivePath, extractDir, 5_000)).rejects.toThrow(
|
||||
/(escapes destination|absolute)/i,
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("extracts tar.gz archives", async () => {
|
||||
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-signal-install-"));
|
||||
try {
|
||||
const archivePath = path.join(workDir, "ok.tgz");
|
||||
const extractDir = path.join(workDir, "extract");
|
||||
const rootDir = path.join(workDir, "root");
|
||||
await fs.mkdir(rootDir, { recursive: true });
|
||||
await fs.writeFile(path.join(rootDir, "signal-cli"), "bin", "utf-8");
|
||||
await tar.c({ cwd: workDir, file: archivePath, gzip: true }, ["root"]);
|
||||
|
||||
await fs.mkdir(extractDir, { recursive: true });
|
||||
await extractSignalCliArchive(archivePath, extractDir, 5_000);
|
||||
|
||||
const extracted = await fs.readFile(path.join(extractDir, "root", "signal-cli"), "utf-8");
|
||||
expect(extracted).toBe("bin");
|
||||
} finally {
|
||||
await fs.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { extractArchive } from "../infra/archive.js";
|
||||
import { resolveBrewExecutable } from "../infra/brew.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { CONFIG_DIR } from "../utils.js";
|
||||
@@ -31,6 +32,15 @@ export type SignalInstallResult = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/** @internal Exported for testing. */
|
||||
export async function extractSignalCliArchive(
|
||||
archivePath: string,
|
||||
installRoot: string,
|
||||
timeoutMs: number,
|
||||
): Promise<void> {
|
||||
await extractArchive({ archivePath, destDir: installRoot, timeoutMs });
|
||||
}
|
||||
|
||||
/** @internal Exported for testing. */
|
||||
export function looksLikeArchive(name: string): boolean {
|
||||
return name.endsWith(".tar.gz") || name.endsWith(".tgz") || name.endsWith(".zip");
|
||||
@@ -241,17 +251,18 @@ async function installSignalCliFromRelease(runtime: RuntimeEnv): Promise<SignalI
|
||||
const installRoot = path.join(CONFIG_DIR, "tools", "signal-cli", version);
|
||||
await fs.mkdir(installRoot, { recursive: true });
|
||||
|
||||
if (asset.name.endsWith(".zip")) {
|
||||
await runCommandWithTimeout(["unzip", "-q", archivePath, "-d", installRoot], {
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
} else if (asset.name.endsWith(".tar.gz") || asset.name.endsWith(".tgz")) {
|
||||
await runCommandWithTimeout(["tar", "-xzf", archivePath, "-C", installRoot], {
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
} else {
|
||||
if (!looksLikeArchive(asset.name.toLowerCase())) {
|
||||
return { ok: false, error: `Unsupported archive type: ${asset.name}` };
|
||||
}
|
||||
try {
|
||||
await extractSignalCliArchive(archivePath, installRoot, 60_000);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
ok: false,
|
||||
error: `Failed to extract ${asset.name}: ${message}`,
|
||||
};
|
||||
}
|
||||
|
||||
const cliPath = await findSignalCliBinary(installRoot);
|
||||
if (!cliPath) {
|
||||
|
||||
Reference in New Issue
Block a user