mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-03 03:03:24 -04:00
fix(browser): sanitize suggested download filenames
This commit is contained in:
@@ -75,6 +75,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Security/Gateway: breaking default-behavior change - canvas IP-based auth fallback now only accepts machine-scoped addresses (RFC1918, link-local, ULA IPv6, CGNAT); public-source IP matches now require bearer token auth. (#14661) Thanks @sumleo.
|
||||
- Security/Link understanding: block loopback/internal host patterns and private/mapped IPv6 addresses in extracted URL handling to close SSRF bypasses in link CLI flows. (#15604) Thanks @AI-Reviewer-QS.
|
||||
- Security/Browser: constrain `POST /trace/stop`, `POST /wait/download`, and `POST /download` output paths to OpenClaw temp roots and reject traversal/escape paths.
|
||||
- Security/Browser: sanitize download `suggestedFilename` to keep implicit `wait/download` paths within the downloads root. Thanks @1seal.
|
||||
- Security/Browser: confine `POST /hooks/file-chooser` upload paths to an OpenClaw temp uploads root and reject traversal/escape paths. Thanks @1seal.
|
||||
- Security/Canvas: serve A2UI assets via the shared safe-open path (`openFileWithinRoot`) to close traversal/TOCTOU gaps, with traversal and symlink regression coverage. (#10525) Thanks @abdelsfane.
|
||||
- Security/WhatsApp: enforce `0o600` on `creds.json` and `creds.json.bak` on save/backup/restore paths to reduce credential file exposure. (#10529) Thanks @abdelsfane.
|
||||
|
||||
@@ -18,9 +18,30 @@ 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);
|
||||
base = base.replace(/[\u0000-\u001f\u007f]/g, "").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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user