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:
Peter Steinberger
2026-02-14 14:42:08 +01:00
committed by GitHub
parent 9a134c8a10
commit 3aa94afcfd
19 changed files with 1179 additions and 100 deletions

49
src/browser/paths.ts Normal file
View File

@@ -0,0 +1,49 @@
import path from "node:path";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
export const DEFAULT_BROWSER_TMP_DIR = resolvePreferredOpenClawTmpDir();
export const DEFAULT_TRACE_DIR = DEFAULT_BROWSER_TMP_DIR;
export const DEFAULT_DOWNLOAD_DIR = path.join(DEFAULT_BROWSER_TMP_DIR, "downloads");
export const DEFAULT_UPLOAD_DIR = path.join(DEFAULT_BROWSER_TMP_DIR, "uploads");
export function resolvePathWithinRoot(params: {
rootDir: string;
requestedPath: string;
scopeLabel: string;
defaultFileName?: string;
}): { ok: true; path: string } | { ok: false; error: string } {
const root = path.resolve(params.rootDir);
const raw = params.requestedPath.trim();
if (!raw) {
if (!params.defaultFileName) {
return { ok: false, error: "path is required" };
}
return { ok: true, path: path.join(root, params.defaultFileName) };
}
const resolved = path.resolve(root, raw);
const rel = path.relative(root, resolved);
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) {
return { ok: false, error: `Invalid path: must stay within ${params.scopeLabel}` };
}
return { ok: true, path: resolved };
}
export function resolvePathsWithinRoot(params: {
rootDir: string;
requestedPaths: string[];
scopeLabel: string;
}): { ok: true; paths: string[] } | { ok: false; error: string } {
const resolvedPaths: string[] = [];
for (const raw of params.requestedPaths) {
const pathResult = resolvePathWithinRoot({
rootDir: params.rootDir,
requestedPath: raw,
scopeLabel: params.scopeLabel,
});
if (!pathResult.ok) {
return { ok: false, error: pathResult.error };
}
resolvedPaths.push(pathResult.path);
}
return { ok: true, paths: resolvedPaths };
}

View File

@@ -18,9 +18,38 @@ import {
toAIFriendlyError,
} from "./pw-tools-core.shared.js";
function sanitizeDownloadFileName(fileName: string): string {
const trimmed = String(fileName ?? "").trim();
if (!trimmed) {
return "download.bin";
}
// `suggestedFilename()` is untrusted (influenced by remote servers). Force a basename so
// path separators/traversal can't escape the downloads dir on any platform.
let base = path.posix.basename(trimmed);
base = path.win32.basename(base);
let cleaned = "";
for (let i = 0; i < base.length; i++) {
const code = base.charCodeAt(i);
if (code < 0x20 || code === 0x7f) {
continue;
}
cleaned += base[i];
}
base = cleaned.trim();
if (!base || base === "." || base === "..") {
return "download.bin";
}
if (base.length > 200) {
base = base.slice(0, 200);
}
return base;
}
function buildTempDownloadPath(fileName: string): string {
const id = crypto.randomUUID();
const safeName = fileName.trim() ? fileName.trim() : "download.bin";
const safeName = sanitizeDownloadFileName(fileName);
return path.join(resolvePreferredOpenClawTmpDir(), "downloads", `${id}-${safeName}`);
}

View File

@@ -171,6 +171,46 @@ describe("pw-tools-core", () => {
expect(path.normalize(res.path)).toContain(path.normalize(expectedDownloadsTail));
expect(tmpDirMocks.resolvePreferredOpenClawTmpDir).toHaveBeenCalled();
});
it("sanitizes suggested download filenames to prevent traversal escapes", async () => {
let downloadHandler: ((download: unknown) => void) | undefined;
const on = vi.fn((event: string, handler: (download: unknown) => void) => {
if (event === "download") {
downloadHandler = handler;
}
});
const off = vi.fn();
const saveAs = vi.fn(async () => {});
const download = {
url: () => "https://example.com/evil",
suggestedFilename: () => "../../../../etc/passwd",
saveAs,
};
tmpDirMocks.resolvePreferredOpenClawTmpDir.mockReturnValue("/tmp/openclaw-preferred");
currentPage = { on, off };
const p = mod.waitForDownloadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
timeoutMs: 1000,
});
await Promise.resolve();
downloadHandler?.(download);
const res = await p;
const outPath = vi.mocked(saveAs).mock.calls[0]?.[0];
expect(typeof outPath).toBe("string");
expect(path.dirname(String(outPath))).toBe(
path.join(path.sep, "tmp", "openclaw-preferred", "downloads"),
);
expect(path.basename(String(outPath))).toMatch(/-passwd$/);
expect(path.normalize(res.path)).toContain(
path.normalize(`${path.join("tmp", "openclaw-preferred", "downloads")}${path.sep}`),
);
});
it("waits for a matching response and returns its body", async () => {
let responseHandler: ((resp: unknown) => void) | undefined;
const on = vi.fn((event: string, handler: (resp: unknown) => void) => {

View File

@@ -14,7 +14,12 @@ import {
resolveProfileContext,
SELECTOR_UNSUPPORTED_MESSAGE,
} from "./agent.shared.js";
import { DEFAULT_DOWNLOAD_DIR, resolvePathWithinRoot } from "./path-output.js";
import {
DEFAULT_DOWNLOAD_DIR,
DEFAULT_UPLOAD_DIR,
resolvePathWithinRoot,
resolvePathsWithinRoot,
} from "./path-output.js";
import { jsonError, toBoolean, toNumber, toStringArray, toStringOrEmpty } from "./utils.js";
export function registerBrowserAgentActRoutes(
@@ -355,6 +360,17 @@ export function registerBrowserAgentActRoutes(
return jsonError(res, 400, "paths are required");
}
try {
const uploadPathsResult = resolvePathsWithinRoot({
rootDir: DEFAULT_UPLOAD_DIR,
requestedPaths: paths,
scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`,
});
if (!uploadPathsResult.ok) {
res.status(400).json({ error: uploadPathsResult.error });
return;
}
const resolvedPaths = uploadPathsResult.paths;
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "file chooser hook");
if (!pw) {
@@ -369,13 +385,13 @@ export function registerBrowserAgentActRoutes(
targetId: tab.targetId,
inputRef,
element,
paths,
paths: resolvedPaths,
});
} else {
await pw.armFileUploadViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
paths,
paths: resolvedPaths,
timeoutMs: timeoutMs ?? undefined,
});
if (ref) {

View File

@@ -1,28 +1 @@
import path from "node:path";
import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js";
export const DEFAULT_BROWSER_TMP_DIR = resolvePreferredOpenClawTmpDir();
export const DEFAULT_TRACE_DIR = DEFAULT_BROWSER_TMP_DIR;
export const DEFAULT_DOWNLOAD_DIR = path.join(DEFAULT_BROWSER_TMP_DIR, "downloads");
export function resolvePathWithinRoot(params: {
rootDir: string;
requestedPath: string;
scopeLabel: string;
defaultFileName?: string;
}): { ok: true; path: string } | { ok: false; error: string } {
const root = path.resolve(params.rootDir);
const raw = params.requestedPath.trim();
if (!raw) {
if (!params.defaultFileName) {
return { ok: false, error: "path is required" };
}
return { ok: true, path: path.join(root, params.defaultFileName) };
}
const resolved = path.resolve(root, raw);
const rel = path.relative(root, resolved);
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) {
return { ok: false, error: `Invalid path: must stay within ${params.scopeLabel}` };
}
return { ok: true, path: resolved };
}
export * from "../paths.js";

View File

@@ -4,6 +4,7 @@ import os from "node:os";
import path from "node:path";
import { fetch as realFetch } from "undici";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_UPLOAD_DIR } from "./paths.js";
let testPort = 0;
let cdpBaseUrl = "";
@@ -413,31 +414,31 @@ describe("browser control server", () => {
const base = await startServerAndBase();
const upload = await postJson(`${base}/hooks/file-chooser`, {
paths: ["/tmp/a.txt"],
paths: ["a.txt"],
timeoutMs: 1234,
});
expect(upload).toMatchObject({ ok: true });
expect(pwMocks.armFileUploadViaPlaywright).toHaveBeenCalledWith({
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
paths: ["/tmp/a.txt"],
paths: [path.join(DEFAULT_UPLOAD_DIR, "a.txt")],
timeoutMs: 1234,
});
const uploadWithRef = await postJson(`${base}/hooks/file-chooser`, {
paths: ["/tmp/b.txt"],
paths: ["b.txt"],
ref: "e12",
});
expect(uploadWithRef).toMatchObject({ ok: true });
const uploadWithInputRef = await postJson(`${base}/hooks/file-chooser`, {
paths: ["/tmp/c.txt"],
paths: ["c.txt"],
inputRef: "e99",
});
expect(uploadWithInputRef).toMatchObject({ ok: true });
const uploadWithElement = await postJson(`${base}/hooks/file-chooser`, {
paths: ["/tmp/d.txt"],
paths: ["d.txt"],
element: "input[type=file]",
});
expect(uploadWithElement).toMatchObject({ ok: true });