Moderate severityNVD Advisory· Published Mar 19, 2026· Updated Mar 20, 2026
OpenClaw < 2026.2.26 - Authorization Bypass via DM Pairing-Store Fallback in Group Allowlist
CVE-2026-32006
Description
OpenClaw versions prior to 2026.2.26 contain an authorization bypass vulnerability where DM pairing-store identities are incorrectly treated as group allowlist identities when dmPolicy=pairing and groupPolicy=allowlist. Remote attackers can send messages and reactions as DM-paired identities without explicit groupAllowFrom membership to bypass group sender authorization checks.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.2.26 | 2026.2.26 |
Affected products
1Patches
21aadf26f9accfix(voice-call): bind webhook dedupe to verified request identity
15 files changed · +329 −74
CHANGELOG.md+1 −0 modified@@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai - Security/Config includes: harden `$include` file loading with verified-open reads, reject hardlinked include aliases, and enforce include file-size guardrails so config include resolution remains bounded to trusted in-root files. This ships in the next npm release (`2026.2.26`). Thanks @zpbrent for reporting. - Security/Node exec approvals: require structured `commandArgv` approvals for `host=node`, enforce versioned `systemRunBindingV1` matching for argv/cwd/session/agent/env context with fail-closed behavior on missing/mismatched bindings, and add `GIT_EXTERNAL_DIFF` to blocked host env keys. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. - Security/Microsoft Teams media fetch: route Graph message/hosted-content/attachment fetches and auth-scope fallback attachment downloads through shared SSRF-guarded fetch paths, and centralize hostname-suffix allowlist policy helpers in the plugin SDK to remove channel/plugin drift. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. +- Security/Voice Call (Twilio): bind webhook replay + manager dedupe identity to authenticated request material, remove unsigned `i-twilio-idempotency-token` trust from replay/dedupe keys, and thread verified request identity through provider parse flow to harden cross-provider event dedupe. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. - Microsoft Teams/File uploads: acknowledge `fileConsent/invoke` immediately (`invokeResponse` before upload + file card send) so Teams no longer shows false "Something went wrong" timeout banners while upload completion continues asynchronously; includes updated async regression coverage. Landed from contributor PR #27641 by @scz2011. - Security/Plugin channel HTTP auth: normalize protected `/api/channels` path checks against canonicalized request paths (case + percent-decoding + slash normalization), resolve encoded dot-segment traversal variants, and fail closed on malformed `%`-encoded channel prefixes so alternate-path variants cannot bypass gateway auth. This ships in the next npm release (`2026.2.26`). Thanks @zpbrent for reporting. - Security/Exec approvals forwarding: prefer turn-source channel/account/thread metadata when resolving approval delivery targets so stale session routes do not misroute approval prompts.
extensions/voice-call/src/providers/base.ts+2 −1 modified@@ -4,6 +4,7 @@ import type { InitiateCallResult, PlayTtsInput, ProviderName, + WebhookParseOptions, ProviderWebhookParseResult, StartListeningInput, StopListeningInput, @@ -36,7 +37,7 @@ export interface VoiceCallProvider { * Parse provider-specific webhook payload into normalized events. * Returns events and optional response to send back to provider. */ - parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult; + parseWebhookEvent(ctx: WebhookContext, options?: WebhookParseOptions): ProviderWebhookParseResult; /** * Initiate an outbound call.
extensions/voice-call/src/providers/mock.ts+5 −1 modified@@ -6,6 +6,7 @@ import type { InitiateCallResult, NormalizedEvent, PlayTtsInput, + WebhookParseOptions, ProviderWebhookParseResult, StartListeningInput, StopListeningInput, @@ -28,7 +29,10 @@ export class MockProvider implements VoiceCallProvider { return { ok: true }; } - parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult { + parseWebhookEvent( + ctx: WebhookContext, + _options?: WebhookParseOptions, + ): ProviderWebhookParseResult { try { const payload = JSON.parse(ctx.rawBody); const events: NormalizedEvent[] = [];
extensions/voice-call/src/providers/plivo.test.ts+22 −0 modified@@ -24,4 +24,26 @@ describe("PlivoProvider", () => { expect(result.providerResponseBody).toContain("<Wait"); expect(result.providerResponseBody).toContain('length="300"'); }); + + it("uses verified request key when provided", () => { + const provider = new PlivoProvider({ + authId: "MA000000000000000000", + authToken: "test-token", + }); + + const result = provider.parseWebhookEvent( + { + headers: { host: "example.com", "x-plivo-signature-v3-nonce": "nonce-1" }, + rawBody: + "CallUUID=call-uuid&CallStatus=in-progress&Direction=outbound&From=%2B15550000000&To=%2B15550000001&Event=StartApp", + url: "https://example.com/voice/webhook?provider=plivo&flow=answer&callId=internal-call-id", + method: "POST", + query: { provider: "plivo", flow: "answer", callId: "internal-call-id" }, + }, + { verifiedRequestKey: "plivo:v3:verified" }, + ); + + expect(result.events).toHaveLength(1); + expect(result.events[0]?.dedupeKey).toBe("plivo:v3:verified"); + }); });
extensions/voice-call/src/providers/plivo.ts+38 −18 modified@@ -1,4 +1,5 @@ import crypto from "node:crypto"; +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk"; import type { PlivoConfig, WebhookSecurityConfig } from "../config.js"; import type { HangupCallInput, @@ -10,6 +11,7 @@ import type { StartListeningInput, StopListeningInput, WebhookContext, + WebhookParseOptions, WebhookVerificationResult, } from "../types.js"; import { escapeXml } from "../voice-mapping.js"; @@ -60,6 +62,7 @@ export class PlivoProvider implements VoiceCallProvider { private readonly authToken: string; private readonly baseUrl: string; private readonly options: PlivoProviderOptions; + private readonly apiHost: string; // Best-effort mapping between create-call request UUID and call UUID. private requestUuidToCallUuid = new Map<string, string>(); @@ -82,6 +85,7 @@ export class PlivoProvider implements VoiceCallProvider { this.authId = config.authId; this.authToken = config.authToken; this.baseUrl = `https://api.plivo.com/v1/Account/${this.authId}`; + this.apiHost = new URL(this.baseUrl).hostname; this.options = options; } @@ -92,25 +96,33 @@ export class PlivoProvider implements VoiceCallProvider { allowNotFound?: boolean; }): Promise<T> { const { method, endpoint, body, allowNotFound } = params; - const response = await fetch(`${this.baseUrl}${endpoint}`, { - method, - headers: { - Authorization: `Basic ${Buffer.from(`${this.authId}:${this.authToken}`).toString("base64")}`, - "Content-Type": "application/json", + const { response, release } = await fetchWithSsrFGuard({ + url: `${this.baseUrl}${endpoint}`, + init: { + method, + headers: { + Authorization: `Basic ${Buffer.from(`${this.authId}:${this.authToken}`).toString("base64")}`, + "Content-Type": "application/json", + }, + body: body ? JSON.stringify(body) : undefined, }, - body: body ? JSON.stringify(body) : undefined, + policy: { allowedHostnames: [this.apiHost] }, + auditContext: "voice-call.plivo.api", }); - - if (!response.ok) { - if (allowNotFound && response.status === 404) { - return undefined as T; + try { + if (!response.ok) { + if (allowNotFound && response.status === 404) { + return undefined as T; + } + const errorText = await response.text(); + throw new Error(`Plivo API error: ${response.status} ${errorText}`); } - const errorText = await response.text(); - throw new Error(`Plivo API error: ${response.status} ${errorText}`); - } - const text = await response.text(); - return text ? (JSON.parse(text) as T) : (undefined as T); + const text = await response.text(); + return text ? (JSON.parse(text) as T) : (undefined as T); + } finally { + await release(); + } } verifyWebhook(ctx: WebhookContext): WebhookVerificationResult { @@ -127,10 +139,18 @@ export class PlivoProvider implements VoiceCallProvider { console.warn(`[plivo] Webhook verification failed: ${result.reason}`); } - return { ok: result.ok, reason: result.reason, isReplay: result.isReplay }; + return { + ok: result.ok, + reason: result.reason, + isReplay: result.isReplay, + verifiedRequestKey: result.verifiedRequestKey, + }; } - parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult { + parseWebhookEvent( + ctx: WebhookContext, + options?: WebhookParseOptions, + ): ProviderWebhookParseResult { const flow = typeof ctx.query?.flow === "string" ? ctx.query.flow.trim() : ""; const parsed = this.parseBody(ctx.rawBody); @@ -196,7 +216,7 @@ export class PlivoProvider implements VoiceCallProvider { // Normal events. const callIdFromQuery = this.getCallIdFromQuery(ctx); - const dedupeKey = createPlivoRequestDedupeKey(ctx); + const dedupeKey = options?.verifiedRequestKey ?? createPlivoRequestDedupeKey(ctx); const event = this.normalizeEvent(parsed, callIdFromQuery, dedupeKey); return {
extensions/voice-call/src/providers/telnyx.test.ts+27 −0 modified@@ -133,7 +133,34 @@ describe("TelnyxProvider.verifyWebhook", () => { expect(first.ok).toBe(true); expect(first.isReplay).toBeFalsy(); + expect(first.verifiedRequestKey).toBeTruthy(); expect(second.ok).toBe(true); expect(second.isReplay).toBe(true); + expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey); + }); +}); + +describe("TelnyxProvider.parseWebhookEvent", () => { + it("uses verified request key for manager dedupe", () => { + const provider = new TelnyxProvider({ + apiKey: "KEY123", + connectionId: "CONN456", + publicKey: undefined, + }); + const result = provider.parseWebhookEvent( + createCtx({ + rawBody: JSON.stringify({ + data: { + id: "evt-123", + event_type: "call.initiated", + payload: { call_control_id: "call-1" }, + }, + }), + }), + { verifiedRequestKey: "telnyx:req:abc" }, + ); + + expect(result.events).toHaveLength(1); + expect(result.events[0]?.dedupeKey).toBe("telnyx:req:abc"); }); });
extensions/voice-call/src/providers/telnyx.ts+39 −19 modified@@ -1,4 +1,5 @@ import crypto from "node:crypto"; +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk"; import type { TelnyxConfig } from "../config.js"; import type { EndReason, @@ -11,6 +12,7 @@ import type { StartListeningInput, StopListeningInput, WebhookContext, + WebhookParseOptions, WebhookVerificationResult, } from "../types.js"; import { verifyTelnyxWebhook } from "../webhook-security.js"; @@ -35,6 +37,7 @@ export class TelnyxProvider implements VoiceCallProvider { private readonly publicKey: string | undefined; private readonly options: TelnyxProviderOptions; private readonly baseUrl = "https://api.telnyx.com/v2"; + private readonly apiHost = "api.telnyx.com"; constructor(config: TelnyxConfig, options: TelnyxProviderOptions = {}) { if (!config.apiKey) { @@ -58,25 +61,33 @@ export class TelnyxProvider implements VoiceCallProvider { body: Record<string, unknown>, options?: { allowNotFound?: boolean }, ): Promise<T> { - const response = await fetch(`${this.baseUrl}${endpoint}`, { - method: "POST", - headers: { - Authorization: `Bearer ${this.apiKey}`, - "Content-Type": "application/json", + const { response, release } = await fetchWithSsrFGuard({ + url: `${this.baseUrl}${endpoint}`, + init: { + method: "POST", + headers: { + Authorization: `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), }, - body: JSON.stringify(body), + policy: { allowedHostnames: [this.apiHost] }, + auditContext: "voice-call.telnyx.api", }); - - if (!response.ok) { - if (options?.allowNotFound && response.status === 404) { - return undefined as T; + try { + if (!response.ok) { + if (options?.allowNotFound && response.status === 404) { + return undefined as T; + } + const errorText = await response.text(); + throw new Error(`Telnyx API error: ${response.status} ${errorText}`); } - const errorText = await response.text(); - throw new Error(`Telnyx API error: ${response.status} ${errorText}`); - } - const text = await response.text(); - return text ? (JSON.parse(text) as T) : (undefined as T); + const text = await response.text(); + return text ? (JSON.parse(text) as T) : (undefined as T); + } finally { + await release(); + } } /** @@ -87,13 +98,21 @@ export class TelnyxProvider implements VoiceCallProvider { skipVerification: this.options.skipVerification, }); - return { ok: result.ok, reason: result.reason, isReplay: result.isReplay }; + return { + ok: result.ok, + reason: result.reason, + isReplay: result.isReplay, + verifiedRequestKey: result.verifiedRequestKey, + }; } /** * Parse Telnyx webhook event into normalized format. */ - parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult { + parseWebhookEvent( + ctx: WebhookContext, + options?: WebhookParseOptions, + ): ProviderWebhookParseResult { try { const payload = JSON.parse(ctx.rawBody); const data = payload.data; @@ -102,7 +121,7 @@ export class TelnyxProvider implements VoiceCallProvider { return { events: [], statusCode: 200 }; } - const event = this.normalizeEvent(data); + const event = this.normalizeEvent(data, options?.verifiedRequestKey); return { events: event ? [event] : [], statusCode: 200, @@ -115,7 +134,7 @@ export class TelnyxProvider implements VoiceCallProvider { /** * Convert Telnyx event to normalized event format. */ - private normalizeEvent(data: TelnyxEvent): NormalizedEvent | null { + private normalizeEvent(data: TelnyxEvent, dedupeKey?: string): NormalizedEvent | null { // Decode client_state from Base64 (we encode it in initiateCall) let callId = ""; if (data.payload?.client_state) { @@ -132,6 +151,7 @@ export class TelnyxProvider implements VoiceCallProvider { const baseEvent = { id: data.id || crypto.randomUUID(), + dedupeKey, callId, providerCallId: data.payload?.call_control_id, timestamp: Date.now(),
extensions/voice-call/src/providers/twilio.test.ts+23 −2 modified@@ -60,7 +60,7 @@ describe("TwilioProvider", () => { expect(result.providerResponseBody).toContain("<Connect>"); }); - it("uses a stable dedupeKey for identical request payloads", () => { + it("uses a stable fallback dedupeKey for identical request payloads", () => { const provider = createProvider(); const rawBody = "CallSid=CA789&Direction=inbound&SpeechResult=hello"; const ctxA = { @@ -78,10 +78,31 @@ describe("TwilioProvider", () => { expect(eventA).toBeDefined(); expect(eventB).toBeDefined(); expect(eventA?.id).not.toBe(eventB?.id); - expect(eventA?.dedupeKey).toBe("twilio:idempotency:idem-123"); + expect(eventA?.dedupeKey).toContain("twilio:fallback:"); expect(eventA?.dedupeKey).toBe(eventB?.dedupeKey); }); + it("uses verified request key for dedupe and ignores idempotency header changes", () => { + const provider = createProvider(); + const rawBody = "CallSid=CA790&Direction=inbound&SpeechResult=hello"; + const ctxA = { + ...createContext(rawBody, { callId: "call-1", turnToken: "turn-1" }), + headers: { "i-twilio-idempotency-token": "idem-a" }, + }; + const ctxB = { + ...createContext(rawBody, { callId: "call-1", turnToken: "turn-1" }), + headers: { "i-twilio-idempotency-token": "idem-b" }, + }; + + const eventA = provider.parseWebhookEvent(ctxA, { verifiedRequestKey: "twilio:req:abc" }) + .events[0]; + const eventB = provider.parseWebhookEvent(ctxB, { verifiedRequestKey: "twilio:req:abc" }) + .events[0]; + + expect(eventA?.dedupeKey).toBe("twilio:req:abc"); + expect(eventB?.dedupeKey).toBe("twilio:req:abc"); + }); + it("keeps turnToken from query on speech events", () => { const provider = createProvider(); const ctx = createContext("CallSid=CA222&Direction=inbound&SpeechResult=hello", {
extensions/voice-call/src/providers/twilio.ts+16 −7 modified@@ -13,6 +13,7 @@ import type { StartListeningInput, StopListeningInput, WebhookContext, + WebhookParseOptions, WebhookVerificationResult, } from "../types.js"; import { escapeXml, mapVoiceToPolly } from "../voice-mapping.js"; @@ -31,19 +32,24 @@ function getHeader( return value; } -function createTwilioRequestDedupeKey(ctx: WebhookContext): string { - const idempotencyToken = getHeader(ctx.headers, "i-twilio-idempotency-token"); - if (idempotencyToken) { - return `twilio:idempotency:${idempotencyToken}`; +function createTwilioRequestDedupeKey(ctx: WebhookContext, verifiedRequestKey?: string): string { + if (verifiedRequestKey) { + return verifiedRequestKey; } const signature = getHeader(ctx.headers, "x-twilio-signature") ?? ""; + const params = new URLSearchParams(ctx.rawBody); + const callSid = params.get("CallSid") ?? ""; + const callStatus = params.get("CallStatus") ?? ""; + const direction = params.get("Direction") ?? ""; const callId = typeof ctx.query?.callId === "string" ? ctx.query.callId.trim() : ""; const flow = typeof ctx.query?.flow === "string" ? ctx.query.flow.trim() : ""; const turnToken = typeof ctx.query?.turnToken === "string" ? ctx.query.turnToken.trim() : ""; return `twilio:fallback:${crypto .createHash("sha256") - .update(`${signature}\n${callId}\n${flow}\n${turnToken}\n${ctx.rawBody}`) + .update( + `${signature}\n${callSid}\n${callStatus}\n${direction}\n${callId}\n${flow}\n${turnToken}\n${ctx.rawBody}`, + ) .digest("hex")}`; } @@ -232,7 +238,10 @@ export class TwilioProvider implements VoiceCallProvider { /** * Parse Twilio webhook event into normalized format. */ - parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult { + parseWebhookEvent( + ctx: WebhookContext, + options?: WebhookParseOptions, + ): ProviderWebhookParseResult { try { const params = new URLSearchParams(ctx.rawBody); const callIdFromQuery = @@ -243,7 +252,7 @@ export class TwilioProvider implements VoiceCallProvider { typeof ctx.query?.turnToken === "string" && ctx.query.turnToken.trim() ? ctx.query.turnToken.trim() : undefined; - const dedupeKey = createTwilioRequestDedupeKey(ctx); + const dedupeKey = createTwilioRequestDedupeKey(ctx, options?.verifiedRequestKey); const event = this.normalizeEvent(params, { callIdOverride: callIdFromQuery, dedupeKey,
extensions/voice-call/src/providers/twilio/webhook.ts+1 −0 modified@@ -29,5 +29,6 @@ export function verifyTwilioProviderWebhook(params: { ok: result.ok, reason: result.reason, isReplay: result.isReplay, + verifiedRequestKey: result.verifiedRequestKey, }; }
extensions/voice-call/src/types.ts+7 −0 modified@@ -177,6 +177,13 @@ export type WebhookVerificationResult = { reason?: string; /** Signature is valid, but request was seen before within replay window. */ isReplay?: boolean; + /** Stable key derived from authenticated request material. */ + verifiedRequestKey?: string; +}; + +export type WebhookParseOptions = { + /** Stable request key from verifyWebhook. */ + verifiedRequestKey?: string; }; export type WebhookContext = {
extensions/voice-call/src/webhook-security.test.ts+54 −0 modified@@ -198,8 +198,10 @@ describe("verifyPlivoWebhook", () => { expect(first.ok).toBe(true); expect(first.isReplay).toBeFalsy(); + expect(first.verifiedRequestKey).toBeTruthy(); expect(second.ok).toBe(true); expect(second.isReplay).toBe(true); + expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey); }); }); @@ -229,8 +231,10 @@ describe("verifyTelnyxWebhook", () => { expect(first.ok).toBe(true); expect(first.isReplay).toBeFalsy(); + expect(first.verifiedRequestKey).toBeTruthy(); expect(second.ok).toBe(true); expect(second.isReplay).toBe(true); + expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey); }); }); @@ -304,8 +308,58 @@ describe("verifyTwilioWebhook", () => { expect(first.ok).toBe(true); expect(first.isReplay).toBeFalsy(); + expect(first.verifiedRequestKey).toBeTruthy(); expect(second.ok).toBe(true); expect(second.isReplay).toBe(true); + expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey); + }); + + it("treats changed idempotency header as replay for identical signed requests", () => { + const authToken = "test-auth-token"; + const publicUrl = "https://example.com/voice/webhook"; + const urlWithQuery = `${publicUrl}?callId=abc`; + const postBody = "CallSid=CS778&CallStatus=completed&From=%2B15550000000"; + const signature = twilioSignature({ authToken, url: urlWithQuery, postBody }); + + const first = verifyTwilioWebhook( + { + headers: { + host: "example.com", + "x-forwarded-proto": "https", + "x-twilio-signature": signature, + "i-twilio-idempotency-token": "idem-replay-a", + }, + rawBody: postBody, + url: "http://local/voice/webhook?callId=abc", + method: "POST", + query: { callId: "abc" }, + }, + authToken, + { publicUrl }, + ); + const second = verifyTwilioWebhook( + { + headers: { + host: "example.com", + "x-forwarded-proto": "https", + "x-twilio-signature": signature, + "i-twilio-idempotency-token": "idem-replay-b", + }, + rawBody: postBody, + url: "http://local/voice/webhook?callId=abc", + method: "POST", + query: { callId: "abc" }, + }, + authToken, + { publicUrl }, + ); + + expect(first.ok).toBe(true); + expect(first.isReplay).toBe(false); + expect(first.verifiedRequestKey).toBeTruthy(); + expect(second.ok).toBe(true); + expect(second.isReplay).toBe(true); + expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey); }); it("rejects invalid signatures even when attacker injects forwarded host", () => {
extensions/voice-call/src/webhook-security.ts+39 −25 modified@@ -81,17 +81,7 @@ export function validateTwilioSignature( return false; } - // Build the string to sign: URL + sorted params (key+value pairs) - let dataToSign = url; - - // Sort params alphabetically and append key+value - const sortedParams = Array.from(params.entries()).toSorted((a, b) => - a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0, - ); - - for (const [key, value] of sortedParams) { - dataToSign += key + value; - } + const dataToSign = buildTwilioDataToSign(url, params); // HMAC-SHA1 with auth token, then base64 encode const expectedSignature = crypto @@ -103,6 +93,24 @@ export function validateTwilioSignature( return timingSafeEqual(signature, expectedSignature); } +function buildTwilioDataToSign(url: string, params: URLSearchParams): string { + let dataToSign = url; + const sortedParams = Array.from(params.entries()).toSorted((a, b) => + a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0, + ); + for (const [key, value] of sortedParams) { + dataToSign += key + value; + } + return dataToSign; +} + +function buildCanonicalTwilioParamString(params: URLSearchParams): string { + return Array.from(params.entries()) + .toSorted((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)) + .map(([key, value]) => `${key}=${value}`) + .join("&"); +} + /** * Timing-safe string comparison to prevent timing attacks. */ @@ -392,26 +400,27 @@ export interface TwilioVerificationResult { isNgrokFreeTier?: boolean; /** Request is cryptographically valid but was already processed recently. */ isReplay?: boolean; + /** Stable request identity derived from signed Twilio material. */ + verifiedRequestKey?: string; } export interface TelnyxVerificationResult { ok: boolean; reason?: string; /** Request is cryptographically valid but was already processed recently. */ isReplay?: boolean; + /** Stable request identity derived from signed Telnyx material. */ + verifiedRequestKey?: string; } function createTwilioReplayKey(params: { - ctx: WebhookContext; - signature: string; verificationUrl: string; + signature: string; + requestParams: URLSearchParams; }): string { - const idempotencyToken = getHeader(params.ctx.headers, "i-twilio-idempotency-token"); - if (idempotencyToken) { - return `twilio:idempotency:${idempotencyToken}`; - } - return `twilio:fallback:${sha256Hex( - `${params.verificationUrl}\n${params.signature}\n${params.ctx.rawBody}`, + const canonicalParams = buildCanonicalTwilioParamString(params.requestParams); + return `twilio:req:${sha256Hex( + `${params.verificationUrl}\n${canonicalParams}\n${params.signature}`, )}`; } @@ -508,7 +517,7 @@ export function verifyTelnyxWebhook( const replayKey = `telnyx:${sha256Hex(`${timestamp}\n${signature}\n${ctx.rawBody}`)}`; const isReplay = markReplay(telnyxReplayCache, replayKey); - return { ok: true, isReplay }; + return { ok: true, isReplay, verifiedRequestKey: replayKey }; } catch (err) { return { ok: false, @@ -583,13 +592,16 @@ export function verifyTwilioWebhook( // Parse the body as URL-encoded params const params = new URLSearchParams(ctx.rawBody); - // Validate signature const isValid = validateTwilioSignature(authToken, signature, verificationUrl, params); if (isValid) { - const replayKey = createTwilioReplayKey({ ctx, signature, verificationUrl }); + const replayKey = createTwilioReplayKey({ + verificationUrl, + signature, + requestParams: params, + }); const isReplay = markReplay(twilioReplayCache, replayKey); - return { ok: true, verificationUrl, isReplay }; + return { ok: true, verificationUrl, isReplay, verifiedRequestKey: replayKey }; } // Check if this is ngrok free tier - the URL might have different format @@ -619,6 +631,8 @@ export interface PlivoVerificationResult { version?: "v3" | "v2"; /** Request is cryptographically valid but was already processed recently. */ isReplay?: boolean; + /** Stable request identity derived from signed Plivo material. */ + verifiedRequestKey?: string; } function normalizeSignatureBase64(input: string): string { @@ -849,7 +863,7 @@ export function verifyPlivoWebhook( } const replayKey = `plivo:v3:${sha256Hex(`${verificationUrl}\n${nonceV3}`)}`; const isReplay = markReplay(plivoReplayCache, replayKey); - return { ok: true, version: "v3", verificationUrl, isReplay }; + return { ok: true, version: "v3", verificationUrl, isReplay, verifiedRequestKey: replayKey }; } if (signatureV2 && nonceV2) { @@ -869,7 +883,7 @@ export function verifyPlivoWebhook( } const replayKey = `plivo:v2:${sha256Hex(`${verificationUrl}\n${nonceV2}`)}`; const isReplay = markReplay(plivoReplayCache, replayKey); - return { ok: true, version: "v2", verificationUrl, isReplay }; + return { ok: true, version: "v2", verificationUrl, isReplay, verifiedRequestKey: replayKey }; } return {
extensions/voice-call/src/webhook.test.ts+52 −0 modified@@ -165,4 +165,56 @@ describe("VoiceCallWebhookServer replay handling", () => { await server.stop(); } }); + + it("passes verified request key from verifyWebhook into parseWebhookEvent", async () => { + const parseWebhookEvent = vi.fn((_ctx: unknown, options?: { verifiedRequestKey?: string }) => ({ + events: [ + { + id: "evt-verified", + dedupeKey: options?.verifiedRequestKey, + type: "call.speech" as const, + callId: "call-1", + providerCallId: "provider-call-1", + timestamp: Date.now(), + transcript: "hello", + isFinal: true, + }, + ], + statusCode: 200, + })); + const verifiedProvider: VoiceCallProvider = { + ...provider, + verifyWebhook: () => ({ ok: true, verifiedRequestKey: "verified:req:123" }), + parseWebhookEvent, + }; + const { manager, processEvent } = createManager([]); + const config = createConfig({ serve: { port: 0, bind: "127.0.0.1", path: "/voice/webhook" } }); + const server = new VoiceCallWebhookServer(config, manager, verifiedProvider); + + try { + const baseUrl = await server.start(); + const address = ( + server as unknown as { server?: { address?: () => unknown } } + ).server?.address?.(); + const requestUrl = new URL(baseUrl); + if (address && typeof address === "object" && "port" in address && address.port) { + requestUrl.port = String(address.port); + } + const response = await fetch(requestUrl.toString(), { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: "CallSid=CA123&SpeechResult=hello", + }); + + expect(response.status).toBe(200); + expect(parseWebhookEvent).toHaveBeenCalledTimes(1); + expect(parseWebhookEvent.mock.calls[0]?.[1]).toEqual({ + verifiedRequestKey: "verified:req:123", + }); + expect(processEvent).toHaveBeenCalledTimes(1); + expect(processEvent.mock.calls[0]?.[0]?.dedupeKey).toBe("verified:req:123"); + } finally { + await server.stop(); + } + }); });
extensions/voice-call/src/webhook.ts+3 −1 modified@@ -343,7 +343,9 @@ export class VoiceCallWebhookServer { } // Parse events - const result = this.provider.parseWebhookEvent(ctx); + const result = this.provider.parseWebhookEvent(ctx, { + verifiedRequestKey: verification.verifiedRequestKey, + }); // Process each event if (verification.isReplay) {
051fdcc42812fix(security): centralize dm/group allowlist auth composition
8 files changed · +428 −108
extensions/mattermost/src/mattermost/monitor-auth.ts+1 −1 modified@@ -44,7 +44,7 @@ export function isMattermostSenderAllowed(params: { allowFrom: string[]; allowNameMatching?: boolean; }): boolean { - const allowFrom = params.allowFrom; + const allowFrom = normalizeMattermostAllowList(params.allowFrom); if (allowFrom.length === 0) { return false; }
extensions/mattermost/src/mattermost/monitor.ts+47 −38 modified@@ -37,11 +37,7 @@ import { type MattermostPost, type MattermostUser, } from "./client.js"; -import { - isMattermostSenderAllowed, - normalizeMattermostAllowList, - resolveMattermostEffectiveAllowFromLists, -} from "./monitor-auth.js"; +import { isMattermostSenderAllowed, normalizeMattermostAllowList } from "./monitor-auth.js"; import { createDedupeCache, formatInboundFromLabel, @@ -360,18 +356,32 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} senderId; const rawText = post.message?.trim() || ""; const dmPolicy = account.config.dmPolicy ?? "pairing"; + const normalizedAllowFrom = normalizeMattermostAllowList(account.config.allowFrom ?? []); + const normalizedGroupAllowFrom = normalizeMattermostAllowList( + account.config.groupAllowFrom ?? [], + ); const storeAllowFrom = normalizeMattermostAllowList( dmPolicy === "allowlist" ? [] : await core.channel.pairing.readAllowFromStore("mattermost").catch(() => []), ); - const { effectiveAllowFrom, effectiveGroupAllowFrom } = - resolveMattermostEffectiveAllowFromLists({ - dmPolicy, - allowFrom: account.config.allowFrom, - groupAllowFrom: account.config.groupAllowFrom, - storeAllowFrom, - }); + const accessDecision = resolveDmGroupAccessWithLists({ + isGroup: kind !== "direct", + dmPolicy, + groupPolicy, + allowFrom: normalizedAllowFrom, + groupAllowFrom: normalizedGroupAllowFrom, + storeAllowFrom, + isSenderAllowed: (allowFrom) => + isMattermostSenderAllowed({ + senderId, + senderName, + allowFrom, + allowNameMatching, + }), + }); + const effectiveAllowFrom = accessDecision.effectiveAllowFrom; + const effectiveGroupAllowFrom = accessDecision.effectiveGroupAllowFrom; const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ cfg, surface: "mattermost", @@ -404,17 +414,15 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} hasControlCommand, }); const commandAuthorized = - kind === "direct" - ? dmPolicy === "open" || senderAllowedForCommands - : commandGate.commandAuthorized; + kind === "direct" ? accessDecision.decision === "allow" : commandGate.commandAuthorized; - if (kind === "direct") { - if (dmPolicy === "disabled") { - logVerboseMessage(`mattermost: drop dm (dmPolicy=disabled sender=${senderId})`); - return; - } - if (dmPolicy !== "open" && !senderAllowedForCommands) { - if (dmPolicy === "pairing") { + if (accessDecision.decision !== "allow") { + if (kind === "direct") { + if (accessDecision.reason === "dmPolicy=disabled") { + logVerboseMessage(`mattermost: drop dm (dmPolicy=disabled sender=${senderId})`); + return; + } + if (accessDecision.decision === "pairing") { const { code, created } = await core.channel.pairing.upsertPairingRequest({ channel: "mattermost", id: senderId, @@ -437,26 +445,27 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} logVerboseMessage(`mattermost: pairing reply failed for ${senderId}: ${String(err)}`); } } - } else { - logVerboseMessage(`mattermost: drop dm sender=${senderId} (dmPolicy=${dmPolicy})`); + return; } + logVerboseMessage(`mattermost: drop dm sender=${senderId} (dmPolicy=${dmPolicy})`); return; } - } else { - if (groupPolicy === "disabled") { + if (accessDecision.reason === "groupPolicy=disabled") { logVerboseMessage("mattermost: drop group message (groupPolicy=disabled)"); return; } - if (groupPolicy === "allowlist") { - if (effectiveGroupAllowFrom.length === 0) { - logVerboseMessage("mattermost: drop group message (no group allowlist)"); - return; - } - if (!groupAllowedForCommands) { - logVerboseMessage(`mattermost: drop group sender=${senderId} (not in groupAllowFrom)`); - return; - } + if (accessDecision.reason === "groupPolicy=allowlist (empty allowlist)") { + logVerboseMessage("mattermost: drop group message (no group allowlist)"); + return; + } + if (accessDecision.reason === "groupPolicy=allowlist (not allowlisted)") { + logVerboseMessage(`mattermost: drop group sender=${senderId} (not in groupAllowFrom)`); + return; } + logVerboseMessage( + `mattermost: drop group message (groupPolicy=${groupPolicy} reason=${accessDecision.reason})`, + ); + return; } if (kind !== "direct" && commandGate.shouldBlock) { @@ -852,14 +861,14 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} isGroup: kind !== "direct", dmPolicy, groupPolicy, - allowFrom: account.config.allowFrom, - groupAllowFrom: account.config.groupAllowFrom, + allowFrom: normalizeMattermostAllowList(account.config.allowFrom ?? []), + groupAllowFrom: normalizeMattermostAllowList(account.config.groupAllowFrom ?? []), storeAllowFrom, isSenderAllowed: (allowFrom) => isMattermostSenderAllowed({ senderId: userId, senderName, - allowFrom: normalizeMattermostAllowList(allowFrom), + allowFrom, allowNameMatching, }), });
extensions/msteams/src/monitor-handler/message-handler.ts+1 −4 modified@@ -146,18 +146,15 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { }); const effectiveDmAllowFrom = resolvedAllowFromLists.effectiveAllowFrom; if (isDirectMessage && msteamsCfg) { - const allowFrom = dmAllowFrom; - if (dmPolicy === "disabled") { log.debug?.("dropping dm (dms disabled)"); return; } if (dmPolicy !== "open") { - const effectiveAllowFrom = [...allowFrom.map((v) => String(v)), ...storedAllowFrom]; const allowNameMatching = isDangerousNameMatchingEnabled(msteamsCfg); const allowMatch = resolveMSTeamsAllowlistMatch({ - allowFrom: effectiveAllowFrom, + allowFrom: effectiveDmAllowFrom, senderId, senderName, allowNameMatching,
package.json+2 −1 modified@@ -54,7 +54,7 @@ "build": "pnpm canvas:a2ui:bundle && tsdown && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-compat.ts", "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", - "check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries", + "check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:auth:no-pairing-store-group", "check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links", "check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500", "deadcode:ci": "pnpm deadcode:report:ci:knip && pnpm deadcode:report:ci:ts-prune && pnpm deadcode:report:ci:ts-unused", @@ -89,6 +89,7 @@ "ios:run": "bash -lc './scripts/ios-configure-signing.sh && cd apps/ios && xcodegen generate && xcodebuild -project OpenClaw.xcodeproj -scheme OpenClaw -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build && xcrun simctl boot \"${IOS_SIM:-iPhone 17}\" || true && xcrun simctl launch booted ai.openclaw.ios'", "lint": "oxlint --type-aware", "lint:all": "pnpm lint && pnpm lint:swift", + "lint:auth:no-pairing-store-group": "node scripts/check-no-pairing-store-group-auth.mjs", "lint:docs": "pnpm dlx markdownlint-cli2", "lint:docs:fix": "pnpm dlx markdownlint-cli2 --fix", "lint:fix": "oxlint --type-aware --fix && pnpm format",
scripts/check-no-pairing-store-group-auth.mjs+227 −0 added@@ -0,0 +1,227 @@ +#!/usr/bin/env node + +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const sourceRoots = [path.join(repoRoot, "src"), path.join(repoRoot, "extensions")]; + +const allowedFiles = new Set([ + path.join(repoRoot, "src", "security", "dm-policy-shared.ts"), + path.join(repoRoot, "src", "channels", "allow-from.ts"), + // Config migration/audit logic may intentionally reference store + group fields. + path.join(repoRoot, "src", "security", "fix.ts"), + path.join(repoRoot, "src", "security", "audit-channel.ts"), +]); + +const storeIdentifierRe = /^(?:storeAllowFrom|storedAllowFrom|storeAllowList)$/i; +const groupNameRe = + /(?:groupAllowFrom|effectiveGroupAllowFrom|groupAllowed|groupAllow|groupAuth|groupSender)/i; +const allowedResolverCallNames = new Set([ + "resolveEffectiveAllowFromLists", + "resolveDmGroupAccessWithLists", + "resolveMattermostEffectiveAllowFromLists", + "resolveIrcEffectiveAllowlists", +]); + +function isTestLikeFile(filePath) { + return ( + filePath.endsWith(".test.ts") || + filePath.endsWith(".test-utils.ts") || + filePath.endsWith(".test-harness.ts") || + filePath.endsWith(".e2e-harness.ts") + ); +} + +async function collectTypeScriptFiles(dir) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const out = []; + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...(await collectTypeScriptFiles(entryPath))); + continue; + } + if (!entry.isFile() || !entryPath.endsWith(".ts") || isTestLikeFile(entryPath)) { + continue; + } + out.push(entryPath); + } + return out; +} + +function toLine(sourceFile, node) { + return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1; +} + +function getPropertyNameText(name) { + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { + return name.text; + } + return null; +} + +function getDeclarationNameText(name) { + if (ts.isIdentifier(name)) { + return name.text; + } + if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name)) { + return name.getText(); + } + return null; +} + +function containsStoreIdentifier(node) { + let found = false; + const visit = (current) => { + if (found) { + return; + } + if (ts.isIdentifier(current) && storeIdentifierRe.test(current.text)) { + found = true; + return; + } + ts.forEachChild(current, visit); + }; + visit(node); + return found; +} + +function getCallName(node) { + if (!ts.isCallExpression(node)) { + return null; + } + if (ts.isIdentifier(node.expression)) { + return node.expression.text; + } + if (ts.isPropertyAccessExpression(node.expression)) { + return node.expression.name.text; + } + return null; +} + +function isSuspiciousNormalizeWithStoreCall(node) { + if (!ts.isCallExpression(node)) { + return false; + } + if (!ts.isIdentifier(node.expression) || node.expression.text !== "normalizeAllowFromWithStore") { + return false; + } + const firstArg = node.arguments[0]; + if (!firstArg || !ts.isObjectLiteralExpression(firstArg)) { + return false; + } + let hasStoreProp = false; + let hasGroupAllowProp = false; + for (const property of firstArg.properties) { + if (!ts.isPropertyAssignment(property)) { + continue; + } + const name = getPropertyNameText(property.name); + if (!name) { + continue; + } + if (name === "storeAllowFrom" && containsStoreIdentifier(property.initializer)) { + hasStoreProp = true; + } + if (name === "allowFrom" && groupNameRe.test(property.initializer.getText())) { + hasGroupAllowProp = true; + } + } + return hasStoreProp && hasGroupAllowProp; +} + +function findViolations(content, filePath) { + const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true); + const violations = []; + + const visit = (node) => { + if (ts.isVariableDeclaration(node) && node.initializer) { + const name = getDeclarationNameText(node.name); + if (name && groupNameRe.test(name) && containsStoreIdentifier(node.initializer)) { + const callName = getCallName(node.initializer); + if (callName && allowedResolverCallNames.has(callName)) { + ts.forEachChild(node, visit); + return; + } + violations.push({ + line: toLine(sourceFile, node), + reason: `group-scoped variable "${name}" references pairing-store identifiers`, + }); + } + } + + if (ts.isPropertyAssignment(node)) { + const propName = getPropertyNameText(node.name); + if (propName && groupNameRe.test(propName) && containsStoreIdentifier(node.initializer)) { + violations.push({ + line: toLine(sourceFile, node), + reason: `group-scoped property "${propName}" references pairing-store identifiers`, + }); + } + } + + if (isSuspiciousNormalizeWithStoreCall(node)) { + violations.push({ + line: toLine(sourceFile, node), + reason: "group allowlist uses normalizeAllowFromWithStore(...) with pairing-store entries", + }); + } + + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + return violations; +} + +async function main() { + const files = ( + await Promise.all(sourceRoots.map(async (root) => await collectTypeScriptFiles(root))) + ).flat(); + + const violations = []; + for (const filePath of files) { + if (allowedFiles.has(filePath)) { + continue; + } + const content = await fs.readFile(filePath, "utf8"); + const fileViolations = findViolations(content, filePath); + for (const violation of fileViolations) { + violations.push({ + path: path.relative(repoRoot, filePath), + ...violation, + }); + } + } + + if (violations.length === 0) { + return; + } + + console.error("Found pairing-store identifiers referenced in group auth composition:"); + for (const violation of violations) { + console.error(`- ${violation.path}:${violation.line} (${violation.reason})`); + } + console.error( + "Group auth must be composed via shared resolvers (resolveDmGroupAccessWithLists / resolveEffectiveAllowFromLists).", + ); + process.exit(1); +} + +const isDirectExecution = (() => { + const entry = process.argv[1]; + if (!entry) { + return false; + } + return path.resolve(entry) === fileURLToPath(import.meta.url); +})(); + +if (isDirectExecution) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +}
src/imessage/monitor/inbound-processing.ts+43 −54 modified@@ -20,7 +20,7 @@ import { resolveChannelGroupRequireMention, } from "../../config/group-policy.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js"; -import { resolveEffectiveAllowFromLists } from "../../security/dm-policy-shared.js"; +import { resolveDmGroupAccessWithLists } from "../../security/dm-policy-shared.js"; import { truncateUtf16Safe } from "../../utils.js"; import { formatIMessageChatTarget, @@ -139,72 +139,61 @@ export function resolveIMessageInboundDecision(params: { } const groupId = isGroup ? groupIdCandidate : undefined; - const { effectiveAllowFrom: effectiveDmAllowFrom, effectiveGroupAllowFrom } = - resolveEffectiveAllowFromLists({ - allowFrom: params.allowFrom, - groupAllowFrom: params.groupAllowFrom, - storeAllowFrom: params.storeAllowFrom, - dmPolicy: params.dmPolicy, - groupAllowFromFallbackToAllowFrom: false, - }); + const accessDecision = resolveDmGroupAccessWithLists({ + isGroup, + dmPolicy: params.dmPolicy, + groupPolicy: params.groupPolicy, + allowFrom: params.allowFrom, + groupAllowFrom: params.groupAllowFrom, + storeAllowFrom: params.storeAllowFrom, + groupAllowFromFallbackToAllowFrom: false, + isSenderAllowed: (allowFrom) => + isAllowedIMessageSender({ + allowFrom, + sender, + chatId, + chatGuid, + chatIdentifier, + }), + }); + const effectiveDmAllowFrom = accessDecision.effectiveAllowFrom; + const effectiveGroupAllowFrom = accessDecision.effectiveGroupAllowFrom; + const dmAuthorized = !isGroup && accessDecision.decision === "allow"; - if (isGroup) { - if (params.groupPolicy === "disabled") { - params.logVerbose?.("Blocked iMessage group message (groupPolicy: disabled)"); - return { kind: "drop", reason: "groupPolicy disabled" }; - } - if (params.groupPolicy === "allowlist") { - if (effectiveGroupAllowFrom.length === 0) { + if (accessDecision.decision !== "allow") { + if (isGroup) { + if (accessDecision.reason === "groupPolicy=disabled") { + params.logVerbose?.("Blocked iMessage group message (groupPolicy: disabled)"); + return { kind: "drop", reason: "groupPolicy disabled" }; + } + if (accessDecision.reason === "groupPolicy=allowlist (empty allowlist)") { params.logVerbose?.( "Blocked iMessage group message (groupPolicy: allowlist, no groupAllowFrom)", ); return { kind: "drop", reason: "groupPolicy allowlist (empty groupAllowFrom)" }; } - const allowed = isAllowedIMessageSender({ - allowFrom: effectiveGroupAllowFrom, - sender, - chatId, - chatGuid, - chatIdentifier, - }); - if (!allowed) { + if (accessDecision.reason === "groupPolicy=allowlist (not allowlisted)") { params.logVerbose?.(`Blocked iMessage sender ${sender} (not in groupAllowFrom)`); return { kind: "drop", reason: "not in groupAllowFrom" }; } + params.logVerbose?.(`Blocked iMessage group message (${accessDecision.reason})`); + return { kind: "drop", reason: accessDecision.reason }; } - if (groupListPolicy.allowlistEnabled && !groupListPolicy.allowed) { - params.logVerbose?.( - `imessage: skipping group message (${groupId ?? "unknown"}) not in allowlist`, - ); - return { kind: "drop", reason: "group id not in allowlist" }; - } - } - - const dmHasWildcard = effectiveDmAllowFrom.includes("*"); - const dmAuthorized = - params.dmPolicy === "open" - ? true - : dmHasWildcard || - (effectiveDmAllowFrom.length > 0 && - isAllowedIMessageSender({ - allowFrom: effectiveDmAllowFrom, - sender, - chatId, - chatGuid, - chatIdentifier, - })); - - if (!isGroup) { - if (params.dmPolicy === "disabled") { + if (accessDecision.reason === "dmPolicy=disabled") { return { kind: "drop", reason: "dmPolicy disabled" }; } - if (!dmAuthorized) { - if (params.dmPolicy === "pairing") { - return { kind: "pairing", senderId: senderNormalized }; - } - params.logVerbose?.(`Blocked iMessage sender ${sender} (dmPolicy=${params.dmPolicy})`); - return { kind: "drop", reason: "dmPolicy blocked" }; + if (accessDecision.decision === "pairing") { + return { kind: "pairing", senderId: senderNormalized }; } + params.logVerbose?.(`Blocked iMessage sender ${sender} (dmPolicy=${params.dmPolicy})`); + return { kind: "drop", reason: "dmPolicy blocked" }; + } + + if (isGroup && groupListPolicy.allowlistEnabled && !groupListPolicy.allowed) { + params.logVerbose?.( + `imessage: skipping group message (${groupId ?? "unknown"}) not in allowlist`, + ); + return { kind: "drop", reason: "group id not in allowlist" }; } const route = resolveAgentRoute({
src/security/dm-policy-channel-smoke.test.ts+65 −0 added@@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { isAllowedBlueBubblesSender } from "../../extensions/bluebubbles/src/targets.js"; +import { isMattermostSenderAllowed } from "../../extensions/mattermost/src/mattermost/monitor-auth.js"; +import { isSignalSenderAllowed, type SignalSender } from "../signal/identity.js"; +import { resolveDmGroupAccessWithLists } from "./dm-policy-shared.js"; + +type ChannelSmokeCase = { + name: string; + storeAllowFrom: string[]; + isSenderAllowed: (allowFrom: string[]) => boolean; +}; + +const signalSender: SignalSender = { + kind: "phone", + raw: "+15550001111", + e164: "+15550001111", +}; + +const cases: ChannelSmokeCase[] = [ + { + name: "bluebubbles", + storeAllowFrom: ["attacker-user"], + isSenderAllowed: (allowFrom) => + isAllowedBlueBubblesSender({ + allowFrom, + sender: "attacker-user", + chatId: 101, + }), + }, + { + name: "signal", + storeAllowFrom: [signalSender.e164], + isSenderAllowed: (allowFrom) => isSignalSenderAllowed(signalSender, allowFrom), + }, + { + name: "mattermost", + storeAllowFrom: ["user:attacker-user"], + isSenderAllowed: (allowFrom) => + isMattermostSenderAllowed({ + senderId: "attacker-user", + senderName: "Attacker", + allowFrom, + }), + }, +]; + +describe("security/dm-policy-shared channel smoke", () => { + for (const testCase of cases) { + for (const ingress of ["message", "reaction"] as const) { + it(`[${testCase.name}] blocks group ${ingress} when sender is only in pairing store`, () => { + const access = resolveDmGroupAccessWithLists({ + isGroup: true, + dmPolicy: "pairing", + groupPolicy: "allowlist", + allowFrom: ["owner-user"], + groupAllowFrom: ["group-owner"], + storeAllowFrom: testCase.storeAllowFrom, + isSenderAllowed: testCase.isSenderAllowed, + }); + expect(access.decision).toBe("block"); + expect(access.reason).toBe("groupPolicy=allowlist (not allowlisted)"); + }); + } + } +});
src/security/dm-policy-shared.test.ts+42 −10 modified@@ -133,56 +133,88 @@ describe("security/dm-policy-shared", () => { const cases = [ { name: "dmPolicy=open", + isGroup: false, dmPolicy: "open" as const, + groupPolicy: "allowlist" as const, allowFrom: [] as string[], - senderAllowed: false, + groupAllowFrom: [] as string[], + storeAllowFrom: [] as string[], + isSenderAllowed: () => false, expectedDecision: "allow" as const, expectedReactionAllowed: true, }, { name: "dmPolicy=disabled", + isGroup: false, dmPolicy: "disabled" as const, + groupPolicy: "allowlist" as const, allowFrom: [] as string[], - senderAllowed: false, + groupAllowFrom: [] as string[], + storeAllowFrom: [] as string[], + isSenderAllowed: () => false, expectedDecision: "block" as const, expectedReactionAllowed: false, }, { name: "dmPolicy=allowlist unauthorized", + isGroup: false, dmPolicy: "allowlist" as const, + groupPolicy: "allowlist" as const, allowFrom: ["owner"], - senderAllowed: false, + groupAllowFrom: [] as string[], + storeAllowFrom: [] as string[], + isSenderAllowed: () => false, expectedDecision: "block" as const, expectedReactionAllowed: false, }, { name: "dmPolicy=allowlist authorized", + isGroup: false, dmPolicy: "allowlist" as const, + groupPolicy: "allowlist" as const, allowFrom: ["owner"], - senderAllowed: true, + groupAllowFrom: [] as string[], + storeAllowFrom: [] as string[], + isSenderAllowed: () => true, expectedDecision: "allow" as const, expectedReactionAllowed: true, }, { name: "dmPolicy=pairing unauthorized", + isGroup: false, dmPolicy: "pairing" as const, + groupPolicy: "allowlist" as const, allowFrom: [] as string[], - senderAllowed: false, + groupAllowFrom: [] as string[], + storeAllowFrom: [] as string[], + isSenderAllowed: () => false, expectedDecision: "pairing" as const, expectedReactionAllowed: false, }, + { + name: "groupPolicy=allowlist rejects DM-paired sender not in explicit group list", + isGroup: true, + dmPolicy: "pairing" as const, + groupPolicy: "allowlist" as const, + allowFrom: ["owner"] as string[], + groupAllowFrom: ["group-owner"] as string[], + storeAllowFrom: ["paired-user"] as string[], + isSenderAllowed: (allowFrom: string[]) => allowFrom.includes("paired-user"), + expectedDecision: "block" as const, + expectedReactionAllowed: false, + }, ]; for (const channel of channels) { for (const testCase of cases) { const access = resolveDmGroupAccessWithLists({ - isGroup: false, + isGroup: testCase.isGroup, dmPolicy: testCase.dmPolicy, - groupPolicy: "allowlist", + groupPolicy: testCase.groupPolicy, allowFrom: testCase.allowFrom, - groupAllowFrom: [], - storeAllowFrom: [], - isSenderAllowed: () => testCase.senderAllowed, + groupAllowFrom: testCase.groupAllowFrom, + storeAllowFrom: testCase.storeAllowFrom, + isSenderAllowed: testCase.isSenderAllowed, }); const reactionAllowed = access.decision === "allow"; expect(access.decision, `[${channel}] ${testCase.name}`).toBe(testCase.expectedDecision);
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- github.com/openclaw/openclaw/commit/051fdcc428129446e7c084260f837b7284279ce9ghsapatchWEB
- github.com/advisories/GHSA-25pw-4h6w-qwvmghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-25pw-4h6w-qwvmghsathird-party-advisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-32006ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-authorization-bypass-via-dm-pairing-store-fallback-in-group-allowlistghsathird-party-advisoryWEB
- github.com/openclaw/openclaw/commit/1aadf26f9acc399affabd859937a09468a9c5cb4ghsaWEB
News mentions
0No linked articles in our index yet.