VYPR
Medium severity5.5NVD Advisory· Published Apr 28, 2026· Updated Apr 28, 2026

CVE-2026-41366

CVE-2026-41366

Description

OpenClaw before 2026.3.31 contains a local roots self-whitelisting vulnerability in appendLocalMediaParentRoots that allows model-initiated arbitrary host file read. Attackers can exploit improper media parent directory validation to exfiltrate credentials and access sensitive files.

Affected products

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

Patches

1
1ca4261d7e05

fix(media): keep local roots configuration-derived (#57770)

https://github.com/openclaw/openclawJacob TomlinsonMar 30, 2026via nvd-ref
4 files changed · +91 91
  • src/agents/tools/media-tool-shared.test.ts+37 0 added
    @@ -0,0 +1,37 @@
    +import path from "node:path";
    +import { pathToFileURL } from "node:url";
    +import { afterEach, describe, expect, it, vi } from "vitest";
    +import { resolveMediaToolLocalRoots } from "./media-tool-shared.js";
    +
    +function normalizeHostPath(value: string): string {
    +  return path.normalize(path.resolve(value));
    +}
    +
    +describe("resolveMediaToolLocalRoots", () => {
    +  afterEach(() => {
    +    vi.unstubAllEnvs();
    +  });
    +
    +  it("does not widen default local roots from media sources", () => {
    +    const stateDir = path.join("/tmp", "openclaw-media-tool-roots-state");
    +    const picturesDir =
    +      process.platform === "win32" ? "C:\\Users\\peter\\Pictures" : "/Users/peter/Pictures";
    +    const moviesDir =
    +      process.platform === "win32" ? "C:\\Users\\peter\\Movies" : "/Users/peter/Movies";
    +
    +    vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
    +
    +    const roots = resolveMediaToolLocalRoots(path.join(stateDir, "workspace-agent"), undefined, [
    +      path.join(picturesDir, "photo.png"),
    +      pathToFileURL(path.join(moviesDir, "clip.mp4")).href,
    +      "/top-level-file.png",
    +    ]);
    +
    +    const normalizedRoots = roots.map(normalizeHostPath);
    +    expect(normalizedRoots).toContain(normalizeHostPath(path.join(stateDir, "workspace-agent")));
    +    expect(normalizedRoots).toContain(normalizeHostPath(path.join(stateDir, "workspace")));
    +    expect(normalizedRoots).not.toContain(normalizeHostPath(picturesDir));
    +    expect(normalizedRoots).not.toContain(normalizeHostPath(moviesDir));
    +    expect(normalizedRoots).not.toContain(normalizeHostPath("/"));
    +  });
    +});
    
  • src/agents/tools/media-tool-shared.ts+2 4 modified
    @@ -1,6 +1,5 @@
     import { type Api, type Model } from "@mariozechner/pi-ai";
     import type { OpenClawConfig } from "../../config/config.js";
    -import { appendLocalMediaParentRoots } from "../../media/local-roots.js";
     import { getDefaultLocalRoots } from "../../media/web-media.js";
     import type { ImageModelConfig } from "./image-tool.helpers.js";
     import type { ToolModelConfig } from "./model-config.helpers.js";
    @@ -56,15 +55,14 @@ function applyAgentDefaultModelConfig(
     export function resolveMediaToolLocalRoots(
       workspaceDirRaw: string | undefined,
       options?: { workspaceOnly?: boolean },
    -  mediaSources?: readonly string[],
    +  _mediaSources?: readonly string[],
     ): string[] {
       const workspaceDir = normalizeWorkspaceDir(workspaceDirRaw);
       if (options?.workspaceOnly) {
         return workspaceDir ? [workspaceDir] : [];
       }
       const roots = getDefaultLocalRoots();
    -  const scopedRoots = workspaceDir ? Array.from(new Set([...roots, workspaceDir])) : [...roots];
    -  return appendLocalMediaParentRoots(scopedRoots, mediaSources);
    +  return workspaceDir ? Array.from(new Set([...roots, workspaceDir])) : [...roots];
     }
     
     export function resolvePromptAndModelOverride(
    
  • src/media/local-roots.test.ts+41 31 modified
    @@ -2,7 +2,6 @@ import path from "node:path";
     import { pathToFileURL } from "node:url";
     import { afterEach, describe, expect, it, vi } from "vitest";
     import {
    -  appendLocalMediaParentRoots,
       getAgentScopedMediaLocalRoots,
       getAgentScopedMediaLocalRootsForSources,
       getDefaultMediaLocalRoots,
    @@ -52,6 +51,14 @@ describe("local media roots", () => {
         expect(normalizedRoots).not.toContain(picturesRoot);
       }
     
    +  function expectPicturesRootAbsent(roots: readonly string[], picturesRoot?: string) {
    +    expectPicturesRootPresence({
    +      roots,
    +      shouldContainPictures: false,
    +      picturesRoot,
    +    });
    +  }
    +
       function expectAgentMediaRootsCase(params: {
         stateDir: string;
         getRoots: () => readonly string[];
    @@ -101,38 +108,12 @@ describe("local media roots", () => {
         });
       });
     
    -  it("adds concrete parent roots for local media sources without widening to filesystem root", () => {
    -    const picturesDir =
    -      process.platform === "win32" ? "C:\\Users\\peter\\Pictures" : "/Users/peter/Pictures";
    -    const moviesDir =
    -      process.platform === "win32" ? "C:\\Users\\peter\\Movies" : "/Users/peter/Movies";
    -
    -    const roots = appendLocalMediaParentRoots(
    -      ["/tmp/base"],
    -      [
    -        path.join(picturesDir, "photo.png"),
    -        pathToFileURL(path.join(moviesDir, "clip.mp4")).href,
    -        "https://example.com/remote.png",
    -        "/top-level-file.png",
    -      ],
    -    );
    -
    -    expect(roots.map(normalizeHostPath)).toEqual(
    -      expect.arrayContaining([
    -        normalizeHostPath("/tmp/base"),
    -        normalizeHostPath(picturesDir),
    -        normalizeHostPath(moviesDir),
    -      ]),
    -    );
    -    expect(roots.map(normalizeHostPath)).not.toContain(normalizeHostPath("/"));
    -  });
    -
       it.each([
         {
    -      name: "widens agent media roots for concrete local sources when workspaceOnly is disabled",
    +      name: "does not widen agent media roots for concrete local sources when workspaceOnly is disabled",
           stateDir: path.join("/tmp", "openclaw-flexible-media-roots-state"),
           cfg: {},
    -      shouldContainPictures: true,
    +      shouldContainPictures: false,
         },
         {
           name: "does not widen agent media roots when workspaceOnly is enabled",
    @@ -147,15 +128,15 @@ describe("local media roots", () => {
           shouldContainPictures: false,
         },
         {
    -      name: "widens media roots again when messaging-profile agents explicitly enable filesystem tools",
    +      name: "does not widen media roots even when messaging-profile agents explicitly enable filesystem tools",
           stateDir: path.join("/tmp", "openclaw-messaging-fs-media-roots-state"),
           cfg: {
             tools: {
               profile: "messaging",
               fs: { workspaceOnly: false },
             },
           },
    -      shouldContainPictures: true,
    +      shouldContainPictures: false,
         },
       ] as const)("$name", ({ stateDir, cfg, shouldContainPictures }) => {
         const roots = withStateDir(stateDir, () =>
    @@ -167,4 +148,33 @@ describe("local media roots", () => {
         );
         expectPicturesRootPresence({ roots, shouldContainPictures });
       });
    +
    +  it("keeps agent-scoped defaults even when mediaSources include file URLs and top-level paths", () => {
    +    const stateDir = path.join("/tmp", "openclaw-file-url-media-roots-state");
    +    const picturesDir =
    +      process.platform === "win32" ? "C:\\Users\\peter\\Pictures" : "/Users/peter/Pictures";
    +    const moviesDir =
    +      process.platform === "win32" ? "C:\\Users\\peter\\Movies" : "/Users/peter/Movies";
    +
    +    const roots = withStateDir(stateDir, () =>
    +      getAgentScopedMediaLocalRootsForSources({
    +        cfg: {},
    +        agentId: "ops",
    +        mediaSources: [
    +          path.join(picturesDir, "photo.png"),
    +          pathToFileURL(path.join(moviesDir, "clip.mp4")).href,
    +          "/top-level-file.png",
    +        ],
    +      }),
    +    );
    +
    +    expectNormalizedRootsContain(roots, [
    +      path.join(stateDir, "media"),
    +      path.join(stateDir, "workspace"),
    +      path.join(stateDir, "workspace-ops"),
    +    ]);
    +    expectPicturesRootAbsent(roots, picturesDir);
    +    expectPicturesRootAbsent(roots, moviesDir);
    +    expect(roots.map(normalizeHostPath)).not.toContain(normalizeHostPath("/"));
    +  });
     });
    
  • src/media/local-roots.ts+11 56 modified
    @@ -1,23 +1,14 @@
     import path from "node:path";
     import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
    -import {
    -  resolveEffectiveToolFsRootExpansionAllowed,
    -  resolveEffectiveToolFsWorkspaceOnly,
    -} from "../agents/tool-fs-policy.js";
     import type { OpenClawConfig } from "../config/config.js";
     import { resolveStateDir } from "../config/paths.js";
    -import { safeFileURLToPath } from "../infra/local-file-access.js";
     import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
    -import { resolveUserPath } from "../utils.js";
     
     type BuildMediaLocalRootsOptions = {
       preferredTmpDir?: string;
     };
     
     let cachedPreferredTmpDir: string | undefined;
    -const HTTP_URL_RE = /^https?:\/\//i;
    -const DATA_URL_RE = /^data:/i;
    -const WINDOWS_DRIVE_RE = /^[A-Za-z]:[\\/]/;
     
     function resolveCachedPreferredTmpDir(): string {
       if (!cachedPreferredTmpDir) {
    @@ -63,60 +54,24 @@ export function getAgentScopedMediaLocalRoots(
       return roots;
     }
     
    -function resolveLocalMediaPath(source: string): string | undefined {
    -  const trimmed = source.trim();
    -  if (!trimmed || HTTP_URL_RE.test(trimmed) || DATA_URL_RE.test(trimmed)) {
    -    return undefined;
    -  }
    -  if (trimmed.startsWith("file://")) {
    -    try {
    -      return safeFileURLToPath(trimmed);
    -    } catch {
    -      return undefined;
    -    }
    -  }
    -  if (trimmed.startsWith("~")) {
    -    return resolveUserPath(trimmed);
    -  }
    -  if (path.isAbsolute(trimmed) || WINDOWS_DRIVE_RE.test(trimmed)) {
    -    return path.resolve(trimmed);
    -  }
    -  return undefined;
    -}
    -
    +/**
    + * @deprecated Kept for plugin-sdk compatibility. Media sources no longer widen allowed roots.
    + */
     export function appendLocalMediaParentRoots(
       roots: readonly string[],
    -  mediaSources?: readonly string[],
    +  _mediaSources?: readonly string[],
     ): string[] {
    -  const appended = Array.from(new Set(roots.map((root) => path.resolve(root))));
    -  for (const source of mediaSources ?? []) {
    -    const localPath = resolveLocalMediaPath(source);
    -    if (!localPath) {
    -      continue;
    -    }
    -    const parentDir = path.dirname(localPath);
    -    if (parentDir === path.parse(parentDir).root) {
    -      continue;
    -    }
    -    const normalizedParent = path.resolve(parentDir);
    -    if (!appended.includes(normalizedParent)) {
    -      appended.push(normalizedParent);
    -    }
    -  }
    -  return appended;
    +  return Array.from(new Set(roots.map((root) => path.resolve(root))));
     }
     
    -export function getAgentScopedMediaLocalRootsForSources(params: {
    +export function getAgentScopedMediaLocalRootsForSources({
    +  cfg,
    +  agentId,
    +  mediaSources: _mediaSources,
    +}: {
       cfg: OpenClawConfig;
       agentId?: string;
       mediaSources?: readonly string[];
     }): readonly string[] {
    -  const roots = getAgentScopedMediaLocalRoots(params.cfg, params.agentId);
    -  if (resolveEffectiveToolFsWorkspaceOnly({ cfg: params.cfg, agentId: params.agentId })) {
    -    return roots;
    -  }
    -  if (!resolveEffectiveToolFsRootExpansionAllowed({ cfg: params.cfg, agentId: params.agentId })) {
    -    return roots;
    -  }
    -  return appendLocalMediaParentRoots(roots, params.mediaSources);
    +  return getAgentScopedMediaLocalRoots(cfg, agentId);
     }
    

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

3

News mentions

0

No linked articles in our index yet.