mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-09 08:11:09 +00:00
236 lines
8.8 KiB
TypeScript
236 lines
8.8 KiB
TypeScript
import {
|
|
isDangerousNameMatchingEnabled,
|
|
resolveNativeCommandsEnabled,
|
|
resolveNativeSkillsEnabled,
|
|
} from "openclaw/plugin-sdk/config-runtime";
|
|
import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime";
|
|
import type { ResolvedDiscordAccount } from "./accounts.js";
|
|
import type { OpenClawConfig } from "./runtime-api.js";
|
|
|
|
function normalizeAllowFromList(list: Array<string | number> | undefined | null): string[] {
|
|
if (!Array.isArray(list)) {
|
|
return [];
|
|
}
|
|
return list.map((value) => String(value).trim()).filter(Boolean);
|
|
}
|
|
|
|
function coerceNativeSetting(value: unknown): boolean | "auto" | undefined {
|
|
if (value === true || value === false || value === "auto") {
|
|
return value;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function addDiscordNameBasedEntries(params: {
|
|
target: Set<string>;
|
|
values: unknown;
|
|
source: string;
|
|
}) {
|
|
if (!Array.isArray(params.values)) {
|
|
return;
|
|
}
|
|
for (const value of params.values) {
|
|
if (!isDiscordMutableAllowEntry(String(value))) {
|
|
continue;
|
|
}
|
|
const text = String(value).trim();
|
|
if (!text) {
|
|
continue;
|
|
}
|
|
params.target.add(`${params.source}:${text}`);
|
|
}
|
|
}
|
|
|
|
export async function collectDiscordSecurityAuditFindings(params: {
|
|
cfg: OpenClawConfig;
|
|
accountId?: string | null;
|
|
account: ResolvedDiscordAccount;
|
|
orderedAccountIds: string[];
|
|
hasExplicitAccountPath: boolean;
|
|
}) {
|
|
const findings: Array<{
|
|
checkId: string;
|
|
severity: "info" | "warn" | "critical";
|
|
title: string;
|
|
detail: string;
|
|
remediation?: string;
|
|
}> = [];
|
|
const discordCfg = params.account.config ?? {};
|
|
const accountId = params.accountId?.trim() || params.account.accountId || "default";
|
|
const dangerousNameMatchingEnabled = isDangerousNameMatchingEnabled(discordCfg);
|
|
const storeAllowFrom = await readChannelAllowFromStore("discord", process.env, accountId).catch(
|
|
() => [],
|
|
);
|
|
const discordNameBasedAllowEntries = new Set<string>();
|
|
const discordPathPrefix =
|
|
params.orderedAccountIds.length > 1 || params.hasExplicitAccountPath
|
|
? `channels.discord.accounts.${accountId}`
|
|
: "channels.discord";
|
|
|
|
addDiscordNameBasedEntries({
|
|
target: discordNameBasedAllowEntries,
|
|
values: discordCfg.allowFrom,
|
|
source: `${discordPathPrefix}.allowFrom`,
|
|
});
|
|
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 guildEntries = (discordCfg.guilds as Record<string, unknown> | undefined) ?? {};
|
|
for (const [guildKey, guildValue] of Object.entries(guildEntries)) {
|
|
if (!guildValue || typeof guildValue !== "object") {
|
|
continue;
|
|
}
|
|
const guild = guildValue as Record<string, unknown>;
|
|
addDiscordNameBasedEntries({
|
|
target: discordNameBasedAllowEntries,
|
|
values: guild.users,
|
|
source: `${discordPathPrefix}.guilds.${guildKey}.users`,
|
|
});
|
|
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") {
|
|
continue;
|
|
}
|
|
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.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,
|
|
});
|
|
if (!nativeEnabled && !nativeSkillsEnabled) {
|
|
return findings;
|
|
}
|
|
|
|
const defaultGroupPolicy = params.cfg.channels?.defaults?.groupPolicy;
|
|
const groupPolicy =
|
|
(discordCfg.groupPolicy as string | undefined) ?? defaultGroupPolicy ?? "allowlist";
|
|
const guildsConfigured = Object.keys(guildEntries).length > 0;
|
|
const hasAnyUserAllowlist = Object.values(guildEntries).some((guild) => {
|
|
if (!guild || typeof guild !== "object") {
|
|
return false;
|
|
}
|
|
const record = guild as Record<string, unknown>;
|
|
if (Array.isArray(record.users) && record.users.length > 0) {
|
|
return true;
|
|
}
|
|
const channels = record.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 channelRecord = channel as Record<string, unknown>;
|
|
return Array.isArray(channelRecord.users) && channelRecord.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) {
|
|
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.",
|
|
});
|
|
}
|
|
|
|
return findings;
|
|
}
|