diff --git a/CHANGELOG.md b/CHANGELOG.md index 96973c7ad2..aba22d18d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - Security/Agents: scope CLI process cleanup to owned child PIDs to avoid killing unrelated processes on shared hosts. Thanks @aether-ai-agent. - Security/Agents (macOS): prevent shell injection when writing Claude CLI keychain credentials. (#15924) Thanks @aether-ai-agent. - Security: fix Chutes manual OAuth login state validation (thanks @aether-ai-agent). (#16058) +- Security/Voice Call (Telnyx): require webhook signature verification when receiving inbound events; configs without `telnyx.publicKey` are now rejected unless `skipSignatureVerification` is enabled. Thanks @p80n-sec. - Security/Discovery: stop treating Bonjour TXT records as authoritative routing (prefer resolved service endpoints) and prevent discovery from overriding stored TLS pins; autoconnect now requires a previously trusted gateway. Thanks @simecek. - macOS: hard-limit unkeyed `openclaw://agent` deep links and ignore `deliver` / `to` / `channel` unless a valid unattended key is provided. Thanks @Cillian-Collins. - Plugins: suppress false duplicate plugin id warnings when the same extension is discovered via multiple paths (config/workspace/global vs bundled), while still warning on genuine duplicates. (#16222) Thanks @shadril238. diff --git a/docs/plugins/voice-call.md b/docs/plugins/voice-call.md index 7e98da11e1..d304f5e86a 100644 --- a/docs/plugins/voice-call.md +++ b/docs/plugins/voice-call.md @@ -112,6 +112,7 @@ Notes: - Twilio/Telnyx require a **publicly reachable** webhook URL. - Plivo requires a **publicly reachable** webhook URL. - `mock` is a local dev provider (no network calls). +- Telnyx requires `telnyx.publicKey` (or `TELNYX_PUBLIC_KEY`) unless `skipSignatureVerification` is true. - `skipSignatureVerification` is for local testing only. - If you use ngrok free tier, set `publicUrl` to the exact ngrok URL; signature verification is always enforced. - `tunnel.allowNgrokFreeTierLoopbackBypass: true` allows Twilio webhooks with invalid signatures **only** when `tunnel.provider="ngrok"` and `serve.bind` is loopback (ngrok local agent). Use for local dev only. diff --git a/extensions/voice-call/README.md b/extensions/voice-call/README.md index 8ced7a9996..19d0d23a03 100644 --- a/extensions/voice-call/README.md +++ b/extensions/voice-call/README.md @@ -76,6 +76,7 @@ Notes: - Twilio/Telnyx/Plivo require a **publicly reachable** webhook URL. - `mock` is a local dev provider (no network calls). +- Telnyx requires `telnyx.publicKey` (or `TELNYX_PUBLIC_KEY`) unless `skipSignatureVerification` is true. - `tunnel.allowNgrokFreeTierLoopbackBypass: true` allows Twilio webhooks with invalid signatures **only** when `tunnel.provider="ngrok"` and `serve.bind` is loopback (ngrok local agent). Use for local dev only. ## TTS for calls diff --git a/extensions/voice-call/src/config.test.ts b/extensions/voice-call/src/config.test.ts index ef99544709..4b1389b35e 100644 --- a/extensions/voice-call/src/config.test.ts +++ b/extensions/voice-call/src/config.test.ts @@ -47,6 +47,7 @@ describe("validateProviderConfig", () => { delete process.env.TWILIO_AUTH_TOKEN; delete process.env.TELNYX_API_KEY; delete process.env.TELNYX_CONNECTION_ID; + delete process.env.TELNYX_PUBLIC_KEY; delete process.env.PLIVO_AUTH_ID; delete process.env.PLIVO_AUTH_TOKEN; }); @@ -121,7 +122,7 @@ describe("validateProviderConfig", () => { describe("telnyx provider", () => { it("passes validation when credentials are in config", () => { const config = createBaseConfig("telnyx"); - config.telnyx = { apiKey: "KEY123", connectionId: "CONN456" }; + config.telnyx = { apiKey: "KEY123", connectionId: "CONN456", publicKey: "public-key" }; const result = validateProviderConfig(config); @@ -132,6 +133,7 @@ describe("validateProviderConfig", () => { it("passes validation when credentials are in environment variables", () => { process.env.TELNYX_API_KEY = "KEY123"; process.env.TELNYX_CONNECTION_ID = "CONN456"; + process.env.TELNYX_PUBLIC_KEY = "public-key"; let config = createBaseConfig("telnyx"); config = resolveVoiceCallConfig(config); @@ -163,7 +165,7 @@ describe("validateProviderConfig", () => { expect(result.valid).toBe(false); expect(result.errors).toContain( - "plugins.entries.voice-call.config.telnyx.publicKey is required for inboundPolicy allowlist/pairing", + "plugins.entries.voice-call.config.telnyx.publicKey is required (or set TELNYX_PUBLIC_KEY env)", ); }); @@ -181,6 +183,17 @@ describe("validateProviderConfig", () => { expect(result.valid).toBe(true); expect(result.errors).toEqual([]); }); + + it("passes validation when skipSignatureVerification is true (even without public key)", () => { + const config = createBaseConfig("telnyx"); + config.skipSignatureVerification = true; + config.telnyx = { apiKey: "KEY123", connectionId: "CONN456" }; + + const result = validateProviderConfig(config); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); }); describe("plivo provider", () => { diff --git a/extensions/voice-call/src/config.ts b/extensions/voice-call/src/config.ts index 610c63869f..9b63c3e817 100644 --- a/extensions/voice-call/src/config.ts +++ b/extensions/voice-call/src/config.ts @@ -485,12 +485,9 @@ export function validateProviderConfig(config: VoiceCallConfig): { "plugins.entries.voice-call.config.telnyx.connectionId is required (or set TELNYX_CONNECTION_ID env)", ); } - if ( - (config.inboundPolicy === "allowlist" || config.inboundPolicy === "pairing") && - !config.telnyx?.publicKey - ) { + if (!config.skipSignatureVerification && !config.telnyx?.publicKey) { errors.push( - "plugins.entries.voice-call.config.telnyx.publicKey is required for inboundPolicy allowlist/pairing", + "plugins.entries.voice-call.config.telnyx.publicKey is required (or set TELNYX_PUBLIC_KEY env)", ); } } diff --git a/extensions/voice-call/src/providers/telnyx.test.ts b/extensions/voice-call/src/providers/telnyx.test.ts new file mode 100644 index 0000000000..ae6f9303c8 --- /dev/null +++ b/extensions/voice-call/src/providers/telnyx.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import type { WebhookContext } from "../types.js"; +import { TelnyxProvider } from "./telnyx.js"; + +function createCtx(params?: Partial): WebhookContext { + return { + headers: {}, + rawBody: "{}", + url: "http://localhost/voice/webhook", + method: "POST", + query: {}, + remoteAddress: "127.0.0.1", + ...params, + }; +} + +describe("TelnyxProvider.verifyWebhook", () => { + it("fails closed when public key is missing and skipVerification is false", () => { + const provider = new TelnyxProvider( + { apiKey: "KEY123", connectionId: "CONN456", publicKey: undefined }, + { skipVerification: false }, + ); + + const result = provider.verifyWebhook(createCtx()); + expect(result.ok).toBe(false); + }); + + it("allows requests when skipVerification is true (development only)", () => { + const provider = new TelnyxProvider( + { apiKey: "KEY123", connectionId: "CONN456", publicKey: undefined }, + { skipVerification: true }, + ); + + const result = provider.verifyWebhook(createCtx()); + expect(result.ok).toBe(true); + }); + + it("fails when signature headers are missing (with public key configured)", () => { + const provider = new TelnyxProvider( + { apiKey: "KEY123", connectionId: "CONN456", publicKey: "public-key" }, + { skipVerification: false }, + ); + + const result = provider.verifyWebhook(createCtx({ headers: {} })); + expect(result.ok).toBe(false); + }); +}); diff --git a/extensions/voice-call/src/providers/telnyx.ts b/extensions/voice-call/src/providers/telnyx.ts index ef53f0b532..895422f72d 100644 --- a/extensions/voice-call/src/providers/telnyx.ts +++ b/extensions/voice-call/src/providers/telnyx.ts @@ -22,8 +22,8 @@ import type { VoiceCallProvider } from "./base.js"; * @see https://developers.telnyx.com/docs/api/v2/call-control */ export interface TelnyxProviderOptions { - /** Allow unsigned webhooks when no public key is configured */ - allowUnsignedWebhooks?: boolean; + /** Skip webhook signature verification (development only, NOT for production) */ + skipVerification?: boolean; } export class TelnyxProvider implements VoiceCallProvider { @@ -82,11 +82,12 @@ export class TelnyxProvider implements VoiceCallProvider { * Verify Telnyx webhook signature using Ed25519. */ verifyWebhook(ctx: WebhookContext): WebhookVerificationResult { + if (this.options.skipVerification) { + console.warn("[telnyx] Webhook verification skipped (skipSignatureVerification=true)"); + return { ok: true, reason: "verification skipped (skipSignatureVerification=true)" }; + } + if (!this.publicKey) { - if (this.options.allowUnsignedWebhooks) { - console.warn("[telnyx] Webhook verification skipped (no public key configured)"); - return { ok: true, reason: "verification skipped (no public key configured)" }; - } return { ok: false, reason: "Missing telnyx.publicKey (configure to verify webhooks)", diff --git a/extensions/voice-call/src/runtime.ts b/extensions/voice-call/src/runtime.ts index bf25a4c277..eb9bf74713 100644 --- a/extensions/voice-call/src/runtime.ts +++ b/extensions/voice-call/src/runtime.ts @@ -55,8 +55,7 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider { publicKey: config.telnyx?.publicKey, }, { - allowUnsignedWebhooks: - config.inboundPolicy === "open" || config.inboundPolicy === "disabled", + skipVerification: config.skipSignatureVerification, }, ); case "twilio":