VYPR
High severity7.6NVD Advisory· Published Apr 2, 2026· Updated Apr 6, 2026

CVE-2026-34426

CVE-2026-34426

Description

OpenClaw versions prior to commit b57b680 contain an approval bypass vulnerability due to inconsistent environment variable normalization between approval and execution paths, allowing attackers to inject attacker-controlled environment variables into execution without approval system validation. Attackers can exploit differing normalization logic to discard non-portable keys during approval processing while accepting them at execution time, bypassing operator review and potentially influencing runtime behavior including execution of attacker-controlled binaries.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.3.222026.3.22

Affected products

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

Patches

3
b57b680c0c34

fix: address issue

https://github.com/openclaw/openclawPavan Kumar GondhiApr 1, 2026via ghsa
6 files changed · +121 3
  • src/gateway/node-invoke-system-run-approval-match.test.ts+26 0 modified
    @@ -123,6 +123,32 @@ describe("evaluateSystemRunApprovalMatch", () => {
         expect(result).toEqual({ ok: true });
       });
     
    +  test("rejects mismatched Windows-compatible env override values", () => {
    +    const result = evaluateSystemRunApprovalMatch({
    +      argv: ["cmd.exe", "/c", "echo ok"],
    +      request: {
    +        host: "node",
    +        command: "cmd.exe /c echo ok",
    +        systemRunBinding: buildSystemRunApprovalBinding({
    +          argv: ["cmd.exe", "/c", "echo ok"],
    +          cwd: null,
    +          agentId: null,
    +          sessionKey: null,
    +          env: { "ProgramFiles(x86)": "C:\\Program Files (x86)" },
    +        }).binding,
    +      },
    +      binding: {
    +        ...defaultBinding,
    +        env: { "ProgramFiles(x86)": "D:\\malicious" },
    +      },
    +    });
    +    expect(result.ok).toBe(false);
    +    if (result.ok) {
    +      throw new Error("unreachable");
    +    }
    +    expect(result.code).toBe("APPROVAL_ENV_MISMATCH");
    +  });
    +
       test("rejects non-node host requests", () => {
         const result = evaluateSystemRunApprovalMatch({
           argv: ["echo", "SAFE"],
    
  • src/gateway/server-methods/server-methods.test.ts+31 0 modified
    @@ -656,6 +656,37 @@ describe("exec approval handlers", () => {
         );
       });
     
    +  it("includes Windows-compatible env keys in approval env bindings", async () => {
    +    const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
    +    await requestExecApproval({
    +      handlers,
    +      respond,
    +      context,
    +      params: {
    +        timeoutMs: 10,
    +        commandArgv: ["cmd.exe", "/c", "echo", "ok"],
    +        command: "cmd.exe /c echo ok",
    +        env: {
    +          "ProgramFiles(x86)": "C:\\Program Files (x86)",
    +        },
    +      },
    +    });
    +    const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested");
    +    expect(requested).toBeTruthy();
    +    const request = (requested?.payload as { request?: Record<string, unknown> })?.request ?? {};
    +    const envBinding = buildSystemRunApprovalEnvBinding({
    +      "ProgramFiles(x86)": "C:\\Program Files (x86)",
    +    });
    +    expect(request["envKeys"]).toEqual(envBinding.envKeys);
    +    expect(request["systemRunBinding"]).toEqual(
    +      buildSystemRunApprovalBinding({
    +        argv: ["cmd.exe", "/c", "echo", "ok"],
    +        cwd: "/tmp",
    +        env: { "ProgramFiles(x86)": "C:\\Program Files (x86)" },
    +      }).binding,
    +    );
    +  });
    +
       it("stores sorted env keys for gateway approvals without node-only binding", async () => {
         const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
         await requestExecApproval({
    
  • src/infra/host-env-security.ts+1 1 modified
    @@ -68,7 +68,7 @@ export function normalizeEnvVarKey(
       return key;
     }
     
    -function normalizeHostOverrideEnvVarKey(rawKey: string): string | null {
    +export function normalizeHostOverrideEnvVarKey(rawKey: string): string | null {
       const key = normalizeEnvVarKey(rawKey);
       if (!key) {
         return null;
    
  • src/infra/system-run-approval-binding.test.ts+27 0 modified
    @@ -117,6 +117,19 @@ describe("buildSystemRunApprovalEnvBinding", () => {
           envKeys: [],
         });
       });
    +
    +  it("includes Windows-compatible override keys in env binding", () => {
    +    const base = buildSystemRunApprovalEnvBinding({
    +      "ProgramFiles(x86)": "C:\\Program Files (x86)",
    +    });
    +    const changed = buildSystemRunApprovalEnvBinding({
    +      "ProgramFiles(x86)": "D:\\SDKs",
    +    });
    +
    +    expect(base.envKeys).toEqual(["ProgramFiles(x86)"]);
    +    expect(base.envHash).toBeTypeOf("string");
    +    expect(base.envHash).not.toEqual(changed.envHash);
    +  });
     });
     
     describe("buildSystemRunApprovalBinding", () => {
    @@ -175,6 +188,20 @@ describe("matchSystemRunApprovalEnvHash", () => {
             details: { envKeys: ["ALPHA"] },
           },
         },
    +    {
    +      name: "reports missing approval env binding when actual env keys are present without hashes",
    +      params: {
    +        expectedEnvHash: null,
    +        actualEnvHash: null,
    +        actualEnvKeys: ["ProgramFiles(x86)"],
    +      },
    +      expected: {
    +        ok: false,
    +        code: "APPROVAL_ENV_BINDING_MISSING",
    +        message: "approval id missing env binding for requested env overrides",
    +        details: { envKeys: ["ProgramFiles(x86)"] },
    +      },
    +    },
         {
           name: "reports env hash mismatches",
           params: {
    
  • src/infra/system-run-approval-binding.ts+12 2 modified
    @@ -4,7 +4,7 @@ import type {
       SystemRunApprovalFileOperand,
       SystemRunApprovalPlan,
     } from "./exec-approvals.js";
    -import { normalizeEnvVarKey } from "./host-env-security.js";
    +import { normalizeHostOverrideEnvVarKey } from "./host-env-security.js";
     import { normalizeNonEmptyString, normalizeStringArray } from "./system-run-normalize.js";
     
     type NormalizedSystemRunEnvEntry = [key: string, value: string];
    @@ -75,7 +75,7 @@ function normalizeSystemRunEnvEntries(env: unknown): NormalizedSystemRunEnvEntry
         if (typeof rawValue !== "string") {
           continue;
         }
    -    const key = normalizeEnvVarKey(rawKey, { portable: true });
    +    const key = normalizeHostOverrideEnvVarKey(rawKey);
         if (!key) {
           continue;
         }
    @@ -162,6 +162,16 @@ export function matchSystemRunApprovalEnvHash(params: {
       actualEnvHash: string | null;
       actualEnvKeys: string[];
     }): SystemRunApprovalMatchResult {
    +  // Fail closed if callers provide inconsistent hash/key state. This guards against
    +  // normalization drift between approval and execution paths.
    +  if (!params.expectedEnvHash && !params.actualEnvHash && params.actualEnvKeys.length > 0) {
    +    return {
    +      ok: false,
    +      code: "APPROVAL_ENV_BINDING_MISSING",
    +      message: "approval id missing env binding for requested env overrides",
    +      details: { envKeys: params.actualEnvKeys },
    +    };
    +  }
       if (!params.expectedEnvHash && !params.actualEnvHash) {
         return { ok: true };
       }
    
  • test/fixtures/system-run-approval-binding-contract.json+24 0 modified
    @@ -48,6 +48,30 @@
           },
           "expected": { "ok": false, "code": "APPROVAL_ENV_MISMATCH" }
         },
    +    {
    +      "name": "binding rejects mismatched Windows-compatible env values",
    +      "request": {
    +        "host": "node",
    +        "command": "cmd.exe /c echo ok",
    +        "binding": {
    +          "argv": ["cmd.exe", "/c", "echo", "ok"],
    +          "cwd": null,
    +          "agentId": null,
    +          "sessionKey": null,
    +          "env": { "ProgramFiles(x86)": "C:\\Program Files (x86)" }
    +        }
    +      },
    +      "invoke": {
    +        "argv": ["cmd.exe", "/c", "echo", "ok"],
    +        "binding": {
    +          "cwd": null,
    +          "agentId": null,
    +          "sessionKey": null,
    +          "env": { "ProgramFiles(x86)": "D:\\malicious" }
    +        }
    +      },
    +      "expected": { "ok": false, "code": "APPROVAL_ENV_MISMATCH" }
    +    },
         {
           "name": "binding rejects unbound env overrides",
           "request": {
    
93880717f1cd

fix(media): harden secondary local path seams

https://github.com/openclaw/openclawPeter SteinbergerMar 23, 2026via ghsa
6 files changed · +89 10
  • src/agents/pi-embedded-runner/run/images.test.ts+17 1 modified
    @@ -1,7 +1,7 @@
     import fs from "node:fs/promises";
     import os from "node:os";
     import path from "node:path";
    -import { describe, expect, it } from "vitest";
    +import { describe, expect, it, vi } from "vitest";
     import { createHostSandboxFsBridge } from "../../test-helpers/host-sandbox-fs-bridge.js";
     import { createUnsafeMountedSandbox } from "../../test-helpers/unsafe-mounted-sandbox.js";
     import {
    @@ -190,6 +190,22 @@ what is this?`);
         // Only 1 ref - the local path (example.com URLs are skipped)
         expect(ref?.resolved).toContain("ChatGPT Image Apr 21, 2025.png");
       });
    +
    +  it("ignores remote-host file URLs", () => {
    +    expectNoImageReferences("See file://attacker/share/evil.png");
    +  });
    +
    +  it("ignores Windows network paths from attachment-style references", () => {
    +    const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
    +
    +    try {
    +      expectNoImageReferences(
    +        "[media attached: \\\\attacker\\share\\photo.png (image/png)] what is this?",
    +      );
    +    } finally {
    +      platformSpy.mockRestore();
    +    }
    +  });
     });
     
     describe("modelSupportsImages", () => {
    
  • src/agents/pi-embedded-runner/run/images.ts+7 2 modified
    @@ -1,6 +1,6 @@
     import path from "node:path";
    -import { fileURLToPath } from "node:url";
     import type { ImageContent } from "@mariozechner/pi-ai";
    +import { assertNoWindowsNetworkPath, safeFileURLToPath } from "../../../infra/local-file-access.js";
     import { loadWebMedia } from "../../../media/web-media.js";
     import { resolveUserPath } from "../../../utils.js";
     import type { ImageSanitizationLimits } from "../../image-sanitization.js";
    @@ -108,6 +108,11 @@ export function detectImageReferences(prompt: string): DetectedImageRef[] {
         if (!isImageExtension(trimmed)) {
           return;
         }
    +    try {
    +      assertNoWindowsNetworkPath(trimmed, "Image path");
    +    } catch {
    +      return;
    +    }
         seen.add(dedupeKey);
         const resolved = trimmed.startsWith("~") ? resolveUserPath(trimmed) : trimmed;
         refs.push({ raw: trimmed, type: "path", resolved });
    @@ -160,7 +165,7 @@ export function detectImageReferences(prompt: string): DetectedImageRef[] {
         seen.add(dedupeKey);
         // Use fileURLToPath for proper handling (e.g., file://localhost/path)
         try {
    -      const resolved = fileURLToPath(raw);
    +      const resolved = safeFileURLToPath(raw);
           refs.push({ raw, type: "path", resolved });
         } catch {
           // Skip malformed file:// URLs
    
  • src/agents/sandbox-paths.test.ts+21 1 modified
    @@ -2,7 +2,7 @@ import fs from "node:fs/promises";
     import os from "node:os";
     import path from "node:path";
     import { pathToFileURL } from "node:url";
    -import { describe, expect, it } from "vitest";
    +import { describe, expect, it, vi } from "vitest";
     import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
     import { resolveSandboxedMediaSource } from "./sandbox-paths.js";
     
    @@ -162,6 +162,11 @@ describe("resolveSandboxedMediaSource", () => {
           media: "file:///etc/passwd",
           expected: /sandbox/i,
         },
    +    {
    +      name: "file:// URLs with remote hosts",
    +      media: "file://attacker/share/photo.png",
    +      expected: /remote hosts are not allowed/i,
    +    },
         {
           name: "invalid file:// URLs",
           media: "file://not a valid url\x00",
    @@ -277,4 +282,19 @@ describe("resolveSandboxedMediaSource", () => {
         });
         expect(result).toBe("");
       });
    +
    +  it("rejects Windows network paths before sandbox resolution", async () => {
    +    const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
    +
    +    try {
    +      await expect(
    +        resolveSandboxedMediaSource({
    +          media: "\\\\attacker\\share\\photo.png",
    +          sandboxRoot: "/any/path",
    +        }),
    +      ).rejects.toThrow(/network paths/i);
    +    } finally {
    +      platformSpy.mockRestore();
    +    }
    +  });
     });
    
  • src/agents/sandbox-paths.ts+8 4 modified
    @@ -1,6 +1,7 @@
     import os from "node:os";
     import path from "node:path";
    -import { fileURLToPath, URL } from "node:url";
    +import { URL } from "node:url";
    +import { assertNoWindowsNetworkPath, safeFileURLToPath } from "../infra/local-file-access.js";
     import { assertNoPathAliasEscape, type PathAliasPolicy } from "../infra/path-alias-guards.js";
     import { isPathInside } from "../infra/path-guards.js";
     import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
    @@ -106,9 +107,11 @@ export async function resolveSandboxedMediaSource(params: {
           candidate = workspaceMappedFromUrl;
         } else {
           try {
    -        candidate = fileURLToPath(candidate);
    -      } catch {
    -        throw new Error(`Invalid file:// URL for sandboxed media: ${raw}`);
    +        candidate = safeFileURLToPath(candidate);
    +      } catch (err) {
    +        throw new Error(`Invalid file:// URL for sandboxed media: ${(err as Error).message}`, {
    +          cause: err,
    +        });
           }
         }
       }
    @@ -119,6 +122,7 @@ export async function resolveSandboxedMediaSource(params: {
       if (containerWorkspaceMapped) {
         candidate = containerWorkspaceMapped;
       }
    +  assertNoWindowsNetworkPath(candidate, "Sandbox media path");
       const tmpMediaPath = await resolveAllowedTmpMediaPath({
         candidate,
         sandboxRoot: params.sandboxRoot,
    
  • src/media-understanding/attachments.normalize.test.ts+29 0 added
    @@ -0,0 +1,29 @@
    +import os from "node:os";
    +import path from "node:path";
    +import { pathToFileURL } from "node:url";
    +import { describe, expect, it, vi } from "vitest";
    +import { normalizeAttachmentPath } from "./attachments.normalize.js";
    +
    +describe("normalizeAttachmentPath", () => {
    +  it("allows localhost file URLs", () => {
    +    const localPath = path.join(os.tmpdir(), "photo.png");
    +    const fileUrl = pathToFileURL(localPath);
    +    fileUrl.hostname = "localhost";
    +
    +    expect(normalizeAttachmentPath(fileUrl.href)).toBe(localPath);
    +  });
    +
    +  it("rejects remote-host file URLs", () => {
    +    expect(normalizeAttachmentPath("file://attacker/share/photo.png")).toBeUndefined();
    +  });
    +
    +  it("rejects Windows network paths", () => {
    +    const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
    +
    +    try {
    +      expect(normalizeAttachmentPath("\\\\attacker\\share\\photo.png")).toBeUndefined();
    +    } finally {
    +      platformSpy.mockRestore();
    +    }
    +  });
    +});
    
  • src/media-understanding/attachments.normalize.ts+7 2 modified
    @@ -1,5 +1,5 @@
    -import { fileURLToPath } from "node:url";
     import type { MsgContext } from "../auto-reply/templating.js";
    +import { assertNoWindowsNetworkPath, safeFileURLToPath } from "../infra/local-file-access.js";
     import { getFileExtension, isAudioFileName, kindFromMime } from "../media/mime.js";
     import type { MediaAttachment } from "./types.js";
     
    @@ -10,11 +10,16 @@ export function normalizeAttachmentPath(raw?: string | null): string | undefined
       }
       if (value.startsWith("file://")) {
         try {
    -      return fileURLToPath(value);
    +      return safeFileURLToPath(value);
         } catch {
           return undefined;
         }
       }
    +  try {
    +    assertNoWindowsNetworkPath(value, "Attachment path");
    +  } catch {
    +    return undefined;
    +  }
       return value;
     }
     
    
4fd7feb0fd4e

fix(media): block remote-host file URLs in loaders

https://github.com/openclaw/openclawPeter SteinbergerMar 23, 2026via ghsa
5 files changed · +218 8
  • extensions/whatsapp/src/media.test.ts+32 0 modified
    @@ -391,6 +391,21 @@ describe("local media root guard", () => {
         expect(result.kind).toBe("image");
       });
     
    +  it("rejects remote-host file URLs before filesystem checks", async () => {
    +    const realpathSpy = vi.spyOn(fs, "realpath");
    +
    +    try {
    +      await expect(
    +        loadWebMedia("file://attacker/share/evil.png", 1024 * 1024, {
    +          localRoots: [resolvePreferredOpenClawTmpDir()],
    +        }),
    +      ).rejects.toMatchObject({ code: "invalid-file-url" });
    +      expect(realpathSpy).not.toHaveBeenCalled();
    +    } finally {
    +      realpathSpy.mockRestore();
    +    }
    +  });
    +
       it("accepts win32 dev=0 stat mismatch for local file loads", async () => {
         const actualLstat = await fs.lstat(tinyPngFile);
         const actualStat = await fs.stat(tinyPngFile);
    @@ -415,6 +430,23 @@ describe("local media root guard", () => {
         }
       });
     
    +  it("rejects Windows network paths before filesystem checks", async () => {
    +    const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
    +    const realpathSpy = vi.spyOn(fs, "realpath");
    +
    +    try {
    +      await expect(
    +        loadWebMedia("\\\\attacker\\share\\evil.png", 1024 * 1024, {
    +          localRoots: [resolvePreferredOpenClawTmpDir()],
    +        }),
    +      ).rejects.toMatchObject({ code: "network-path-not-allowed" });
    +      expect(realpathSpy).not.toHaveBeenCalled();
    +    } finally {
    +      realpathSpy.mockRestore();
    +      platformSpy.mockRestore();
    +    }
    +  });
    +
       it("requires readFile override for localRoots bypass", async () => {
         await expect(
           loadWebMedia(tinyPngFile, {
    
  • extensions/whatsapp/src/media.ts+51 4 modified
    @@ -1,6 +1,6 @@
     import fs from "node:fs/promises";
     import path from "node:path";
    -import { fileURLToPath } from "node:url";
    +import { fileURLToPath, URL } from "node:url";
     import { SafeOpenError, readLocalFileSafely } from "openclaw/plugin-sdk/infra-runtime";
     import type { SsrFPolicy } from "openclaw/plugin-sdk/infra-runtime";
     import { type MediaKind, maxBytesForKind } from "openclaw/plugin-sdk/media-runtime";
    @@ -55,10 +55,43 @@ function resolveWebMediaOptions(params: {
       };
     }
     
    +function isWindowsNetworkPath(filePath: string): boolean {
    +  if (process.platform !== "win32") {
    +    return false;
    +  }
    +  const normalized = filePath.replace(/\//g, "\\");
    +  return normalized.startsWith("\\\\?\\UNC\\") || normalized.startsWith("\\\\");
    +}
    +
    +function assertNoWindowsNetworkPath(filePath: string, label = "Path"): void {
    +  if (isWindowsNetworkPath(filePath)) {
    +    throw new Error(`${label} cannot use Windows network paths: ${filePath}`);
    +  }
    +}
    +
    +function safeFileURLToPath(fileUrl: string): string {
    +  let parsed: URL;
    +  try {
    +    parsed = new URL(fileUrl);
    +  } catch {
    +    throw new Error(`Invalid file:// URL: ${fileUrl}`);
    +  }
    +  if (parsed.protocol !== "file:") {
    +    throw new Error(`Invalid file:// URL: ${fileUrl}`);
    +  }
    +  if (parsed.hostname !== "" && parsed.hostname.toLowerCase() !== "localhost") {
    +    throw new Error(`file:// URLs with remote hosts are not allowed: ${fileUrl}`);
    +  }
    +  const filePath = fileURLToPath(parsed);
    +  assertNoWindowsNetworkPath(filePath, "Local file URL");
    +  return filePath;
    +}
    +
     export type LocalMediaAccessErrorCode =
       | "path-not-allowed"
       | "invalid-root"
       | "invalid-file-url"
    +  | "network-path-not-allowed"
       | "unsafe-bypass"
       | "not-found"
       | "invalid-path"
    @@ -85,6 +118,13 @@ async function assertLocalMediaAllowed(
       if (localRoots === "any") {
         return;
       }
    +  try {
    +    assertNoWindowsNetworkPath(mediaPath, "Local media path");
    +  } catch (err) {
    +    throw new LocalMediaAccessError("network-path-not-allowed", (err as Error).message, {
    +      cause: err,
    +    });
    +  }
       const roots = localRoots ?? getDefaultLocalRoots();
       // Resolve symlinks so a symlink under /tmp pointing to /etc/passwd is caught.
       let resolved: string;
    @@ -248,9 +288,9 @@ async function loadWebMediaInternal(
       // Use fileURLToPath for proper handling of file:// URLs (handles file://localhost/path, etc.)
       if (mediaUrl.startsWith("file://")) {
         try {
    -      mediaUrl = fileURLToPath(mediaUrl);
    -    } catch {
    -      throw new LocalMediaAccessError("invalid-file-url", `Invalid file:// URL: ${mediaUrl}`);
    +      mediaUrl = safeFileURLToPath(mediaUrl);
    +    } catch (err) {
    +      throw new LocalMediaAccessError("invalid-file-url", (err as Error).message, { cause: err });
         }
       }
     
    @@ -341,6 +381,13 @@ async function loadWebMediaInternal(
       if (mediaUrl.startsWith("~")) {
         mediaUrl = resolveUserPath(mediaUrl);
       }
    +  try {
    +    assertNoWindowsNetworkPath(mediaUrl, "Local media path");
    +  } catch (err) {
    +    throw new LocalMediaAccessError("network-path-not-allowed", (err as Error).message, {
    +      cause: err,
    +    });
    +  }
     
       if ((sandboxValidated || localRoots === "any") && !readFileOverride) {
         throw new LocalMediaAccessError(
    
  • src/infra/local-file-access.ts+37 0 added
    @@ -0,0 +1,37 @@
    +import { fileURLToPath, URL } from "node:url";
    +
    +function isLocalFileUrlHost(hostname: string): boolean {
    +  return hostname === "" || hostname.toLowerCase() === "localhost";
    +}
    +
    +export function isWindowsNetworkPath(filePath: string): boolean {
    +  if (process.platform !== "win32") {
    +    return false;
    +  }
    +  const normalized = filePath.replace(/\//g, "\\");
    +  return normalized.startsWith("\\\\?\\UNC\\") || normalized.startsWith("\\\\");
    +}
    +
    +export function assertNoWindowsNetworkPath(filePath: string, label = "Path"): void {
    +  if (isWindowsNetworkPath(filePath)) {
    +    throw new Error(`${label} cannot use Windows network paths: ${filePath}`);
    +  }
    +}
    +
    +export function safeFileURLToPath(fileUrl: string): string {
    +  let parsed: URL;
    +  try {
    +    parsed = new URL(fileUrl);
    +  } catch {
    +    throw new Error(`Invalid file:// URL: ${fileUrl}`);
    +  }
    +  if (parsed.protocol !== "file:") {
    +    throw new Error(`Invalid file:// URL: ${fileUrl}`);
    +  }
    +  if (!isLocalFileUrlHost(parsed.hostname)) {
    +    throw new Error(`file:// URLs with remote hosts are not allowed: ${fileUrl}`);
    +  }
    +  const filePath = fileURLToPath(parsed);
    +  assertNoWindowsNetworkPath(filePath, "Local file URL");
    +  return filePath;
    +}
    
  • src/media/web-media.test.ts+79 0 added
    @@ -0,0 +1,79 @@
    +import fs from "node:fs/promises";
    +import path from "node:path";
    +import { pathToFileURL } from "node:url";
    +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
    +import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
    +import { loadWebMedia } from "./web-media.js";
    +
    +const TINY_PNG_BASE64 =
    +  "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=";
    +
    +let fixtureRoot = "";
    +let tinyPngFile = "";
    +
    +beforeAll(async () => {
    +  fixtureRoot = await fs.mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), "web-media-core-"));
    +  tinyPngFile = path.join(fixtureRoot, "tiny.png");
    +  await fs.writeFile(tinyPngFile, Buffer.from(TINY_PNG_BASE64, "base64"));
    +});
    +
    +afterAll(async () => {
    +  if (fixtureRoot) {
    +    await fs.rm(fixtureRoot, { recursive: true, force: true });
    +  }
    +});
    +
    +describe("loadWebMedia", () => {
    +  it("allows localhost file URLs for local files", async () => {
    +    const fileUrl = pathToFileURL(tinyPngFile);
    +    fileUrl.hostname = "localhost";
    +
    +    const result = await loadWebMedia(fileUrl.href, {
    +      maxBytes: 1024 * 1024,
    +      localRoots: [fixtureRoot],
    +    });
    +
    +    expect(result.kind).toBe("image");
    +    expect(result.buffer.length).toBeGreaterThan(0);
    +  });
    +
    +  it("rejects remote-host file URLs before filesystem checks", async () => {
    +    const realpathSpy = vi.spyOn(fs, "realpath");
    +
    +    try {
    +      await expect(
    +        loadWebMedia("file://attacker/share/evil.png", {
    +          maxBytes: 1024 * 1024,
    +          localRoots: [fixtureRoot],
    +        }),
    +      ).rejects.toMatchObject({ code: "invalid-file-url" });
    +      await expect(
    +        loadWebMedia("file://attacker/share/evil.png", {
    +          maxBytes: 1024 * 1024,
    +          localRoots: [fixtureRoot],
    +        }),
    +      ).rejects.toThrow(/remote hosts are not allowed/i);
    +      expect(realpathSpy).not.toHaveBeenCalled();
    +    } finally {
    +      realpathSpy.mockRestore();
    +    }
    +  });
    +
    +  it("rejects Windows network paths before filesystem checks", async () => {
    +    const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
    +    const realpathSpy = vi.spyOn(fs, "realpath");
    +
    +    try {
    +      await expect(
    +        loadWebMedia("\\\\attacker\\share\\evil.png", {
    +          maxBytes: 1024 * 1024,
    +          localRoots: [fixtureRoot],
    +        }),
    +      ).rejects.toMatchObject({ code: "network-path-not-allowed" });
    +      expect(realpathSpy).not.toHaveBeenCalled();
    +    } finally {
    +      realpathSpy.mockRestore();
    +      platformSpy.mockRestore();
    +    }
    +  });
    +});
    
  • src/media/web-media.ts+19 4 modified
    @@ -1,8 +1,8 @@
     import fs from "node:fs/promises";
     import path from "node:path";
    -import { fileURLToPath } from "node:url";
     import { logVerbose, shouldLogVerbose } from "../globals.js";
     import { SafeOpenError, readLocalFileSafely } from "../infra/fs-safe.js";
    +import { assertNoWindowsNetworkPath, safeFileURLToPath } from "../infra/local-file-access.js";
     import type { SsrFPolicy } from "../infra/net/ssrf.js";
     import { resolveUserPath } from "../utils.js";
     import { maxBytesForKind, type MediaKind } from "./constants.js";
    @@ -59,6 +59,7 @@ export type LocalMediaAccessErrorCode =
       | "path-not-allowed"
       | "invalid-root"
       | "invalid-file-url"
    +  | "network-path-not-allowed"
       | "unsafe-bypass"
       | "not-found"
       | "invalid-path"
    @@ -85,6 +86,13 @@ async function assertLocalMediaAllowed(
       if (localRoots === "any") {
         return;
       }
    +  try {
    +    assertNoWindowsNetworkPath(mediaPath, "Local media path");
    +  } catch (err) {
    +    throw new LocalMediaAccessError("network-path-not-allowed", (err as Error).message, {
    +      cause: err,
    +    });
    +  }
       const roots = localRoots ?? getDefaultLocalRoots();
       // Resolve symlinks so a symlink under /tmp pointing to /etc/passwd is caught.
       let resolved: string;
    @@ -248,9 +256,9 @@ async function loadWebMediaInternal(
       // Use fileURLToPath for proper handling of file:// URLs (handles file://localhost/path, etc.)
       if (mediaUrl.startsWith("file://")) {
         try {
    -      mediaUrl = fileURLToPath(mediaUrl);
    -    } catch {
    -      throw new LocalMediaAccessError("invalid-file-url", `Invalid file:// URL: ${mediaUrl}`);
    +      mediaUrl = safeFileURLToPath(mediaUrl);
    +    } catch (err) {
    +      throw new LocalMediaAccessError("invalid-file-url", (err as Error).message, { cause: err });
         }
       }
     
    @@ -341,6 +349,13 @@ async function loadWebMediaInternal(
       if (mediaUrl.startsWith("~")) {
         mediaUrl = resolveUserPath(mediaUrl);
       }
    +  try {
    +    assertNoWindowsNetworkPath(mediaUrl, "Local media path");
    +  } catch (err) {
    +    throw new LocalMediaAccessError("network-path-not-allowed", (err as Error).message, {
    +      cause: err,
    +    });
    +  }
     
       if ((sandboxValidated || localRoots === "any") && !readFileOverride) {
         throw new LocalMediaAccessError(
    

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

9

News mentions

0

No linked articles in our index yet.