Voice Call: enforce exact webhook path match

This commit is contained in:
Andrii Furmanets
2026-03-02 18:51:52 +02:00
committed by Peter Steinberger
parent dde43121c0
commit 3bd0505433
2 changed files with 57 additions and 1 deletions

View File

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

View File

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