mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 11:58:38 +00:00
refactor(voice-call): enforce verified webhook key contract
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
import { getHeader } from "./http-headers.js";
|
||||||
import type { WebhookContext } from "./types.js";
|
import type { WebhookContext } from "./types.js";
|
||||||
|
|
||||||
const REPLAY_WINDOW_MS = 10 * 60 * 1000;
|
const REPLAY_WINDOW_MS = 10 * 60 * 1000;
|
||||||
@@ -29,6 +30,10 @@ function sha256Hex(input: string): string {
|
|||||||
return crypto.createHash("sha256").update(input).digest("hex");
|
return crypto.createHash("sha256").update(input).digest("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createSkippedVerificationReplayKey(provider: string, ctx: WebhookContext): string {
|
||||||
|
return `${provider}:skip:${sha256Hex(`${ctx.method}\n${ctx.url}\n${ctx.rawBody}`)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function pruneReplayCache(cache: ReplayCache, now: number): void {
|
function pruneReplayCache(cache: ReplayCache, now: number): void {
|
||||||
for (const [key, expiresAt] of cache.seenUntil) {
|
for (const [key, expiresAt] of cache.seenUntil) {
|
||||||
if (expiresAt <= now) {
|
if (expiresAt <= now) {
|
||||||
@@ -361,20 +366,6 @@ function buildTwilioVerificationUrl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a header value, handling both string and string[] types.
|
|
||||||
*/
|
|
||||||
function getHeader(
|
|
||||||
headers: Record<string, string | string[] | undefined>,
|
|
||||||
name: string,
|
|
||||||
): string | undefined {
|
|
||||||
const value = headers[name.toLowerCase()];
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return value[0];
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLoopbackAddress(address?: string): boolean {
|
function isLoopbackAddress(address?: string): boolean {
|
||||||
if (!address) {
|
if (!address) {
|
||||||
return false;
|
return false;
|
||||||
@@ -479,7 +470,14 @@ export function verifyTelnyxWebhook(
|
|||||||
},
|
},
|
||||||
): TelnyxVerificationResult {
|
): TelnyxVerificationResult {
|
||||||
if (options?.skipVerification) {
|
if (options?.skipVerification) {
|
||||||
return { ok: true, reason: "verification skipped (dev mode)" };
|
const replayKey = createSkippedVerificationReplayKey("telnyx", ctx);
|
||||||
|
const isReplay = markReplay(telnyxReplayCache, replayKey);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
reason: "verification skipped (dev mode)",
|
||||||
|
isReplay,
|
||||||
|
verifiedRequestKey: replayKey,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!publicKey) {
|
if (!publicKey) {
|
||||||
@@ -569,7 +567,14 @@ export function verifyTwilioWebhook(
|
|||||||
): TwilioVerificationResult {
|
): TwilioVerificationResult {
|
||||||
// Allow skipping verification for development/testing
|
// Allow skipping verification for development/testing
|
||||||
if (options?.skipVerification) {
|
if (options?.skipVerification) {
|
||||||
return { ok: true, reason: "verification skipped (dev mode)" };
|
const replayKey = createSkippedVerificationReplayKey("twilio", ctx);
|
||||||
|
const isReplay = markReplay(twilioReplayCache, replayKey);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
reason: "verification skipped (dev mode)",
|
||||||
|
isReplay,
|
||||||
|
verifiedRequestKey: replayKey,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const signature = getHeader(ctx.headers, "x-twilio-signature");
|
const signature = getHeader(ctx.headers, "x-twilio-signature");
|
||||||
@@ -805,7 +810,14 @@ export function verifyPlivoWebhook(
|
|||||||
},
|
},
|
||||||
): PlivoVerificationResult {
|
): PlivoVerificationResult {
|
||||||
if (options?.skipVerification) {
|
if (options?.skipVerification) {
|
||||||
return { ok: true, reason: "verification skipped (dev mode)" };
|
const replayKey = createSkippedVerificationReplayKey("plivo", ctx);
|
||||||
|
const isReplay = markReplay(plivoReplayCache, replayKey);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
reason: "verification skipped (dev mode)",
|
||||||
|
isReplay,
|
||||||
|
verifiedRequestKey: replayKey,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const signatureV3 = getHeader(ctx.headers, "x-plivo-signature-v3");
|
const signatureV3 = getHeader(ctx.headers, "x-plivo-signature-v3");
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type { VoiceCallProvider } from "./providers/base.js";
|
|||||||
import { OpenAIRealtimeSTTProvider } from "./providers/stt-openai-realtime.js";
|
import { OpenAIRealtimeSTTProvider } from "./providers/stt-openai-realtime.js";
|
||||||
import type { TwilioProvider } from "./providers/twilio.js";
|
import type { TwilioProvider } from "./providers/twilio.js";
|
||||||
import type { NormalizedEvent, WebhookContext } from "./types.js";
|
import type { NormalizedEvent, WebhookContext } from "./types.js";
|
||||||
|
import { startStaleCallReaper } from "./webhook/stale-call-reaper.js";
|
||||||
|
|
||||||
const MAX_WEBHOOK_BODY_BYTES = 1024 * 1024;
|
const MAX_WEBHOOK_BODY_BYTES = 1024 * 1024;
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ export class VoiceCallWebhookServer {
|
|||||||
private manager: CallManager;
|
private manager: CallManager;
|
||||||
private provider: VoiceCallProvider;
|
private provider: VoiceCallProvider;
|
||||||
private coreConfig: CoreConfig | null;
|
private coreConfig: CoreConfig | null;
|
||||||
private staleCallReaperInterval: ReturnType<typeof setInterval> | null = null;
|
private stopStaleCallReaper: (() => void) | null = null;
|
||||||
|
|
||||||
/** Media stream handler for bidirectional audio (when streaming enabled) */
|
/** Media stream handler for bidirectional audio (when streaming enabled) */
|
||||||
private mediaStreamHandler: MediaStreamHandler | null = null;
|
private mediaStreamHandler: MediaStreamHandler | null = null;
|
||||||
@@ -217,48 +218,21 @@ export class VoiceCallWebhookServer {
|
|||||||
resolve(url);
|
resolve(url);
|
||||||
|
|
||||||
// Start the stale call reaper if configured
|
// Start the stale call reaper if configured
|
||||||
this.startStaleCallReaper();
|
this.stopStaleCallReaper = startStaleCallReaper({
|
||||||
|
manager: this.manager,
|
||||||
|
staleCallReaperSeconds: this.config.staleCallReaperSeconds,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Start a periodic reaper that ends calls older than the configured threshold.
|
|
||||||
* Catches calls stuck in unexpected states (e.g., notify-mode calls that never
|
|
||||||
* receive a terminal webhook from the provider).
|
|
||||||
*/
|
|
||||||
private startStaleCallReaper(): void {
|
|
||||||
const maxAgeSeconds = this.config.staleCallReaperSeconds;
|
|
||||||
if (!maxAgeSeconds || maxAgeSeconds <= 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CHECK_INTERVAL_MS = 30_000; // Check every 30 seconds
|
|
||||||
const maxAgeMs = maxAgeSeconds * 1000;
|
|
||||||
|
|
||||||
this.staleCallReaperInterval = setInterval(() => {
|
|
||||||
const now = Date.now();
|
|
||||||
for (const call of this.manager.getActiveCalls()) {
|
|
||||||
const age = now - call.startedAt;
|
|
||||||
if (age > maxAgeMs) {
|
|
||||||
console.log(
|
|
||||||
`[voice-call] Reaping stale call ${call.callId} (age: ${Math.round(age / 1000)}s, state: ${call.state})`,
|
|
||||||
);
|
|
||||||
void this.manager.endCall(call.callId).catch((err) => {
|
|
||||||
console.warn(`[voice-call] Reaper failed to end call ${call.callId}:`, err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, CHECK_INTERVAL_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop the webhook server.
|
* Stop the webhook server.
|
||||||
*/
|
*/
|
||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
if (this.staleCallReaperInterval) {
|
if (this.stopStaleCallReaper) {
|
||||||
clearInterval(this.staleCallReaperInterval);
|
this.stopStaleCallReaper();
|
||||||
this.staleCallReaperInterval = null;
|
this.stopStaleCallReaper = null;
|
||||||
}
|
}
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (this.server) {
|
if (this.server) {
|
||||||
@@ -341,6 +315,12 @@ export class VoiceCallWebhookServer {
|
|||||||
res.end("Unauthorized");
|
res.end("Unauthorized");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!verification.verifiedRequestKey) {
|
||||||
|
console.warn("[voice-call] Webhook verification succeeded without request identity key");
|
||||||
|
res.statusCode = 401;
|
||||||
|
res.end("Unauthorized");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Parse events
|
// Parse events
|
||||||
const result = this.provider.parseWebhookEvent(ctx, {
|
const result = this.provider.parseWebhookEvent(ctx, {
|
||||||
|
|||||||
33
extensions/voice-call/src/webhook/stale-call-reaper.ts
Normal file
33
extensions/voice-call/src/webhook/stale-call-reaper.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type { CallManager } from "../manager.js";
|
||||||
|
|
||||||
|
const CHECK_INTERVAL_MS = 30_000;
|
||||||
|
|
||||||
|
export function startStaleCallReaper(params: {
|
||||||
|
manager: CallManager;
|
||||||
|
staleCallReaperSeconds?: number;
|
||||||
|
}): (() => void) | null {
|
||||||
|
const maxAgeSeconds = params.staleCallReaperSeconds;
|
||||||
|
if (!maxAgeSeconds || maxAgeSeconds <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxAgeMs = maxAgeSeconds * 1000;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const call of params.manager.getActiveCalls()) {
|
||||||
|
const age = now - call.startedAt;
|
||||||
|
if (age > maxAgeMs) {
|
||||||
|
console.log(
|
||||||
|
`[voice-call] Reaping stale call ${call.callId} (age: ${Math.round(age / 1000)}s, state: ${call.state})`,
|
||||||
|
);
|
||||||
|
void params.manager.endCall(call.callId).catch((err) => {
|
||||||
|
console.warn(`[voice-call] Reaper failed to end call ${call.callId}:`, err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, CHECK_INTERVAL_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user