mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-30 08:12:21 +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 { getHeader } from "./http-headers.js";
|
||||
import type { WebhookContext } from "./types.js";
|
||||
|
||||
const REPLAY_WINDOW_MS = 10 * 60 * 1000;
|
||||
@@ -29,6 +30,10 @@ function sha256Hex(input: string): string {
|
||||
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 {
|
||||
for (const [key, expiresAt] of cache.seenUntil) {
|
||||
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 {
|
||||
if (!address) {
|
||||
return false;
|
||||
@@ -479,7 +470,14 @@ export function verifyTelnyxWebhook(
|
||||
},
|
||||
): TelnyxVerificationResult {
|
||||
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) {
|
||||
@@ -569,7 +567,14 @@ export function verifyTwilioWebhook(
|
||||
): TwilioVerificationResult {
|
||||
// Allow skipping verification for development/testing
|
||||
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");
|
||||
@@ -805,7 +810,14 @@ export function verifyPlivoWebhook(
|
||||
},
|
||||
): PlivoVerificationResult {
|
||||
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");
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { VoiceCallProvider } from "./providers/base.js";
|
||||
import { OpenAIRealtimeSTTProvider } from "./providers/stt-openai-realtime.js";
|
||||
import type { TwilioProvider } from "./providers/twilio.js";
|
||||
import type { NormalizedEvent, WebhookContext } from "./types.js";
|
||||
import { startStaleCallReaper } from "./webhook/stale-call-reaper.js";
|
||||
|
||||
const MAX_WEBHOOK_BODY_BYTES = 1024 * 1024;
|
||||
|
||||
@@ -28,7 +29,7 @@ export class VoiceCallWebhookServer {
|
||||
private manager: CallManager;
|
||||
private provider: VoiceCallProvider;
|
||||
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) */
|
||||
private mediaStreamHandler: MediaStreamHandler | null = null;
|
||||
@@ -217,48 +218,21 @@ export class VoiceCallWebhookServer {
|
||||
resolve(url);
|
||||
|
||||
// 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.
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
if (this.staleCallReaperInterval) {
|
||||
clearInterval(this.staleCallReaperInterval);
|
||||
this.staleCallReaperInterval = null;
|
||||
if (this.stopStaleCallReaper) {
|
||||
this.stopStaleCallReaper();
|
||||
this.stopStaleCallReaper = null;
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
if (this.server) {
|
||||
@@ -341,6 +315,12 @@ export class VoiceCallWebhookServer {
|
||||
res.end("Unauthorized");
|
||||
return;
|
||||
}
|
||||
if (!verification.verifiedRequestKey) {
|
||||
console.warn("[voice-call] Webhook verification succeeded without request identity key");
|
||||
res.statusCode = 401;
|
||||
res.end("Unauthorized");
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse events
|
||||
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