mirror of https://github.com/openclaw/openclaw.git
fix(voice-call): fail closed when Telnyx webhook public key missing
This commit is contained in:
parent
ff11d8793b
commit
29b587e73c
|
|
@ -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: 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/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: 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ Notes:
|
||||||
- Twilio/Telnyx require a **publicly reachable** webhook URL.
|
- Twilio/Telnyx require a **publicly reachable** webhook URL.
|
||||||
- Plivo requires a **publicly reachable** webhook URL.
|
- Plivo requires a **publicly reachable** webhook URL.
|
||||||
- `mock` is a local dev provider (no network calls).
|
- `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.
|
- `skipSignatureVerification` is for local testing only.
|
||||||
- If you use ngrok free tier, set `publicUrl` to the exact ngrok URL; signature verification is always enforced.
|
- 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.
|
- `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.
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ Notes:
|
||||||
|
|
||||||
- Twilio/Telnyx/Plivo require a **publicly reachable** webhook URL.
|
- Twilio/Telnyx/Plivo require a **publicly reachable** webhook URL.
|
||||||
- `mock` is a local dev provider (no network calls).
|
- `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.
|
- `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
|
## TTS for calls
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ describe("validateProviderConfig", () => {
|
||||||
delete process.env.TWILIO_AUTH_TOKEN;
|
delete process.env.TWILIO_AUTH_TOKEN;
|
||||||
delete process.env.TELNYX_API_KEY;
|
delete process.env.TELNYX_API_KEY;
|
||||||
delete process.env.TELNYX_CONNECTION_ID;
|
delete process.env.TELNYX_CONNECTION_ID;
|
||||||
|
delete process.env.TELNYX_PUBLIC_KEY;
|
||||||
delete process.env.PLIVO_AUTH_ID;
|
delete process.env.PLIVO_AUTH_ID;
|
||||||
delete process.env.PLIVO_AUTH_TOKEN;
|
delete process.env.PLIVO_AUTH_TOKEN;
|
||||||
});
|
});
|
||||||
|
|
@ -121,7 +122,7 @@ describe("validateProviderConfig", () => {
|
||||||
describe("telnyx provider", () => {
|
describe("telnyx provider", () => {
|
||||||
it("passes validation when credentials are in config", () => {
|
it("passes validation when credentials are in config", () => {
|
||||||
const config = createBaseConfig("telnyx");
|
const config = createBaseConfig("telnyx");
|
||||||
config.telnyx = { apiKey: "KEY123", connectionId: "CONN456" };
|
config.telnyx = { apiKey: "KEY123", connectionId: "CONN456", publicKey: "public-key" };
|
||||||
|
|
||||||
const result = validateProviderConfig(config);
|
const result = validateProviderConfig(config);
|
||||||
|
|
||||||
|
|
@ -132,6 +133,7 @@ describe("validateProviderConfig", () => {
|
||||||
it("passes validation when credentials are in environment variables", () => {
|
it("passes validation when credentials are in environment variables", () => {
|
||||||
process.env.TELNYX_API_KEY = "KEY123";
|
process.env.TELNYX_API_KEY = "KEY123";
|
||||||
process.env.TELNYX_CONNECTION_ID = "CONN456";
|
process.env.TELNYX_CONNECTION_ID = "CONN456";
|
||||||
|
process.env.TELNYX_PUBLIC_KEY = "public-key";
|
||||||
let config = createBaseConfig("telnyx");
|
let config = createBaseConfig("telnyx");
|
||||||
config = resolveVoiceCallConfig(config);
|
config = resolveVoiceCallConfig(config);
|
||||||
|
|
||||||
|
|
@ -163,7 +165,7 @@ describe("validateProviderConfig", () => {
|
||||||
|
|
||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(false);
|
||||||
expect(result.errors).toContain(
|
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.valid).toBe(true);
|
||||||
expect(result.errors).toEqual([]);
|
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", () => {
|
describe("plivo provider", () => {
|
||||||
|
|
|
||||||
|
|
@ -485,12 +485,9 @@ export function validateProviderConfig(config: VoiceCallConfig): {
|
||||||
"plugins.entries.voice-call.config.telnyx.connectionId is required (or set TELNYX_CONNECTION_ID env)",
|
"plugins.entries.voice-call.config.telnyx.connectionId is required (or set TELNYX_CONNECTION_ID env)",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (
|
if (!config.skipSignatureVerification && !config.telnyx?.publicKey) {
|
||||||
(config.inboundPolicy === "allowlist" || config.inboundPolicy === "pairing") &&
|
|
||||||
!config.telnyx?.publicKey
|
|
||||||
) {
|
|
||||||
errors.push(
|
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)",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>): 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -22,8 +22,8 @@ import type { VoiceCallProvider } from "./base.js";
|
||||||
* @see https://developers.telnyx.com/docs/api/v2/call-control
|
* @see https://developers.telnyx.com/docs/api/v2/call-control
|
||||||
*/
|
*/
|
||||||
export interface TelnyxProviderOptions {
|
export interface TelnyxProviderOptions {
|
||||||
/** Allow unsigned webhooks when no public key is configured */
|
/** Skip webhook signature verification (development only, NOT for production) */
|
||||||
allowUnsignedWebhooks?: boolean;
|
skipVerification?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TelnyxProvider implements VoiceCallProvider {
|
export class TelnyxProvider implements VoiceCallProvider {
|
||||||
|
|
@ -82,11 +82,12 @@ export class TelnyxProvider implements VoiceCallProvider {
|
||||||
* Verify Telnyx webhook signature using Ed25519.
|
* Verify Telnyx webhook signature using Ed25519.
|
||||||
*/
|
*/
|
||||||
verifyWebhook(ctx: WebhookContext): WebhookVerificationResult {
|
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.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 {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
reason: "Missing telnyx.publicKey (configure to verify webhooks)",
|
reason: "Missing telnyx.publicKey (configure to verify webhooks)",
|
||||||
|
|
|
||||||
|
|
@ -55,8 +55,7 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
|
||||||
publicKey: config.telnyx?.publicKey,
|
publicKey: config.telnyx?.publicKey,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
allowUnsignedWebhooks:
|
skipVerification: config.skipSignatureVerification,
|
||||||
config.inboundPolicy === "open" || config.inboundPolicy === "disabled",
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
case "twilio":
|
case "twilio":
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue