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.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.3.22 | 2026.3.22 |
Affected products
1Patches
3b57b680c0c34fix: address issue
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": {
93880717f1cdfix(media): harden secondary local path seams
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; }
4fd7feb0fd4efix(media): block remote-host file URLs in loaders
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- github.com/openclaw/openclaw/commit/b57b680c0c34de907d57f60c38fb358e82aef8f7nvdPatchWEB
- github.com/openclaw/openclaw/pull/59182nvdIssue TrackingPatchWEB
- github.com/advisories/GHSA-h3x4-hc5v-v2gmghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-98ch-45wp-ch47nvdVendor Advisory
- nvd.nist.gov/vuln/detail/CVE-2026-34426ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-approval-bypass-via-environment-variable-normalizationnvdThird Party AdvisoryWEB
- github.com/openclaw/openclaw/commit/4fd7feb0fd4ec16c48ed983980dba79a09b3aaf5ghsaWEB
- github.com/openclaw/openclaw/commit/93880717f1cd34feaa45e74e939b7a5256288901ghsaWEB
- github.com/openclaw/openclaw/security/advisories/GHSA-h3x4-hc5v-v2gmghsaWEB
News mentions
0No linked articles in our index yet.