From 3bd0505433e7c5202eaee04a59a455c526865dbc Mon Sep 17 00:00:00 2001 From: Andrii Furmanets Date: Mon, 2 Mar 2026 18:51:52 +0200 Subject: [PATCH] Voice Call: enforce exact webhook path match --- extensions/voice-call/src/webhook.test.ts | 37 +++++++++++++++++++++++ extensions/voice-call/src/webhook.ts | 21 ++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/extensions/voice-call/src/webhook.test.ts b/extensions/voice-call/src/webhook.test.ts index e4a2ff1e1..83916ba7f 100644 --- a/extensions/voice-call/src/webhook.test.ts +++ b/extensions/voice-call/src/webhook.test.ts @@ -135,6 +135,43 @@ describe("VoiceCallWebhookServer stale call reaper", () => { }); describe("VoiceCallWebhookServer replay handling", () => { + it("rejects lookalike webhook paths that only match by prefix", async () => { + const verifyWebhook = vi.fn(() => ({ ok: true, verifiedRequestKey: "verified:req:prefix" })); + const parseWebhookEvent = vi.fn(() => ({ events: [], statusCode: 200 })); + const strictProvider: VoiceCallProvider = { + ...provider, + verifyWebhook, + parseWebhookEvent, + }; + const { manager } = createManager([]); + const config = createConfig({ serve: { port: 0, bind: "127.0.0.1", path: "/voice/webhook" } }); + const server = new VoiceCallWebhookServer(config, manager, strictProvider); + + try { + const baseUrl = await server.start(); + const address = ( + server as unknown as { server?: { address?: () => unknown } } + ).server?.address?.(); + const requestUrl = new URL(baseUrl); + if (address && typeof address === "object" && "port" in address && address.port) { + requestUrl.port = String(address.port); + } + requestUrl.pathname = "/voice/webhook-evil"; + + const response = await fetch(requestUrl.toString(), { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: "CallSid=CA123&SpeechResult=hello", + }); + + expect(response.status).toBe(404); + expect(verifyWebhook).not.toHaveBeenCalled(); + expect(parseWebhookEvent).not.toHaveBeenCalled(); + } finally { + await server.stop(); + } + }); + it("acknowledges replayed webhook requests and skips event side effects", async () => { const replayProvider: VoiceCallProvider = { ...provider, diff --git a/extensions/voice-call/src/webhook.ts b/extensions/voice-call/src/webhook.ts index 95d6628b5..4e1585e26 100644 --- a/extensions/voice-call/src/webhook.ts +++ b/extensions/voice-call/src/webhook.ts @@ -255,6 +255,25 @@ export class VoiceCallWebhookServer { } } + private normalizeWebhookPathForMatch(pathname: string): string { + const trimmed = pathname.trim(); + if (!trimmed) { + return "/"; + } + const prefixed = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + if (prefixed === "/") { + return prefixed; + } + return prefixed.endsWith("/") ? prefixed.slice(0, -1) : prefixed; + } + + private isWebhookPathMatch(requestPath: string, configuredPath: string): boolean { + return ( + this.normalizeWebhookPathForMatch(requestPath) === + this.normalizeWebhookPathForMatch(configuredPath) + ); + } + /** * Handle incoming HTTP request. */ @@ -266,7 +285,7 @@ export class VoiceCallWebhookServer { const url = new URL(req.url || "/", `http://${req.headers.host}`); // Check path - if (!url.pathname.startsWith(webhookPath)) { + if (!this.isWebhookPathMatch(url.pathname, webhookPath)) { res.statusCode = 404; res.end("Not Found"); return;