mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 12:31:32 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
12
src/meeting-bot/audio-base64.test.ts
Normal file
12
src/meeting-bot/audio-base64.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
13
src/meeting-bot/audio-base64.ts
Normal file
13
src/meeting-bot/audio-base64.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
81
src/meeting-bot/realtime-node-audio-transport.test.ts
Normal file
81
src/meeting-bot/realtime-node-audio-transport.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user