VYPR
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.

PackageAffected versionsPatched versions
openclawnpm
< 2026.2.262026.2.26

Affected products

1

Patches

2
1aadf26f9acc

fix(voice-call): bind webhook dedupe to verified request identity

https://github.com/openclaw/openclawPeter SteinbergerFeb 26, 2026via ghsa
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) {
    
051fdcc42812

fix(security): centralize dm/group allowlist auth composition

https://github.com/openclaw/openclawPeter SteinbergerFeb 26, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.