VYPR
Moderate severityNVD Advisory· Published Feb 19, 2026· Updated Feb 20, 2026

OpenClaw has a Path Traversal in Browser Download Functionality

CVE-2026-26972

Description

OpenClaw is a personal AI assistant. In versions 2026.1.12 through 2026.2.12, OpenClaw browser download helpers accepted an unsanitized output path. When invoked via the browser control gateway routes, this allowed path traversal to write downloads outside the intended OpenClaw temp downloads directory. This issue is not exposed via the AI agent tool schema (no download action). Exploitation requires authenticated CLI access or an authenticated gateway RPC token. Version 2026.2.13 fixes the issue.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
>= 2026.1.12, < 2026.2.132026.2.13

Affected products

1

Patches

1
7f0489e4731c

Security/Browser: constrain trace and download output paths to OpenClaw temp roots (#15652)

https://github.com/openclaw/openclawMarianoFeb 13, 2026via ghsa
10 files changed · +166 16
  • CHANGELOG.md+1 0 modified
    @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai
     - Security/WhatsApp: enforce `0o600` on `creds.json` and `creds.json.bak` on save/backup/restore paths to reduce credential file exposure. (#10529) Thanks @abdelsfane.
     - WhatsApp: preserve outbound document filenames for web-session document sends instead of always sending `"file"`. (#15594) Thanks @TsekaLuk.
     - Security/Gateway + ACP: block high-risk tools (`sessions_spawn`, `sessions_send`, `gateway`, `whatsapp_login`) from HTTP `/tools/invoke` by default with `gateway.tools.{allow,deny}` overrides, and harden ACP permission selection to fail closed when tool identity/options are ambiguous while supporting `allow_always`/`reject_always`. (#15390) Thanks @aether-ai-agent.
    +- Security/Browser: constrain `POST /trace/stop`, `POST /wait/download`, and `POST /download` output paths to OpenClaw temp roots and reject traversal/escape paths.
     - Gateway/Tools Invoke: sanitize `/tools/invoke` execution failures while preserving `400` for tool input errors and returning `500` for unexpected runtime failures, with regression coverage and docs updates. (#13185) Thanks @davidrudduck.
     - MS Teams: preserve parsed mention entities/text when appending OneDrive fallback file links, and accept broader real-world Teams mention ID formats (`29:...`, `8:orgid:...`) while still rejecting placeholder patterns. (#15436) Thanks @hyojin.
     - Security/Audit: distinguish external webhooks (`hooks.enabled`) from internal hooks (`hooks.internal.enabled`) in attack-surface summaries to avoid false exposure signals when only internal hooks are enabled. (#13474) Thanks @mcaxtr.
    
  • docs/tools/browser.md+5 2 modified
    @@ -409,8 +409,8 @@ Actions:
     - `openclaw browser scrollintoview e12`
     - `openclaw browser drag 10 11`
     - `openclaw browser select 9 OptionA OptionB`
    -- `openclaw browser download e12 /tmp/report.pdf`
    -- `openclaw browser waitfordownload /tmp/report.pdf`
    +- `openclaw browser download e12 report.pdf`
    +- `openclaw browser waitfordownload report.pdf`
     - `openclaw browser upload /tmp/file.pdf`
     - `openclaw browser fill --fields '[{"ref":"1","type":"text","value":"Ada"}]'`
     - `openclaw browser dialog --accept`
    @@ -444,6 +444,9 @@ Notes:
     
     - `upload` and `dialog` are **arming** calls; run them before the click/press
       that triggers the chooser/dialog.
    +- Download and trace output paths are constrained to OpenClaw temp roots:
    +  - traces: `/tmp/openclaw` (fallback: `${os.tmpdir()}/openclaw`)
    +  - downloads: `/tmp/openclaw/downloads` (fallback: `${os.tmpdir()}/openclaw/downloads`)
     - `upload` can also set file inputs directly via `--input-ref` or `--element`.
     - `snapshot`:
       - `--format ai` (default when Playwright is installed): returns an AI snapshot with numeric refs (`aria-ref="<n>"`).
    
  • extensions/bluebubbles/src/monitor.test.ts+2 2 modified
    @@ -404,7 +404,7 @@ describe("BlueBubbles webhook monitor", () => {
           expect(res.statusCode).toBe(400);
         });
     
    -    it("returns 400 when request body times out (Slow-Loris protection)", async () => {
    +    it("returns 408 when request body times out (Slow-Loris protection)", async () => {
           vi.useFakeTimers();
           try {
             const account = createMockAccount();
    @@ -439,7 +439,7 @@ describe("BlueBubbles webhook monitor", () => {
     
             const handled = await handledPromise;
             expect(handled).toBe(true);
    -        expect(res.statusCode).toBe(400);
    +        expect(res.statusCode).toBe(408);
             expect(req.destroy).toHaveBeenCalled();
           } finally {
             vi.useRealTimers();
    
  • src/browser/routes/agent.act.ts+26 3 modified
    @@ -14,6 +14,7 @@ import {
       resolveProfileContext,
       SELECTOR_UNSUPPORTED_MESSAGE,
     } from "./agent.shared.js";
    +import { DEFAULT_DOWNLOAD_DIR, resolvePathWithinRoot } from "./path-output.js";
     import { jsonError, toBoolean, toNumber, toStringArray, toStringOrEmpty } from "./utils.js";
     
     export function registerBrowserAgentActRoutes(
    @@ -430,18 +431,31 @@ export function registerBrowserAgentActRoutes(
         }
         const body = readBody(req);
         const targetId = toStringOrEmpty(body.targetId) || undefined;
    -    const out = toStringOrEmpty(body.path) || undefined;
    +    const out = toStringOrEmpty(body.path) || "";
         const timeoutMs = toNumber(body.timeoutMs);
         try {
           const tab = await profileCtx.ensureTabAvailable(targetId);
           const pw = await requirePwAi(res, "wait for download");
           if (!pw) {
             return;
           }
    +      let downloadPath: string | undefined;
    +      if (out.trim()) {
    +        const downloadPathResult = resolvePathWithinRoot({
    +          rootDir: DEFAULT_DOWNLOAD_DIR,
    +          requestedPath: out,
    +          scopeLabel: "downloads directory",
    +        });
    +        if (!downloadPathResult.ok) {
    +          res.status(400).json({ error: downloadPathResult.error });
    +          return;
    +        }
    +        downloadPath = downloadPathResult.path;
    +      }
           const result = await pw.waitForDownloadViaPlaywright({
             cdpUrl: profileCtx.profile.cdpUrl,
             targetId: tab.targetId,
    -        path: out,
    +        path: downloadPath,
             timeoutMs: timeoutMs ?? undefined,
           });
           res.json({ ok: true, targetId: tab.targetId, download: result });
    @@ -467,6 +481,15 @@ export function registerBrowserAgentActRoutes(
           return jsonError(res, 400, "path is required");
         }
         try {
    +      const downloadPathResult = resolvePathWithinRoot({
    +        rootDir: DEFAULT_DOWNLOAD_DIR,
    +        requestedPath: out,
    +        scopeLabel: "downloads directory",
    +      });
    +      if (!downloadPathResult.ok) {
    +        res.status(400).json({ error: downloadPathResult.error });
    +        return;
    +      }
           const tab = await profileCtx.ensureTabAvailable(targetId);
           const pw = await requirePwAi(res, "download");
           if (!pw) {
    @@ -476,7 +499,7 @@ export function registerBrowserAgentActRoutes(
             cdpUrl: profileCtx.profile.cdpUrl,
             targetId: tab.targetId,
             ref,
    -        path: out,
    +        path: downloadPathResult.path,
             timeoutMs: timeoutMs ?? undefined,
           });
           res.json({ ok: true, targetId: tab.targetId, download: result });
    
  • src/browser/routes/agent.debug.ts+12 4 modified
    @@ -3,12 +3,10 @@ import fs from "node:fs/promises";
     import path from "node:path";
     import type { BrowserRouteContext } from "../server-context.js";
     import type { BrowserRouteRegistrar } from "./types.js";
    -import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js";
     import { handleRouteError, readBody, requirePwAi, resolveProfileContext } from "./agent.shared.js";
    +import { DEFAULT_TRACE_DIR, resolvePathWithinRoot } from "./path-output.js";
     import { toBoolean, toStringOrEmpty } from "./utils.js";
     
    -const DEFAULT_TRACE_DIR = resolvePreferredOpenClawTmpDir();
    -
     export function registerBrowserAgentDebugRoutes(
       app: BrowserRouteRegistrar,
       ctx: BrowserRouteContext,
    @@ -136,7 +134,17 @@ export function registerBrowserAgentDebugRoutes(
           const id = crypto.randomUUID();
           const dir = DEFAULT_TRACE_DIR;
           await fs.mkdir(dir, { recursive: true });
    -      const tracePath = out.trim() || path.join(dir, `browser-trace-${id}.zip`);
    +      const tracePathResult = resolvePathWithinRoot({
    +        rootDir: dir,
    +        requestedPath: out,
    +        scopeLabel: "trace directory",
    +        defaultFileName: `browser-trace-${id}.zip`,
    +      });
    +      if (!tracePathResult.ok) {
    +        res.status(400).json({ error: tracePathResult.error });
    +        return;
    +      }
    +      const tracePath = tracePathResult.path;
           await pw.traceStopViaPlaywright({
             cdpUrl: profileCtx.profile.cdpUrl,
             targetId: tab.targetId,
    
  • src/browser/routes/path-output.ts+28 0 added
    @@ -0,0 +1,28 @@
    +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 };
    +}
    
  • src/browser/server.agent-contract-form-layout-act-commands.test.ts+82 2 modified
    @@ -49,6 +49,7 @@ const pwMocks = vi.hoisted(() => ({
       selectOptionViaPlaywright: vi.fn(async () => {}),
       setInputFilesViaPlaywright: vi.fn(async () => {}),
       snapshotAiViaPlaywright: vi.fn(async () => ({ snapshot: "ok" })),
    +  traceStopViaPlaywright: vi.fn(async () => {}),
       takeScreenshotViaPlaywright: vi.fn(async () => ({
         buffer: Buffer.from("png"),
       })),
    @@ -434,14 +435,14 @@ describe("browser control server", () => {
         expect(dialog).toMatchObject({ ok: true });
     
         const waitDownload = await postJson(`${base}/wait/download`, {
    -      path: "/tmp/report.pdf",
    +      path: "report.pdf",
           timeoutMs: 1111,
         });
         expect(waitDownload).toMatchObject({ ok: true });
     
         const download = await postJson(`${base}/download`, {
           ref: "e12",
    -      path: "/tmp/report.pdf",
    +      path: "report.pdf",
         });
         expect(download).toMatchObject({ ok: true });
     
    @@ -480,4 +481,83 @@ describe("browser control server", () => {
         expect(stopped.ok).toBe(true);
         expect(stopped.stopped).toBe(true);
       });
    +
    +  it("trace stop rejects traversal path outside trace dir", async () => {
    +    const base = await startServerAndBase();
    +    const res = await postJson<{ error?: string }>(`${base}/trace/stop`, {
    +      path: "../../pwned.zip",
    +    });
    +    expect(res.error).toContain("Invalid path");
    +    expect(pwMocks.traceStopViaPlaywright).not.toHaveBeenCalled();
    +  });
    +
    +  it("trace stop accepts in-root relative output path", async () => {
    +    const base = await startServerAndBase();
    +    const res = await postJson<{ ok?: boolean; path?: string }>(`${base}/trace/stop`, {
    +      path: "safe-trace.zip",
    +    });
    +    expect(res.ok).toBe(true);
    +    expect(res.path).toContain("safe-trace.zip");
    +    expect(pwMocks.traceStopViaPlaywright).toHaveBeenCalledWith(
    +      expect.objectContaining({
    +        cdpUrl: cdpBaseUrl,
    +        targetId: "abcd1234",
    +        path: expect.stringContaining("safe-trace.zip"),
    +      }),
    +    );
    +  });
    +
    +  it("wait/download rejects traversal path outside downloads dir", async () => {
    +    const base = await startServerAndBase();
    +    const waitRes = await postJson<{ error?: string }>(`${base}/wait/download`, {
    +      path: "../../pwned.pdf",
    +    });
    +    expect(waitRes.error).toContain("Invalid path");
    +    expect(pwMocks.waitForDownloadViaPlaywright).not.toHaveBeenCalled();
    +  });
    +
    +  it("download rejects traversal path outside downloads dir", async () => {
    +    const base = await startServerAndBase();
    +    const downloadRes = await postJson<{ error?: string }>(`${base}/download`, {
    +      ref: "e12",
    +      path: "../../pwned.pdf",
    +    });
    +    expect(downloadRes.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 } }>(
    +      `${base}/wait/download`,
    +      {
    +        path: "safe-wait.pdf",
    +      },
    +    );
    +    expect(res.ok).toBe(true);
    +    expect(pwMocks.waitForDownloadViaPlaywright).toHaveBeenCalledWith(
    +      expect.objectContaining({
    +        cdpUrl: cdpBaseUrl,
    +        targetId: "abcd1234",
    +        path: expect.stringContaining("safe-wait.pdf"),
    +      }),
    +    );
    +  });
    +
    +  it("download accepts in-root relative output path", async () => {
    +    const base = await startServerAndBase();
    +    const res = await postJson<{ ok?: boolean; download?: { path?: string } }>(`${base}/download`, {
    +      ref: "e12",
    +      path: "safe-download.pdf",
    +    });
    +    expect(res.ok).toBe(true);
    +    expect(pwMocks.downloadViaPlaywright).toHaveBeenCalledWith(
    +      expect.objectContaining({
    +        cdpUrl: cdpBaseUrl,
    +        targetId: "abcd1234",
    +        ref: "e12",
    +        path: expect.stringContaining("safe-download.pdf"),
    +      }),
    +    );
    +  });
     });
    
  • src/cli/browser-cli-actions-input/register.files-downloads.ts+5 2 modified
    @@ -59,7 +59,7 @@ export function registerBrowserFilesAndDownloadsCommands(
         .description("Wait for the next download (and save it)")
         .argument(
           "[path]",
    -      "Save path (default: /tmp/openclaw/downloads/...; fallback: os.tmpdir()/openclaw/downloads/...)",
    +      "Save path within openclaw temp downloads dir (default: /tmp/openclaw/downloads/...; fallback: os.tmpdir()/openclaw/downloads/...)",
         )
         .option("--target-id <id>", "CDP target id (or unique prefix)")
         .option(
    @@ -100,7 +100,10 @@ export function registerBrowserFilesAndDownloadsCommands(
         .command("download")
         .description("Click a ref and save the resulting download")
         .argument("<ref>", "Ref id from snapshot to click")
    -    .argument("<path>", "Save path")
    +    .argument(
    +      "<path>",
    +      "Save path within openclaw temp downloads dir (e.g. report.pdf or /tmp/openclaw/downloads/report.pdf)",
    +    )
         .option("--target-id <id>", "CDP target id (or unique prefix)")
         .option(
           "--timeout-ms <ms>",
    
  • src/cli/browser-cli-debug.ts+4 1 modified
    @@ -179,7 +179,10 @@ export function registerBrowserDebugCommands(
       trace
         .command("stop")
         .description("Stop trace recording and write a .zip")
    -    .option("--out <path>", "Output path for the trace zip")
    +    .option(
    +      "--out <path>",
    +      "Output path within openclaw temp dir (e.g. trace.zip or /tmp/openclaw/trace.zip)",
    +    )
         .option("--target-id <id>", "CDP target id (or unique prefix)")
         .action(async (opts, cmd) => {
           const parent = parentOpts(cmd);
    
  • src/discord/monitor/threading.test.ts+1 0 modified
    @@ -115,6 +115,7 @@ describe("resolveDiscordReplyDeliveryPlan", () => {
     
     describe("maybeCreateDiscordAutoThread", () => {
       it("returns existing thread ID when creation fails due to race condition", async () => {
    +    // First call succeeds (simulating another agent creating the thread)
         const client = {
           rest: {
             post: async () => {
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.