Skip to content

Commit 44bf1c6

Browse files
fix(voice-call): require realtime websocket path boundary
Tighten voice-call realtime WebSocket upgrade matching so configured stream paths match exactly or as slash-delimited children only. Rejects same-prefix sibling paths that previously entered the realtime handler via a raw startsWith check. Preserves root stream-path child routing and adds bound VoiceCallWebhookServer regression coverage for valid child, sibling rejection, and root child behavior. Verification: - node scripts/run-vitest.mjs run extensions/voice-call/src/webhook.test.ts - node scripts/run-vitest.mjs run extensions/voice-call/src/webhook.test.ts extensions/voice-call/src/webhook/realtime-handler.test.ts - node scripts/run-oxlint.mjs --tsconfig config/tsconfig/oxlint.extensions.json extensions/voice-call/src/webhook.ts extensions/voice-call/src/webhook.test.ts - ./node_modules/.bin/oxfmt --check --threads=1 extensions/voice-call/src/webhook.ts extensions/voice-call/src/webhook.test.ts - git diff --check - GitHub PR checks passing
1 parent 7a7165a commit 44bf1c6

2 files changed

Lines changed: 151 additions & 1 deletion

File tree

extensions/voice-call/src/webhook.test.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,145 @@ async function postWebhookFormWithHeadersResult(
559559
});
560560
}
561561

562+
async function requestWebSocketUpgrade(
563+
server: VoiceCallWebhookServer,
564+
baseUrl: string,
565+
pathname: string,
566+
): Promise<
567+
| { kind: "response"; statusCode: number; body: string }
568+
| { kind: "upgrade"; statusCode: number }
569+
| { kind: "error"; code: string | undefined }
570+
> {
571+
const requestUrl = requireBoundRequestUrl(server, baseUrl);
572+
requestUrl.pathname = pathname;
573+
requestUrl.search = "";
574+
return await new Promise((resolve) => {
575+
let settled = false;
576+
const finish = (
577+
result:
578+
| { kind: "response"; statusCode: number; body: string }
579+
| { kind: "upgrade"; statusCode: number }
580+
| { kind: "error"; code: string | undefined },
581+
) => {
582+
if (settled) {
583+
return;
584+
}
585+
settled = true;
586+
clearTimeout(timer);
587+
resolve(result);
588+
};
589+
const timer = setTimeout(() => {
590+
req.destroy();
591+
finish({ kind: "error", code: "timeout" });
592+
}, 2_000);
593+
const req = request(
594+
{
595+
hostname: requestUrl.hostname,
596+
port: requestUrl.port,
597+
path: requestUrl.pathname,
598+
method: "GET",
599+
headers: {
600+
connection: "Upgrade",
601+
upgrade: "websocket",
602+
},
603+
},
604+
(res) => {
605+
res.setEncoding("utf8");
606+
let responseBody = "";
607+
res.on("data", (chunk) => {
608+
responseBody += chunk;
609+
});
610+
res.on("end", () => {
611+
finish({
612+
kind: "response",
613+
statusCode: res.statusCode ?? 0,
614+
body: responseBody,
615+
});
616+
});
617+
},
618+
);
619+
req.on("upgrade", (res, socket) => {
620+
socket.destroy();
621+
finish({ kind: "upgrade", statusCode: res.statusCode ?? 0 });
622+
});
623+
req.on("error", (error: NodeJS.ErrnoException) => {
624+
finish({ kind: "error", code: error.code });
625+
});
626+
req.end();
627+
});
628+
}
629+
630+
describe("VoiceCallWebhookServer realtime WebSocket routing", () => {
631+
function createRealtimeRoutingServer(streamPathPattern: string): {
632+
server: VoiceCallWebhookServer;
633+
handleWebSocketUpgrade: ReturnType<typeof vi.fn<RealtimeCallHandler["handleWebSocketUpgrade"]>>;
634+
} {
635+
const { manager } = createManager([]);
636+
const server = new VoiceCallWebhookServer(
637+
createConfig({
638+
realtime: {
639+
enabled: true,
640+
streamPath: streamPathPattern,
641+
instructions: "Be helpful.",
642+
toolPolicy: "safe-read-only",
643+
tools: [],
644+
providers: {},
645+
},
646+
}),
647+
manager,
648+
provider,
649+
);
650+
const handleWebSocketUpgrade = vi.fn<RealtimeCallHandler["handleWebSocketUpgrade"]>(
651+
(_req, socket) => {
652+
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
653+
socket.destroy();
654+
},
655+
);
656+
server.setRealtimeHandler({
657+
buildTwiMLPayload: () => ({
658+
statusCode: 200,
659+
headers: { "Content-Type": "text/xml" },
660+
body: "<Response />",
661+
}),
662+
getStreamPathPattern: () => streamPathPattern,
663+
handleWebSocketUpgrade,
664+
registerToolHandler: () => {},
665+
setPublicUrl: () => {},
666+
} as unknown as RealtimeCallHandler);
667+
return { server, handleWebSocketUpgrade };
668+
}
669+
670+
it("does not route sibling paths through the realtime stream handler", async () => {
671+
const { server, handleWebSocketUpgrade } =
672+
createRealtimeRoutingServer("/voice/stream/realtime");
673+
674+
try {
675+
const baseUrl = await server.start();
676+
const valid = await requestWebSocketUpgrade(server, baseUrl, "/voice/stream/realtime/token");
677+
expect(valid).toMatchObject({ kind: "response", statusCode: 401 });
678+
expect(handleWebSocketUpgrade).toHaveBeenCalledTimes(1);
679+
680+
await requestWebSocketUpgrade(server, baseUrl, "/voice/stream/realtime-extra/token");
681+
expect(handleWebSocketUpgrade).toHaveBeenCalledTimes(1);
682+
} finally {
683+
await server.stop();
684+
}
685+
});
686+
687+
it("routes root stream child paths through the realtime stream handler", async () => {
688+
const { server, handleWebSocketUpgrade } = createRealtimeRoutingServer("/");
689+
690+
try {
691+
const baseUrl = await server.start();
692+
const valid = await requestWebSocketUpgrade(server, baseUrl, "/token");
693+
expect(valid).toMatchObject({ kind: "response", statusCode: 401 });
694+
expect(handleWebSocketUpgrade).toHaveBeenCalledTimes(1);
695+
} finally {
696+
await server.stop();
697+
}
698+
});
699+
});
700+
562701
describe("VoiceCallWebhookServer stale call reaper", () => {
563702
beforeEach(() => {
564703
vi.useFakeTimers();

extensions/voice-call/src/webhook.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -916,7 +916,18 @@ export class VoiceCallWebhookServer {
916916
try {
917917
const pathname = buildRequestUrl(req.url).pathname;
918918
const pattern = this.realtimeHandler?.getStreamPathPattern();
919-
return Boolean(pattern && pathname.startsWith(pattern));
919+
if (!pattern) {
920+
return false;
921+
}
922+
const normalizedPattern = this.normalizeWebhookPathForMatch(pattern);
923+
const normalizedPathname = this.normalizeWebhookPathForMatch(pathname);
924+
if (normalizedPattern === "/") {
925+
return true;
926+
}
927+
return (
928+
normalizedPathname === normalizedPattern ||
929+
normalizedPathname.startsWith(`${normalizedPattern}/`)
930+
);
920931
} catch {
921932
return false;
922933
}

0 commit comments

Comments
 (0)