diff --git a/CHANGELOG.md b/CHANGELOG.md index b74f1d444c8..dd9255de381 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/Security: enforce gateway auth for the exact `/api/channels` plugin root path (plus `/api/channels/` descendants), with regression coverage for query/trailing-slash variants and near-miss paths that must remain plugin-owned. (#25753) Thanks @bmendonca3. +- Security/Exec: sanitize inherited host execution environment before merge, canonicalize inherited PATH handling, and strip dangerous keys (`LD_*`, `DYLD_*`, `SSLKEYLOGFILE`, and related injection vectors) from non-sandboxed exec runs. (#25755) Thanks @bmendonca3. +- Security/Hooks: normalize hook session-key classification with trim/lowercase plus Unicode NFKC folding (for example full-width `HOOK:...`) so external-content wrapping cannot be bypassed by mixed-case or lookalike prefixes. (#25750) Thanks @bmendonca3. +- Security/Voice Call: add Telnyx webhook replay detection and canonicalize replay-key signature encoding (Base64/Base64URL equivalent forms dedupe together), so duplicate signed webhook deliveries no longer re-trigger side effects. (#25832) Thanks @bmendonca3. - Discord/Block streaming: restore block-streamed reply delivery by suppressing only reasoning payloads (instead of all `block` payloads), fixing missing Discord replies in `channels.discord.streaming=block` mode. (#25839, #25836, #25792) Thanks @pewallin. - Matrix/Read receipts: send read receipts as soon as Matrix messages arrive (before handler pipeline work), so clients no longer show long-lived unread/sent states while replies are processing. (#25841, #25840) Thanks @joshjhall. - Sandbox/FS bridge: build canonical-path shell scripts with newline separators (not `; ` joins) to avoid POSIX `sh` `do;` syntax errors that broke sandbox file/image read-write operations. (#25737, #25824, #25868) Thanks @DennisGoldfinger and @peteragility. @@ -598,7 +602,6 @@ Docs: https://docs.openclaw.ai - Security/Media: harden local media ingestion against TOCTOU/symlink swap attacks by pinning reads to a single file descriptor with symlink rejection and inode/device verification in `saveMediaSource`. Thanks @dorjoos for reporting. - Security/Lobster (Windows): for the next npm release, remove shell-based fallback when launching Lobster wrappers (`.cmd`/`.bat`) and switch to explicit argv execution with wrapper entrypoint resolution, preventing command injection while preserving Windows wrapper compatibility. Thanks @allsmog for reporting. - Security/Exec: require `tools.exec.safeBins` binaries to resolve from trusted bin directories (system defaults plus gateway startup `PATH`) so PATH-hijacked trojan binaries cannot bypass allowlist checks. Thanks @jackhax for reporting. -- Security/Exec: sanitize inherited host execution environment before merge and strip dangerous keys (`LD_*`, `DYLD_*`, `SSLKEYLOGFILE`, and related injection vectors) from non-sandboxed exec runs. (#9792) - Security/Exec: remove file-existence oracle behavior from `tools.exec.safeBins` by using deterministic argv-only stdin-safe validation and blocking file-oriented flags (for example `sort -o`, `jq -f`, `grep -f`) so allow/deny results no longer disclose host file presence. Thanks @nedlir for reporting. - Security/Browser: route browser URL navigation through one SSRF-guarded validation path for tab-open/CDP-target/Playwright navigation flows and block private/metadata destinations by default (configurable via `browser.ssrfPolicy`). Thanks @dorjoos for reporting. - Security/Exec: for the next npm release, harden safe-bin stdin-only enforcement by blocking output/recursive flags (`sort -o/--output`, grep recursion) and tightening default safe bins to remove `sort`/`grep`, preventing safe-bin allowlist bypass for file writes/recursive reads. Thanks @nedlir for reporting. diff --git a/extensions/voice-call/src/providers/telnyx.test.ts b/extensions/voice-call/src/providers/telnyx.test.ts index 7fcd756b943..e81c41376a3 100644 --- a/extensions/voice-call/src/providers/telnyx.test.ts +++ b/extensions/voice-call/src/providers/telnyx.test.ts @@ -136,4 +136,52 @@ describe("TelnyxProvider.verifyWebhook", () => { expect(second.ok).toBe(true); expect(second.isReplay).toBe(true); }); + + it("treats base64url signature variants as replay of the same request", () => { + const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); + const spkiDer = publicKey.export({ format: "der", type: "spki" }) as Buffer; + const provider = new TelnyxProvider( + { apiKey: "KEY123", connectionId: "CONN456", publicKey: spkiDer.toString("base64") }, + { skipVerification: false }, + ); + + const rawBody = JSON.stringify({ + event_type: "call.initiated", + payload: { call_control_id: "call-replay-test-url" }, + nonce: crypto.randomUUID(), + }); + const timestamp = String(Math.floor(Date.now() / 1000)); + const signedPayload = `${timestamp}|${rawBody}`; + const signatureBase64 = crypto + .sign(null, Buffer.from(signedPayload), privateKey) + .toString("base64"); + const signatureBase64Url = signatureBase64 + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); + + const first = provider.verifyWebhook( + createCtx({ + rawBody, + headers: { + "telnyx-signature-ed25519": signatureBase64, + "telnyx-timestamp": timestamp, + }, + }), + ); + const second = provider.verifyWebhook( + createCtx({ + rawBody, + headers: { + "telnyx-signature-ed25519": signatureBase64Url, + "telnyx-timestamp": timestamp, + }, + }), + ); + + expect(first.ok).toBe(true); + expect(first.isReplay).toBeFalsy(); + expect(second.ok).toBe(true); + expect(second.isReplay).toBe(true); + }); }); diff --git a/extensions/voice-call/src/webhook-security.test.ts b/extensions/voice-call/src/webhook-security.test.ts index e85838a1383..9b7be8b7b40 100644 --- a/extensions/voice-call/src/webhook-security.test.ts +++ b/extensions/voice-call/src/webhook-security.test.ts @@ -232,6 +232,54 @@ describe("verifyTelnyxWebhook", () => { expect(second.ok).toBe(true); expect(second.isReplay).toBe(true); }); + + it("detects replay across equivalent base64/base64url signature encodings", () => { + const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); + const pemPublicKey = publicKey.export({ format: "pem", type: "spki" }).toString(); + const timestamp = String(Math.floor(Date.now() / 1000)); + const rawBody = JSON.stringify({ + data: { event_type: "call.answered", payload: { call_control_id: "call-2" } }, + nonce: crypto.randomUUID(), + }); + const signedPayload = `${timestamp}|${rawBody}`; + const signatureBase64 = crypto + .sign(null, Buffer.from(signedPayload), privateKey) + .toString("base64"); + const signatureBase64Url = signatureBase64 + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); + + const first = verifyTelnyxWebhook( + { + headers: { + "telnyx-signature-ed25519": signatureBase64, + "telnyx-timestamp": timestamp, + }, + rawBody, + url: "https://example.com/voice/webhook", + method: "POST", + }, + pemPublicKey, + ); + const second = verifyTelnyxWebhook( + { + headers: { + "telnyx-signature-ed25519": signatureBase64Url, + "telnyx-timestamp": timestamp, + }, + rawBody, + url: "https://example.com/voice/webhook", + method: "POST", + }, + pemPublicKey, + ); + + expect(first.ok).toBe(true); + expect(first.isReplay).toBeFalsy(); + expect(second.ok).toBe(true); + expect(second.isReplay).toBe(true); + }); }); describe("verifyTwilioWebhook", () => { diff --git a/extensions/voice-call/src/webhook-security.ts b/extensions/voice-call/src/webhook-security.ts index d190ed8f9ff..f20f0cd2a0a 100644 --- a/extensions/voice-call/src/webhook-security.ts +++ b/extensions/voice-call/src/webhook-security.ts @@ -401,6 +401,18 @@ export interface TelnyxVerificationResult { isReplay?: boolean; } +function createTelnyxReplayKey(params: { + timestampSec: number; + signatureBytes: Buffer; + rawBody: string; +}): string { + // Canonicalize signature/timestamp so equivalent header encodings + // (for example Base64 vs Base64URL) map to the same replay key. + return `telnyx:${sha256Hex( + `${params.timestampSec}\n${params.signatureBytes.toString("base64")}\n${params.rawBody}`, + )}`; +} + function createTwilioReplayKey(params: { ctx: WebhookContext; signature: string; @@ -506,7 +518,11 @@ export function verifyTelnyxWebhook( return { ok: false, reason: "Timestamp too old" }; } - const replayKey = `telnyx:${sha256Hex(`${timestamp}\n${signature}\n${ctx.rawBody}`)}`; + const replayKey = createTelnyxReplayKey({ + timestampSec: eventTimeSec, + signatureBytes: signatureBuffer, + rawBody: ctx.rawBody, + }); const isReplay = markReplay(telnyxReplayCache, replayKey); return { ok: true, isReplay }; } catch (err) {