VYPR
High severityNVD Advisory· Published Mar 19, 2026· Updated Mar 26, 2026

OpenClaw < 2026.2.26 - Improper Authorization via DM Pairing Store Identity Inheritance in Group Allowlist

CVE-2026-32027

Description

OpenClaw versions prior to 2026.2.26 contain an authorization bypass vulnerability where DM pairing-store identities are incorrectly eligible for group allowlist authorization checks. Attackers can exploit this cross-context authorization flaw by using a sender approved via DM pairing to satisfy group sender allowlist checks without explicit presence in groupAllowFrom, bypassing group message access controls.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.2.262026.2.26

Affected products

1

Patches

2
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);
    
8bdda7a651c2

fix(security): keep DM pairing allowlists out of group auth

https://github.com/openclaw/openclawPeter SteinbergerFeb 26, 2026via ghsa
15 files changed · +194 54
  • CHANGELOG.md+1 0 modified
    @@ -95,6 +95,7 @@ Docs: https://docs.openclaw.ai
     - Security/Slack member + message subtype events: gate `member_*` plus `message_changed`/`message_deleted`/`thread_broadcast` system-event enqueue through shared sender authorization so DM `dmPolicy`/`allowFrom` and channel `users` allowlists are enforced consistently for non-message ingress; message subtype system events now fail closed when sender identity is missing, with regression coverage. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
     - Security/Telegram reactions: enforce `dmPolicy`/`allowFrom` and group allowlist authorization on `message_reaction` events before enqueueing reaction system events, preventing unauthorized reaction-triggered input in DMs and groups; ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
     - Security/Telegram group allowlist: fail closed for group sender authorization by removing DM pairing-store fallback from group allowlist evaluation; group sender access now requires explicit `groupAllowFrom` or per-group/per-topic `allowFrom`. (#25988) Thanks @bmendonca3.
    +- Security/DM-group allowlist boundaries: keep DM pairing-store approvals DM-only by removing pairing-store inheritance from group sender authorization in LINE and Mattermost message preflight, and by centralizing shared DM/group allowlist composition so group checks never include pairing-store entries. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
     - Security/Slack interactions: enforce channel/DM authorization and modal actor binding (`private_metadata.userId`) before enqueueing `block_action`/`view_submission`/`view_closed` system events, with regression coverage for unauthorized senders and missing/mismatched actor metadata. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
     - Security/Nextcloud Talk: drop replayed signed webhook events with persistent per-account replay dedupe across restarts, and reject unexpected webhook backend origins when account base URL is configured. Thanks @aristorechina for reporting.
     - Security/Nextcloud Talk: reject unsigned webhook traffic before full body reads, reducing unauthenticated request-body exposure, with auth-order regression coverage. (#26118) Thanks @bmendonca3.
    
  • docs/channels/groups.md+1 0 modified
    @@ -184,6 +184,7 @@ Notes:
     
     - `groupPolicy` is separate from mention-gating (which requires @mentions).
     - WhatsApp/Telegram/Signal/iMessage/Microsoft Teams/Zalo: use `groupAllowFrom` (fallback: explicit `allowFrom`).
    +- DM pairing approvals (`*-allowFrom` store entries) apply to DM access only; group sender authorization stays explicit to group allowlists.
     - Discord: allowlist uses `channels.discord.guilds.<id>.channels`.
     - Slack: allowlist uses `channels.slack.channels`.
     - Matrix: allowlist uses `channels.matrix.groups` (room IDs, aliases, or names). Use `channels.matrix.groupAllowFrom` to restrict senders; per-room `users` allowlists are also supported.
    
  • extensions/mattermost/src/mattermost/monitor.authz.test.ts+37 0 added
    @@ -0,0 +1,37 @@
    +import { describe, expect, it } from "vitest";
    +import { resolveMattermostEffectiveAllowFromLists } from "./monitor.js";
    +
    +describe("mattermost monitor authz", () => {
    +  it("keeps DM allowlist merged with pairing-store entries", () => {
    +    const resolved = resolveMattermostEffectiveAllowFromLists({
    +      dmPolicy: "pairing",
    +      allowFrom: ["@trusted-user"],
    +      groupAllowFrom: ["@group-owner"],
    +      storeAllowFrom: ["user:attacker"],
    +    });
    +
    +    expect(resolved.effectiveAllowFrom).toEqual(["trusted-user", "attacker"]);
    +  });
    +
    +  it("uses explicit groupAllowFrom without pairing-store inheritance", () => {
    +    const resolved = resolveMattermostEffectiveAllowFromLists({
    +      dmPolicy: "pairing",
    +      allowFrom: ["@trusted-user"],
    +      groupAllowFrom: ["@group-owner"],
    +      storeAllowFrom: ["user:attacker"],
    +    });
    +
    +    expect(resolved.effectiveGroupAllowFrom).toEqual(["group-owner"]);
    +  });
    +
    +  it("does not inherit pairing-store entries into group allowlist", () => {
    +    const resolved = resolveMattermostEffectiveAllowFromLists({
    +      dmPolicy: "pairing",
    +      allowFrom: ["@trusted-user"],
    +      storeAllowFrom: ["user:attacker"],
    +    });
    +
    +    expect(resolved.effectiveAllowFrom).toEqual(["trusted-user", "attacker"]);
    +    expect(resolved.effectiveGroupAllowFrom).toEqual(["trusted-user"]);
    +  });
    +});
    
  • extensions/mattermost/src/mattermost/monitor.ts+25 9 modified
    @@ -18,6 +18,7 @@ import {
       isDangerousNameMatchingEnabled,
       resolveControlCommandGate,
       resolveDmGroupAccessWithLists,
    +  resolveEffectiveAllowFromLists,
       resolveAllowlistProviderRuntimeGroupPolicy,
       resolveDefaultGroupPolicy,
       resolveChannelMediaMaxBytes,
    @@ -150,6 +151,23 @@ function normalizeAllowList(entries: Array<string | number>): string[] {
       return Array.from(new Set(normalized));
     }
     
    +export function resolveMattermostEffectiveAllowFromLists(params: {
    +  allowFrom?: Array<string | number> | null;
    +  groupAllowFrom?: Array<string | number> | null;
    +  storeAllowFrom?: Array<string | number> | null;
    +  dmPolicy?: string | null;
    +}): {
    +  effectiveAllowFrom: string[];
    +  effectiveGroupAllowFrom: string[];
    +} {
    +  return resolveEffectiveAllowFromLists({
    +    allowFrom: normalizeAllowList(params.allowFrom ?? []),
    +    groupAllowFrom: normalizeAllowList(params.groupAllowFrom ?? []),
    +    storeAllowFrom: normalizeAllowList(params.storeAllowFrom ?? []),
    +    dmPolicy: params.dmPolicy,
    +  });
    +}
    +
     function isSenderAllowed(params: {
       senderId: string;
       senderName?: string;
    @@ -400,20 +418,18 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
           senderId;
         const rawText = post.message?.trim() || "";
         const dmPolicy = account.config.dmPolicy ?? "pairing";
    -    const configAllowFrom = normalizeAllowList(account.config.allowFrom ?? []);
    -    const configGroupAllowFrom = normalizeAllowList(account.config.groupAllowFrom ?? []);
         const storeAllowFrom = normalizeAllowList(
           dmPolicy === "allowlist"
             ? []
             : await core.channel.pairing.readAllowFromStore("mattermost").catch(() => []),
         );
    -    const effectiveAllowFrom = Array.from(new Set([...configAllowFrom, ...storeAllowFrom]));
    -    const effectiveGroupAllowFrom = Array.from(
    -      new Set([
    -        ...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom),
    -        ...storeAllowFrom,
    -      ]),
    -    );
    +    const { effectiveAllowFrom, effectiveGroupAllowFrom } =
    +      resolveMattermostEffectiveAllowFromLists({
    +        dmPolicy,
    +        allowFrom: account.config.allowFrom,
    +        groupAllowFrom: account.config.groupAllowFrom,
    +        storeAllowFrom,
    +      });
         const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
           cfg,
           surface: "mattermost",
    
  • src/channels/allow-from.test.ts+30 5 modified
    @@ -1,10 +1,15 @@
     import { describe, expect, it } from "vitest";
    -import { firstDefined, isSenderIdAllowed, mergeAllowFromSources } from "./allow-from.js";
    +import {
    +  firstDefined,
    +  isSenderIdAllowed,
    +  mergeDmAllowFromSources,
    +  resolveGroupAllowFromSources,
    +} from "./allow-from.js";
     
    -describe("mergeAllowFromSources", () => {
    +describe("mergeDmAllowFromSources", () => {
       it("merges, trims, and filters empty values", () => {
         expect(
    -      mergeAllowFromSources({
    +      mergeDmAllowFromSources({
             allowFrom: ["  line:user:abc  ", "", 123],
             storeAllowFrom: ["   ", "telegram:456"],
           }),
    @@ -13,7 +18,7 @@ describe("mergeAllowFromSources", () => {
     
       it("excludes pairing-store entries when dmPolicy is allowlist", () => {
         expect(
    -      mergeAllowFromSources({
    +      mergeDmAllowFromSources({
             allowFrom: ["+1111"],
             storeAllowFrom: ["+2222", "+3333"],
             dmPolicy: "allowlist",
    @@ -23,7 +28,7 @@ describe("mergeAllowFromSources", () => {
     
       it("keeps pairing-store entries for non-allowlist policies", () => {
         expect(
    -      mergeAllowFromSources({
    +      mergeDmAllowFromSources({
             allowFrom: ["+1111"],
             storeAllowFrom: ["+2222"],
             dmPolicy: "pairing",
    @@ -32,6 +37,26 @@ describe("mergeAllowFromSources", () => {
       });
     });
     
    +describe("resolveGroupAllowFromSources", () => {
    +  it("prefers explicit group allowlist", () => {
    +    expect(
    +      resolveGroupAllowFromSources({
    +        allowFrom: ["owner"],
    +        groupAllowFrom: ["group-owner", " group-admin "],
    +      }),
    +    ).toEqual(["group-owner", "group-admin"]);
    +  });
    +
    +  it("falls back to DM allowlist when group allowlist is unset/empty", () => {
    +    expect(
    +      resolveGroupAllowFromSources({
    +        allowFrom: [" owner ", "", "owner2"],
    +        groupAllowFrom: [],
    +      }),
    +    ).toEqual(["owner", "owner2"]);
    +  });
    +});
    +
     describe("firstDefined", () => {
       it("returns the first non-undefined value", () => {
         expect(firstDefined(undefined, undefined, "x", "y")).toBe("x");
    
  • src/channels/allow-from.ts+13 2 modified
    @@ -1,6 +1,6 @@
    -export function mergeAllowFromSources(params: {
    +export function mergeDmAllowFromSources(params: {
       allowFrom?: Array<string | number>;
    -  storeAllowFrom?: string[];
    +  storeAllowFrom?: Array<string | number>;
       dmPolicy?: string;
     }): string[] {
       const storeEntries = params.dmPolicy === "allowlist" ? [] : (params.storeAllowFrom ?? []);
    @@ -9,6 +9,17 @@ export function mergeAllowFromSources(params: {
         .filter(Boolean);
     }
     
    +export function resolveGroupAllowFromSources(params: {
    +  allowFrom?: Array<string | number>;
    +  groupAllowFrom?: Array<string | number>;
    +}): string[] {
    +  const scoped =
    +    params.groupAllowFrom && params.groupAllowFrom.length > 0
    +      ? params.groupAllowFrom
    +      : (params.allowFrom ?? []);
    +  return scoped.map((value) => String(value).trim()).filter(Boolean);
    +}
    +
     export function firstDefined<T>(...values: Array<T | undefined>) {
       for (const value of values) {
         if (typeof value !== "undefined") {
    
  • src/line/bot-access.ts+7 3 modified
    @@ -1,4 +1,8 @@
    -import { firstDefined, isSenderIdAllowed, mergeAllowFromSources } from "../channels/allow-from.js";
    +import {
    +  firstDefined,
    +  isSenderIdAllowed,
    +  mergeDmAllowFromSources,
    +} from "../channels/allow-from.js";
     
     export type NormalizedAllowFrom = {
       entries: string[];
    @@ -27,11 +31,11 @@ export const normalizeAllowFrom = (list?: Array<string | number>): NormalizedAll
       };
     };
     
    -export const normalizeAllowFromWithStore = (params: {
    +export const normalizeDmAllowFromWithStore = (params: {
       allowFrom?: Array<string | number>;
       storeAllowFrom?: string[];
       dmPolicy?: string;
    -}): NormalizedAllowFrom => normalizeAllowFrom(mergeAllowFromSources(params));
    +}): NormalizedAllowFrom => normalizeAllowFrom(mergeDmAllowFromSources(params));
     
     export const isSenderAllowed = (params: {
       allow: NormalizedAllowFrom;
    
  • src/line/bot-handlers.test.ts+35 0 modified
    @@ -182,6 +182,41 @@ describe("handleLineWebhookEvents", () => {
         expect(processMessage).toHaveBeenCalledTimes(1);
       });
     
    +  it("blocks group sender that is only present in pairing-store allowlist", async () => {
    +    const processMessage = vi.fn();
    +    readAllowFromStoreMock.mockResolvedValueOnce(["user-paired"]);
    +    const event = {
    +      type: "message",
    +      message: { id: "m3b", type: "text", text: "hi" },
    +      replyToken: "reply-token",
    +      timestamp: Date.now(),
    +      source: { type: "group", groupId: "group-1", userId: "user-paired" },
    +      mode: "active",
    +      webhookEventId: "evt-3b",
    +      deliveryContext: { isRedelivery: false },
    +    } as MessageEvent;
    +
    +    await handleLineWebhookEvents([event], {
    +      cfg: {
    +        channels: { line: { groupPolicy: "allowlist", groupAllowFrom: ["user-owner"] } },
    +      },
    +      account: {
    +        accountId: "default",
    +        enabled: true,
    +        channelAccessToken: "token",
    +        channelSecret: "secret",
    +        tokenSource: "config",
    +        config: { groupPolicy: "allowlist", groupAllowFrom: ["user-owner"] },
    +      },
    +      runtime: createRuntime(),
    +      mediaMaxBytes: 1,
    +      processMessage,
    +    });
    +
    +    expect(buildLineMessageContextMock).not.toHaveBeenCalled();
    +    expect(processMessage).not.toHaveBeenCalled();
    +  });
    +
       it("blocks group messages when wildcard group config disables groups", async () => {
         const processMessage = vi.fn();
         const event = {
    
  • src/line/bot-handlers.ts+10 7 modified
    @@ -21,7 +21,12 @@ import {
       upsertChannelPairingRequest,
     } from "../pairing/pairing-store.js";
     import type { RuntimeEnv } from "../runtime.js";
    -import { firstDefined, isSenderAllowed, normalizeAllowFromWithStore } from "./bot-access.js";
    +import {
    +  firstDefined,
    +  isSenderAllowed,
    +  normalizeAllowFrom,
    +  normalizeDmAllowFromWithStore,
    +} from "./bot-access.js";
     import {
       getLineSourceInfo,
       buildLineMessageContext,
    @@ -117,7 +122,7 @@ async function shouldProcessLineEvent(
       const dmPolicy = account.config.dmPolicy ?? "pairing";
     
       const storeAllowFrom = await readChannelAllowFromStore("line").catch(() => []);
    -  const effectiveDmAllow = normalizeAllowFromWithStore({
    +  const effectiveDmAllow = normalizeDmAllowFromWithStore({
         allowFrom: account.config.allowFrom,
         storeAllowFrom,
         dmPolicy,
    @@ -132,11 +137,9 @@ async function shouldProcessLineEvent(
         account.config.groupAllowFrom,
         fallbackGroupAllowFrom,
       );
    -  const effectiveGroupAllow = normalizeAllowFromWithStore({
    -    allowFrom: groupAllowFrom,
    -    storeAllowFrom,
    -    dmPolicy,
    -  });
    +  // Group authorization stays explicit to group allowlists and must not
    +  // inherit DM pairing-store identities.
    +  const effectiveGroupAllow = normalizeAllowFrom(groupAllowFrom);
       const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
       const { groupPolicy, providerMissingFallbackApplied } =
         resolveAllowlistProviderRuntimeGroupPolicy({
    
  • src/security/dm-policy-shared.test.ts+5 5 modified
    @@ -41,7 +41,7 @@ describe("security/dm-policy-shared", () => {
           storeAllowFrom: [" owner3 ", ""],
         });
         expect(lists.effectiveAllowFrom).toEqual(["owner", "owner2", "owner3"]);
    -    expect(lists.effectiveGroupAllowFrom).toEqual(["group:abc", "owner3"]);
    +    expect(lists.effectiveGroupAllowFrom).toEqual(["group:abc"]);
       });
     
       it("falls back to DM allowlist for groups when groupAllowFrom is empty", () => {
    @@ -51,7 +51,7 @@ describe("security/dm-policy-shared", () => {
           storeAllowFrom: [" owner2 "],
         });
         expect(lists.effectiveAllowFrom).toEqual(["owner", "owner2"]);
    -    expect(lists.effectiveGroupAllowFrom).toEqual(["owner", "owner2"]);
    +    expect(lists.effectiveGroupAllowFrom).toEqual(["owner"]);
       });
     
       it("excludes storeAllowFrom when dmPolicy is allowlist", () => {
    @@ -65,15 +65,15 @@ describe("security/dm-policy-shared", () => {
         expect(lists.effectiveGroupAllowFrom).toEqual(["group:abc"]);
       });
     
    -  it("includes storeAllowFrom when dmPolicy is pairing", () => {
    +  it("keeps group allowlist explicit when dmPolicy is pairing", () => {
         const lists = resolveEffectiveAllowFromLists({
           allowFrom: ["+1111"],
           groupAllowFrom: [],
           storeAllowFrom: ["+2222"],
           dmPolicy: "pairing",
         });
         expect(lists.effectiveAllowFrom).toEqual(["+1111", "+2222"]);
    -    expect(lists.effectiveGroupAllowFrom).toEqual(["+1111", "+2222"]);
    +    expect(lists.effectiveGroupAllowFrom).toEqual(["+1111"]);
       });
     
       it("resolves access + effective allowlists in one shared call", () => {
    @@ -89,7 +89,7 @@ describe("security/dm-policy-shared", () => {
         expect(resolved.decision).toBe("allow");
         expect(resolved.reason).toBe("dmPolicy=pairing (allowlisted)");
         expect(resolved.effectiveAllowFrom).toEqual(["owner", "paired-user"]);
    -    expect(resolved.effectiveGroupAllowFrom).toEqual(["group:room", "paired-user"]);
    +    expect(resolved.effectiveGroupAllowFrom).toEqual(["group:room"]);
       });
     
       it("keeps allowlist mode strict in shared resolver (no pairing-store fallback)", () => {
    
  • src/security/dm-policy-shared.ts+16 13 modified
    @@ -1,3 +1,4 @@
    +import { mergeDmAllowFromSources, resolveGroupAllowFromSources } from "../channels/allow-from.js";
     import type { ChannelId } from "../channels/plugins/types.js";
     import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
     import { normalizeStringEntries } from "../shared/string-normalization.js";
    @@ -11,21 +12,23 @@ export function resolveEffectiveAllowFromLists(params: {
       effectiveAllowFrom: string[];
       effectiveGroupAllowFrom: string[];
     } {
    -  const configAllowFrom = normalizeStringEntries(
    -    Array.isArray(params.allowFrom) ? params.allowFrom : undefined,
    +  const allowFrom = Array.isArray(params.allowFrom) ? params.allowFrom : undefined;
    +  const groupAllowFrom = Array.isArray(params.groupAllowFrom) ? params.groupAllowFrom : undefined;
    +  const storeAllowFrom = Array.isArray(params.storeAllowFrom) ? params.storeAllowFrom : undefined;
    +  const effectiveAllowFrom = normalizeStringEntries(
    +    mergeDmAllowFromSources({
    +      allowFrom,
    +      storeAllowFrom,
    +      dmPolicy: params.dmPolicy ?? undefined,
    +    }),
       );
    -  const configGroupAllowFrom = normalizeStringEntries(
    -    Array.isArray(params.groupAllowFrom) ? params.groupAllowFrom : undefined,
    +  // Group auth is explicit (groupAllowFrom fallback allowFrom). Pairing store is DM-only.
    +  const effectiveGroupAllowFrom = normalizeStringEntries(
    +    resolveGroupAllowFromSources({
    +      allowFrom,
    +      groupAllowFrom,
    +    }),
       );
    -  const storeAllowFrom =
    -    params.dmPolicy === "allowlist"
    -      ? []
    -      : normalizeStringEntries(
    -          Array.isArray(params.storeAllowFrom) ? params.storeAllowFrom : undefined,
    -        );
    -  const effectiveAllowFrom = normalizeStringEntries([...configAllowFrom, ...storeAllowFrom]);
    -  const groupBase = configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom;
    -  const effectiveGroupAllowFrom = normalizeStringEntries([...groupBase, ...storeAllowFrom]);
       return { effectiveAllowFrom, effectiveGroupAllowFrom };
     }
     
    
  • src/telegram/bot-access.ts+7 3 modified
    @@ -1,4 +1,8 @@
    -import { firstDefined, isSenderIdAllowed, mergeAllowFromSources } from "../channels/allow-from.js";
    +import {
    +  firstDefined,
    +  isSenderIdAllowed,
    +  mergeDmAllowFromSources,
    +} from "../channels/allow-from.js";
     import type { AllowlistMatch } from "../channels/allowlist-match.js";
     import { createSubsystemLogger } from "../logging/subsystem.js";
     
    @@ -53,11 +57,11 @@ export const normalizeAllowFrom = (list?: Array<string | number>): NormalizedAll
       };
     };
     
    -export const normalizeAllowFromWithStore = (params: {
    +export const normalizeDmAllowFromWithStore = (params: {
       allowFrom?: Array<string | number>;
       storeAllowFrom?: string[];
       dmPolicy?: string;
    -}): NormalizedAllowFrom => normalizeAllowFrom(mergeAllowFromSources(params));
    +}): NormalizedAllowFrom => normalizeAllowFrom(mergeDmAllowFromSources(params));
     
     export const isSenderAllowed = (params: {
       allow: NormalizedAllowFrom;
    
  • src/telegram/bot-handlers.ts+3 3 modified
    @@ -28,7 +28,7 @@ import { resolveThreadSessionKeys } from "../routing/session-key.js";
     import { withTelegramApiErrorLogging } from "./api-logging.js";
     import {
       isSenderAllowed,
    -  normalizeAllowFromWithStore,
    +  normalizeDmAllowFromWithStore,
       type NormalizedAllowFrom,
     } from "./bot-access.js";
     import type { TelegramMediaRef } from "./bot-message-context.js";
    @@ -615,7 +615,7 @@ export const registerTelegramHandlers = ({
             return { allowed: false, reason: "direct-disabled" };
           }
           if (dmPolicy !== "open") {
    -        const effectiveDmAllow = normalizeAllowFromWithStore({
    +        const effectiveDmAllow = normalizeDmAllowFromWithStore({
               allowFrom,
               storeAllowFrom,
               dmPolicy,
    @@ -1273,7 +1273,7 @@ export const registerTelegramHandlers = ({
             effectiveGroupAllow,
             hasGroupAllowOverride,
           } = eventAuthContext;
    -      const effectiveDmAllow = normalizeAllowFromWithStore({
    +      const effectiveDmAllow = normalizeDmAllowFromWithStore({
             allowFrom,
             storeAllowFrom,
             dmPolicy,
    
  • src/telegram/bot-message-context.ts+2 2 modified
    @@ -40,7 +40,7 @@ import {
       firstDefined,
       isSenderAllowed,
       normalizeAllowFrom,
    -  normalizeAllowFromWithStore,
    +  normalizeDmAllowFromWithStore,
     } from "./bot-access.js";
     import {
       buildGroupLabel,
    @@ -195,7 +195,7 @@ export const buildTelegramMessageContext = async ({
           : null;
       const sessionKey = threadKeys?.sessionKey ?? baseSessionKey;
       const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
    -  const effectiveDmAllow = normalizeAllowFromWithStore({ allowFrom, storeAllowFrom, dmPolicy });
    +  const effectiveDmAllow = normalizeDmAllowFromWithStore({ allowFrom, storeAllowFrom, dmPolicy });
       const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom);
       // Group sender checks are explicit and must not inherit DM pairing-store entries.
       const effectiveGroupAllow = normalizeAllowFrom(groupAllowOverride ?? groupAllowFrom);
    
  • src/telegram/bot-native-commands.ts+2 2 modified
    @@ -41,7 +41,7 @@ import { resolveAgentRoute } from "../routing/resolve-route.js";
     import { resolveThreadSessionKeys } from "../routing/session-key.js";
     import type { RuntimeEnv } from "../runtime.js";
     import { withTelegramApiErrorLogging } from "./api-logging.js";
    -import { isSenderAllowed, normalizeAllowFromWithStore } from "./bot-access.js";
    +import { isSenderAllowed, normalizeDmAllowFromWithStore } from "./bot-access.js";
     import {
       buildCappedTelegramMenuCommands,
       buildPluginTelegramMenuCommands,
    @@ -251,7 +251,7 @@ async function resolveTelegramCommandAuth(params: {
         }
       }
     
    -  const dmAllow = normalizeAllowFromWithStore({
    +  const dmAllow = normalizeDmAllowFromWithStore({
         allowFrom: allowFrom,
         storeAllowFrom,
         dmPolicy: telegramCfg.dmPolicy ?? "pairing",
    

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.