From 74c5415e2ef06036fd23528ded1deb7bc64595fb Mon Sep 17 00:00:00 2001 From: sunlit-deng Date: Thu, 23 Jul 2026 05:25:42 +0800 Subject: [PATCH] fix(google-meet): reject malformed audio base64 (#106474) * fix(google-meet): reject malformed audio base64 * refactor(meeting-bot): validate node audio centrally Make the shared meeting-bot owner reject malformed push and pull audio for Google Meet, Teams, and Zoom without plugin-specific callbacks or permissive fallbacks. Co-authored-by: sunlit-deng --------- Co-authored-by: Peter Steinberger --- extensions/google-meet/node-host.test.ts | 11 +++ .../src/node-invoke-policy.test.ts | 1 + src/meeting-bot/audio-base64.test.ts | 12 +++ src/meeting-bot/audio-base64.ts | 13 +++ src/meeting-bot/node-host.ts | 3 +- src/meeting-bot/node-invoke-policy.ts | 7 ++ .../realtime-node-audio-transport.test.ts | 81 +++++++++++++++++++ .../realtime-node-audio-transport.ts | 7 +- 8 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 src/meeting-bot/audio-base64.test.ts create mode 100644 src/meeting-bot/audio-base64.ts create mode 100644 src/meeting-bot/realtime-node-audio-transport.test.ts diff --git a/extensions/google-meet/node-host.test.ts b/extensions/google-meet/node-host.test.ts index 90d764193442..748a671e9e21 100644 --- a/extensions/google-meet/node-host.test.ts +++ b/extensions/google-meet/node-host.test.ts @@ -314,6 +314,17 @@ describe("google-meet node host bridge sessions", () => { expect(children[2]?.stdin?.write).toHaveBeenCalledWith(audio); expect(firstOutput?.stdin?.write).not.toHaveBeenCalled(); + await expect( + handleGoogleMeetNodeHostCommand( + JSON.stringify({ + action: "pushAudio", + bridgeId: start.bridgeId, + base64: "not-base64!", + }), + ), + ).rejects.toThrow("pushAudio base64 must be a valid audio payload"); + expect(children[2]?.stdin?.write).toHaveBeenCalledTimes(1); + await handleGoogleMeetNodeHostCommand( JSON.stringify({ action: "stop", diff --git a/extensions/google-meet/src/node-invoke-policy.test.ts b/extensions/google-meet/src/node-invoke-policy.test.ts index b21706ef116f..c0b1690c55a2 100644 --- a/extensions/google-meet/src/node-invoke-policy.test.ts +++ b/extensions/google-meet/src/node-invoke-policy.test.ts @@ -127,6 +127,7 @@ describe("Google Meet node invoke policy", () => { for (const params of [ { action: "pullAudio" }, { action: "pushAudio", bridgeId: "bridge-1" }, + { action: "pushAudio", bridgeId: "bridge-1", base64: "not-base64!" }, { action: "clearAudio", bridgeId: "" }, { action: "stopByUrl" }, { action: "stopByUrl", url: "https://example.com/not-meet" }, diff --git a/src/meeting-bot/audio-base64.test.ts b/src/meeting-bot/audio-base64.test.ts new file mode 100644 index 000000000000..9241730d0a4d --- /dev/null +++ b/src/meeting-bot/audio-base64.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { decodeMeetingAudioBase64, isMeetingAudioBase64 } from "./audio-base64.js"; + +describe("meeting audio base64", () => { + it("canonicalizes valid audio and rejects malformed payloads", () => { + expect(decodeMeetingAudioBase64(" YXVkaW8 \n", "pullAudio").toString()).toBe("audio"); + expect(isMeetingAudioBase64("not-base64!")).toBe(false); + expect(() => decodeMeetingAudioBase64("not-base64!", "pushAudio")).toThrow( + "pushAudio base64 must be a valid audio payload", + ); + }); +}); diff --git a/src/meeting-bot/audio-base64.ts b/src/meeting-bot/audio-base64.ts new file mode 100644 index 000000000000..82bafcdb9311 --- /dev/null +++ b/src/meeting-bot/audio-base64.ts @@ -0,0 +1,13 @@ +import { canonicalizeBase64 } from "@openclaw/media-core/base64"; + +export function decodeMeetingAudioBase64(base64: string, action: string): Buffer { + const canonicalBase64 = canonicalizeBase64(base64); + if (!canonicalBase64) { + throw new Error(`${action} base64 must be a valid audio payload`); + } + return Buffer.from(canonicalBase64, "base64"); +} + +export function isMeetingAudioBase64(base64: string): boolean { + return canonicalizeBase64(base64) !== undefined; +} diff --git a/src/meeting-bot/node-host.ts b/src/meeting-bot/node-host.ts index 2cdc15ee84c9..0f9199f07be4 100644 --- a/src/meeting-bot/node-host.ts +++ b/src/meeting-bot/node-host.ts @@ -1,5 +1,6 @@ import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { randomUUID } from "node:crypto"; +import { decodeMeetingAudioBase64 } from "./audio-base64.js"; import { terminateMeetingBridgeProcess } from "./bridge-process.js"; import { MeetingNodeAudioPullWaiters } from "./node-audio-pull-waiters.js"; @@ -237,7 +238,7 @@ export function createMeetingNodeHost(options: MeetingNodeHostOptions): { if (!session || session.closed) { throw new Error(`bridge is not open: ${bridgeId}`); } - const audio = Buffer.from(base64, "base64"); + const audio = decodeMeetingAudioBase64(base64, "pushAudio"); session.lastOutputAt = new Date().toISOString(); session.lastOutputBytes += audio.byteLength; try { diff --git a/src/meeting-bot/node-invoke-policy.ts b/src/meeting-bot/node-invoke-policy.ts index 2b2403473f04..a00cb5368bd4 100644 --- a/src/meeting-bot/node-invoke-policy.ts +++ b/src/meeting-bot/node-invoke-policy.ts @@ -2,6 +2,7 @@ import type { OpenClawPluginNodeInvokePolicy, OpenClawPluginNodeInvokePolicyResult, } from "../plugins/plugin-registration.types.js"; +import { isMeetingAudioBase64 } from "./audio-base64.js"; export type MeetingBrowserNodeStartConfig = { launch: boolean; @@ -194,6 +195,12 @@ function buildForwardParams( if (!base64) { return denyMissing(options, action, "base64"); } + if (!isMeetingAudioBase64(base64)) { + return { + approved: false, + result: denied(options, "base64 must be a valid audio payload"), + }; + } forwarded.bridgeId = bridgeId; forwarded.base64 = base64; return approved(forwarded); diff --git a/src/meeting-bot/realtime-node-audio-transport.test.ts b/src/meeting-bot/realtime-node-audio-transport.test.ts new file mode 100644 index 000000000000..ae3fdca5da97 --- /dev/null +++ b/src/meeting-bot/realtime-node-audio-transport.test.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createNodeMeetingRealtimeAudioTransport } from "./realtime-node-audio-transport.js"; + +function createTransport(invoke: ReturnType) { + return createNodeMeetingRealtimeAudioTransport({ + runtime: { nodes: { invoke } } as never, + nodeId: "node-1", + bridgeId: "bridge-1", + logger: { warn: vi.fn(), debug: vi.fn() } as never, + commandName: "meeting.chrome", + logScope: "[meeting]", + logPrefix: "node", + }); +} + +describe("node meeting realtime audio transport", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("rejects malformed input and continues with the next valid chunk", async () => { + vi.useFakeTimers(); + let pullCount = 0; + let releaseIdlePull: (() => void) | undefined; + const invoke = vi.fn(async ({ params }: { params: { action: string } }) => { + if (params.action !== "pullAudio") { + releaseIdlePull?.(); + return { ok: true }; + } + pullCount += 1; + if (pullCount === 1) { + return { base64: "not-base64!" }; + } + if (pullCount === 2) { + return { base64: Buffer.from([5, 4, 3]).toString("base64") }; + } + await new Promise((resolve) => { + releaseIdlePull = resolve; + }); + return {}; + }); + const transport = createTransport(invoke); + const onAudio = vi.fn(); + + transport.startInput(onAudio); + await vi.advanceTimersByTimeAsync(250); + await vi.waitFor(() => { + expect(onAudio).toHaveBeenCalledWith(Buffer.from([5, 4, 3])); + }); + expect(transport.getHealth?.()).toEqual({ + consecutiveInputErrors: 0, + lastInputError: undefined, + }); + + await transport.stop(); + }); + + it("signals fatal after five malformed input chunks", async () => { + vi.useFakeTimers(); + const invoke = vi.fn(async ({ params }: { params: { action: string } }) => + params.action === "pullAudio" ? { base64: "not-base64!" } : { ok: true }, + ); + const transport = createTransport(invoke); + const onFatal = vi.fn(); + const onAudio = vi.fn(); + transport.onFatal(onFatal); + + transport.startInput(onAudio); + await vi.advanceTimersByTimeAsync(1_000); + await vi.waitFor(() => { + expect(onFatal).toHaveBeenCalledTimes(1); + }); + expect(onAudio).not.toHaveBeenCalled(); + expect(transport.getHealth?.()).toEqual({ + consecutiveInputErrors: 5, + lastInputError: "pullAudio base64 must be a valid audio payload", + }); + + await transport.stop(); + }); +}); diff --git a/src/meeting-bot/realtime-node-audio-transport.ts b/src/meeting-bot/realtime-node-audio-transport.ts index 555a711fbc19..afaf456f1397 100644 --- a/src/meeting-bot/realtime-node-audio-transport.ts +++ b/src/meeting-bot/realtime-node-audio-transport.ts @@ -1,5 +1,6 @@ import { formatErrorMessage } from "../infra/errors.js"; import type { PluginRuntime, RuntimeLogger } from "../plugins/runtime/types.js"; +import { decodeMeetingAudioBase64 } from "./audio-base64.js"; import type { MeetingRealtimeAudioTransport } from "./realtime-audio-transport.js"; function asRecord(value: unknown): Record { @@ -61,12 +62,12 @@ export function createNodeMeetingRealtimeAudioTransport(params: { timeoutMs: 2_000, }); const result = asRecord(asRecord(raw).payload ?? raw); - consecutiveInputErrors = 0; - lastInputError = undefined; const base64 = readString(result.base64); if (base64) { - onAudio(Buffer.from(base64, "base64")); + onAudio(decodeMeetingAudioBase64(base64, "pullAudio")); } + consecutiveInputErrors = 0; + lastInputError = undefined; if (result.closed === true) { signalFatal(); break;