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

CVE-2026-41389

CVE-2026-41389

Description

OpenClaw versions 2026.4.7 before 2026.4.15 fail to enforce local-root containment on tool-result media paths, allowing arbitrary local and UNC file access. Attackers can craft malicious tool-result media references to trigger host-side file reads or Windows network path access, potentially disclosing sensitive files or exposing credentials.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
>= 2026.4.7, < 2026.4.152026.4.15

Affected products

1
  • cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*
    Range: >=2026.4.7,<2026.4.15

Patches

3
52ef42302ead

fix: tighten trusted tool media passthrough (#67303)

https://github.com/openclaw/openclawDevin RobisonApr 15, 2026via ghsa
14 files changed · +345 9
  • CHANGELOG.md+2 0 modified
    @@ -8,6 +8,8 @@ Docs: https://docs.openclaw.ai
     
     ### Fixes
     
    +- Gateway/tools: anchor trusted local `MEDIA:` tool-result passthrough on the exact raw name of this run's registered built-in tools, and reject client tool definitions whose names normalize-collide with a built-in or with another client tool in the same request (`400 invalid_request_error` on both JSON and SSE paths), so a client-supplied tool named like a built-in can no longer inherit its local-media trust. (#67303)
    +
     ## 2026.4.15-beta.1
     
     ### Changes
    
  • src/agents/pi-embedded-runner/run/attempt.ts+38 1 modified
    @@ -29,6 +29,7 @@ import {
       resolveProviderTextTransforms,
       transformProviderSystemPrompt,
     } from "../../../plugins/provider-runtime.js";
    +import { getPluginToolMeta } from "../../../plugins/tools.js";
     import { isSubagentSessionKey } from "../../../routing/session-key.js";
     import { normalizeOptionalLowercaseString } from "../../../shared/string-coerce.js";
     import { normalizeOptionalString } from "../../../shared/string-coerce.js";
    @@ -86,7 +87,11 @@ import {
     import { subscribeEmbeddedPiSession } from "../../pi-embedded-subscribe.js";
     import { createPreparedEmbeddedPiSettingsManager } from "../../pi-project-settings.js";
     import { applyPiAutoCompactionGuard } from "../../pi-settings.js";
    -import { toClientToolDefinitions } from "../../pi-tool-definition-adapter.js";
    +import {
    +  createClientToolNameConflictError,
    +  findClientToolNameConflicts,
    +  toClientToolDefinitions,
    +} from "../../pi-tool-definition-adapter.js";
     import { createOpenClawCodingTools, resolveToolLoopDetectionConfig } from "../../pi-tools.js";
     import { wrapStreamFnTextTransforms } from "../../plugin-text-transforms.js";
     import { describeProviderRequestRoutingSummary } from "../../provider-attribution.js";
    @@ -962,6 +967,37 @@ export async function runEmbeddedAttempt(
             cfg: params.config,
             agentId: sessionAgentId,
           });
    +      // Exact raw names of every tool registered for this run, including
    +      // bundled/plugin tools. Used as the raw-name set for the trusted local
    +      // MEDIA: passthrough gate: a normalized alias is not sufficient — the
    +      // emitted tool name must match an exact registration of this run.
    +      const builtinToolNames = new Set(
    +        effectiveTools.flatMap((tool) => {
    +          const name = (tool.name ?? "").trim();
    +          return name ? [name] : [];
    +        }),
    +      );
    +      // Admission-time conflict check only against non-plugin core tools, to
    +      // preserve prior behavior where client tools may coexist with unrelated
    +      // plugin tool names. MEDIA passthrough is still gated by the raw-name
    +      // set above, so a client tool that normalize-collides with a plugin
    +      // tool cannot inherit the plugin's local-media trust.
    +      const coreBuiltinToolNames = new Set(
    +        effectiveTools.flatMap((tool) => {
    +          const name = (tool.name ?? "").trim();
    +          if (!name || getPluginToolMeta(tool)) {
    +            return [];
    +          }
    +          return [name];
    +        }),
    +      );
    +      const clientToolNameConflicts = findClientToolNameConflicts({
    +        tools: clientTools ?? [],
    +        existingToolNames: coreBuiltinToolNames,
    +      });
    +      if (clientToolNameConflicts.length > 0) {
    +        throw createClientToolNameConflictError(clientToolNameConflicts);
    +      }
           const clientToolDefs = clientTools
             ? toClientToolDefinitions(
                 clientTools,
    @@ -1529,6 +1565,7 @@ export async function runEmbeddedAttempt(
               sessionKey: sandboxSessionKey,
               sessionId: params.sessionId,
               agentId: sessionAgentId,
    +          builtinToolNames,
               internalEvents: params.internalEvents,
             }),
           );
    
  • src/agents/pi-embedded-subscribe.handlers.tools.media.test.ts+42 0 modified
    @@ -10,6 +10,7 @@ function createMockContext(overrides?: {
       shouldEmitToolOutput?: boolean;
       onToolResult?: ReturnType<typeof vi.fn>;
       toolResultFormat?: "markdown" | "plain";
    +  builtinToolNames?: ReadonlySet<string>;
     }): EmbeddedPiSubscribeContext {
       const onToolResult = overrides?.onToolResult ?? vi.fn();
       return {
    @@ -39,6 +40,7 @@ function createMockContext(overrides?: {
           deterministicApprovalPromptSent: false,
         },
         log: { debug: vi.fn(), warn: vi.fn() },
    +    builtinToolNames: overrides?.builtinToolNames,
         shouldEmitToolResult: vi.fn(() => false),
         shouldEmitToolOutput: vi.fn(() => overrides?.shouldEmitToolOutput ?? false),
         emitToolSummary: vi.fn(),
    @@ -173,6 +175,46 @@ describe("handleToolExecutionEnd media emission", () => {
         expect(ctx.state.pendingToolMediaUrls).toEqual([]);
       });
     
    +  it("does NOT emit local media for case-variant collisions with trusted built-ins", async () => {
    +    const ctx = createMockContext({
    +      shouldEmitToolOutput: false,
    +      onToolResult: vi.fn(),
    +      builtinToolNames: new Set(["web_search"]),
    +    });
    +
    +    await handleToolExecutionEnd(ctx, {
    +      type: "tool_execution_end",
    +      toolName: "Web_Search",
    +      toolCallId: "tc-1",
    +      isError: false,
    +      result: {
    +        content: [{ type: "text", text: "MEDIA:/tmp/secret.png" }],
    +      },
    +    });
    +
    +    expect(ctx.state.pendingToolMediaUrls).toEqual([]);
    +  });
    +
    +  it("still emits remote media for case-variant collisions with trusted built-ins", async () => {
    +    const ctx = createMockContext({
    +      shouldEmitToolOutput: false,
    +      onToolResult: vi.fn(),
    +      builtinToolNames: new Set(["web_search"]),
    +    });
    +
    +    await handleToolExecutionEnd(ctx, {
    +      type: "tool_execution_end",
    +      toolName: "Web_Search",
    +      toolCallId: "tc-1",
    +      isError: false,
    +      result: {
    +        content: [{ type: "text", text: "MEDIA:https://example.com/file.png" }],
    +      },
    +    });
    +
    +    expect(ctx.state.pendingToolMediaUrls).toEqual(["https://example.com/file.png"]);
    +  });
    +
       it("emits remote media for MCP-provenance results", async () => {
         const onToolResult = vi.fn();
         const ctx = createMockContext({ shouldEmitToolOutput: false, onToolResult });
    
  • src/agents/pi-embedded-subscribe.handlers.tools.ts+21 6 modified
    @@ -433,12 +433,13 @@ function readExecApprovalUnavailableDetails(result: unknown): {
     async function emitToolResultOutput(params: {
       ctx: ToolHandlerContext;
       toolName: string;
    +  rawToolName: string;
       meta?: string;
       isToolError: boolean;
       result: unknown;
       sanitizedResult: unknown;
     }) {
    -  const { ctx, toolName, meta, isToolError, result, sanitizedResult } = params;
    +  const { ctx, toolName, rawToolName, meta, isToolError, result, sanitizedResult } = params;
       const hasStructuredMedia =
         result &&
         typeof result === "object" &&
    @@ -511,10 +512,10 @@ async function emitToolResultOutput(params: {
         ctx.shouldEmitToolOutput() || shouldEmitCompactToolOutput({ toolName, result, outputText });
       if (shouldEmitOutput) {
         if (outputText) {
    -      ctx.emitToolOutput(toolName, meta, outputText, result);
    +      ctx.emitToolOutput(rawToolName, meta, outputText, result);
           if (ctx.params.toolResultFormat === "plain") {
             emittedToolOutputMediaUrls = await collectEmittedToolOutputMediaUrls(
    -          toolName,
    +          rawToolName,
               outputText,
               result,
             );
    @@ -533,7 +534,12 @@ async function emitToolResultOutput(params: {
       if (!mediaReply) {
         return;
       }
    -  const mediaUrls = filterToolResultMediaUrls(toolName, mediaReply.mediaUrls, result);
    +  const mediaUrls = filterToolResultMediaUrls(
    +    rawToolName,
    +    mediaReply.mediaUrls,
    +    result,
    +    ctx.builtinToolNames,
    +  );
       const pendingMediaUrls =
         mediaReply.audioAsVoice || emittedToolOutputMediaUrls.length === 0
           ? mediaUrls
    @@ -779,7 +785,8 @@ export async function handleToolExecutionEnd(
         result?: unknown;
       },
     ) {
    -  const toolName = normalizeToolName(evt.toolName);
    +  const rawToolName = evt.toolName;
    +  const toolName = normalizeToolName(rawToolName);
       const toolCallId = evt.toolCallId;
       const runId = ctx.params.runId;
       const isError = evt.isError;
    @@ -1099,7 +1106,15 @@ export async function handleToolExecutionEnd(
         `embedded run tool end: runId=${ctx.params.runId} tool=${toolName} toolCallId=${toolCallId}`,
       );
     
    -  await emitToolResultOutput({ ctx, toolName, meta, isToolError, result, sanitizedResult });
    +  await emitToolResultOutput({
    +    ctx,
    +    toolName,
    +    rawToolName,
    +    meta,
    +    isToolError,
    +    result,
    +    sanitizedResult,
    +  });
     
       // Run after_tool_call plugin hook (fire-and-forget)
       const hookRunnerAfter = ctx.hookRunner ?? (await loadHookRunnerGlobal()).getGlobalHookRunner();
    
  • src/agents/pi-embedded-subscribe.handlers.types.ts+2 0 modified
    @@ -93,6 +93,7 @@ export type EmbeddedPiSubscribeContext = {
       blockChunking?: BlockReplyChunking;
       blockChunker: EmbeddedBlockChunker | null;
       hookRunner?: HookRunner;
    +  builtinToolNames?: ReadonlySet<string>;
       noteLastAssistant: (msg: AgentMessage) => void;
     
       shouldEmitToolResult: () => boolean;
    @@ -179,6 +180,7 @@ export type ToolHandlerContext = {
       state: ToolHandlerState;
       log: EmbeddedSubscribeLogger;
       hookRunner?: HookRunner;
    +  builtinToolNames?: ReadonlySet<string>;
       flushBlockReplyBuffer: () => void | Promise<void>;
       shouldEmitToolResult: () => boolean;
       shouldEmitToolOutput: () => boolean;
    
  • src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts+29 0 modified
    @@ -209,6 +209,35 @@ describe("subscribeEmbeddedPiSession", () => {
         expect(onPartialReply).not.toHaveBeenCalled();
       });
     
    +  it("blocks local MEDIA urls from case-variant tool names in verbose output", async () => {
    +    const onToolResult = vi.fn();
    +    const { emit } = createSubscribedHarness({
    +      runId: "run",
    +      onToolResult,
    +      verboseLevel: "full",
    +      builtinToolNames: new Set(["web_search"]),
    +    });
    +
    +    emitToolRun({
    +      emit,
    +      toolName: "Web_Search",
    +      toolCallId: "tool-1",
    +      isError: false,
    +      result: {
    +        content: [{ type: "text", text: "Fetched page\nMEDIA:/tmp/secret.png" }],
    +      },
    +    });
    +
    +    await vi.waitFor(() => {
    +      expect(onToolResult).toHaveBeenCalled();
    +    });
    +    const payload = onToolResult.mock.calls.at(-1)?.[0] as
    +      | { text?: string; mediaUrls?: string[] }
    +      | undefined;
    +    expect(payload?.text ?? "").toContain("Fetched page");
    +    expect(payload?.mediaUrls).toBeUndefined();
    +  });
    +
       it("attaches media from internal completion events even when assistant omits MEDIA lines", async () => {
         const onBlockReply = vi.fn();
         const { emit } = createSubscribedHarness({
    
  • src/agents/pi-embedded-subscribe.tools.media.test.ts+61 0 modified
    @@ -277,6 +277,67 @@ describe("extractToolResultMediaPaths", () => {
         expect(isToolResultMediaTrusted("music_generate")).toBe(true);
       });
     
    +  it("blocks trusted-media aliases that are not exact registered built-ins", () => {
    +    expect(filterToolResultMediaUrls("bash", ["/etc/passwd"], undefined, new Set(["exec"]))).toEqual(
    +      [],
    +    );
    +    expect(
    +      filterToolResultMediaUrls(
    +        "Web_Search",
    +        ["/etc/passwd"],
    +        undefined,
    +        new Set(["web_search"]),
    +      ),
    +    ).toEqual([]);
    +  });
    +
    +  it("keeps local media for exact registered built-in tool names", () => {
    +    expect(
    +      filterToolResultMediaUrls(
    +        "web_search",
    +        ["/tmp/screenshot.png"],
    +        undefined,
    +        new Set(["web_search"]),
    +      ),
    +    ).toEqual(["/tmp/screenshot.png"]);
    +  });
    +
    +  it("keeps local media for bundled plugin tool names registered in this run", () => {
    +    // music_generate is a bundled-plugin trusted tool; when the runner
    +    // registers it for this run, its raw name must be allowed through the
    +    // exact-name gate just like a core built-in.
    +    expect(
    +      filterToolResultMediaUrls(
    +        "music_generate",
    +        ["/tmp/song.mp3"],
    +        undefined,
    +        new Set(["music_generate"]),
    +      ),
    +    ).toEqual(["/tmp/song.mp3"]);
    +  });
    +
    +  it("strips local media for plugin-name collisions when the plugin is not registered", () => {
    +    expect(
    +      filterToolResultMediaUrls(
    +        "Music_Generate",
    +        ["/etc/passwd"],
    +        undefined,
    +        new Set(["music_generate"]),
    +      ),
    +    ).toEqual([]);
    +  });
    +
    +  it("still allows remote media for colliding aliases", () => {
    +    expect(
    +      filterToolResultMediaUrls(
    +        "bash",
    +        ["/etc/passwd", "https://example.com/file.png"],
    +        undefined,
    +        new Set(["exec"]),
    +      ),
    +    ).toEqual(["https://example.com/file.png"]);
    +  });
    +
       it("does not trust local MEDIA paths for MCP-provenance results", () => {
         expect(
           filterToolResultMediaUrls("browser", ["/tmp/screenshot.png"], {
    
  • src/agents/pi-embedded-subscribe.tools.ts+13 0 modified
    @@ -211,11 +211,24 @@ export function filterToolResultMediaUrls(
       toolName: string | undefined,
       mediaUrls: string[],
       result?: unknown,
    +  builtinToolNames?: ReadonlySet<string>,
     ): string[] {
       if (mediaUrls.length === 0) {
         return mediaUrls;
       }
       if (isToolResultMediaTrusted(toolName, result)) {
    +    // When the current run provides its exact registered tool names (core
    +    // built-ins plus bundled/trusted plugin tools), require the raw emitted
    +    // tool name to match one of them before allowing local MEDIA: paths.
    +    // This blocks normalized aliases and case-variant collisions such as
    +    // "Bash" -> "bash" or "Web_Search" -> "web_search" from inheriting a
    +    // registered tool's media trust.
    +    if (builtinToolNames !== undefined) {
    +      const registeredName = toolName?.trim();
    +      if (!registeredName || !builtinToolNames.has(registeredName)) {
    +        return mediaUrls.filter((url) => HTTP_URL_RE.test(url.trim()));
    +      }
    +    }
         return mediaUrls;
       }
       return mediaUrls.filter((url) => HTTP_URL_RE.test(url.trim()));
    
  • src/agents/pi-embedded-subscribe.ts+7 1 modified
    @@ -415,7 +415,12 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
           return;
         }
         const { text: cleanedText, mediaUrls } = parseReplyDirectives(message);
    -    const filteredMediaUrls = filterToolResultMediaUrls(toolName, mediaUrls ?? [], result);
    +    const filteredMediaUrls = filterToolResultMediaUrls(
    +      toolName,
    +      mediaUrls ?? [],
    +      result,
    +      params.builtinToolNames,
    +    );
         if (!cleanedText && filteredMediaUrls.length === 0) {
           return;
         }
    @@ -724,6 +729,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
         blockChunking,
         blockChunker,
         hookRunner: params.hookRunner,
    +    builtinToolNames: params.builtinToolNames,
         noteLastAssistant,
         shouldEmitToolResult,
         shouldEmitToolOutput,
    
  • src/agents/pi-embedded-subscribe.types.ts+6 0 modified
    @@ -39,5 +39,11 @@ export type SubscribeEmbeddedPiSessionParams = {
       sessionId?: string;
       /** Agent identity for hook context — resolved from session config in attempt.ts. */
       agentId?: string;
    +  /**
    +   * Exact raw names of non-plugin OpenClaw tools registered for this run.
    +   * When provided, MEDIA: passthrough requires an exact match instead of only
    +   * a normalized-name collision with a trusted built-in.
    +   */
    +  builtinToolNames?: ReadonlySet<string>;
       internalEvents?: AgentInternalEvent[];
     };
    
  • src/agents/pi-tool-definition-adapter.test.ts+34 1 modified
    @@ -2,7 +2,14 @@ import type { AgentTool } from "@mariozechner/pi-agent-core";
     import { Type } from "@sinclair/typebox";
     import { describe, expect, it } from "vitest";
     import type { ClientToolDefinition } from "./pi-embedded-runner/run/params.js";
    -import { toClientToolDefinitions, toToolDefinitions } from "./pi-tool-definition-adapter.js";
    +import {
    +  CLIENT_TOOL_NAME_CONFLICT_PREFIX,
    +  createClientToolNameConflictError,
    +  findClientToolNameConflicts,
    +  isClientToolNameConflictError,
    +  toClientToolDefinitions,
    +  toToolDefinitions,
    +} from "./pi-tool-definition-adapter.js";
     
     type ToolExecute = ReturnType<typeof toToolDefinitions>[number]["execute"];
     const extensionContext = {} as Parameters<ToolExecute>[4];
    @@ -177,3 +184,29 @@ describe("toClientToolDefinitions – param coercion", () => {
         expect(calledWith).toEqual({ action: "search", params: { q: "test", page: 1 } });
       });
     });
    +
    +describe("client tool name conflict checks", () => {
    +  it("detects collisions with existing built-in names after normalization", () => {
    +    expect(
    +      findClientToolNameConflicts({
    +        tools: [makeClientTool("Web_Search"), makeClientTool("exec")],
    +        existingToolNames: ["web_search", "read"],
    +      }),
    +    ).toEqual(["Web_Search"]);
    +  });
    +
    +  it("detects duplicate client tool names after normalization", () => {
    +    expect(
    +      findClientToolNameConflicts({
    +        tools: [makeClientTool("Weather"), makeClientTool("weather")],
    +      }),
    +    ).toEqual(["Weather", "weather"]);
    +  });
    +
    +  it("wraps conflict errors with a stable prefix", () => {
    +    const err = createClientToolNameConflictError(["exec", "Web_Search"]);
    +    expect(err.message).toBe(`${CLIENT_TOOL_NAME_CONFLICT_PREFIX} exec, Web_Search`);
    +    expect(isClientToolNameConflictError(err)).toBe(true);
    +    expect(isClientToolNameConflictError(new Error("other failure"))).toBe(false);
    +  });
    +});
    
  • src/agents/pi-tool-definition-adapter.ts+44 0 modified
    @@ -169,6 +169,50 @@ function splitToolExecuteArgs(args: ToolExecuteArgsAny): {
       };
     }
     
    +export const CLIENT_TOOL_NAME_CONFLICT_PREFIX = "client tool name conflict:";
    +
    +export function findClientToolNameConflicts(params: {
    +  tools: ClientToolDefinition[];
    +  existingToolNames?: Iterable<string>;
    +}): string[] {
    +  const existingNormalized = new Set<string>();
    +  for (const name of params.existingToolNames ?? []) {
    +    const trimmed = name.trim();
    +    if (trimmed) {
    +      existingNormalized.add(normalizeToolName(trimmed));
    +    }
    +  }
    +
    +  const conflicts = new Set<string>();
    +  const seenClientNames = new Map<string, string>();
    +  for (const tool of params.tools) {
    +    const rawName = (tool.function?.name ?? "").trim();
    +    if (!rawName) {
    +      continue;
    +    }
    +    const normalizedName = normalizeToolName(rawName);
    +    if (existingNormalized.has(normalizedName)) {
    +      conflicts.add(rawName);
    +    }
    +    const priorClientName = seenClientNames.get(normalizedName);
    +    if (priorClientName) {
    +      conflicts.add(priorClientName);
    +      conflicts.add(rawName);
    +      continue;
    +    }
    +    seenClientNames.set(normalizedName, rawName);
    +  }
    +  return Array.from(conflicts);
    +}
    +
    +export function createClientToolNameConflictError(conflicts: string[]): Error {
    +  return new Error(`${CLIENT_TOOL_NAME_CONFLICT_PREFIX} ${conflicts.join(", ")}`);
    +}
    +
    +export function isClientToolNameConflictError(err: unknown): err is Error {
    +  return err instanceof Error && err.message.startsWith(CLIENT_TOOL_NAME_CONFLICT_PREFIX);
    +}
    +
     export function toToolDefinitions(tools: AnyAgentTool[]): ToolDefinition[] {
       return tools.map((tool) => {
         const name = tool.name || "tool";
    
  • src/gateway/openresponses-http.test.ts+16 0 modified
    @@ -2,6 +2,7 @@ import fs from "node:fs/promises";
     import http from "node:http";
     import path from "node:path";
     import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
    +import { createClientToolNameConflictError } from "../agents/pi-tool-definition-adapter.js";
     import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js";
     import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js";
     import { emitAgentEvent } from "../infra/agent-events.js";
    @@ -323,6 +324,21 @@ describe("OpenResponses HTTP API (e2e)", () => {
           expect(agentCommand).toHaveBeenCalledTimes(0);
           await ensureResponseConsumed(resInvalidOverride);
     
    +      agentCommand.mockClear();
    +      agentCommand.mockRejectedValueOnce(createClientToolNameConflictError(["exec"]));
    +      const resToolConflict = await postResponses(port, {
    +        model: "openclaw",
    +        input: "hi",
    +        tools: WEATHER_TOOL,
    +      });
    +      expect(resToolConflict.status).toBe(400);
    +      const toolConflictJson = (await resToolConflict.json()) as {
    +        error?: { code?: string; message?: string };
    +      };
    +      expect(toolConflictJson.error?.code).toBe("invalid_request_error");
    +      expect(toolConflictJson.error?.message).toBe("invalid tool configuration");
    +      await ensureResponseConsumed(resToolConflict);
    +
           mockAgentOnce([{ text: "hello" }]);
           const resUser = await postResponses(port, {
             user: "alice",
    
  • src/gateway/openresponses-http.ts+30 0 modified
    @@ -10,6 +10,7 @@ import { createHash, randomUUID } from "node:crypto";
     import type { IncomingMessage, ServerResponse } from "node:http";
     import type { ImageContent } from "../agents/command/types.js";
     import type { ClientToolDefinition } from "../agents/pi-embedded-runner/run/params.js";
    +import { isClientToolNameConflictError } from "../agents/pi-tool-definition-adapter.js";
     import { createDefaultDeps } from "../cli/deps.js";
     import type { CliDeps } from "../cli/deps.types.js";
     import { agentCommandFromIngress } from "../commands/agent.js";
    @@ -779,6 +780,17 @@ export async function handleOpenResponsesHttpRequest(
             return true;
           }
           logWarn(`openresponses: non-stream response failed: ${String(err)}`);
    +      if (isClientToolNameConflictError(err)) {
    +        const response = createResponseResource({
    +          id: responseId,
    +          model,
    +          status: "failed",
    +          output: [],
    +          error: { code: "invalid_request_error", message: "invalid tool configuration" },
    +        });
    +        sendJson(res, 400, response);
    +        return true;
    +      }
           const response = createResponseResource({
             id: responseId,
             model,
    @@ -1101,6 +1113,24 @@ export async function handleOpenResponsesHttpRequest(
           logWarn(`openresponses: streaming response failed: ${String(err)}`);
     
           finalUsage = finalUsage ?? createEmptyUsage();
    +      if (isClientToolNameConflictError(err)) {
    +        const errorResponse = createResponseResource({
    +          id: responseId,
    +          model,
    +          status: "failed",
    +          output: [],
    +          error: { code: "invalid_request_error", message: "invalid tool configuration" },
    +          usage: finalUsage,
    +        });
    +
    +        writeSseEvent(res, { type: "response.failed", response: errorResponse });
    +        emitAgentEvent({
    +          runId: responseId,
    +          stream: "lifecycle",
    +          data: { phase: "error" },
    +        });
    +        return;
    +      }
           const errorResponse = createResponseResource({
             id: responseId,
             model,
    
1470de5d3e09

fix(webchat): reject remote-host file:// URLs in media embedding path [AI-assisted] (#67293)

https://github.com/openclaw/openclawPavan Kumar GondhiApr 15, 2026via ghsa
6 files changed · +138 8
  • CHANGELOG.md+2 0 modified
    @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai
     
     ### Fixes
     
    +- fix(webchat): reject remote-host file:// URLs in media embedding path [AI-assisted]. (#67293) Thanks @pgondhi987.
     - fix(gateway): enforce localRoots containment on webchat audio embedding path [AI-assisted]. (#67298) Thanks @pgondhi987.
     - fix(matrix): block DM pairing-store entries from authorizing room control commands [AI-assisted]. (#67294) Thanks @pgondhi987.
     - Docker/build: verify `@matrix-org/matrix-sdk-crypto-nodejs` native bindings with `find` under `node_modules` instead of a hardcoded `.pnpm/...` path so pnpm v10+ virtual-store layouts no longer fail the image build. (#67143) thanks @ly85206559.
    @@ -31,6 +32,7 @@ Docs: https://docs.openclaw.ai
     - Docs/showcase: add a scannable hero, complete section jump links, and a responsive video grid for community examples. (#48493) Thanks @jchopard69.
     
     ### Fixes
    +
     - Security/approvals: redact secrets in exec approval prompts so inline approval review can no longer leak credential material in rendered prompt content. (#61077, #64790)
     - CLI/configure: re-read the persisted config hash after writes so config updates stop failing with stale-hash races. (#64188, #66528)
     - CLI/update: prune stale packaged `dist` chunks after npm upgrades and keep downgrade/verify inventory checks compat-safe so global upgrades stop failing on stale chunk imports. (#66959) Thanks @obviyus.
    
  • src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts+11 0 modified
    @@ -107,6 +107,9 @@ const ttsMocks = vi.hoisted(() => ({
       normalizeTtsAutoMode: vi.fn((value: unknown) => (typeof value === "string" ? value : undefined)),
       resolveTtsConfig: vi.fn((_cfg: OpenClawConfig) => ({ mode: "final" })),
     }));
    +const replyMediaPathMocks = vi.hoisted(() => ({
    +  createReplyMediaPathNormalizer: vi.fn(() => async (payload: ReplyPayload) => payload),
    +}));
     const threadInfoMocks = vi.hoisted(() => ({
       parseSessionThreadInfo: vi.fn<
         (sessionKey: string | undefined) => {
    @@ -127,6 +130,7 @@ export {
       pluginConversationBindingMocks,
       sessionBindingMocks,
       sessionStoreMocks,
    +  replyMediaPathMocks,
       threadInfoMocks,
       ttsMocks,
     };
    @@ -263,6 +267,10 @@ vi.mock("../../tts/tts.js", () => ({
     vi.mock("../../tts/tts.runtime.js", () => ({
       maybeApplyTtsToPayload: (params: unknown) => ttsMocks.maybeApplyTtsToPayload(params),
     }));
    +vi.mock("./reply-media-paths.runtime.js", () => ({
    +  createReplyMediaPathNormalizer: (params: unknown) =>
    +    replyMediaPathMocks.createReplyMediaPathNormalizer(params),
    +}));
     vi.mock("../../tts/status-config.js", () => ({
       resolveStatusTtsSnapshot: () => ({
         autoMode: "always",
    @@ -311,6 +319,9 @@ export function resetPluginTtsAndThreadMocks() {
         .mockReset()
         .mockImplementation((value: unknown) => (typeof value === "string" ? value : undefined));
       ttsMocks.resolveTtsConfig.mockReset().mockReturnValue({ mode: "final" });
    +  replyMediaPathMocks.createReplyMediaPathNormalizer
    +    .mockReset()
    +    .mockReturnValue(async (payload: ReplyPayload) => payload);
       threadInfoMocks.parseSessionThreadInfo
         .mockReset()
         .mockImplementation(parseGenericThreadSessionInfo);
    
  • src/auto-reply/reply/dispatch-from-config.test.ts+58 0 modified
    @@ -142,6 +142,9 @@ const ttsMocks = vi.hoisted(() => {
         resolveTtsConfig: vi.fn((_cfg: OpenClawConfig) => ({ mode: "final" })),
       };
     });
    +const replyMediaPathMocks = vi.hoisted(() => ({
    +  createReplyMediaPathNormalizer: vi.fn(() => async (payload: ReplyPayload) => payload),
    +}));
     const threadInfoMocks = vi.hoisted(() => ({
       parseSessionThreadInfo: vi.fn<
         (sessionKey: string | undefined) => {
    @@ -326,6 +329,10 @@ vi.mock("../../tts/tts.js", () => ({
     vi.mock("../../tts/tts.runtime.js", () => ({
       maybeApplyTtsToPayload: (params: unknown) => ttsMocks.maybeApplyTtsToPayload(params),
     }));
    +vi.mock("./reply-media-paths.runtime.js", () => ({
    +  createReplyMediaPathNormalizer: (params: unknown) =>
    +    replyMediaPathMocks.createReplyMediaPathNormalizer(params),
    +}));
     vi.mock("../../tts/status-config.js", () => ({
       resolveStatusTtsSnapshot: () => ({
         autoMode: "always",
    @@ -642,6 +649,10 @@ describe("dispatchReplyFromConfig", () => {
         ttsMocks.resolveTtsConfig.mockReturnValue({
           mode: "final",
         });
    +    replyMediaPathMocks.createReplyMediaPathNormalizer.mockReset();
    +    replyMediaPathMocks.createReplyMediaPathNormalizer.mockReturnValue(
    +      async (payload: ReplyPayload) => payload,
    +    );
       });
       it("does not route when Provider matches OriginatingChannel (even if Surface is missing)", async () => {
         setNoAbort();
    @@ -968,6 +979,12 @@ describe("dispatchReplyFromConfig", () => {
     
         await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
     
    +    expect(replyMediaPathMocks.createReplyMediaPathNormalizer).toHaveBeenCalledWith(
    +      expect.objectContaining({
    +        cfg,
    +        messageProvider: "telegram",
    +      }),
    +    );
         expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
         expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
         expect(mocks.routeReply).toHaveBeenCalledTimes(1);
    @@ -1032,6 +1049,47 @@ describe("dispatchReplyFromConfig", () => {
         expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
       });
     
    +  it("normalizes tool-result media before delivery and drops blocked file URLs", async () => {
    +    setNoAbort();
    +    replyMediaPathMocks.createReplyMediaPathNormalizer.mockReturnValue(
    +      async (payload: ReplyPayload) => ({
    +        ...payload,
    +        mediaUrl: undefined,
    +        mediaUrls: undefined,
    +      }),
    +    );
    +    const cfg = emptyConfig;
    +    const dispatcher = createDispatcher();
    +    const ctx = buildTestCtx({
    +      Provider: "webchat",
    +      Surface: "webchat",
    +      ChatType: "group",
    +    });
    +
    +    const replyResolver = async (
    +      _ctx: MsgContext,
    +      opts?: GetReplyOptions,
    +      _cfg?: OpenClawConfig,
    +    ) => {
    +      await opts?.onToolResult?.({
    +        text: "NO_REPLY",
    +        mediaUrls: ["file://attacker/share/probe.mp3"],
    +      });
    +      return { text: "done" } satisfies ReplyPayload;
    +    };
    +
    +    await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
    +
    +    expect(replyMediaPathMocks.createReplyMediaPathNormalizer).toHaveBeenCalledWith(
    +      expect.objectContaining({
    +        cfg,
    +        messageProvider: "webchat",
    +      }),
    +    );
    +    expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
    +    expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "done" });
    +  });
    +
       it("delivers tool summaries in forum topic sessions (group + IsForum)", async () => {
         setNoAbort();
         const cfg = emptyConfig;
    
  • src/auto-reply/reply/dispatch-from-config.ts+41 6 modified
    @@ -1,6 +1,10 @@
     import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
     import { isParentOwnedBackgroundAcpSession } from "../../acp/session-interaction-mode.js";
    -import { resolveAgentConfig, resolveSessionAgentId } from "../../agents/agent-scope.js";
    +import {
    +  resolveAgentConfig,
    +  resolveAgentWorkspaceDir,
    +  resolveSessionAgentId,
    +} from "../../agents/agent-scope.js";
     import {
       resolveConversationBindingRecord,
       touchConversationBindingRecord,
    @@ -73,6 +77,8 @@ let getReplyFromConfigRuntimePromise: Promise<
     > | null = null;
     let abortRuntimePromise: Promise<typeof import("./abort.runtime.js")> | null = null;
     let ttsRuntimePromise: Promise<typeof import("../../tts/tts.runtime.js")> | null = null;
    +let replyMediaPathsRuntimePromise: Promise<typeof import("./reply-media-paths.runtime.js")> | null =
    +  null;
     
     function loadRouteReplyRuntime() {
       routeReplyRuntimePromise ??= import("./route-reply.runtime.js");
    @@ -94,6 +100,11 @@ function loadTtsRuntime() {
       return ttsRuntimePromise;
     }
     
    +function loadReplyMediaPathsRuntime() {
    +  replyMediaPathsRuntimePromise ??= import("./reply-media-paths.runtime.js");
    +  return replyMediaPathsRuntimePromise;
    +}
    +
     async function maybeApplyTtsToReplyPayload(
       params: Parameters<Awaited<ReturnType<typeof loadTtsRuntime>>["maybeApplyTtsToPayload"]>[0],
     ) {
    @@ -336,6 +347,27 @@ export async function dispatchReplyFromConfig(
         });
       const originatingTo = ctx.OriginatingTo;
       const ttsChannel = shouldRouteToOriginating ? originatingChannel : currentSurface;
    +  const { createReplyMediaPathNormalizer } = await loadReplyMediaPathsRuntime();
    +  const normalizeReplyMediaPaths = createReplyMediaPathNormalizer({
    +    cfg,
    +    sessionKey: acpDispatchSessionKey,
    +    workspaceDir: resolveAgentWorkspaceDir(cfg, sessionAgentId),
    +    messageProvider: ttsChannel,
    +    accountId: ctx.AccountId,
    +    groupId,
    +    groupChannel: ctx.GroupChannel,
    +    groupSpace: ctx.GroupSpace,
    +    requesterSenderId: ctx.SenderId,
    +    requesterSenderName: ctx.SenderName,
    +    requesterSenderUsername: ctx.SenderUsername,
    +    requesterSenderE164: ctx.SenderE164,
    +  });
    +  const normalizeReplyMediaPayload = async (payload: ReplyPayload): Promise<ReplyPayload> => {
    +    if (!resolveSendableOutboundReplyParts(payload).hasMedia) {
    +      return payload;
    +    }
    +    return await normalizeReplyMediaPaths(payload);
    +  };
     
       const routeReplyToOriginating = async (
         payload: ReplyPayload,
    @@ -610,7 +642,8 @@ export async function dispatchReplyFromConfig(
             inboundAudio,
             ttsAuto: sessionTtsAuto,
           });
    -      const result = await routeReplyToOriginating(ttsPayload);
    +      const normalizedPayload = await normalizeReplyMediaPayload(ttsPayload);
    +      const result = await routeReplyToOriginating(normalizedPayload);
           if (result) {
             if (!result.ok) {
               logVerbose(
    @@ -623,7 +656,7 @@ export async function dispatchReplyFromConfig(
             };
           }
           return {
    -        queuedFinal: dispatcher.sendFinalReply(ttsPayload),
    +        queuedFinal: dispatcher.sendFinalReply(normalizedPayload),
             routedFinalCount: 0,
           };
         };
    @@ -868,7 +901,8 @@ export async function dispatchReplyFromConfig(
                   inboundAudio,
                   ttsAuto: sessionTtsAuto,
                 });
    -            const deliveryPayload = resolveToolDeliveryPayload(ttsPayload);
    +            const normalizedPayload = await normalizeReplyMediaPayload(ttsPayload);
    +            const deliveryPayload = resolveToolDeliveryPayload(normalizedPayload);
                 if (!deliveryPayload) {
                   return;
                 }
    @@ -947,10 +981,11 @@ export async function dispatchReplyFromConfig(
                   inboundAudio,
                   ttsAuto: sessionTtsAuto,
                 });
    +            const normalizedPayload = await normalizeReplyMediaPayload(ttsPayload);
                 if (shouldRouteToOriginating) {
    -              await sendPayloadAsync(ttsPayload, context?.abortSignal, false);
    +              await sendPayloadAsync(normalizedPayload, context?.abortSignal, false);
                 } else {
    -              dispatcher.sendBlockReply(ttsPayload);
    +              dispatcher.sendBlockReply(normalizedPayload);
                 }
               };
               return run();
    
  • src/gateway/server-methods/chat-webchat-media.test.ts+19 0 modified
    @@ -86,6 +86,25 @@ describe("buildWebchatAudioContentBlocksFromReplyPayloads", () => {
         expect((blocks[0] as { type?: string }).type).toBe("audio");
       });
     
    +  it("drops tool-result file:// URLs with remote hosts before touching the filesystem", async () => {
    +    const statSpy = vi.spyOn(fs, "statSync");
    +    const readSpy = vi.spyOn(fs, "readFileSync");
    +
    +    const blocks = await buildWebchatAudioContentBlocksFromReplyPayloads([
    +      {
    +        text: "MEDIA:file://attacker/share/probe.mp3",
    +        mediaUrl: "file://attacker/share/probe.mp3",
    +      },
    +    ]);
    +
    +    expect(blocks).toHaveLength(0);
    +    expect(statSpy).not.toHaveBeenCalled();
    +    expect(readSpy).not.toHaveBeenCalled();
    +
    +    statSpy.mockRestore();
    +    readSpy.mockRestore();
    +  });
    +
       it("rejects a local audio file outside configured localRoots", async () => {
         tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-webchat-audio-"));
         const allowedRoot = path.join(tmpDir, "allowed");
    
  • src/gateway/server-methods/chat-webchat-media.ts+7 2 modified
    @@ -1,8 +1,8 @@
     import fs from "node:fs";
     import path from "node:path";
    -import { fileURLToPath } from "node:url";
     import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
     import { assertLocalMediaAllowed, LocalMediaAccessError } from "../../media/local-media-access.js";
    +import { assertNoWindowsNetworkPath, safeFileURLToPath } from "../../infra/local-file-access.js";
     import { isAudioFileName } from "../../media/mime.js";
     import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js";
     import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
    @@ -40,7 +40,7 @@ function resolveLocalMediaPathForEmbedding(raw: string): string | null {
       }
       if (trimmed.startsWith("file:")) {
         try {
    -      const p = fileURLToPath(trimmed);
    +      const p = safeFileURLToPath(trimmed);
           if (!path.isAbsolute(p)) {
             return null;
           }
    @@ -52,6 +52,11 @@ function resolveLocalMediaPathForEmbedding(raw: string): string | null {
       if (!path.isAbsolute(trimmed)) {
         return null;
       }
    +  try {
    +    assertNoWindowsNetworkPath(trimmed, "Local media path");
    +  } catch {
    +    return null;
    +  }
       return trimmed;
     }
     
    
6e58f1f9f54b

fix(gateway): enforce localRoots containment on webchat audio embedding path [AI-assisted] (#67298)

https://github.com/openclaw/openclawPavan Kumar GondhiApr 15, 2026via ghsa
4 files changed · +105 26
  • CHANGELOG.md+1 0 modified
    @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai
     
     ### Fixes
     
    +- fix(gateway): enforce localRoots containment on webchat audio embedding path [AI-assisted]. (#67298) Thanks @pgondhi987.
     - fix(matrix): block DM pairing-store entries from authorizing room control commands [AI-assisted]. (#67294) Thanks @pgondhi987.
     - Docker/build: verify `@matrix-org/matrix-sdk-crypto-nodejs` native bindings with `find` under `node_modules` instead of a hardcoded `.pnpm/...` path so pnpm v10+ virtual-store layouts no longer fail the image build. (#67143) thanks @ly85206559.
     - Matrix/E2EE: keep startup bootstrap conservative for passwordless token-auth bots, still attempt the guarded repair pass without requiring `channels.matrix.password`, and document the remaining password-UIA limitation. (#66228) Thanks @SARAMALI15792.
    
  • src/gateway/server-methods/chat.ts+21 6 modified
    @@ -13,6 +13,7 @@ import type { MsgContext } from "../../auto-reply/templating.js";
     import { extractCanvasFromText } from "../../chat/canvas-render.js";
     import { resolveSessionFilePath } from "../../config/sessions.js";
     import { jsonUtf8Bytes } from "../../infra/json-utf8-bytes.js";
    +import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
     import { isAudioFileName } from "../../media/mime.js";
     import type { PromptImageOrderEntry } from "../../media/prompt-image-order.js";
     import { type SavedMedia, saveMediaBuffer } from "../../media/store.js";
    @@ -121,10 +122,19 @@ function isMediaBearingPayload(payload: ReplyPayload): boolean {
       return false;
     }
     
    -function buildWebchatAudioOnlyAssistantMessage(
    +async function buildWebchatAudioOnlyAssistantMessage(
       payloads: ReplyPayload[],
    -): { content: Array<Record<string, unknown>>; transcriptText: string } | null {
    -  const audioBlocks = buildWebchatAudioContentBlocksFromReplyPayloads(payloads);
    +  options?: {
    +    localRoots?: readonly string[];
    +    onLocalAudioAccessDenied?: (message: string) => void;
    +  },
    +): Promise<{ content: Array<Record<string, unknown>>; transcriptText: string } | null> {
    +  const audioBlocks = await buildWebchatAudioContentBlocksFromReplyPayloads(payloads, {
    +    localRoots: options?.localRoots,
    +    onLocalAudioAccessDenied: (err) => {
    +      options?.onLocalAudioAccessDenied?.(formatForLog(err));
    +    },
    +  });
       if (audioBlocks.length === 0) {
         return null;
       }
    @@ -2075,11 +2085,16 @@ export const chatHandlers: GatewayRequestHandlers = {
               savedImages: await persistedImagesPromise,
             });
           };
    -      const appendWebchatAgentAudioTranscriptIfNeeded = (payload: ReplyPayload) => {
    +      const appendWebchatAgentAudioTranscriptIfNeeded = async (payload: ReplyPayload) => {
             if (!agentRunStarted || appendedWebchatAgentAudio || !isMediaBearingPayload(payload)) {
               return;
             }
    -        const audioMessage = buildWebchatAudioOnlyAssistantMessage([payload]);
    +        const audioMessage = await buildWebchatAudioOnlyAssistantMessage([payload], {
    +          localRoots: getAgentScopedMediaLocalRoots(cfg, agentId),
    +          onLocalAudioAccessDenied: (message) => {
    +            context.logGateway.warn(`webchat audio embedding denied local path: ${message}`);
    +          },
    +        });
             if (!audioMessage) {
               return;
             }
    @@ -2113,7 +2128,7 @@ export const chatHandlers: GatewayRequestHandlers = {
                 case "block":
                 case "final":
                   deliveredReplies.push({ payload, kind: info.kind });
    -              appendWebchatAgentAudioTranscriptIfNeeded(payload);
    +              await appendWebchatAgentAudioTranscriptIfNeeded(payload);
                   break;
                 case "tool":
                   // Tool results that carry audio (e.g. the TTS tool) must be promoted
    
  • src/gateway/server-methods/chat-webchat-media.test.ts+64 15 modified
    @@ -3,6 +3,7 @@ import os from "node:os";
     import path from "node:path";
     import { pathToFileURL } from "node:url";
     import { afterEach, describe, expect, it, vi } from "vitest";
    +import { getDefaultLocalRoots } from "../../media/local-media-access.js";
     import { buildWebchatAudioContentBlocksFromReplyPayloads } from "./chat-webchat-media.js";
     
     describe("buildWebchatAudioContentBlocksFromReplyPayloads", () => {
    @@ -15,12 +16,15 @@ describe("buildWebchatAudioContentBlocksFromReplyPayloads", () => {
         tmpDir = undefined;
       });
     
    -  it("embeds a local audio file as a base64 gateway chat block", () => {
    +  it("embeds a local audio file as a base64 gateway chat block when it is under localRoots", async () => {
         tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-webchat-audio-"));
         const audioPath = path.join(tmpDir, "clip.mp3");
         fs.writeFileSync(audioPath, Buffer.from([0xff, 0xfb, 0x90, 0x00]));
     
    -    const blocks = buildWebchatAudioContentBlocksFromReplyPayloads([{ mediaUrl: audioPath }]);
    +    const blocks = await buildWebchatAudioContentBlocksFromReplyPayloads(
    +      [{ mediaUrl: audioPath }],
    +      { localRoots: [tmpDir] },
    +    );
     
         expect(blocks).toHaveLength(1);
         const block = blocks[0] as {
    @@ -36,48 +40,90 @@ describe("buildWebchatAudioContentBlocksFromReplyPayloads", () => {
         );
       });
     
    -  it("skips remote URLs", () => {
    -    const blocks = buildWebchatAudioContentBlocksFromReplyPayloads([
    +  it("skips remote URLs", async () => {
    +    const blocks = await buildWebchatAudioContentBlocksFromReplyPayloads([
           { mediaUrl: "https://example.com/a.mp3" },
         ]);
         expect(blocks).toHaveLength(0);
       });
     
    -  it("skips non-audio local files", () => {
    +  it("skips non-audio local files", async () => {
         tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-webchat-audio-"));
         const imagePath = path.join(tmpDir, "clip.png");
         fs.writeFileSync(imagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
     
    -    const blocks = buildWebchatAudioContentBlocksFromReplyPayloads([{ mediaUrl: imagePath }]);
    +    const blocks = await buildWebchatAudioContentBlocksFromReplyPayloads(
    +      [{ mediaUrl: imagePath }],
    +      { localRoots: [tmpDir] },
    +    );
     
         expect(blocks).toHaveLength(0);
       });
     
    -  it("dedupes repeated paths", () => {
    +  it("dedupes repeated paths", async () => {
         tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-webchat-audio-"));
         const audioPath = path.join(tmpDir, "clip.mp3");
         fs.writeFileSync(audioPath, Buffer.from([0x00]));
     
    -    const blocks = buildWebchatAudioContentBlocksFromReplyPayloads([
    -      { mediaUrl: audioPath },
    -      { mediaUrl: audioPath },
    -    ]);
    +    const blocks = await buildWebchatAudioContentBlocksFromReplyPayloads(
    +      [{ mediaUrl: audioPath }, { mediaUrl: audioPath }],
    +      { localRoots: [tmpDir] },
    +    );
         expect(blocks).toHaveLength(1);
       });
     
    -  it("embeds file:// URLs pointing at a local file", () => {
    +  it("embeds file:// URLs pointing at a local file within localRoots", async () => {
         tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-webchat-audio-"));
         const audioPath = path.join(tmpDir, "clip.mp3");
         fs.writeFileSync(audioPath, Buffer.from([0x01]));
     
         const fileUrl = pathToFileURL(audioPath).href;
    -    const blocks = buildWebchatAudioContentBlocksFromReplyPayloads([{ mediaUrl: fileUrl }]);
    +    const blocks = await buildWebchatAudioContentBlocksFromReplyPayloads([{ mediaUrl: fileUrl }], {
    +      localRoots: [tmpDir],
    +    });
    +
    +    expect(blocks).toHaveLength(1);
    +    expect((blocks[0] as { type?: string }).type).toBe("audio");
    +  });
    +
    +  it("rejects a local audio file outside configured localRoots", async () => {
    +    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-webchat-audio-"));
    +    const allowedRoot = path.join(tmpDir, "allowed");
    +    const outsideRoot = path.join(tmpDir, "outside");
    +    fs.mkdirSync(allowedRoot, { recursive: true });
    +    fs.mkdirSync(outsideRoot, { recursive: true });
    +    const audioPath = path.join(outsideRoot, "clip.mp3");
    +    fs.writeFileSync(audioPath, Buffer.from([0x03]));
    +
    +    const onLocalAudioAccessDenied = vi.fn();
    +    const blocks = await buildWebchatAudioContentBlocksFromReplyPayloads(
    +      [{ mediaUrl: audioPath }],
    +      {
    +        localRoots: [allowedRoot],
    +        onLocalAudioAccessDenied,
    +      },
    +    );
    +
    +    expect(blocks).toHaveLength(0);
    +    expect(onLocalAudioAccessDenied).toHaveBeenCalledOnce();
    +  });
    +
    +  it("falls back to default localRoots when explicit roots are omitted", async () => {
    +    const [defaultRoot] = getDefaultLocalRoots();
    +    expect(defaultRoot).toBeTruthy();
    +
    +    fs.mkdirSync(defaultRoot, { recursive: true });
    +    tmpDir = fs.mkdtempSync(path.join(defaultRoot, "openclaw-webchat-audio-default-"));
    +    const audioPath = path.join(tmpDir, "clip.mp3");
    +    fs.writeFileSync(audioPath, Buffer.from([0x04]));
    +
    +    const blocks = await buildWebchatAudioContentBlocksFromReplyPayloads([{ mediaUrl: audioPath }]);
     
         expect(blocks).toHaveLength(1);
         expect((blocks[0] as { type?: string }).type).toBe("audio");
       });
     
    -  it("does not read file contents when stat reports size over the cap", () => {
    +  it("does not read file contents when stat reports size over the cap", async () => {
         tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-webchat-audio-"));
         const audioPath = path.join(tmpDir, "huge.mp3");
         fs.writeFileSync(audioPath, Buffer.from([0x02]));
    @@ -91,7 +137,10 @@ describe("buildWebchatAudioContentBlocksFromReplyPayloads", () => {
         });
         const readSpy = vi.spyOn(fs, "readFileSync");
     
    -    const blocks = buildWebchatAudioContentBlocksFromReplyPayloads([{ mediaUrl: audioPath }]);
    +    const blocks = await buildWebchatAudioContentBlocksFromReplyPayloads(
    +      [{ mediaUrl: audioPath }],
    +      { localRoots: [tmpDir] },
    +    );
     
         expect(blocks).toHaveLength(0);
         expect(readSpy).not.toHaveBeenCalled();
    
  • src/gateway/server-methods/chat-webchat-media.ts+19 5 modified
    @@ -2,6 +2,7 @@ import fs from "node:fs";
     import path from "node:path";
     import { fileURLToPath } from "node:url";
     import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
    +import { assertLocalMediaAllowed, LocalMediaAccessError } from "../../media/local-media-access.js";
     import { isAudioFileName } from "../../media/mime.js";
     import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js";
     import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
    @@ -20,6 +21,11 @@ const MIME_BY_EXT: Record<string, string> = {
       ".webm": "audio/webm",
     };
     
    +type WebchatAudioEmbeddingOptions = {
    +  localRoots?: readonly string[];
    +  onLocalAudioAccessDenied?: (err: LocalMediaAccessError) => void;
    +};
    +
     /** Map `mediaUrl` strings to an absolute filesystem path for local embedding (plain paths or `file:` URLs). */
     function resolveLocalMediaPathForEmbedding(raw: string): string | null {
       const trimmed = raw.trim();
    @@ -50,7 +56,10 @@ function resolveLocalMediaPathForEmbedding(raw: string): string | null {
     }
     
     /** Returns a readable local file path when it is a regular file and within the size cap (single stat before read). */
    -function resolveLocalAudioFileForEmbedding(raw: string): string | null {
    +async function resolveLocalAudioFileForEmbedding(
    +  raw: string,
    +  options: WebchatAudioEmbeddingOptions | undefined,
    +): Promise<string | null> {
       const resolved = resolveLocalMediaPathForEmbedding(raw);
       if (!resolved) {
         return null;
    @@ -59,12 +68,16 @@ function resolveLocalAudioFileForEmbedding(raw: string): string | null {
         return null;
       }
       try {
    +    await assertLocalMediaAllowed(resolved, options?.localRoots);
         const st = fs.statSync(resolved);
         if (!st.isFile() || st.size > MAX_WEBCHAT_AUDIO_BYTES) {
           return null;
         }
         return resolved;
    -  } catch {
    +  } catch (err) {
    +    if (err instanceof LocalMediaAccessError) {
    +      options?.onLocalAudioAccessDenied?.(err);
    +    }
         return null;
       }
     }
    @@ -78,9 +91,10 @@ function mimeTypeForPath(filePath: string): string {
      * Build Control UI / transcript `content` blocks for local TTS (or other) audio files
      * referenced by slash-command / agent replies when the webchat path only had text aggregation.
      */
    -export function buildWebchatAudioContentBlocksFromReplyPayloads(
    +export async function buildWebchatAudioContentBlocksFromReplyPayloads(
       payloads: ReplyPayload[],
    -): Array<Record<string, unknown>> {
    +  options?: WebchatAudioEmbeddingOptions,
    +): Promise<Array<Record<string, unknown>>> {
       const seen = new Set<string>();
       const blocks: Array<Record<string, unknown>> = [];
       for (const payload of payloads) {
    @@ -90,7 +104,7 @@ export function buildWebchatAudioContentBlocksFromReplyPayloads(
           if (!url) {
             continue;
           }
    -      const resolved = resolveLocalAudioFileForEmbedding(url);
    +      const resolved = await resolveLocalAudioFileForEmbedding(url, options);
           if (!resolved || seen.has(resolved)) {
             continue;
           }
    

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

10

News mentions

0

No linked articles in our index yet.