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 <yang.jiajun1@xydigit.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
sunlit-deng
2026-07-23 05:25:42 +08:00
committed by GitHub
parent a220cc00bd
commit 74c5415e2e
8 changed files with 131 additions and 4 deletions

View File

@@ -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",

View File

@@ -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" },

View File

@@ -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",
);
});
});

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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);

View File

@@ -0,0 +1,81 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createNodeMeetingRealtimeAudioTransport } from "./realtime-node-audio-transport.js";
function createTransport(invoke: ReturnType<typeof vi.fn>) {
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<void>((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();
});
});

View File

@@ -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<string, unknown> {
@@ -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;