VYPR
High severityNVD Advisory· Published Feb 19, 2026· Updated Feb 20, 2026

OpenClaw has Missing Webhook Authentication in Telnyx Provider Allowing Unauthenticated Requests

CVE-2026-26319

Description

OpenClaw is a personal AI assistant. Versions 2026.2.13 and below allow the optional @openclaw/voice-call plugin Telnyx webhook handler to accept unsigned inbound webhook requests when telnyx.publicKey is not configured, enabling unauthenticated callers to forge Telnyx events. Telnyx webhooks are expected to be authenticated via Ed25519 signature verification. In affected versions, TelnyxProvider.verifyWebhook() could effectively fail open when no Telnyx public key was configured, allowing arbitrary HTTP POST requests to the voice-call webhook endpoint to be treated as legitimate Telnyx events. This only impacts deployments where the Voice Call plugin is installed, enabled, and the webhook endpoint is reachable from the attacker (for example, publicly exposed via a tunnel/proxy). The issue has been fixed in version 2026.2.14.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.2.142026.2.14

Affected products

1

Patches

2
f47584fec86d

refactor(voice-call): centralize Telnyx webhook verification

https://github.com/openclaw/openclawPeter SteinbergerFeb 14, 2026via ghsa
6 files changed · +206 59
  • docs/plugins/voice-call.md+8 0 modified
    @@ -70,6 +70,14 @@ Set config under `plugins.entries.voice-call.config`:
                 authToken: "...",
               },
     
    +          telnyx: {
    +            apiKey: "...",
    +            connectionId: "...",
    +            // Telnyx webhook public key from the Telnyx Mission Control Portal
    +            // (Base64 string; can also be set via TELNYX_PUBLIC_KEY).
    +            publicKey: "...",
    +          },
    +
               plivo: {
                 authId: "MAxxxxxxxxxxxxxxxxxxxx",
                 authToken: "...",
    
  • extensions/voice-call/README.md+8 0 modified
    @@ -45,6 +45,14 @@ Put under `plugins.entries.voice-call.config`:
         authToken: "your_token",
       },
     
    +  telnyx: {
    +    apiKey: "KEYxxxx",
    +    connectionId: "CONNxxxx",
    +    // Telnyx webhook public key from the Telnyx Mission Control Portal
    +    // (Base64 string; can also be set via TELNYX_PUBLIC_KEY).
    +    publicKey: "...",
    +  },
    +
       plivo: {
         authId: "MAxxxxxxxxxxxxxxxxxxxx",
         authToken: "your_token",
    
  • extensions/voice-call/src/providers/telnyx.test.ts+74 0 modified
    @@ -1,3 +1,4 @@
    +import crypto from "node:crypto";
     import { describe, expect, it } from "vitest";
     import type { WebhookContext } from "../types.js";
     import { TelnyxProvider } from "./telnyx.js";
    @@ -14,6 +15,13 @@ function createCtx(params?: Partial<WebhookContext>): WebhookContext {
       };
     }
     
    +function decodeBase64Url(input: string): Buffer {
    +  const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
    +  const padLen = (4 - (normalized.length % 4)) % 4;
    +  const padded = normalized + "=".repeat(padLen);
    +  return Buffer.from(padded, "base64");
    +}
    +
     describe("TelnyxProvider.verifyWebhook", () => {
       it("fails closed when public key is missing and skipVerification is false", () => {
         const provider = new TelnyxProvider(
    @@ -44,4 +52,70 @@ describe("TelnyxProvider.verifyWebhook", () => {
         const result = provider.verifyWebhook(createCtx({ headers: {} }));
         expect(result.ok).toBe(false);
       });
    +
    +  it("verifies a valid signature with a raw Ed25519 public key (Base64)", () => {
    +    const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
    +
    +    const jwk = publicKey.export({ format: "jwk" }) as JsonWebKey;
    +    expect(jwk.kty).toBe("OKP");
    +    expect(jwk.crv).toBe("Ed25519");
    +    expect(typeof jwk.x).toBe("string");
    +
    +    const rawPublicKey = decodeBase64Url(jwk.x as string);
    +    const rawPublicKeyBase64 = rawPublicKey.toString("base64");
    +
    +    const provider = new TelnyxProvider(
    +      { apiKey: "KEY123", connectionId: "CONN456", publicKey: rawPublicKeyBase64 },
    +      { skipVerification: false },
    +    );
    +
    +    const rawBody = JSON.stringify({
    +      event_type: "call.initiated",
    +      payload: { call_control_id: "x" },
    +    });
    +    const timestamp = String(Math.floor(Date.now() / 1000));
    +    const signedPayload = `${timestamp}|${rawBody}`;
    +    const signature = crypto.sign(null, Buffer.from(signedPayload), privateKey).toString("base64");
    +
    +    const result = provider.verifyWebhook(
    +      createCtx({
    +        rawBody,
    +        headers: {
    +          "telnyx-signature-ed25519": signature,
    +          "telnyx-timestamp": timestamp,
    +        },
    +      }),
    +    );
    +    expect(result.ok).toBe(true);
    +  });
    +
    +  it("verifies a valid signature with a DER SPKI public key (Base64)", () => {
    +    const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
    +    const spkiDer = publicKey.export({ format: "der", type: "spki" }) as Buffer;
    +    const spkiDerBase64 = spkiDer.toString("base64");
    +
    +    const provider = new TelnyxProvider(
    +      { apiKey: "KEY123", connectionId: "CONN456", publicKey: spkiDerBase64 },
    +      { skipVerification: false },
    +    );
    +
    +    const rawBody = JSON.stringify({
    +      event_type: "call.initiated",
    +      payload: { call_control_id: "x" },
    +    });
    +    const timestamp = String(Math.floor(Date.now() / 1000));
    +    const signedPayload = `${timestamp}|${rawBody}`;
    +    const signature = crypto.sign(null, Buffer.from(signedPayload), privateKey).toString("base64");
    +
    +    const result = provider.verifyWebhook(
    +      createCtx({
    +        rawBody,
    +        headers: {
    +          "telnyx-signature-ed25519": signature,
    +          "telnyx-timestamp": timestamp,
    +        },
    +      }),
    +    );
    +    expect(result.ok).toBe(true);
    +  });
     });
    
  • extensions/voice-call/src/providers/telnyx.ts+5 59 modified
    @@ -14,6 +14,7 @@ import type {
       WebhookVerificationResult,
     } from "../types.js";
     import type { VoiceCallProvider } from "./base.js";
    +import { verifyTelnyxWebhook } from "../webhook-security.js";
     
     /**
      * Telnyx Voice API provider implementation.
    @@ -82,66 +83,11 @@ 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) {
    -      return {
    -        ok: false,
    -        reason: "Missing telnyx.publicKey (configure to verify webhooks)",
    -      };
    -    }
    -
    -    const signature = ctx.headers["telnyx-signature-ed25519"];
    -    const timestamp = ctx.headers["telnyx-timestamp"];
    -
    -    if (!signature || !timestamp) {
    -      return { ok: false, reason: "Missing signature or timestamp header" };
    -    }
    -
    -    const signatureStr = Array.isArray(signature) ? signature[0] : signature;
    -    const timestampStr = Array.isArray(timestamp) ? timestamp[0] : timestamp;
    -
    -    if (!signatureStr || !timestampStr) {
    -      return { ok: false, reason: "Empty signature or timestamp" };
    -    }
    -
    -    try {
    -      const signedPayload = `${timestampStr}|${ctx.rawBody}`;
    -      const signatureBuffer = Buffer.from(signatureStr, "base64");
    -      const publicKeyBuffer = Buffer.from(this.publicKey, "base64");
    -
    -      const isValid = crypto.verify(
    -        null, // Ed25519 doesn't use a digest
    -        Buffer.from(signedPayload),
    -        {
    -          key: publicKeyBuffer,
    -          format: "der",
    -          type: "spki",
    -        },
    -        signatureBuffer,
    -      );
    -
    -      if (!isValid) {
    -        return { ok: false, reason: "Invalid signature" };
    -      }
    -
    -      // Check timestamp is within 5 minutes
    -      const eventTime = parseInt(timestampStr, 10) * 1000;
    -      const now = Date.now();
    -      if (Math.abs(now - eventTime) > 5 * 60 * 1000) {
    -        return { ok: false, reason: "Timestamp too old" };
    -      }
    +    const result = verifyTelnyxWebhook(ctx, this.publicKey, {
    +      skipVerification: this.options.skipVerification,
    +    });
     
    -      return { ok: true };
    -    } catch (err) {
    -      return {
    -        ok: false,
    -        reason: `Verification error: ${err instanceof Error ? err.message : String(err)}`,
    -      };
    -    }
    +    return { ok: result.ok, reason: result.reason };
       }
     
       /**
    
  • extensions/voice-call/src/runtime.ts+6 0 modified
    @@ -112,6 +112,12 @@ export async function createVoiceCallRuntime(params: {
         throw new Error("Voice call disabled. Enable the plugin entry in config.");
       }
     
    +  if (config.skipSignatureVerification) {
    +    log.warn(
    +      "[voice-call] SECURITY WARNING: skipSignatureVerification=true disables webhook signature verification (development only). Do not use in production.",
    +    );
    +  }
    +
       const validation = validateProviderConfig(config);
       if (!validation.valid) {
         throw new Error(`Invalid voice-call config: ${validation.errors.join("; ")}`);
    
  • extensions/voice-call/src/webhook-security.ts+105 0 modified
    @@ -330,6 +330,111 @@ export interface TwilioVerificationResult {
       isNgrokFreeTier?: boolean;
     }
     
    +export interface TelnyxVerificationResult {
    +  ok: boolean;
    +  reason?: string;
    +}
    +
    +function decodeBase64OrBase64Url(input: string): Buffer {
    +  // Telnyx docs say Base64; some tooling emits Base64URL. Accept both.
    +  const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
    +  const padLen = (4 - (normalized.length % 4)) % 4;
    +  const padded = normalized + "=".repeat(padLen);
    +  return Buffer.from(padded, "base64");
    +}
    +
    +function base64UrlEncode(buf: Buffer): string {
    +  return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
    +}
    +
    +function importEd25519PublicKey(publicKey: string): crypto.KeyObject | string {
    +  const trimmed = publicKey.trim();
    +
    +  // PEM (spki) support.
    +  if (trimmed.startsWith("-----BEGIN")) {
    +    return trimmed;
    +  }
    +
    +  // Base64-encoded raw Ed25519 key (32 bytes) or Base64-encoded DER SPKI key.
    +  const decoded = decodeBase64OrBase64Url(trimmed);
    +  if (decoded.length === 32) {
    +    // JWK is the easiest portable way to import raw Ed25519 keys in Node crypto.
    +    return crypto.createPublicKey({
    +      key: { kty: "OKP", crv: "Ed25519", x: base64UrlEncode(decoded) },
    +      format: "jwk",
    +    });
    +  }
    +
    +  return crypto.createPublicKey({
    +    key: decoded,
    +    format: "der",
    +    type: "spki",
    +  });
    +}
    +
    +/**
    + * Verify Telnyx webhook signature using Ed25519.
    + *
    + * Telnyx signs `timestamp|payload` and provides:
    + * - `telnyx-signature-ed25519` (Base64 signature)
    + * - `telnyx-timestamp` (Unix seconds)
    + */
    +export function verifyTelnyxWebhook(
    +  ctx: WebhookContext,
    +  publicKey: string | undefined,
    +  options?: {
    +    /** Skip verification entirely (only for development) */
    +    skipVerification?: boolean;
    +    /** Maximum allowed clock skew (ms). Defaults to 5 minutes. */
    +    maxSkewMs?: number;
    +  },
    +): TelnyxVerificationResult {
    +  if (options?.skipVerification) {
    +    return { ok: true, reason: "verification skipped (dev mode)" };
    +  }
    +
    +  if (!publicKey) {
    +    return { ok: false, reason: "Missing telnyx.publicKey (configure to verify webhooks)" };
    +  }
    +
    +  const signature = getHeader(ctx.headers, "telnyx-signature-ed25519");
    +  const timestamp = getHeader(ctx.headers, "telnyx-timestamp");
    +
    +  if (!signature || !timestamp) {
    +    return { ok: false, reason: "Missing signature or timestamp header" };
    +  }
    +
    +  const eventTimeSec = parseInt(timestamp, 10);
    +  if (!Number.isFinite(eventTimeSec)) {
    +    return { ok: false, reason: "Invalid timestamp header" };
    +  }
    +
    +  try {
    +    const signedPayload = `${timestamp}|${ctx.rawBody}`;
    +    const signatureBuffer = decodeBase64OrBase64Url(signature);
    +    const key = importEd25519PublicKey(publicKey);
    +
    +    const isValid = crypto.verify(null, Buffer.from(signedPayload), key, signatureBuffer);
    +    if (!isValid) {
    +      return { ok: false, reason: "Invalid signature" };
    +    }
    +
    +    const maxSkewMs = options?.maxSkewMs ?? 5 * 60 * 1000;
    +    const eventTimeMs = eventTimeSec * 1000;
    +    const now = Date.now();
    +    if (Math.abs(now - eventTimeMs) > maxSkewMs) {
    +      return { ok: false, reason: "Timestamp too old" };
    +    }
    +
    +    return { ok: true };
    +  } catch (err) {
    +    return {
    +      ok: false,
    +      reason: `Verification error: ${err instanceof Error ? err.message : String(err)}`,
    +    };
    +  }
    +}
    +
     /**
      * Verify Twilio webhook with full context and detailed result.
      */
    
29b587e73cbd

fix(voice-call): fail closed when Telnyx webhook public key missing

https://github.com/openclaw/openclawPeter SteinbergerFeb 14, 2026via ghsa
8 files changed · +75 15
  • CHANGELOG.md+1 0 modified
    @@ -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.
    
  • docs/plugins/voice-call.md+1 0 modified
    @@ -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.
    
  • extensions/voice-call/README.md+1 0 modified
    @@ -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
    
  • extensions/voice-call/src/config.test.ts+15 2 modified
    @@ -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", () => {
    
  • extensions/voice-call/src/config.ts+2 5 modified
    @@ -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)",
           );
         }
       }
    
  • extensions/voice-call/src/providers/telnyx.test.ts+47 0 added
    @@ -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);
    +  });
    +});
    
  • extensions/voice-call/src/providers/telnyx.ts+7 6 modified
    @@ -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)",
    
  • extensions/voice-call/src/runtime.ts+1 2 modified
    @@ -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":
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.