VYPR
High severity8.1NVD Advisory· Published Apr 28, 2026· Updated Apr 28, 2026

CVE-2026-41364

CVE-2026-41364

Description

OpenClaw before 2026.3.31 contains a symlink following vulnerability in SSH sandbox tar upload that allows remote attackers to write arbitrary files. Attackers can exploit this by uploading tar archives containing symlinks to escape the sandbox and overwrite files on the remote host.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.3.312026.3.31

Affected products

1
  • cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*
    Range: <2026.3.31

Patches

1
3d5af14984ac

fix(agents): reject escaping symlinks in ssh sandbox uploads (#58220)

https://github.com/openclaw/openclawVincent KocMar 31, 2026via ghsa
4 files changed · +149 8
  • CHANGELOG.md+1 0 modified
    @@ -239,6 +239,7 @@ Docs: https://docs.openclaw.ai
     - Models/openai-completions: default non-native OpenAI-compatible providers to omit tool-definition `strict` fields unless users explicitly opt back in, so tool calling keeps working on providers that reject that option. (#45497) Thanks @sahancava.
     - Nostr/config: redact `channels.nostr.privateKey` in config snapshots and Control UI config views, so Nostr signing keys no longer appear in plain text. Thanks @ccreater222.
     - Subagents/announcements: preserve the requester agent id for inline deterministic tool spawns so named agents without channel bindings can still announce completions through the correct owner session. (#55437) Thanks @kAIborg24.
    +- SSH sandbox/upload: reject workspace symlinks that resolve outside the uploaded tree before syncing to the remote sandbox, so later agent writes cannot be redirected through escaped links. Thanks @AntAISecurityLab and @vincentkoc.
     - Tlon/media: route inbound image downloads through the shared media store, cap each download at 6 MB, and stop after 8 images per message so large Tlon posts no longer balloon local media storage. Thanks @AntAISecurityLab and @vincentkoc.
     - Telegram/Anthropic streaming: replace raw invalid stream-order provider errors with a safe retry message so internal `message_start/message_stop` failures do not leak into chats. (#55408) Thanks @imydal.
     - Plugins/context engines: retry strict legacy `assemble()` calls without the new `prompt` field when older engines reject it, preserving prompt-aware retrieval compatibility for pre-prompt plugins. (#50848) thanks @danhdoan.
    
  • src/agents/sandbox/ssh.spawn-env.test.ts+60 8 modified
    @@ -1,7 +1,10 @@
     import type { ChildProcess, SpawnOptions } from "node:child_process";
     import { EventEmitter } from "node:events";
    +import fs from "node:fs/promises";
    +import os from "node:os";
    +import path from "node:path";
     import { PassThrough } from "node:stream";
    -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
    +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
     
     const spawnMock = vi.hoisted(() => vi.fn());
     
    @@ -32,18 +35,22 @@ vi.mock("node:child_process", async (importOriginal) => {
     let runSshSandboxCommand: typeof import("./ssh.js").runSshSandboxCommand;
     let uploadDirectoryToSshTarget: typeof import("./ssh.js").uploadDirectoryToSshTarget;
     
    -beforeAll(async () => {
    -  ({ runSshSandboxCommand, uploadDirectoryToSshTarget } = await import("./ssh.js"));
    -});
    -
     describe("ssh subprocess env sanitization", () => {
       const originalEnv = { ...process.env };
    +  const tempDirs: string[] = [];
     
    -  beforeEach(() => {
    +  beforeEach(async () => {
    +    vi.resetModules();
         vi.clearAllMocks();
    +    ({ runSshSandboxCommand, uploadDirectoryToSshTarget } = await import("./ssh.js"));
       });
     
    -  afterEach(() => {
    +  afterEach(async () => {
    +    await Promise.all(
    +      tempDirs.splice(0).map(async (dir) => {
    +        await fs.rm(dir, { recursive: true, force: true });
    +      }),
    +    );
         for (const key of Object.keys(process.env)) {
           if (!(key in originalEnv)) {
             delete process.env[key];
    @@ -104,14 +111,16 @@ describe("ssh subprocess env sanitization", () => {
     
         process.env.ANTHROPIC_API_KEY = "sk-test-secret";
         process.env.NODE_ENV = "test";
    +    const localDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ssh-upload-env-"));
    +    tempDirs.push(localDir);
     
         await uploadDirectoryToSshTarget({
           session: {
             command: "ssh",
             configPath: "/tmp/openclaw-test-ssh-config",
             host: "openclaw-sandbox",
           },
    -      localDir: "/tmp/workspace",
    +      localDir,
           remoteDir: "/remote/workspace",
         });
     
    @@ -120,4 +129,47 @@ describe("ssh subprocess env sanitization", () => {
         expect(env?.ANTHROPIC_API_KEY).toBeUndefined();
         expect(env?.NODE_ENV).toBe("test");
       });
    +
    +  it.runIf(process.platform !== "win32")(
    +    "allows in-workspace symlinks to upload normally",
    +    async () => {
    +      spawnMock
    +        .mockImplementationOnce(
    +          (_command: string, _args: readonly string[], _options: SpawnOptions): ChildProcess => {
    +            const child = createMockChildProcess();
    +            process.nextTick(() => {
    +              child.emit("close", 0);
    +            });
    +            return child as unknown as ChildProcess;
    +          },
    +        )
    +        .mockImplementationOnce(
    +          (_command: string, _args: readonly string[], _options: SpawnOptions): ChildProcess => {
    +            const child = createMockChildProcess();
    +            process.nextTick(() => {
    +              child.emit("close", 0);
    +            });
    +            return child as unknown as ChildProcess;
    +          },
    +        );
    +
    +      const localDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ssh-upload-safe-"));
    +      tempDirs.push(localDir);
    +      await fs.mkdir(path.join(localDir, "real"), { recursive: true });
    +      await fs.writeFile(path.join(localDir, "real", "payload.txt"), "ok\n", "utf8");
    +      await fs.symlink("real", path.join(localDir, "linked-dir"));
    +
    +      await uploadDirectoryToSshTarget({
    +        session: {
    +          command: "ssh",
    +          configPath: "/tmp/openclaw-test-ssh-config",
    +          host: "openclaw-sandbox",
    +        },
    +        localDir,
    +        remoteDir: "/remote/workspace",
    +      });
    +
    +      expect(spawnMock).toHaveBeenCalledTimes(2);
    +    },
    +  );
     });
    
  • src/agents/sandbox/ssh.test.ts+55 0 modified
    @@ -1,20 +1,29 @@
     import fs from "node:fs/promises";
    +import os from "node:os";
    +import path from "node:path";
     import { afterEach, describe, expect, it } from "vitest";
     import {
       buildExecRemoteCommand,
       createSshSandboxSessionFromSettings,
       disposeSshSandboxSession,
       type SshSandboxSession,
    +  uploadDirectoryToSshTarget,
     } from "./ssh.js";
     
     const sessions: SshSandboxSession[] = [];
    +const tempDirs: string[] = [];
     
     afterEach(async () => {
       await Promise.all(
         sessions.splice(0).map(async (session) => {
           await disposeSshSandboxSession(session);
         }),
       );
    +  await Promise.all(
    +    tempDirs.splice(0).map(async (dir) => {
    +      await fs.rm(dir, { recursive: true, force: true });
    +    }),
    +  );
     });
     
     describe("sandbox ssh helpers", () => {
    @@ -100,4 +109,50 @@ describe("sandbox ssh helpers", () => {
         expect(command).toContain(`'TOKEN=abc 123'`);
         expect(command).toContain(`'cd '"'"'/sandbox/project'"'"' && pwd && printenv TOKEN'`);
       });
    +
    +  it.runIf(process.platform !== "win32")(
    +    "rejects upload trees with symlinks that escape the local workspace",
    +    async () => {
    +      const localDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ssh-upload-"));
    +      tempDirs.push(localDir);
    +      await fs.symlink("/etc", path.join(localDir, "escape"));
    +
    +      await expect(
    +        uploadDirectoryToSshTarget({
    +          session: {
    +            command: "ssh",
    +            configPath: "/tmp/openclaw-test-ssh-config",
    +            host: "openclaw-sandbox",
    +          },
    +          localDir,
    +          remoteDir: "/remote/workspace",
    +        }),
    +      ).rejects.toThrow(/refuses symlink escaping the workspace: escape/i);
    +    },
    +  );
    +
    +  it.runIf(process.platform !== "win32")(
    +    "allows in-workspace symlinks that point to hardlinked files",
    +    async () => {
    +      const localDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ssh-upload-safe-"));
    +      tempDirs.push(localDir);
    +      const fakeSsh = path.join(localDir, "fake-ssh.sh");
    +      await fs.writeFile(fakeSsh, "#!/bin/sh\ncat >/dev/null\n", { mode: 0o755 });
    +      await fs.writeFile(path.join(localDir, "source.txt"), "hello");
    +      await fs.link(path.join(localDir, "source.txt"), path.join(localDir, "hardlinked.txt"));
    +      await fs.symlink("source.txt", path.join(localDir, "link.txt"));
    +
    +      await expect(
    +        uploadDirectoryToSshTarget({
    +          session: {
    +            command: fakeSsh,
    +            configPath: "/tmp/openclaw-test-ssh-config",
    +            host: "openclaw-sandbox",
    +          },
    +          localDir,
    +          remoteDir: "/remote/workspace",
    +        }),
    +      ).resolves.toBeUndefined();
    +    },
    +  );
     });
    
  • src/agents/sandbox/ssh.ts+33 0 modified
    @@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
     import fs from "node:fs/promises";
     import os from "node:os";
     import path from "node:path";
    +import { resolveBoundaryPath } from "../../infra/boundary-path.js";
     import { parseSshTarget } from "../../infra/ssh-tunnel.js";
     import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js";
     import { resolveUserPath } from "../../utils.js";
    @@ -257,6 +258,7 @@ export async function uploadDirectoryToSshTarget(params: {
       remoteDir: string;
       signal?: AbortSignal;
     }): Promise<void> {
    +  await assertSafeUploadSymlinks(params.localDir);
       const remoteCommand = buildRemoteCommand([
         "/bin/sh",
         "-c",
    @@ -337,6 +339,37 @@ export async function uploadDirectoryToSshTarget(params: {
       });
     }
     
    +async function assertSafeUploadSymlinks(localDir: string): Promise<void> {
    +  const rootDir = path.resolve(localDir);
    +  await walkDirectory(rootDir);
    +
    +  async function walkDirectory(currentDir: string): Promise<void> {
    +    const entries = await fs.readdir(currentDir, { withFileTypes: true });
    +    for (const entry of entries) {
    +      const entryPath = path.join(currentDir, entry.name);
    +      if (entry.isSymbolicLink()) {
    +        try {
    +          await resolveBoundaryPath({
    +            absolutePath: entryPath,
    +            rootPath: rootDir,
    +            boundaryLabel: "SSH sandbox upload tree",
    +          });
    +        } catch (error) {
    +          const relativePath = path.relative(rootDir, entryPath).split(path.sep).join("/");
    +          throw new Error(
    +            `SSH sandbox upload refuses symlink escaping the workspace: ${relativePath}`,
    +            { cause: error },
    +          );
    +        }
    +        continue;
    +      }
    +      if (entry.isDirectory()) {
    +        await walkDirectory(entryPath);
    +      }
    +    }
    +  }
    +}
    +
     function parseSshConfigHost(configText: string): string | null {
       const hostMatch = configText.match(/^\s*Host\s+(\S+)/m);
       return hostMatch?.[1]?.trim() || null;
    

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

6

News mentions

0

No linked articles in our index yet.