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

View File

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

View File

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

View File

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

View File

@@ -14,12 +14,21 @@ import {
migrateLegacyConfig,
readConfigFileSnapshot,
} from "../config/config.js";
import { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import { parseToolsBySenderTypedKey } from "../config/types.tools.js";
import {
listInterpreterLikeSafeBins,
resolveMergedSafeBinProfileFixtures,
} 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 { note } from "../terminal/note.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>;
}
function asOptionalBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
function collectTelegramAccountScopes(
cfg: OpenClawConfig,
): Array<{ prefix: string; account: Record<string, unknown> }> {
@@ -589,148 +594,6 @@ type MutableAllowlistHit = {
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: {
hits: MutableAllowlistHit[];
pathLabel: string;
@@ -762,7 +625,7 @@ function addMutableAllowlistHits(params: {
function scanMutableAllowlistEntries(cfg: OpenClawConfig): MutableAllowlistHit[] {
const hits: MutableAllowlistHit[] = [];
for (const scope of collectProviderAccountScopes(cfg, "discord")) {
for (const scope of collectProviderDangerousNameMatchingScopes(cfg, "discord")) {
if (scope.dangerousNameMatchingEnabled) {
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) {
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) {
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) {
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) {
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) {
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 { recordInboundSession } from "../../channels/session.js";
import type { OpenClawConfig } from "../../config/config.js";
import { isDangerousNameMatchingEnabled } from "../../config/dangerous-name-matching.js";
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js";
import type { DiscordAccountConfig } from "../../config/types.discord.js";
@@ -365,7 +366,7 @@ async function ensureAgentComponentInteractionAllowed(params: {
replyOpts: params.replyOpts,
componentLabel: params.componentLabel,
unauthorizedReply: params.unauthorizedReply,
allowNameMatching: params.ctx.discordConfig?.dangerouslyAllowNameMatching === true,
allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig),
});
if (!memberAllowed) {
return null;
@@ -481,7 +482,7 @@ async function ensureDmComponentAuthorized(params: {
name: user.username,
tag: formatDiscordUserTag(user),
},
allowNameMatching: ctx.discordConfig?.dangerouslyAllowNameMatching === true,
allowNameMatching: isDangerousNameMatchingEnabled(ctx.discordConfig),
})
: { allowed: false };
if (allowMatch.allowed) {
@@ -784,7 +785,7 @@ async function dispatchDiscordComponentEvent(params: {
channelConfig,
guildInfo,
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 envelopeOptions = resolveEnvelopeFormatOptions(ctx.cfg);
@@ -982,7 +983,7 @@ async function handleDiscordComponentEvent(params: {
replyOpts,
componentLabel: params.componentLabel,
unauthorizedReply,
allowNameMatching: params.ctx.discordConfig?.dangerouslyAllowNameMatching === true,
allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig),
});
if (!memberAllowed) {
return;
@@ -995,7 +996,7 @@ async function handleDiscordComponentEvent(params: {
replyOpts,
componentLabel: params.componentLabel,
unauthorizedReply,
allowNameMatching: params.ctx.discordConfig?.dangerouslyAllowNameMatching === true,
allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig),
});
if (!componentAllowed) {
return;
@@ -1134,7 +1135,7 @@ async function handleDiscordModalTrigger(params: {
replyOpts,
componentLabel: "form",
unauthorizedReply,
allowNameMatching: params.ctx.discordConfig?.dangerouslyAllowNameMatching === true,
allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig),
});
if (!memberAllowed) {
return;
@@ -1147,7 +1148,7 @@ async function handleDiscordModalTrigger(params: {
replyOpts,
componentLabel: "form",
unauthorizedReply,
allowNameMatching: params.ctx.discordConfig?.dangerouslyAllowNameMatching === true,
allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig),
});
if (!componentAllowed) {
return;
@@ -1583,7 +1584,7 @@ class DiscordComponentModal extends Modal {
replyOpts,
componentLabel: "form",
unauthorizedReply: "You are not authorized to use this form.",
allowNameMatching: this.ctx.discordConfig?.dangerouslyAllowNameMatching === true,
allowNameMatching: isDangerousNameMatchingEnabled(this.ctx.discordConfig),
});
if (!memberAllowed) {
return;

View File

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

View File

@@ -21,6 +21,7 @@ import {
type StatusReactionAdapter,
} from "../../channels/status-reactions.js";
import { createTypingCallbacks } from "../../channels/typing.js";
import { isDangerousNameMatchingEnabled } from "../../config/dangerous-name-matching.js";
import { resolveDiscordPreviewStreamMode } from "../../config/discord-preview-streaming.js";
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js";
@@ -199,7 +200,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
channelConfig,
guildInfo,
sender: { id: sender.id, name: sender.name, tag: sender.tag },
allowNameMatching: discordConfig?.dangerouslyAllowNameMatching === true,
allowNameMatching: isDangerousNameMatchingEnabled(discordConfig),
});
const storePath = resolveStorePath(cfg.session?.store, {
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 { createReplyPrefixOptions } from "../../channels/reply-prefix.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 { loadSessionStore, resolveStorePath } from "../../config/sessions.js";
import { logVerbose } from "../../globals.js";
@@ -1283,7 +1284,7 @@ async function dispatchDiscordCommandInteraction(params: {
name: sender.name,
tag: sender.tag,
},
{ allowNameMatching: discordConfig?.dangerouslyAllowNameMatching === true },
{ allowNameMatching: isDangerousNameMatchingEnabled(discordConfig) },
)
: false;
const guildInfo = resolveDiscordGuildEntry({
@@ -1374,7 +1375,7 @@ async function dispatchDiscordCommandInteraction(params: {
name: sender.name,
tag: sender.tag,
},
{ allowNameMatching: discordConfig?.dangerouslyAllowNameMatching === true },
{ allowNameMatching: isDangerousNameMatchingEnabled(discordConfig) },
)
: false;
if (!permitted) {
@@ -1412,7 +1413,7 @@ async function dispatchDiscordCommandInteraction(params: {
guildInfo,
memberRoleIds,
sender,
allowNameMatching: discordConfig?.dangerouslyAllowNameMatching === true,
allowNameMatching: isDangerousNameMatchingEnabled(discordConfig),
});
const authorizers = useAccessGroups
? [
@@ -1518,7 +1519,7 @@ async function dispatchDiscordCommandInteraction(params: {
channelConfig,
guildInfo,
sender: { id: sender.id, name: sender.name, tag: sender.tag },
allowNameMatching: discordConfig?.dangerouslyAllowNameMatching === true,
allowNameMatching: isDangerousNameMatchingEnabled(discordConfig),
});
const ctxPayload = finalizeInboundContext({
Body: prompt,

View File

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

View File

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

View File

@@ -91,6 +91,7 @@ export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
export type { OpenClawConfig } from "../config/config.js";
/** @deprecated Use OpenClawConfig instead */
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 { acquireFileLock, withFileLock } from "./file-lock.js";

View File

@@ -8,36 +8,17 @@ import {
import { formatCliCommand } from "../cli/command-format.js";
import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../config/commands.js";
import type { OpenClawConfig } from "../config/config.js";
import { isDangerousNameMatchingEnabled } from "../config/dangerous-name-matching.js";
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
import { normalizeStringEntries } from "../shared/string-normalization.js";
import type { SecurityAuditFinding, SecurityAuditSeverity } from "./audit.js";
import { resolveDmAllowState } from "./dm-policy-shared.js";
import { isDiscordMutableAllowEntry } from "./mutable-allowlist-detectors.js";
function normalizeAllowFromList(list: Array<string | number> | undefined | null): string[] {
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: {
target: Set<string>;
values: unknown;
@@ -47,7 +28,7 @@ function addDiscordNameBasedEntries(params: {
return;
}
for (const value of params.values) {
if (!isDiscordNameBasedAllowEntry(value as string | number)) {
if (!isDiscordMutableAllowEntry(String(value))) {
continue;
}
const text = String(value).trim();
@@ -76,6 +57,42 @@ function classifyChannelWarningSeverity(message: string): SecurityAuditSeverity
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: {
cfg: OpenClawConfig;
plugins: ReturnType<typeof listChannelPlugins>;
@@ -166,299 +183,317 @@ export async function collectChannelSecurityFindings(params: {
cfg: params.cfg,
accountIds,
});
const account = plugin.config.resolveAccount(params.cfg, defaultAccountId);
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 orderedAccountIds = Array.from(new Set([defaultAccountId, ...accountIds]));
const accountConfig = (account as { config?: Record<string, unknown> } | null | undefined)
?.config;
if (accountConfig?.dangerouslyAllowNameMatching === true) {
findings.push({
checkId: `channels.${plugin.id}.allowFrom.dangerous_name_matching_enabled`,
severity: "info",
title: `${plugin.meta.label ?? plugin.id} dangerous name matching is enabled`,
detail:
"dangerouslyAllowNameMatching=true re-enables mutable name/email/tag matching for sender authorization. This is a break-glass compatibility mode, not a hardened default.",
remediation:
"Prefer stable sender IDs in allowlists, then disable dangerouslyAllowNameMatching.",
});
}
for (const accountId of orderedAccountIds) {
const hasExplicitAccountPath = hasExplicitProviderAccountConfig(
params.cfg,
plugin.id,
accountId,
);
const account = plugin.config.resolveAccount(params.cfg, accountId);
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;
}
if (plugin.id === "discord") {
const discordCfg =
(account as { config?: Record<string, unknown> } | null)?.config ??
({} as Record<string, unknown>);
const dangerousNameMatchingEnabled = discordCfg.dangerouslyAllowNameMatching === true;
const storeAllowFrom = await readChannelAllowFromStore("discord").catch(() => []);
const discordNameBasedAllowEntries = new Set<string>();
addDiscordNameBasedEntries({
target: discordNameBasedAllowEntries,
values: discordCfg.allowFrom,
source: "channels.discord.allowFrom",
});
addDiscordNameBasedEntries({
target: discordNameBasedAllowEntries,
values: (discordCfg.dm as { allowFrom?: unknown } | undefined)?.allowFrom,
source: "channels.discord.dm.allowFrom",
});
addDiscordNameBasedEntries({
target: discordNameBasedAllowEntries,
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;
}
const guild = guildValue as Record<string, unknown>;
const accountConfig = (account as { config?: Record<string, unknown> } | null | undefined)
?.config;
if (isDangerousNameMatchingEnabled(accountConfig)) {
const accountNote =
orderedAccountIds.length > 1 || hasExplicitAccountPath ? ` (account: ${accountId})` : "";
findings.push({
checkId: `channels.${plugin.id}.allowFrom.dangerous_name_matching_enabled`,
severity: "info",
title: `${plugin.meta.label ?? plugin.id} dangerous name matching is enabled${accountNote}`,
detail:
"dangerouslyAllowNameMatching=true re-enables mutable name/email/tag matching for sender authorization. This is a break-glass compatibility mode, not a hardened default.",
remediation:
"Prefer stable sender IDs in allowlists, then disable dangerouslyAllowNameMatching.",
});
}
if (plugin.id === "discord") {
const discordCfg =
(account as { config?: Record<string, unknown> } | null)?.config ??
({} as Record<string, unknown>);
const dangerousNameMatchingEnabled = isDangerousNameMatchingEnabled(discordCfg);
const storeAllowFrom = await readChannelAllowFromStore("discord").catch(() => []);
const discordNameBasedAllowEntries = new Set<string>();
const discordPathPrefix =
orderedAccountIds.length > 1 || hasExplicitAccountPath
? `channels.discord.accounts.${accountId}`
: "channels.discord";
addDiscordNameBasedEntries({
target: discordNameBasedAllowEntries,
values: guild.users,
source: `channels.discord.guilds.${guildKey}.users`,
values: discordCfg.allowFrom,
source: `${discordPathPrefix}.allowFrom`,
});
const channels = guild.channels;
if (!channels || typeof channels !== "object") {
continue;
}
for (const [channelKey, channelValue] of Object.entries(
channels as Record<string, unknown>,
)) {
if (!channelValue || typeof channelValue !== "object") {
addDiscordNameBasedEntries({
target: discordNameBasedAllowEntries,
values: (discordCfg.dm as { allowFrom?: unknown } | undefined)?.allowFrom,
source: `${discordPathPrefix}.dm.allowFrom`,
});
addDiscordNameBasedEntries({
target: discordNameBasedAllowEntries,
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;
}
const channel = channelValue as Record<string, unknown>;
const guild = guildValue as Record<string, unknown>;
addDiscordNameBasedEntries({
target: discordNameBasedAllowEntries,
values: channel.users,
source: `channels.discord.guilds.${guildKey}.channels.${channelKey}.users`,
values: guild.users,
source: `${discordPathPrefix}.guilds.${guildKey}.users`,
});
}
}
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;
const channels = guild.channels;
if (!channels || typeof channels !== "object") {
return false;
continue;
}
return Object.values(channels as Record<string, unknown>).some((channel) => {
if (!channel || typeof channel !== "object") {
return false;
for (const [channelKey, channelValue] of Object.entries(
channels as Record<string, unknown>,
)) {
if (!channelValue || typeof channelValue !== "object") {
continue;
}
const c = channel as Record<string, unknown>;
return Array.isArray(c.users) && c.users.length > 0;
});
});
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
) {
const channel = channelValue as Record<string, unknown>;
addDiscordNameBasedEntries({
target: discordNameBasedAllowEntries,
values: channel.users,
source: `${discordPathPrefix}.guilds.${guildKey}.channels.${channelKey}.users`,
});
}
}
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.commands.native.unrestricted",
severity: "critical",
title: "Discord slash commands are unrestricted",
detail:
"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:
"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.",
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.",
});
}
}
}
if (plugin.id === "slack") {
const slackCfg =
(account as { config?: Record<string, unknown>; dm?: Record<string, unknown> } | null)
?.config ?? ({} as Record<string, unknown>);
const nativeEnabled = resolveNativeCommandsEnabled({
providerId: "slack",
providerSetting: coerceNativeSetting(
(slackCfg.commands as { native?: unknown } | undefined)?.native,
),
globalSetting: params.cfg.commands?.native,
});
const nativeSkillsEnabled = resolveNativeSkillsEnabled({
providerId: "slack",
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") {
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 channel = value as Record<string, unknown>;
return Array.isArray(channel.users) && channel.users.length > 0;
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") {
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({
checkId: "channels.slack.commands.slash.no_allowlists",
severity: "warn",
title: "Slack slash commands have no allowlists",
checkId: "channels.discord.commands.native.unrestricted",
severity: "critical",
title: "Discord slash commands are unrestricted",
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:
"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?.({
cfg: params.cfg,
accountId: defaultAccountId,
account,
});
if (dmPolicy) {
await warnDmPolicy({
label: plugin.meta.label ?? plugin.id,
provider: plugin.id,
dmPolicy: dmPolicy.policy,
allowFrom: dmPolicy.allowFrom,
policyPath: dmPolicy.policyPath,
allowFromPath: dmPolicy.allowFromPath,
normalizeEntry: dmPolicy.normalizeEntry,
});
}
if (plugin.id === "slack") {
const slackCfg =
(account as { config?: Record<string, unknown>; dm?: Record<string, unknown> } | null)
?.config ?? ({} as Record<string, unknown>);
const nativeEnabled = resolveNativeCommandsEnabled({
providerId: "slack",
providerSetting: coerceNativeSetting(
(slackCfg.commands as { native?: unknown } | undefined)?.native,
),
globalSetting: params.cfg.commands?.native,
});
const nativeSkillsEnabled = resolveNativeSkillsEnabled({
providerId: "slack",
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 warnings = await plugin.security.collectWarnings({
const dmPolicy = plugin.security.resolveDmPolicy?.({
cfg: params.cfg,
accountId: defaultAccountId,
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 (dmPolicy) {
await warnDmPolicy({
label: plugin.meta.label ?? plugin.id,
provider: plugin.id,
dmPolicy: dmPolicy.policy,
allowFrom: dmPolicy.allowFrom,
policyPath: dmPolicy.policyPath,
allowFromPath: dmPolicy.allowFromPath,
normalizeEntry: dmPolicy.normalizeEntry,
});
}
}
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;
if (!allowTextCommands) {
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: {
id: "discord" | "slack" | "telegram";
label: string;
resolveAccount: (cfg: OpenClawConfig) => unknown;
resolveAccount: (cfg: OpenClawConfig, accountId: string | null | undefined) => unknown;
listAccountIds?: (cfg: OpenClawConfig) => string[];
}): ChannelPlugin {
return {
id: params.id,
@@ -31,11 +32,15 @@ function stubChannelPlugin(params: {
},
security: {},
config: {
listAccountIds: (cfg) => {
const enabled = Boolean((cfg.channels as Record<string, unknown> | undefined)?.[params.id]);
return enabled ? ["default"] : [];
},
resolveAccount: (cfg) => params.resolveAccount(cfg),
listAccountIds:
params.listAccountIds ??
((cfg) => {
const enabled = Boolean(
(cfg.channels as Record<string, unknown> | undefined)?.[params.id],
);
return enabled ? ["default"] : [];
}),
resolveAccount: (cfg, accountId) => params.resolveAccount(cfg, accountId),
isEnabled: () => true,
isConfigured: () => true,
},
@@ -45,19 +50,46 @@ function stubChannelPlugin(params: {
const discordPlugin = stubChannelPlugin({
id: "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({
id: "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({
id: "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) {
@@ -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 () => {
await withChannelSecurityStateDir(async () => {
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,
} from "../../channels/allowlists/resolve-utils.js";
import { loadConfig } from "../../config/config.js";
import { isDangerousNameMatchingEnabled } from "../../config/dangerous-name-matching.js";
import {
resolveOpenProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
@@ -210,7 +211,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
dmEnabled,
dmPolicy,
allowFrom,
allowNameMatching: slackCfg.dangerouslyAllowNameMatching === true,
allowNameMatching: isDangerousNameMatchingEnabled(slackCfg),
groupDmEnabled,
groupDmChannels,
defaultRequireMention: slackCfg.requireMention,