fix(security): harden browser trace/download temp path handling

This commit is contained in:
Peter Steinberger
2026-02-26 01:01:17 +01:00
parent e56b0cf1a0
commit 496a76c03b
8 changed files with 322 additions and 40 deletions

View File

@@ -1,7 +1,9 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fetch as realFetch } from "undici";
import { describe, expect, it } from "vitest";
import { DEFAULT_UPLOAD_DIR } from "./paths.js";
import { DEFAULT_DOWNLOAD_DIR, DEFAULT_TRACE_DIR, DEFAULT_UPLOAD_DIR } from "./paths.js";
import {
installAgentContractHooks,
postJson,
@@ -16,6 +18,23 @@ import {
const state = getBrowserControlServerTestState();
const pwMocks = getPwMocks();
async function withSymlinkPathEscape<T>(params: {
rootDir: string;
run: (relativePath: string) => Promise<T>;
}): Promise<T> {
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-route-escape-"));
const linkName = `escape-link-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const linkPath = path.join(params.rootDir, linkName);
await fs.mkdir(params.rootDir, { recursive: true });
await fs.symlink(outsideDir, linkPath);
try {
return await params.run(`${linkName}/pwned.zip`);
} finally {
await fs.unlink(linkPath).catch(() => {});
await fs.rm(outsideDir, { recursive: true, force: true }).catch(() => {});
}
}
describe("browser control server", () => {
installAgentContractHooks();
@@ -268,6 +287,58 @@ describe("browser control server", () => {
expect(pwMocks.downloadViaPlaywright).not.toHaveBeenCalled();
});
it.runIf(process.platform !== "win32")(
"trace stop rejects symlinked write path escape under trace dir",
async () => {
const base = await startServerAndBase();
await withSymlinkPathEscape({
rootDir: DEFAULT_TRACE_DIR,
run: async (pathEscape) => {
const res = await postJson<{ error?: string }>(`${base}/trace/stop`, {
path: pathEscape,
});
expect(res.error).toContain("Invalid path");
expect(pwMocks.traceStopViaPlaywright).not.toHaveBeenCalled();
},
});
},
);
it.runIf(process.platform !== "win32")(
"wait/download rejects symlinked write path escape under downloads dir",
async () => {
const base = await startServerAndBase();
await withSymlinkPathEscape({
rootDir: DEFAULT_DOWNLOAD_DIR,
run: async (pathEscape) => {
const res = await postJson<{ error?: string }>(`${base}/wait/download`, {
path: pathEscape,
});
expect(res.error).toContain("Invalid path");
expect(pwMocks.waitForDownloadViaPlaywright).not.toHaveBeenCalled();
},
});
},
);
it.runIf(process.platform !== "win32")(
"download rejects symlinked write path escape under downloads dir",
async () => {
const base = await startServerAndBase();
await withSymlinkPathEscape({
rootDir: DEFAULT_DOWNLOAD_DIR,
run: async (pathEscape) => {
const res = await postJson<{ error?: string }>(`${base}/download`, {
ref: "e12",
path: pathEscape,
});
expect(res.error).toContain("Invalid path");
expect(pwMocks.downloadViaPlaywright).not.toHaveBeenCalled();
},
});
},
);
it("wait/download accepts in-root relative output path", async () => {
const base = await startServerAndBase();
const res = await postJson<{ ok?: boolean; download?: { path?: string } }>(