diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a56440dd0f..2571cdc45cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Security/Voice Call: harden `voice-call` telephony TTS override merging by blocking unsafe deep-merge keys (`__proto__`, `prototype`, `constructor`) and add regression coverage for top-level and nested prototype-pollution payloads. - Security/Net: enforce strict dotted-decimal IPv4 literals in SSRF checks and fail closed on unsupported legacy forms (octal/hex/short/packed, for example `0177.0.0.1`, `127.1`, `2130706433`) before DNS lookup. - Security/Discord: enforce trusted-sender guild permission checks for moderation actions (`timeout`, `kick`, `ban`) and ignore untrusted `senderUserId` params to prevent privilege escalation in tool-driven flows. Thanks @aether-ai-agent for reporting. - Security/ACP+Exec: add `openclaw acp --token-file/--password-file` secret-file support (with inline secret flag warnings), redact ACP working-directory prefixes to `~` home-relative paths, constrain exec script preflight file inspection to the effective `workdir` boundary, and add security-audit warnings when `tools.exec.host="sandbox"` is configured while sandbox mode is off. diff --git a/extensions/voice-call/src/telephony-tts.test.ts b/extensions/voice-call/src/telephony-tts.test.ts new file mode 100644 index 00000000000..ea039d66889 --- /dev/null +++ b/extensions/voice-call/src/telephony-tts.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it } from "vitest"; +import type { VoiceCallTtsConfig } from "./config.js"; +import type { CoreConfig } from "./core-bridge.js"; +import { createTelephonyTtsProvider } from "./telephony-tts.js"; + +function createCoreConfig(): CoreConfig { + const tts: VoiceCallTtsConfig = { + provider: "openai", + openai: { + model: "gpt-4o-mini-tts", + voice: "alloy", + }, + }; + return { messages: { tts } }; +} + +async function mergeOverride(override: unknown): Promise> { + let mergedConfig: CoreConfig | undefined; + const provider = createTelephonyTtsProvider({ + coreConfig: createCoreConfig(), + ttsOverride: override as VoiceCallTtsConfig, + runtime: { + textToSpeechTelephony: async ({ cfg }) => { + mergedConfig = cfg; + return { + success: true, + audioBuffer: Buffer.alloc(2), + sampleRate: 8000, + }; + }, + }, + }); + + await provider.synthesizeForTelephony("hello"); + expect(mergedConfig?.messages?.tts).toBeDefined(); + return mergedConfig?.messages?.tts as Record; +} + +afterEach(() => { + delete (Object.prototype as Record).polluted; +}); + +describe("createTelephonyTtsProvider deepMerge hardening", () => { + it("merges safe nested overrides", async () => { + const tts = await mergeOverride({ + openai: { voice: "coral" }, + }); + const openai = tts.openai as Record; + + expect(openai.voice).toBe("coral"); + expect(openai.model).toBe("gpt-4o-mini-tts"); + }); + + it("blocks top-level __proto__ keys", async () => { + const tts = await mergeOverride( + JSON.parse('{"__proto__":{"polluted":"top"},"openai":{"voice":"coral"}}'), + ); + const openai = tts.openai as Record; + + expect((Object.prototype as Record).polluted).toBeUndefined(); + expect(tts.polluted).toBeUndefined(); + expect(openai.voice).toBe("coral"); + }); + + it("blocks nested __proto__ keys", async () => { + const tts = await mergeOverride( + JSON.parse('{"openai":{"model":"safe","__proto__":{"polluted":"nested"}}}'), + ); + const openai = tts.openai as Record; + + expect((Object.prototype as Record).polluted).toBeUndefined(); + expect(openai.polluted).toBeUndefined(); + expect(openai.model).toBe("safe"); + }); +}); diff --git a/extensions/voice-call/src/telephony-tts.ts b/extensions/voice-call/src/telephony-tts.ts index dde2fbc2899..da8e5f71a90 100644 --- a/extensions/voice-call/src/telephony-tts.ts +++ b/extensions/voice-call/src/telephony-tts.ts @@ -20,6 +20,8 @@ export type TelephonyTtsProvider = { synthesizeForTelephony: (text: string) => Promise; }; +const BLOCKED_MERGE_KEYS = new Set(["__proto__", "prototype", "constructor"]); + export function createTelephonyTtsProvider(params: { coreConfig: CoreConfig; ttsOverride?: VoiceCallTtsConfig; @@ -86,7 +88,7 @@ function deepMerge(base: T, override: T): T { } const result: Record = { ...base }; for (const [key, value] of Object.entries(override)) { - if (value === undefined) { + if (BLOCKED_MERGE_KEYS.has(key) || value === undefined) { continue; } const existing = (base as Record)[key];