refactor(security): unify dangerous name matching handling

This commit is contained in:
Peter Steinberger
2026-02-24 01:32:23 +00:00
parent 6a7c303dcc
commit 161d9841dc
17 changed files with 671 additions and 471 deletions

View File

@@ -6,6 +6,7 @@ import {
readJsonBodyWithLimit, readJsonBodyWithLimit,
registerWebhookTarget, registerWebhookTarget,
rejectNonPostWebhookRequest, rejectNonPostWebhookRequest,
isDangerousNameMatchingEnabled,
resolveAllowlistProviderRuntimeGroupPolicy, resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy, resolveDefaultGroupPolicy,
resolveSingleWebhookTargetAsync, resolveSingleWebhookTargetAsync,
@@ -410,7 +411,7 @@ async function processMessageWithPipeline(params: {
const senderId = sender?.name ?? ""; const senderId = sender?.name ?? "";
const senderName = sender?.displayName ?? ""; const senderName = sender?.displayName ?? "";
const senderEmail = sender?.email ?? undefined; const senderEmail = sender?.email ?? undefined;
const allowNameMatching = account.config.dangerouslyAllowNameMatching === true; const allowNameMatching = isDangerousNameMatchingEnabled(account.config);
const allowBots = account.config.allowBots === true; const allowBots = account.config.allowBots === true;
if (!allowBots) { if (!allowBots) {

View File

@@ -4,6 +4,7 @@ import {
createReplyPrefixOptions, createReplyPrefixOptions,
formatTextWithAttachmentLinks, formatTextWithAttachmentLinks,
logInboundDrop, logInboundDrop,
isDangerousNameMatchingEnabled,
resolveControlCommandGate, resolveControlCommandGate,
resolveOutboundMediaUrls, resolveOutboundMediaUrls,
resolveAllowlistProviderRuntimeGroupPolicy, resolveAllowlistProviderRuntimeGroupPolicy,
@@ -78,7 +79,7 @@ export async function handleIrcInbound(params: {
const senderDisplay = message.senderHost const senderDisplay = message.senderHost
? `${message.senderNick}!${message.senderUser ?? "?"}@${message.senderHost}` ? `${message.senderNick}!${message.senderUser ?? "?"}@${message.senderHost}`
: message.senderNick; : message.senderNick;
const allowNameMatching = account.config.dangerouslyAllowNameMatching === true; const allowNameMatching = isDangerousNameMatchingEnabled(account.config);
const dmPolicy = account.config.dmPolicy ?? "pairing"; const dmPolicy = account.config.dmPolicy ?? "pairing";
const defaultGroupPolicy = resolveDefaultGroupPolicy(config); const defaultGroupPolicy = resolveDefaultGroupPolicy(config);

View File

@@ -15,6 +15,7 @@ import {
clearHistoryEntriesIfEnabled, clearHistoryEntriesIfEnabled,
DEFAULT_GROUP_HISTORY_LIMIT, DEFAULT_GROUP_HISTORY_LIMIT,
recordPendingHistoryEntryIfEnabled, recordPendingHistoryEntryIfEnabled,
isDangerousNameMatchingEnabled,
resolveControlCommandGate, resolveControlCommandGate,
resolveAllowlistProviderRuntimeGroupPolicy, resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy, resolveDefaultGroupPolicy,
@@ -212,7 +213,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
cfg, cfg,
accountId: opts.accountId, accountId: opts.accountId,
}); });
const allowNameMatching = account.config.dangerouslyAllowNameMatching === true; const allowNameMatching = isDangerousNameMatchingEnabled(account.config);
const botToken = opts.botToken?.trim() || account.botToken?.trim(); const botToken = opts.botToken?.trim() || account.botToken?.trim();
if (!botToken) { if (!botToken) {
throw new Error( throw new Error(

View File

@@ -6,6 +6,7 @@ import {
recordPendingHistoryEntryIfEnabled, recordPendingHistoryEntryIfEnabled,
resolveControlCommandGate, resolveControlCommandGate,
resolveDefaultGroupPolicy, resolveDefaultGroupPolicy,
isDangerousNameMatchingEnabled,
resolveMentionGating, resolveMentionGating,
formatAllowlistMatchMeta, formatAllowlistMatchMeta,
type HistoryEntry, type HistoryEntry,
@@ -145,7 +146,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
if (dmPolicy !== "open") { if (dmPolicy !== "open") {
const effectiveAllowFrom = [...allowFrom.map((v) => String(v)), ...storedAllowFrom]; const effectiveAllowFrom = [...allowFrom.map((v) => String(v)), ...storedAllowFrom];
const allowNameMatching = msteamsCfg.dangerouslyAllowNameMatching === true; const allowNameMatching = isDangerousNameMatchingEnabled(msteamsCfg);
const allowMatch = resolveMSTeamsAllowlistMatch({ const allowMatch = resolveMSTeamsAllowlistMatch({
allowFrom: effectiveAllowFrom, allowFrom: effectiveAllowFrom,
senderId, senderId,
@@ -228,7 +229,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
return; return;
} }
if (effectiveGroupAllowFrom.length > 0) { if (effectiveGroupAllowFrom.length > 0) {
const allowNameMatching = msteamsCfg.dangerouslyAllowNameMatching === true; const allowNameMatching = isDangerousNameMatchingEnabled(msteamsCfg);
const allowMatch = resolveMSTeamsAllowlistMatch({ const allowMatch = resolveMSTeamsAllowlistMatch({
allowFrom: effectiveGroupAllowFrom, allowFrom: effectiveGroupAllowFrom,
senderId, senderId,
@@ -252,14 +253,14 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
allowFrom: effectiveDmAllowFrom, allowFrom: effectiveDmAllowFrom,
senderId, senderId,
senderName, senderName,
allowNameMatching: msteamsCfg?.dangerouslyAllowNameMatching === true, allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
}); });
const groupAllowedForCommands = isMSTeamsGroupAllowed({ const groupAllowedForCommands = isMSTeamsGroupAllowed({
groupPolicy: "allowlist", groupPolicy: "allowlist",
allowFrom: effectiveGroupAllowFrom, allowFrom: effectiveGroupAllowFrom,
senderId, senderId,
senderName, senderName,
allowNameMatching: msteamsCfg?.dangerouslyAllowNameMatching === true, allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
}); });
const hasControlCommandInMessage = core.channel.text.hasControlCommand(text, cfg); const hasControlCommandInMessage = core.channel.text.hasControlCommand(text, cfg);
const commandGate = resolveControlCommandGate({ const commandGate = resolveControlCommandGate({

View File

@@ -14,12 +14,21 @@ import {
migrateLegacyConfig, migrateLegacyConfig,
readConfigFileSnapshot, readConfigFileSnapshot,
} from "../config/config.js"; } from "../config/config.js";
import { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import { parseToolsBySenderTypedKey } from "../config/types.tools.js"; import { parseToolsBySenderTypedKey } from "../config/types.tools.js";
import { import {
listInterpreterLikeSafeBins, listInterpreterLikeSafeBins,
resolveMergedSafeBinProfileFixtures, resolveMergedSafeBinProfileFixtures,
} from "../infra/exec-safe-bin-runtime-policy.js"; } from "../infra/exec-safe-bin-runtime-policy.js";
import {
isDiscordMutableAllowEntry,
isGoogleChatMutableAllowEntry,
isIrcMutableAllowEntry,
isMSTeamsMutableAllowEntry,
isMattermostMutableAllowEntry,
isSlackMutableAllowEntry,
} from "../security/mutable-allowlist-detectors.js";
import { listTelegramAccountIds, resolveTelegramAccount } from "../telegram/accounts.js"; import { listTelegramAccountIds, resolveTelegramAccount } from "../telegram/accounts.js";
import { note } from "../terminal/note.js"; import { note } from "../terminal/note.js";
import { isRecord, resolveHomeDir } from "../utils.js"; import { isRecord, resolveHomeDir } from "../utils.js";
@@ -192,10 +201,6 @@ function asObjectRecord(value: unknown): Record<string, unknown> | null {
return value as Record<string, unknown>; return value as Record<string, unknown>;
} }
function asOptionalBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
function collectTelegramAccountScopes( function collectTelegramAccountScopes(
cfg: OpenClawConfig, cfg: OpenClawConfig,
): Array<{ prefix: string; account: Record<string, unknown> }> { ): Array<{ prefix: string; account: Record<string, unknown> }> {
@@ -589,148 +594,6 @@ type MutableAllowlistHit = {
dangerousFlagPath: string; dangerousFlagPath: string;
}; };
type ProviderAccountScope = {
prefix: string;
account: Record<string, unknown>;
dangerousNameMatchingEnabled: boolean;
dangerousFlagPath: string;
};
function collectProviderAccountScopes(
cfg: OpenClawConfig,
provider: string,
): ProviderAccountScope[] {
const scopes: ProviderAccountScope[] = [];
const channels = asObjectRecord(cfg.channels);
if (!channels) {
return scopes;
}
const providerCfg = asObjectRecord(channels[provider]);
if (!providerCfg) {
return scopes;
}
const providerPrefix = `channels.${provider}`;
const providerDangerousFlagPath = `${providerPrefix}.dangerouslyAllowNameMatching`;
const providerDangerousNameMatchingEnabled = providerCfg.dangerouslyAllowNameMatching === true;
scopes.push({
prefix: providerPrefix,
account: providerCfg,
dangerousNameMatchingEnabled: providerDangerousNameMatchingEnabled,
dangerousFlagPath: providerDangerousFlagPath,
});
const accounts = asObjectRecord(providerCfg.accounts);
if (!accounts) {
return scopes;
}
for (const key of Object.keys(accounts)) {
const account = asObjectRecord(accounts[key]);
if (!account) {
continue;
}
const accountPrefix = `${providerPrefix}.accounts.${key}`;
const accountDangerousNameMatching = asOptionalBoolean(account.dangerouslyAllowNameMatching);
scopes.push({
prefix: accountPrefix,
account,
dangerousNameMatchingEnabled:
accountDangerousNameMatching ?? providerDangerousNameMatchingEnabled,
dangerousFlagPath:
accountDangerousNameMatching == null
? providerDangerousFlagPath
: `${accountPrefix}.dangerouslyAllowNameMatching`,
});
}
return scopes;
}
function isDiscordMutableAllowEntry(raw: string): boolean {
const text = raw.trim();
if (!text || text === "*") {
return false;
}
const maybeMentionId = text.replace(/^<@!?/, "").replace(/>$/, "");
if (/^\d+$/.test(maybeMentionId)) {
return false;
}
for (const prefix of ["discord:", "user:", "pk:"]) {
if (!text.startsWith(prefix)) {
continue;
}
return text.slice(prefix.length).trim().length === 0;
}
return true;
}
function isSlackMutableAllowEntry(raw: string): boolean {
const text = raw.trim();
if (!text || text === "*") {
return false;
}
const mentionMatch = text.match(/^<@([A-Z0-9]+)>$/i);
if (mentionMatch && /^[A-Z0-9]{8,}$/i.test(mentionMatch[1] ?? "")) {
return false;
}
const withoutPrefix = text.replace(/^(slack|user):/i, "").trim();
if (/^[UWBCGDT][A-Z0-9]{2,}$/.test(withoutPrefix)) {
return false;
}
if (/^[A-Z0-9]{8,}$/i.test(withoutPrefix)) {
return false;
}
return true;
}
function isGoogleChatMutableAllowEntry(raw: string): boolean {
const text = raw.trim();
if (!text || text === "*") {
return false;
}
const withoutPrefix = text.replace(/^(googlechat|google-chat|gchat):/i, "").trim();
if (!withoutPrefix) {
return false;
}
const withoutUsers = withoutPrefix.replace(/^users\//i, "");
return withoutUsers.includes("@");
}
function isMSTeamsMutableAllowEntry(raw: string): boolean {
const text = raw.trim();
if (!text || text === "*") {
return false;
}
const withoutPrefix = text.replace(/^(msteams|user):/i, "").trim();
return /\s/.test(withoutPrefix) || withoutPrefix.includes("@");
}
function isMattermostMutableAllowEntry(raw: string): boolean {
const text = raw.trim();
if (!text || text === "*") {
return false;
}
const normalized = text
.replace(/^(mattermost|user):/i, "")
.replace(/^@/, "")
.trim()
.toLowerCase();
// Mattermost user IDs are stable 26-char lowercase/number tokens.
if (/^[a-z0-9]{26}$/.test(normalized)) {
return false;
}
return true;
}
function isIrcMutableAllowEntry(raw: string): boolean {
const text = raw.trim().toLowerCase();
if (!text || text === "*") {
return false;
}
const normalized = text
.replace(/^irc:/, "")
.replace(/^user:/, "")
.trim();
return !normalized.includes("!") && !normalized.includes("@");
}
function addMutableAllowlistHits(params: { function addMutableAllowlistHits(params: {
hits: MutableAllowlistHit[]; hits: MutableAllowlistHit[];
pathLabel: string; pathLabel: string;
@@ -762,7 +625,7 @@ function addMutableAllowlistHits(params: {
function scanMutableAllowlistEntries(cfg: OpenClawConfig): MutableAllowlistHit[] { function scanMutableAllowlistEntries(cfg: OpenClawConfig): MutableAllowlistHit[] {
const hits: MutableAllowlistHit[] = []; const hits: MutableAllowlistHit[] = [];
for (const scope of collectProviderAccountScopes(cfg, "discord")) { for (const scope of collectProviderDangerousNameMatchingScopes(cfg, "discord")) {
if (scope.dangerousNameMatchingEnabled) { if (scope.dangerousNameMatchingEnabled) {
continue; continue;
} }
@@ -823,7 +686,7 @@ function scanMutableAllowlistEntries(cfg: OpenClawConfig): MutableAllowlistHit[]
} }
} }
for (const scope of collectProviderAccountScopes(cfg, "slack")) { for (const scope of collectProviderDangerousNameMatchingScopes(cfg, "slack")) {
if (scope.dangerousNameMatchingEnabled) { if (scope.dangerousNameMatchingEnabled) {
continue; continue;
} }
@@ -866,7 +729,7 @@ function scanMutableAllowlistEntries(cfg: OpenClawConfig): MutableAllowlistHit[]
} }
} }
for (const scope of collectProviderAccountScopes(cfg, "googlechat")) { for (const scope of collectProviderDangerousNameMatchingScopes(cfg, "googlechat")) {
if (scope.dangerousNameMatchingEnabled) { if (scope.dangerousNameMatchingEnabled) {
continue; continue;
} }
@@ -909,7 +772,7 @@ function scanMutableAllowlistEntries(cfg: OpenClawConfig): MutableAllowlistHit[]
} }
} }
for (const scope of collectProviderAccountScopes(cfg, "msteams")) { for (const scope of collectProviderDangerousNameMatchingScopes(cfg, "msteams")) {
if (scope.dangerousNameMatchingEnabled) { if (scope.dangerousNameMatchingEnabled) {
continue; continue;
} }
@@ -931,7 +794,7 @@ function scanMutableAllowlistEntries(cfg: OpenClawConfig): MutableAllowlistHit[]
}); });
} }
for (const scope of collectProviderAccountScopes(cfg, "mattermost")) { for (const scope of collectProviderDangerousNameMatchingScopes(cfg, "mattermost")) {
if (scope.dangerousNameMatchingEnabled) { if (scope.dangerousNameMatchingEnabled) {
continue; continue;
} }
@@ -953,7 +816,7 @@ function scanMutableAllowlistEntries(cfg: OpenClawConfig): MutableAllowlistHit[]
}); });
} }
for (const scope of collectProviderAccountScopes(cfg, "irc")) { for (const scope of collectProviderDangerousNameMatchingScopes(cfg, "irc")) {
if (scope.dangerousNameMatchingEnabled) { if (scope.dangerousNameMatchingEnabled) {
continue; continue;
} }

View File

@@ -0,0 +1,84 @@
import type { OpenClawConfig } from "./config.js";
export type DangerousNameMatchingConfig = {
dangerouslyAllowNameMatching?: boolean;
};
export type ProviderDangerousNameMatchingScope = {
prefix: string;
account: Record<string, unknown>;
dangerousNameMatchingEnabled: boolean;
dangerousFlagPath: string;
};
function asObjectRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function asOptionalBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
export function isDangerousNameMatchingEnabled(
config: DangerousNameMatchingConfig | null | undefined,
): boolean {
return config?.dangerouslyAllowNameMatching === true;
}
export function collectProviderDangerousNameMatchingScopes(
cfg: OpenClawConfig,
provider: string,
): ProviderDangerousNameMatchingScope[] {
const scopes: ProviderDangerousNameMatchingScope[] = [];
const channels = asObjectRecord(cfg.channels);
if (!channels) {
return scopes;
}
const providerCfg = asObjectRecord(channels[provider]);
if (!providerCfg) {
return scopes;
}
const providerPrefix = `channels.${provider}`;
const providerDangerousFlagPath = `${providerPrefix}.dangerouslyAllowNameMatching`;
const providerDangerousNameMatchingEnabled = isDangerousNameMatchingEnabled(providerCfg);
scopes.push({
prefix: providerPrefix,
account: providerCfg,
dangerousNameMatchingEnabled: providerDangerousNameMatchingEnabled,
dangerousFlagPath: providerDangerousFlagPath,
});
const accounts = asObjectRecord(providerCfg.accounts);
if (!accounts) {
return scopes;
}
for (const key of Object.keys(accounts)) {
const account = asObjectRecord(accounts[key]);
if (!account) {
continue;
}
const accountPrefix = `${providerPrefix}.accounts.${key}`;
const accountDangerousNameMatching = asOptionalBoolean(account.dangerouslyAllowNameMatching);
scopes.push({
prefix: accountPrefix,
account,
dangerousNameMatchingEnabled:
accountDangerousNameMatching ?? providerDangerousNameMatchingEnabled,
dangerousFlagPath:
accountDangerousNameMatching == null
? providerDangerousFlagPath
: `${accountPrefix}.dangerouslyAllowNameMatching`,
});
}
return scopes;
}

View File

@@ -26,6 +26,7 @@ import { createReplyReferencePlanner } from "../../auto-reply/reply/reply-refere
import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; import { createReplyPrefixOptions } from "../../channels/reply-prefix.js";
import { recordInboundSession } from "../../channels/session.js"; import { recordInboundSession } from "../../channels/session.js";
import type { OpenClawConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/config.js";
import { isDangerousNameMatchingEnabled } from "../../config/dangerous-name-matching.js";
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js"; import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js";
import type { DiscordAccountConfig } from "../../config/types.discord.js"; import type { DiscordAccountConfig } from "../../config/types.discord.js";
@@ -365,7 +366,7 @@ async function ensureAgentComponentInteractionAllowed(params: {
replyOpts: params.replyOpts, replyOpts: params.replyOpts,
componentLabel: params.componentLabel, componentLabel: params.componentLabel,
unauthorizedReply: params.unauthorizedReply, unauthorizedReply: params.unauthorizedReply,
allowNameMatching: params.ctx.discordConfig?.dangerouslyAllowNameMatching === true, allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig),
}); });
if (!memberAllowed) { if (!memberAllowed) {
return null; return null;
@@ -481,7 +482,7 @@ async function ensureDmComponentAuthorized(params: {
name: user.username, name: user.username,
tag: formatDiscordUserTag(user), tag: formatDiscordUserTag(user),
}, },
allowNameMatching: ctx.discordConfig?.dangerouslyAllowNameMatching === true, allowNameMatching: isDangerousNameMatchingEnabled(ctx.discordConfig),
}) })
: { allowed: false }; : { allowed: false };
if (allowMatch.allowed) { if (allowMatch.allowed) {
@@ -784,7 +785,7 @@ async function dispatchDiscordComponentEvent(params: {
channelConfig, channelConfig,
guildInfo, guildInfo,
sender: { id: interactionCtx.user.id, name: interactionCtx.user.username, tag: senderTag }, sender: { id: interactionCtx.user.id, name: interactionCtx.user.username, tag: senderTag },
allowNameMatching: ctx.discordConfig?.dangerouslyAllowNameMatching === true, allowNameMatching: isDangerousNameMatchingEnabled(ctx.discordConfig),
}); });
const storePath = resolveStorePath(ctx.cfg.session?.store, { agentId }); const storePath = resolveStorePath(ctx.cfg.session?.store, { agentId });
const envelopeOptions = resolveEnvelopeFormatOptions(ctx.cfg); const envelopeOptions = resolveEnvelopeFormatOptions(ctx.cfg);
@@ -982,7 +983,7 @@ async function handleDiscordComponentEvent(params: {
replyOpts, replyOpts,
componentLabel: params.componentLabel, componentLabel: params.componentLabel,
unauthorizedReply, unauthorizedReply,
allowNameMatching: params.ctx.discordConfig?.dangerouslyAllowNameMatching === true, allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig),
}); });
if (!memberAllowed) { if (!memberAllowed) {
return; return;
@@ -995,7 +996,7 @@ async function handleDiscordComponentEvent(params: {
replyOpts, replyOpts,
componentLabel: params.componentLabel, componentLabel: params.componentLabel,
unauthorizedReply, unauthorizedReply,
allowNameMatching: params.ctx.discordConfig?.dangerouslyAllowNameMatching === true, allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig),
}); });
if (!componentAllowed) { if (!componentAllowed) {
return; return;
@@ -1134,7 +1135,7 @@ async function handleDiscordModalTrigger(params: {
replyOpts, replyOpts,
componentLabel: "form", componentLabel: "form",
unauthorizedReply, unauthorizedReply,
allowNameMatching: params.ctx.discordConfig?.dangerouslyAllowNameMatching === true, allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig),
}); });
if (!memberAllowed) { if (!memberAllowed) {
return; return;
@@ -1147,7 +1148,7 @@ async function handleDiscordModalTrigger(params: {
replyOpts, replyOpts,
componentLabel: "form", componentLabel: "form",
unauthorizedReply, unauthorizedReply,
allowNameMatching: params.ctx.discordConfig?.dangerouslyAllowNameMatching === true, allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig),
}); });
if (!componentAllowed) { if (!componentAllowed) {
return; return;
@@ -1583,7 +1584,7 @@ class DiscordComponentModal extends Modal {
replyOpts, replyOpts,
componentLabel: "form", componentLabel: "form",
unauthorizedReply: "You are not authorized to use this form.", unauthorizedReply: "You are not authorized to use this form.",
allowNameMatching: this.ctx.discordConfig?.dangerouslyAllowNameMatching === true, allowNameMatching: isDangerousNameMatchingEnabled(this.ctx.discordConfig),
}); });
if (!memberAllowed) { if (!memberAllowed) {
return; return;

View File

@@ -14,6 +14,7 @@ import { resolveControlCommandGate } from "../../channels/command-gating.js";
import { logInboundDrop } from "../../channels/logging.js"; import { logInboundDrop } from "../../channels/logging.js";
import { resolveMentionGatingWithBypass } from "../../channels/mention-gating.js"; import { resolveMentionGatingWithBypass } from "../../channels/mention-gating.js";
import { loadConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js";
import { isDangerousNameMatchingEnabled } from "../../config/dangerous-name-matching.js";
import { logVerbose, shouldLogVerbose } from "../../globals.js"; import { logVerbose, shouldLogVerbose } from "../../globals.js";
import { recordChannelActivity } from "../../infra/channel-activity.js"; import { recordChannelActivity } from "../../infra/channel-activity.js";
import { enqueueSystemEvent } from "../../infra/system-events.js"; import { enqueueSystemEvent } from "../../infra/system-events.js";
@@ -190,7 +191,7 @@ export async function preflightDiscordMessage(
name: sender.name, name: sender.name,
tag: sender.tag, tag: sender.tag,
}, },
allowNameMatching: params.discordConfig?.dangerouslyAllowNameMatching === true, allowNameMatching: isDangerousNameMatchingEnabled(params.discordConfig),
}) })
: { allowed: false }; : { allowed: false };
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch); const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
@@ -564,7 +565,7 @@ export async function preflightDiscordMessage(
guildInfo, guildInfo,
memberRoleIds, memberRoleIds,
sender, sender,
allowNameMatching: params.discordConfig?.dangerouslyAllowNameMatching === true, allowNameMatching: isDangerousNameMatchingEnabled(params.discordConfig),
}); });
if (!isDirectMessage) { if (!isDirectMessage) {
@@ -581,7 +582,7 @@ export async function preflightDiscordMessage(
name: sender.name, name: sender.name,
tag: sender.tag, tag: sender.tag,
}, },
{ allowNameMatching: params.discordConfig?.dangerouslyAllowNameMatching === true }, { allowNameMatching: isDangerousNameMatchingEnabled(params.discordConfig) },
) )
: false; : false;
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false; const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;

View File

@@ -21,6 +21,7 @@ import {
type StatusReactionAdapter, type StatusReactionAdapter,
} from "../../channels/status-reactions.js"; } from "../../channels/status-reactions.js";
import { createTypingCallbacks } from "../../channels/typing.js"; import { createTypingCallbacks } from "../../channels/typing.js";
import { isDangerousNameMatchingEnabled } from "../../config/dangerous-name-matching.js";
import { resolveDiscordPreviewStreamMode } from "../../config/discord-preview-streaming.js"; import { resolveDiscordPreviewStreamMode } from "../../config/discord-preview-streaming.js";
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js"; import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js";
@@ -199,7 +200,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
channelConfig, channelConfig,
guildInfo, guildInfo,
sender: { id: sender.id, name: sender.name, tag: sender.tag }, sender: { id: sender.id, name: sender.name, tag: sender.tag },
allowNameMatching: discordConfig?.dangerouslyAllowNameMatching === true, allowNameMatching: isDangerousNameMatchingEnabled(discordConfig),
}); });
const storePath = resolveStorePath(cfg.session?.store, { const storePath = resolveStorePath(cfg.session?.store, {
agentId: route.agentId, agentId: route.agentId,

View File

@@ -39,6 +39,7 @@ import type { ReplyPayload } from "../../auto-reply/types.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; import { createReplyPrefixOptions } from "../../channels/reply-prefix.js";
import type { OpenClawConfig, loadConfig } from "../../config/config.js"; import type { OpenClawConfig, loadConfig } from "../../config/config.js";
import { isDangerousNameMatchingEnabled } from "../../config/dangerous-name-matching.js";
import { resolveOpenProviderRuntimeGroupPolicy } from "../../config/runtime-group-policy.js"; import { resolveOpenProviderRuntimeGroupPolicy } from "../../config/runtime-group-policy.js";
import { loadSessionStore, resolveStorePath } from "../../config/sessions.js"; import { loadSessionStore, resolveStorePath } from "../../config/sessions.js";
import { logVerbose } from "../../globals.js"; import { logVerbose } from "../../globals.js";
@@ -1283,7 +1284,7 @@ async function dispatchDiscordCommandInteraction(params: {
name: sender.name, name: sender.name,
tag: sender.tag, tag: sender.tag,
}, },
{ allowNameMatching: discordConfig?.dangerouslyAllowNameMatching === true }, { allowNameMatching: isDangerousNameMatchingEnabled(discordConfig) },
) )
: false; : false;
const guildInfo = resolveDiscordGuildEntry({ const guildInfo = resolveDiscordGuildEntry({
@@ -1374,7 +1375,7 @@ async function dispatchDiscordCommandInteraction(params: {
name: sender.name, name: sender.name,
tag: sender.tag, tag: sender.tag,
}, },
{ allowNameMatching: discordConfig?.dangerouslyAllowNameMatching === true }, { allowNameMatching: isDangerousNameMatchingEnabled(discordConfig) },
) )
: false; : false;
if (!permitted) { if (!permitted) {
@@ -1412,7 +1413,7 @@ async function dispatchDiscordCommandInteraction(params: {
guildInfo, guildInfo,
memberRoleIds, memberRoleIds,
sender, sender,
allowNameMatching: discordConfig?.dangerouslyAllowNameMatching === true, allowNameMatching: isDangerousNameMatchingEnabled(discordConfig),
}); });
const authorizers = useAccessGroups const authorizers = useAccessGroups
? [ ? [
@@ -1518,7 +1519,7 @@ async function dispatchDiscordCommandInteraction(params: {
channelConfig, channelConfig,
guildInfo, guildInfo,
sender: { id: sender.id, name: sender.name, tag: sender.tag }, sender: { id: sender.id, name: sender.name, tag: sender.tag },
allowNameMatching: discordConfig?.dangerouslyAllowNameMatching === true, allowNameMatching: isDangerousNameMatchingEnabled(discordConfig),
}); });
const ctxPayload = finalizeInboundContext({ const ctxPayload = finalizeInboundContext({
Body: prompt, Body: prompt,

View File

@@ -21,6 +21,7 @@ import {
} from "../../config/commands.js"; } from "../../config/commands.js";
import type { OpenClawConfig, ReplyToMode } from "../../config/config.js"; import type { OpenClawConfig, ReplyToMode } from "../../config/config.js";
import { loadConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js";
import { isDangerousNameMatchingEnabled } from "../../config/dangerous-name-matching.js";
import { import {
GROUP_POLICY_BLOCKED_LABEL, GROUP_POLICY_BLOCKED_LABEL,
resolveOpenProviderRuntimeGroupPolicy, resolveOpenProviderRuntimeGroupPolicy,
@@ -559,7 +560,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
accountId: account.accountId, accountId: account.accountId,
runtime, runtime,
botUserId, botUserId,
allowNameMatching: discordCfg.dangerouslyAllowNameMatching === true, allowNameMatching: isDangerousNameMatchingEnabled(discordCfg),
guildEntries, guildEntries,
logger, logger,
}), }),
@@ -571,7 +572,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
accountId: account.accountId, accountId: account.accountId,
runtime, runtime,
botUserId, botUserId,
allowNameMatching: discordCfg.dangerouslyAllowNameMatching === true, allowNameMatching: isDangerousNameMatchingEnabled(discordCfg),
guildEntries, guildEntries,
logger, logger,
}), }),

View File

@@ -12,6 +12,7 @@ import {
} from "discord-api-types/v10"; } from "discord-api-types/v10";
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
import type { OpenClawConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/config.js";
import { isDangerousNameMatchingEnabled } from "../../config/dangerous-name-matching.js";
import type { DiscordAccountConfig } from "../../config/types.js"; import type { DiscordAccountConfig } from "../../config/types.js";
import { import {
allowListMatches, allowListMatches,
@@ -156,7 +157,7 @@ async function authorizeVoiceCommand(
guildInfo, guildInfo,
memberRoleIds, memberRoleIds,
sender, sender,
allowNameMatching: params.discordConfig?.dangerouslyAllowNameMatching === true, allowNameMatching: isDangerousNameMatchingEnabled(params.discordConfig),
}); });
const ownerAllowList = normalizeDiscordAllowList( const ownerAllowList = normalizeDiscordAllowList(
@@ -171,7 +172,7 @@ async function authorizeVoiceCommand(
name: sender.name, name: sender.name,
tag: sender.tag, tag: sender.tag,
}, },
{ allowNameMatching: params.discordConfig?.dangerouslyAllowNameMatching === true }, { allowNameMatching: isDangerousNameMatchingEnabled(params.discordConfig) },
) )
: false; : false;

View File

@@ -91,6 +91,7 @@ export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
export type { OpenClawConfig } from "../config/config.js"; export type { OpenClawConfig } from "../config/config.js";
/** @deprecated Use OpenClawConfig instead */ /** @deprecated Use OpenClawConfig instead */
export type { OpenClawConfig as ClawdbotConfig } from "../config/config.js"; export type { OpenClawConfig as ClawdbotConfig } from "../config/config.js";
export { isDangerousNameMatchingEnabled } from "../config/dangerous-name-matching.js";
export type { FileLockHandle, FileLockOptions } from "./file-lock.js"; export type { FileLockHandle, FileLockOptions } from "./file-lock.js";
export { acquireFileLock, withFileLock } from "./file-lock.js"; export { acquireFileLock, withFileLock } from "./file-lock.js";

View File

@@ -8,36 +8,17 @@ import {
import { formatCliCommand } from "../cli/command-format.js"; import { formatCliCommand } from "../cli/command-format.js";
import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../config/commands.js"; import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../config/commands.js";
import type { OpenClawConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js";
import { isDangerousNameMatchingEnabled } from "../config/dangerous-name-matching.js";
import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
import { normalizeStringEntries } from "../shared/string-normalization.js"; import { normalizeStringEntries } from "../shared/string-normalization.js";
import type { SecurityAuditFinding, SecurityAuditSeverity } from "./audit.js"; import type { SecurityAuditFinding, SecurityAuditSeverity } from "./audit.js";
import { resolveDmAllowState } from "./dm-policy-shared.js"; import { resolveDmAllowState } from "./dm-policy-shared.js";
import { isDiscordMutableAllowEntry } from "./mutable-allowlist-detectors.js";
function normalizeAllowFromList(list: Array<string | number> | undefined | null): string[] { function normalizeAllowFromList(list: Array<string | number> | undefined | null): string[] {
return normalizeStringEntries(Array.isArray(list) ? list : undefined); return normalizeStringEntries(Array.isArray(list) ? list : undefined);
} }
const DISCORD_ALLOWLIST_ID_PREFIXES = ["discord:", "user:", "pk:"] as const;
function isDiscordNameBasedAllowEntry(raw: string | number): boolean {
const text = String(raw).trim();
if (!text || text === "*") {
return false;
}
const maybeId = text.replace(/^<@!?/, "").replace(/>$/, "");
if (/^\d+$/.test(maybeId)) {
return false;
}
const prefixed = DISCORD_ALLOWLIST_ID_PREFIXES.find((prefix) => text.startsWith(prefix));
if (prefixed) {
const candidate = text.slice(prefixed.length);
if (candidate) {
return false;
}
}
return true;
}
function addDiscordNameBasedEntries(params: { function addDiscordNameBasedEntries(params: {
target: Set<string>; target: Set<string>;
values: unknown; values: unknown;
@@ -47,7 +28,7 @@ function addDiscordNameBasedEntries(params: {
return; return;
} }
for (const value of params.values) { for (const value of params.values) {
if (!isDiscordNameBasedAllowEntry(value as string | number)) { if (!isDiscordMutableAllowEntry(String(value))) {
continue; continue;
} }
const text = String(value).trim(); const text = String(value).trim();
@@ -76,6 +57,42 @@ function classifyChannelWarningSeverity(message: string): SecurityAuditSeverity
return "warn"; return "warn";
} }
function dedupeFindings(findings: SecurityAuditFinding[]): SecurityAuditFinding[] {
const seen = new Set<string>();
const out: SecurityAuditFinding[] = [];
for (const finding of findings) {
const key = [
finding.checkId,
finding.severity,
finding.title,
finding.detail ?? "",
finding.remediation ?? "",
].join("\n");
if (seen.has(key)) {
continue;
}
seen.add(key);
out.push(finding);
}
return out;
}
function hasExplicitProviderAccountConfig(
cfg: OpenClawConfig,
provider: string,
accountId: string,
): boolean {
const channel = cfg.channels?.[provider];
if (!channel || typeof channel !== "object") {
return false;
}
const accounts = (channel as { accounts?: Record<string, unknown> }).accounts;
if (!accounts || typeof accounts !== "object") {
return false;
}
return accountId in accounts;
}
export async function collectChannelSecurityFindings(params: { export async function collectChannelSecurityFindings(params: {
cfg: OpenClawConfig; cfg: OpenClawConfig;
plugins: ReturnType<typeof listChannelPlugins>; plugins: ReturnType<typeof listChannelPlugins>;
@@ -166,299 +183,317 @@ export async function collectChannelSecurityFindings(params: {
cfg: params.cfg, cfg: params.cfg,
accountIds, accountIds,
}); });
const account = plugin.config.resolveAccount(params.cfg, defaultAccountId); const orderedAccountIds = Array.from(new Set([defaultAccountId, ...accountIds]));
const enabled = plugin.config.isEnabled ? plugin.config.isEnabled(account, params.cfg) : true;
if (!enabled) {
continue;
}
const configured = plugin.config.isConfigured
? await plugin.config.isConfigured(account, params.cfg)
: true;
if (!configured) {
continue;
}
const accountConfig = (account as { config?: Record<string, unknown> } | null | undefined) for (const accountId of orderedAccountIds) {
?.config; const hasExplicitAccountPath = hasExplicitProviderAccountConfig(
if (accountConfig?.dangerouslyAllowNameMatching === true) { params.cfg,
findings.push({ plugin.id,
checkId: `channels.${plugin.id}.allowFrom.dangerous_name_matching_enabled`, accountId,
severity: "info", );
title: `${plugin.meta.label ?? plugin.id} dangerous name matching is enabled`, const account = plugin.config.resolveAccount(params.cfg, accountId);
detail: const enabled = plugin.config.isEnabled ? plugin.config.isEnabled(account, params.cfg) : true;
"dangerouslyAllowNameMatching=true re-enables mutable name/email/tag matching for sender authorization. This is a break-glass compatibility mode, not a hardened default.", if (!enabled) {
remediation: continue;
"Prefer stable sender IDs in allowlists, then disable dangerouslyAllowNameMatching.", }
}); const configured = plugin.config.isConfigured
} ? await plugin.config.isConfigured(account, params.cfg)
: true;
if (!configured) {
continue;
}
if (plugin.id === "discord") { const accountConfig = (account as { config?: Record<string, unknown> } | null | undefined)
const discordCfg = ?.config;
(account as { config?: Record<string, unknown> } | null)?.config ?? if (isDangerousNameMatchingEnabled(accountConfig)) {
({} as Record<string, unknown>); const accountNote =
const dangerousNameMatchingEnabled = discordCfg.dangerouslyAllowNameMatching === true; orderedAccountIds.length > 1 || hasExplicitAccountPath ? ` (account: ${accountId})` : "";
const storeAllowFrom = await readChannelAllowFromStore("discord").catch(() => []); findings.push({
const discordNameBasedAllowEntries = new Set<string>(); checkId: `channels.${plugin.id}.allowFrom.dangerous_name_matching_enabled`,
addDiscordNameBasedEntries({ severity: "info",
target: discordNameBasedAllowEntries, title: `${plugin.meta.label ?? plugin.id} dangerous name matching is enabled${accountNote}`,
values: discordCfg.allowFrom, detail:
source: "channels.discord.allowFrom", "dangerouslyAllowNameMatching=true re-enables mutable name/email/tag matching for sender authorization. This is a break-glass compatibility mode, not a hardened default.",
}); remediation:
addDiscordNameBasedEntries({ "Prefer stable sender IDs in allowlists, then disable dangerouslyAllowNameMatching.",
target: discordNameBasedAllowEntries, });
values: (discordCfg.dm as { allowFrom?: unknown } | undefined)?.allowFrom, }
source: "channels.discord.dm.allowFrom",
}); if (plugin.id === "discord") {
addDiscordNameBasedEntries({ const discordCfg =
target: discordNameBasedAllowEntries, (account as { config?: Record<string, unknown> } | null)?.config ??
values: storeAllowFrom, ({} as Record<string, unknown>);
source: "~/.openclaw/credentials/discord-allowFrom.json", const dangerousNameMatchingEnabled = isDangerousNameMatchingEnabled(discordCfg);
}); const storeAllowFrom = await readChannelAllowFromStore("discord").catch(() => []);
const discordGuildEntries = (discordCfg.guilds as Record<string, unknown> | undefined) ?? {}; const discordNameBasedAllowEntries = new Set<string>();
for (const [guildKey, guildValue] of Object.entries(discordGuildEntries)) { const discordPathPrefix =
if (!guildValue || typeof guildValue !== "object") { orderedAccountIds.length > 1 || hasExplicitAccountPath
continue; ? `channels.discord.accounts.${accountId}`
} : "channels.discord";
const guild = guildValue as Record<string, unknown>;
addDiscordNameBasedEntries({ addDiscordNameBasedEntries({
target: discordNameBasedAllowEntries, target: discordNameBasedAllowEntries,
values: guild.users, values: discordCfg.allowFrom,
source: `channels.discord.guilds.${guildKey}.users`, source: `${discordPathPrefix}.allowFrom`,
}); });
const channels = guild.channels; addDiscordNameBasedEntries({
if (!channels || typeof channels !== "object") { target: discordNameBasedAllowEntries,
continue; values: (discordCfg.dm as { allowFrom?: unknown } | undefined)?.allowFrom,
} source: `${discordPathPrefix}.dm.allowFrom`,
for (const [channelKey, channelValue] of Object.entries( });
channels as Record<string, unknown>, addDiscordNameBasedEntries({
)) { target: discordNameBasedAllowEntries,
if (!channelValue || typeof channelValue !== "object") { values: storeAllowFrom,
source: "~/.openclaw/credentials/discord-allowFrom.json",
});
const discordGuildEntries =
(discordCfg.guilds as Record<string, unknown> | undefined) ?? {};
for (const [guildKey, guildValue] of Object.entries(discordGuildEntries)) {
if (!guildValue || typeof guildValue !== "object") {
continue; continue;
} }
const channel = channelValue as Record<string, unknown>; const guild = guildValue as Record<string, unknown>;
addDiscordNameBasedEntries({ addDiscordNameBasedEntries({
target: discordNameBasedAllowEntries, target: discordNameBasedAllowEntries,
values: channel.users, values: guild.users,
source: `channels.discord.guilds.${guildKey}.channels.${channelKey}.users`, source: `${discordPathPrefix}.guilds.${guildKey}.users`,
}); });
} const channels = guild.channels;
}
if (discordNameBasedAllowEntries.size > 0) {
const examples = Array.from(discordNameBasedAllowEntries).slice(0, 5);
const more =
discordNameBasedAllowEntries.size > examples.length
? ` (+${discordNameBasedAllowEntries.size - examples.length} more)`
: "";
findings.push({
checkId: "channels.discord.allowFrom.name_based_entries",
severity: dangerousNameMatchingEnabled ? "info" : "warn",
title: dangerousNameMatchingEnabled
? "Discord allowlist uses break-glass name/tag matching"
: "Discord allowlist contains name or tag entries",
detail: dangerousNameMatchingEnabled
? "Discord name/tag allowlist matching is explicitly enabled via dangerouslyAllowNameMatching. This mutable-identity mode is operator-selected break-glass behavior and out-of-scope for vulnerability reports by itself. " +
`Found: ${examples.join(", ")}${more}.`
: "Discord name/tag allowlist matching uses normalized slugs and can collide across users. " +
`Found: ${examples.join(", ")}${more}.`,
remediation: dangerousNameMatchingEnabled
? "Prefer stable Discord IDs (or <@id>/user:<id>/pk:<id>), then disable dangerouslyAllowNameMatching."
: "Prefer stable Discord IDs (or <@id>/user:<id>/pk:<id>) in channels.discord.allowFrom and channels.discord.guilds.*.users, or explicitly opt in with dangerouslyAllowNameMatching=true if you accept the risk.",
});
}
const nativeEnabled = resolveNativeCommandsEnabled({
providerId: "discord",
providerSetting: coerceNativeSetting(
(discordCfg.commands as { native?: unknown } | undefined)?.native,
),
globalSetting: params.cfg.commands?.native,
});
const nativeSkillsEnabled = resolveNativeSkillsEnabled({
providerId: "discord",
providerSetting: coerceNativeSetting(
(discordCfg.commands as { nativeSkills?: unknown } | undefined)?.nativeSkills,
),
globalSetting: params.cfg.commands?.nativeSkills,
});
const slashEnabled = nativeEnabled || nativeSkillsEnabled;
if (slashEnabled) {
const defaultGroupPolicy = params.cfg.channels?.defaults?.groupPolicy;
const groupPolicy =
(discordCfg.groupPolicy as string | undefined) ?? defaultGroupPolicy ?? "allowlist";
const guildEntries = discordGuildEntries;
const guildsConfigured = Object.keys(guildEntries).length > 0;
const hasAnyUserAllowlist = Object.values(guildEntries).some((guild) => {
if (!guild || typeof guild !== "object") {
return false;
}
const g = guild as Record<string, unknown>;
if (Array.isArray(g.users) && g.users.length > 0) {
return true;
}
const channels = g.channels;
if (!channels || typeof channels !== "object") { if (!channels || typeof channels !== "object") {
return false; continue;
} }
return Object.values(channels as Record<string, unknown>).some((channel) => { for (const [channelKey, channelValue] of Object.entries(
if (!channel || typeof channel !== "object") { channels as Record<string, unknown>,
return false; )) {
if (!channelValue || typeof channelValue !== "object") {
continue;
} }
const c = channel as Record<string, unknown>; const channel = channelValue as Record<string, unknown>;
return Array.isArray(c.users) && c.users.length > 0; addDiscordNameBasedEntries({
}); target: discordNameBasedAllowEntries,
}); values: channel.users,
const dmAllowFromRaw = (discordCfg.dm as { allowFrom?: unknown } | undefined)?.allowFrom; source: `${discordPathPrefix}.guilds.${guildKey}.channels.${channelKey}.users`,
const dmAllowFrom = Array.isArray(dmAllowFromRaw) ? dmAllowFromRaw : []; });
const ownerAllowFromConfigured = }
normalizeAllowFromList([...dmAllowFrom, ...storeAllowFrom]).length > 0; }
if (discordNameBasedAllowEntries.size > 0) {
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false; const examples = Array.from(discordNameBasedAllowEntries).slice(0, 5);
if ( const more =
!useAccessGroups && discordNameBasedAllowEntries.size > examples.length
groupPolicy !== "disabled" && ? ` (+${discordNameBasedAllowEntries.size - examples.length} more)`
guildsConfigured && : "";
!hasAnyUserAllowlist
) {
findings.push({ findings.push({
checkId: "channels.discord.commands.native.unrestricted", checkId: "channels.discord.allowFrom.name_based_entries",
severity: "critical", severity: dangerousNameMatchingEnabled ? "info" : "warn",
title: "Discord slash commands are unrestricted", title: dangerousNameMatchingEnabled
detail: ? "Discord allowlist uses break-glass name/tag matching"
"commands.useAccessGroups=false disables sender allowlists for Discord slash commands unless a per-guild/channel users allowlist is configured; with no users allowlist, any user in allowed guild channels can invoke /… commands.", : "Discord allowlist contains name or tag entries",
remediation: detail: dangerousNameMatchingEnabled
"Set commands.useAccessGroups=true (recommended), or configure channels.discord.guilds.<id>.users (or channels.discord.guilds.<id>.channels.<channel>.users).", ? "Discord name/tag allowlist matching is explicitly enabled via dangerouslyAllowNameMatching. This mutable-identity mode is operator-selected break-glass behavior and out-of-scope for vulnerability reports by itself. " +
}); `Found: ${examples.join(", ")}${more}.`
} else if ( : "Discord name/tag allowlist matching uses normalized slugs and can collide across users. " +
useAccessGroups && `Found: ${examples.join(", ")}${more}.`,
groupPolicy !== "disabled" && remediation: dangerousNameMatchingEnabled
guildsConfigured && ? "Prefer stable Discord IDs (or <@id>/user:<id>/pk:<id>), then disable dangerouslyAllowNameMatching."
!ownerAllowFromConfigured && : "Prefer stable Discord IDs (or <@id>/user:<id>/pk:<id>) in channels.discord.allowFrom and channels.discord.guilds.*.users, or explicitly opt in with dangerouslyAllowNameMatching=true if you accept the risk.",
!hasAnyUserAllowlist
) {
findings.push({
checkId: "channels.discord.commands.native.no_allowlists",
severity: "warn",
title: "Discord slash commands have no allowlists",
detail:
"Discord slash commands are enabled, but neither an owner allowFrom list nor any per-guild/channel users allowlist is configured; /… commands will be rejected for everyone.",
remediation:
"Add your user id to channels.discord.allowFrom (or approve yourself via pairing), or configure channels.discord.guilds.<id>.users.",
}); });
} }
} const nativeEnabled = resolveNativeCommandsEnabled({
} providerId: "discord",
providerSetting: coerceNativeSetting(
if (plugin.id === "slack") { (discordCfg.commands as { native?: unknown } | undefined)?.native,
const slackCfg = ),
(account as { config?: Record<string, unknown>; dm?: Record<string, unknown> } | null) globalSetting: params.cfg.commands?.native,
?.config ?? ({} as Record<string, unknown>); });
const nativeEnabled = resolveNativeCommandsEnabled({ const nativeSkillsEnabled = resolveNativeSkillsEnabled({
providerId: "slack", providerId: "discord",
providerSetting: coerceNativeSetting( providerSetting: coerceNativeSetting(
(slackCfg.commands as { native?: unknown } | undefined)?.native, (discordCfg.commands as { nativeSkills?: unknown } | undefined)?.nativeSkills,
), ),
globalSetting: params.cfg.commands?.native, globalSetting: params.cfg.commands?.nativeSkills,
}); });
const nativeSkillsEnabled = resolveNativeSkillsEnabled({ const slashEnabled = nativeEnabled || nativeSkillsEnabled;
providerId: "slack", if (slashEnabled) {
providerSetting: coerceNativeSetting( const defaultGroupPolicy = params.cfg.channels?.defaults?.groupPolicy;
(slackCfg.commands as { nativeSkills?: unknown } | undefined)?.nativeSkills, const groupPolicy =
), (discordCfg.groupPolicy as string | undefined) ?? defaultGroupPolicy ?? "allowlist";
globalSetting: params.cfg.commands?.nativeSkills, const guildEntries = discordGuildEntries;
}); const guildsConfigured = Object.keys(guildEntries).length > 0;
const slashCommandEnabled = const hasAnyUserAllowlist = Object.values(guildEntries).some((guild) => {
nativeEnabled || if (!guild || typeof guild !== "object") {
nativeSkillsEnabled ||
(slackCfg.slashCommand as { enabled?: unknown } | undefined)?.enabled === true;
if (slashCommandEnabled) {
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
if (!useAccessGroups) {
findings.push({
checkId: "channels.slack.commands.slash.useAccessGroups_off",
severity: "critical",
title: "Slack slash commands bypass access groups",
detail:
"Slack slash/native commands are enabled while commands.useAccessGroups=false; this can allow unrestricted /… command execution from channels/users you didn't explicitly authorize.",
remediation: "Set commands.useAccessGroups=true (recommended).",
});
} else {
const allowFromRaw = (
account as
| { config?: { allowFrom?: unknown }; dm?: { allowFrom?: unknown } }
| null
| undefined
)?.config?.allowFrom;
const legacyAllowFromRaw = (
account as { dm?: { allowFrom?: unknown } } | null | undefined
)?.dm?.allowFrom;
const allowFrom = Array.isArray(allowFromRaw)
? allowFromRaw
: Array.isArray(legacyAllowFromRaw)
? legacyAllowFromRaw
: [];
const storeAllowFrom = await readChannelAllowFromStore("slack").catch(() => []);
const ownerAllowFromConfigured =
normalizeAllowFromList([...allowFrom, ...storeAllowFrom]).length > 0;
const channels = (slackCfg.channels as Record<string, unknown> | undefined) ?? {};
const hasAnyChannelUsersAllowlist = Object.values(channels).some((value) => {
if (!value || typeof value !== "object") {
return false; return false;
} }
const channel = value as Record<string, unknown>; const g = guild as Record<string, unknown>;
return Array.isArray(channel.users) && channel.users.length > 0; if (Array.isArray(g.users) && g.users.length > 0) {
return true;
}
const channels = g.channels;
if (!channels || typeof channels !== "object") {
return false;
}
return Object.values(channels as Record<string, unknown>).some((channel) => {
if (!channel || typeof channel !== "object") {
return false;
}
const c = channel as Record<string, unknown>;
return Array.isArray(c.users) && c.users.length > 0;
});
}); });
if (!ownerAllowFromConfigured && !hasAnyChannelUsersAllowlist) { const dmAllowFromRaw = (discordCfg.dm as { allowFrom?: unknown } | undefined)?.allowFrom;
const dmAllowFrom = Array.isArray(dmAllowFromRaw) ? dmAllowFromRaw : [];
const ownerAllowFromConfigured =
normalizeAllowFromList([...dmAllowFrom, ...storeAllowFrom]).length > 0;
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
if (
!useAccessGroups &&
groupPolicy !== "disabled" &&
guildsConfigured &&
!hasAnyUserAllowlist
) {
findings.push({ findings.push({
checkId: "channels.slack.commands.slash.no_allowlists", checkId: "channels.discord.commands.native.unrestricted",
severity: "warn", severity: "critical",
title: "Slack slash commands have no allowlists", title: "Discord slash commands are unrestricted",
detail: detail:
"Slack slash/native commands are enabled, but neither an owner allowFrom list nor any channels.<id>.users allowlist is configured; /… commands will be rejected for everyone.", "commands.useAccessGroups=false disables sender allowlists for Discord slash commands unless a per-guild/channel users allowlist is configured; with no users allowlist, any user in allowed guild channels can invoke /… commands.",
remediation: remediation:
"Approve yourself via pairing (recommended), or set channels.slack.allowFrom and/or channels.slack.channels.<id>.users.", "Set commands.useAccessGroups=true (recommended), or configure channels.discord.guilds.<id>.users (or channels.discord.guilds.<id>.channels.<channel>.users).",
});
} else if (
useAccessGroups &&
groupPolicy !== "disabled" &&
guildsConfigured &&
!ownerAllowFromConfigured &&
!hasAnyUserAllowlist
) {
findings.push({
checkId: "channels.discord.commands.native.no_allowlists",
severity: "warn",
title: "Discord slash commands have no allowlists",
detail:
"Discord slash commands are enabled, but neither an owner allowFrom list nor any per-guild/channel users allowlist is configured; /… commands will be rejected for everyone.",
remediation:
"Add your user id to channels.discord.allowFrom (or approve yourself via pairing), or configure channels.discord.guilds.<id>.users.",
}); });
} }
} }
} }
}
const dmPolicy = plugin.security.resolveDmPolicy?.({ if (plugin.id === "slack") {
cfg: params.cfg, const slackCfg =
accountId: defaultAccountId, (account as { config?: Record<string, unknown>; dm?: Record<string, unknown> } | null)
account, ?.config ?? ({} as Record<string, unknown>);
}); const nativeEnabled = resolveNativeCommandsEnabled({
if (dmPolicy) { providerId: "slack",
await warnDmPolicy({ providerSetting: coerceNativeSetting(
label: plugin.meta.label ?? plugin.id, (slackCfg.commands as { native?: unknown } | undefined)?.native,
provider: plugin.id, ),
dmPolicy: dmPolicy.policy, globalSetting: params.cfg.commands?.native,
allowFrom: dmPolicy.allowFrom, });
policyPath: dmPolicy.policyPath, const nativeSkillsEnabled = resolveNativeSkillsEnabled({
allowFromPath: dmPolicy.allowFromPath, providerId: "slack",
normalizeEntry: dmPolicy.normalizeEntry, providerSetting: coerceNativeSetting(
}); (slackCfg.commands as { nativeSkills?: unknown } | undefined)?.nativeSkills,
} ),
globalSetting: params.cfg.commands?.nativeSkills,
});
const slashCommandEnabled =
nativeEnabled ||
nativeSkillsEnabled ||
(slackCfg.slashCommand as { enabled?: unknown } | undefined)?.enabled === true;
if (slashCommandEnabled) {
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
if (!useAccessGroups) {
findings.push({
checkId: "channels.slack.commands.slash.useAccessGroups_off",
severity: "critical",
title: "Slack slash commands bypass access groups",
detail:
"Slack slash/native commands are enabled while commands.useAccessGroups=false; this can allow unrestricted /… command execution from channels/users you didn't explicitly authorize.",
remediation: "Set commands.useAccessGroups=true (recommended).",
});
} else {
const allowFromRaw = (
account as
| { config?: { allowFrom?: unknown }; dm?: { allowFrom?: unknown } }
| null
| undefined
)?.config?.allowFrom;
const legacyAllowFromRaw = (
account as { dm?: { allowFrom?: unknown } } | null | undefined
)?.dm?.allowFrom;
const allowFrom = Array.isArray(allowFromRaw)
? allowFromRaw
: Array.isArray(legacyAllowFromRaw)
? legacyAllowFromRaw
: [];
const storeAllowFrom = await readChannelAllowFromStore("slack").catch(() => []);
const ownerAllowFromConfigured =
normalizeAllowFromList([...allowFrom, ...storeAllowFrom]).length > 0;
const channels = (slackCfg.channels as Record<string, unknown> | undefined) ?? {};
const hasAnyChannelUsersAllowlist = Object.values(channels).some((value) => {
if (!value || typeof value !== "object") {
return false;
}
const channel = value as Record<string, unknown>;
return Array.isArray(channel.users) && channel.users.length > 0;
});
if (!ownerAllowFromConfigured && !hasAnyChannelUsersAllowlist) {
findings.push({
checkId: "channels.slack.commands.slash.no_allowlists",
severity: "warn",
title: "Slack slash commands have no allowlists",
detail:
"Slack slash/native commands are enabled, but neither an owner allowFrom list nor any channels.<id>.users allowlist is configured; /… commands will be rejected for everyone.",
remediation:
"Approve yourself via pairing (recommended), or set channels.slack.allowFrom and/or channels.slack.channels.<id>.users.",
});
}
}
}
}
if (plugin.security.collectWarnings) { const dmPolicy = plugin.security.resolveDmPolicy?.({
const warnings = await plugin.security.collectWarnings({
cfg: params.cfg, cfg: params.cfg,
accountId: defaultAccountId, accountId,
account, account,
}); });
for (const message of warnings ?? []) { if (dmPolicy) {
const trimmed = String(message).trim(); await warnDmPolicy({
if (!trimmed) { label: plugin.meta.label ?? plugin.id,
continue; provider: plugin.id,
} dmPolicy: dmPolicy.policy,
findings.push({ allowFrom: dmPolicy.allowFrom,
checkId: `channels.${plugin.id}.warning.${findings.length + 1}`, policyPath: dmPolicy.policyPath,
severity: classifyChannelWarningSeverity(trimmed), allowFromPath: dmPolicy.allowFromPath,
title: `${plugin.meta.label ?? plugin.id} security warning`, normalizeEntry: dmPolicy.normalizeEntry,
detail: trimmed.replace(/^-\s*/, ""),
}); });
} }
}
if (plugin.id === "telegram") { if (plugin.security.collectWarnings) {
const warnings = await plugin.security.collectWarnings({
cfg: params.cfg,
accountId,
account,
});
for (const message of warnings ?? []) {
const trimmed = String(message).trim();
if (!trimmed) {
continue;
}
findings.push({
checkId: `channels.${plugin.id}.warning.${findings.length + 1}`,
severity: classifyChannelWarningSeverity(trimmed),
title: `${plugin.meta.label ?? plugin.id} security warning`,
detail: trimmed.replace(/^-\s*/, ""),
});
}
}
if (plugin.id !== "telegram") {
continue;
}
const allowTextCommands = params.cfg.commands?.text !== false; const allowTextCommands = params.cfg.commands?.text !== false;
if (!allowTextCommands) { if (!allowTextCommands) {
continue; continue;
@@ -614,5 +649,5 @@ export async function collectChannelSecurityFindings(params: {
} }
} }
return findings; return dedupeFindings(findings);
} }

View File

@@ -15,7 +15,8 @@ const isWindows = process.platform === "win32";
function stubChannelPlugin(params: { function stubChannelPlugin(params: {
id: "discord" | "slack" | "telegram"; id: "discord" | "slack" | "telegram";
label: string; label: string;
resolveAccount: (cfg: OpenClawConfig) => unknown; resolveAccount: (cfg: OpenClawConfig, accountId: string | null | undefined) => unknown;
listAccountIds?: (cfg: OpenClawConfig) => string[];
}): ChannelPlugin { }): ChannelPlugin {
return { return {
id: params.id, id: params.id,
@@ -31,11 +32,15 @@ function stubChannelPlugin(params: {
}, },
security: {}, security: {},
config: { config: {
listAccountIds: (cfg) => { listAccountIds:
const enabled = Boolean((cfg.channels as Record<string, unknown> | undefined)?.[params.id]); params.listAccountIds ??
return enabled ? ["default"] : []; ((cfg) => {
}, const enabled = Boolean(
resolveAccount: (cfg) => params.resolveAccount(cfg), (cfg.channels as Record<string, unknown> | undefined)?.[params.id],
);
return enabled ? ["default"] : [];
}),
resolveAccount: (cfg, accountId) => params.resolveAccount(cfg, accountId),
isEnabled: () => true, isEnabled: () => true,
isConfigured: () => true, isConfigured: () => true,
}, },
@@ -45,19 +50,46 @@ function stubChannelPlugin(params: {
const discordPlugin = stubChannelPlugin({ const discordPlugin = stubChannelPlugin({
id: "discord", id: "discord",
label: "Discord", label: "Discord",
resolveAccount: (cfg) => ({ config: cfg.channels?.discord ?? {} }), listAccountIds: (cfg) => {
const ids = Object.keys(cfg.channels?.discord?.accounts ?? {});
return ids.length > 0 ? ids : ["default"];
},
resolveAccount: (cfg, accountId) => {
const resolvedAccountId = typeof accountId === "string" && accountId ? accountId : "default";
const base = cfg.channels?.discord ?? {};
const account = cfg.channels?.discord?.accounts?.[resolvedAccountId] ?? {};
return { config: { ...base, ...account } };
},
}); });
const slackPlugin = stubChannelPlugin({ const slackPlugin = stubChannelPlugin({
id: "slack", id: "slack",
label: "Slack", label: "Slack",
resolveAccount: (cfg) => ({ config: cfg.channels?.slack ?? {} }), listAccountIds: (cfg) => {
const ids = Object.keys(cfg.channels?.slack?.accounts ?? {});
return ids.length > 0 ? ids : ["default"];
},
resolveAccount: (cfg, accountId) => {
const resolvedAccountId = typeof accountId === "string" && accountId ? accountId : "default";
const base = cfg.channels?.slack ?? {};
const account = cfg.channels?.slack?.accounts?.[resolvedAccountId] ?? {};
return { config: { ...base, ...account } };
},
}); });
const telegramPlugin = stubChannelPlugin({ const telegramPlugin = stubChannelPlugin({
id: "telegram", id: "telegram",
label: "Telegram", label: "Telegram",
resolveAccount: (cfg) => ({ config: cfg.channels?.telegram ?? {} }), listAccountIds: (cfg) => {
const ids = Object.keys(cfg.channels?.telegram?.accounts ?? {});
return ids.length > 0 ? ids : ["default"];
},
resolveAccount: (cfg, accountId) => {
const resolvedAccountId = typeof accountId === "string" && accountId ? accountId : "default";
const base = cfg.channels?.telegram ?? {};
const account = cfg.channels?.telegram?.accounts?.[resolvedAccountId] ?? {};
return { config: { ...base, ...account } };
},
}); });
function successfulProbeResult(url: string) { function successfulProbeResult(url: string) {
@@ -1537,6 +1569,79 @@ describe("security audit", () => {
}); });
}); });
it("audits non-default Discord accounts for dangerous name matching", async () => {
await withChannelSecurityStateDir(async () => {
const cfg: OpenClawConfig = {
channels: {
discord: {
enabled: true,
token: "t",
accounts: {
alpha: { token: "a" },
beta: {
token: "b",
dangerouslyAllowNameMatching: true,
},
},
},
},
};
const res = await runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: true,
plugins: [discordPlugin],
});
expect(res.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "channels.discord.allowFrom.dangerous_name_matching_enabled",
title: expect.stringContaining("(account: beta)"),
severity: "info",
}),
]),
);
});
});
it("audits name-based allowlists on non-default Discord accounts", async () => {
await withChannelSecurityStateDir(async () => {
const cfg: OpenClawConfig = {
channels: {
discord: {
enabled: true,
token: "t",
accounts: {
alpha: {
token: "a",
allowFrom: ["123456789012345678"],
},
beta: {
token: "b",
allowFrom: ["Alice#1234"],
},
},
},
},
};
const res = await runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: true,
plugins: [discordPlugin],
});
const finding = res.findings.find(
(entry) => entry.checkId === "channels.discord.allowFrom.name_based_entries",
);
expect(finding).toBeDefined();
expect(finding?.detail).toContain("channels.discord.accounts.beta.allowFrom:Alice#1234");
});
});
it("does not warn when Discord allowlists use ID-style entries only", async () => { it("does not warn when Discord allowlists use ID-style entries only", async () => {
await withChannelSecurityStateDir(async () => { await withChannelSecurityStateDir(async () => {
const cfg: OpenClawConfig = { const cfg: OpenClawConfig = {

View File

@@ -0,0 +1,101 @@
export function isDiscordMutableAllowEntry(raw: string): boolean {
const text = raw.trim();
if (!text || text === "*") {
return false;
}
const maybeMentionId = text.replace(/^<@!?/, "").replace(/>$/, "");
if (/^\d+$/.test(maybeMentionId)) {
return false;
}
for (const prefix of ["discord:", "user:", "pk:"]) {
if (!text.startsWith(prefix)) {
continue;
}
return text.slice(prefix.length).trim().length === 0;
}
return true;
}
export function isSlackMutableAllowEntry(raw: string): boolean {
const text = raw.trim();
if (!text || text === "*") {
return false;
}
const mentionMatch = text.match(/^<@([A-Z0-9]+)>$/i);
if (mentionMatch && /^[A-Z0-9]{8,}$/i.test(mentionMatch[1] ?? "")) {
return false;
}
const withoutPrefix = text.replace(/^(slack|user):/i, "").trim();
if (/^[UWBCGDT][A-Z0-9]{2,}$/.test(withoutPrefix)) {
return false;
}
if (/^[A-Z0-9]{8,}$/i.test(withoutPrefix)) {
return false;
}
return true;
}
export function isGoogleChatMutableAllowEntry(raw: string): boolean {
const text = raw.trim();
if (!text || text === "*") {
return false;
}
const withoutPrefix = text.replace(/^(googlechat|google-chat|gchat):/i, "").trim();
if (!withoutPrefix) {
return false;
}
const withoutUsers = withoutPrefix.replace(/^users\//i, "");
return withoutUsers.includes("@");
}
export function isMSTeamsMutableAllowEntry(raw: string): boolean {
const text = raw.trim();
if (!text || text === "*") {
return false;
}
const withoutPrefix = text.replace(/^(msteams|user):/i, "").trim();
return /\s/.test(withoutPrefix) || withoutPrefix.includes("@");
}
export function isMattermostMutableAllowEntry(raw: string): boolean {
const text = raw.trim();
if (!text || text === "*") {
return false;
}
const normalized = text
.replace(/^(mattermost|user):/i, "")
.replace(/^@/, "")
.trim()
.toLowerCase();
// Mattermost user IDs are stable 26-char lowercase/number tokens.
if (/^[a-z0-9]{26}$/.test(normalized)) {
return false;
}
return true;
}
export function isIrcMutableAllowEntry(raw: string): boolean {
const text = raw.trim().toLowerCase();
if (!text || text === "*") {
return false;
}
const normalized = text
.replace(/^irc:/, "")
.replace(/^user:/, "")
.trim();
return !normalized.includes("!") && !normalized.includes("@");
}

View File

@@ -10,6 +10,7 @@ import {
summarizeMapping, summarizeMapping,
} from "../../channels/allowlists/resolve-utils.js"; } from "../../channels/allowlists/resolve-utils.js";
import { loadConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js";
import { isDangerousNameMatchingEnabled } from "../../config/dangerous-name-matching.js";
import { import {
resolveOpenProviderRuntimeGroupPolicy, resolveOpenProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy, resolveDefaultGroupPolicy,
@@ -210,7 +211,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
dmEnabled, dmEnabled,
dmPolicy, dmPolicy,
allowFrom, allowFrom,
allowNameMatching: slackCfg.dangerouslyAllowNameMatching === true, allowNameMatching: isDangerousNameMatchingEnabled(slackCfg),
groupDmEnabled, groupDmEnabled,
groupDmChannels, groupDmChannels,
defaultRequireMention: slackCfg.requireMention, defaultRequireMention: slackCfg.requireMention,