mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
fix(security): make allowFrom id-only by default with dangerous name opt-in (#24907)
* fix(channels): default allowFrom to id-only; add dangerous name opt-in * docs(security): align channel allowFrom docs with id-only default
This commit is contained in:
committed by
GitHub
parent
41b0568b35
commit
cfa44ea6b4
@@ -2,8 +2,9 @@ import { describe, expect, it } from "vitest";
|
||||
import { isSenderAllowed } from "./monitor.js";
|
||||
|
||||
describe("isSenderAllowed", () => {
|
||||
it("matches allowlist entries with raw email", () => {
|
||||
expect(isSenderAllowed("users/123", "Jane@Example.com", ["jane@example.com"])).toBe(true);
|
||||
it("matches raw email entries only when dangerous name matching is enabled", () => {
|
||||
expect(isSenderAllowed("users/123", "Jane@Example.com", ["jane@example.com"])).toBe(false);
|
||||
expect(isSenderAllowed("users/123", "Jane@Example.com", ["jane@example.com"], true)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat users/<email> entries as email allowlist (deprecated form)", () => {
|
||||
@@ -17,6 +18,8 @@ describe("isSenderAllowed", () => {
|
||||
});
|
||||
|
||||
it("rejects non-matching raw email entries", () => {
|
||||
expect(isSenderAllowed("users/123", "jane@example.com", ["other@example.com"])).toBe(false);
|
||||
expect(isSenderAllowed("users/123", "jane@example.com", ["other@example.com"], true)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -287,6 +287,7 @@ export function isSenderAllowed(
|
||||
senderId: string,
|
||||
senderEmail: string | undefined,
|
||||
allowFrom: string[],
|
||||
allowNameMatching = false,
|
||||
) {
|
||||
if (allowFrom.includes("*")) {
|
||||
return true;
|
||||
@@ -305,8 +306,8 @@ export function isSenderAllowed(
|
||||
return normalizeUserId(withoutPrefix) === normalizedSenderId;
|
||||
}
|
||||
|
||||
// Raw email allowlist entries remain supported for usability.
|
||||
if (normalizedEmail && isEmailLike(withoutPrefix)) {
|
||||
// Raw email allowlist entries are a break-glass override.
|
||||
if (allowNameMatching && normalizedEmail && isEmailLike(withoutPrefix)) {
|
||||
return withoutPrefix === normalizedEmail;
|
||||
}
|
||||
|
||||
@@ -409,6 +410,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 allowBots = account.config.allowBots === true;
|
||||
if (!allowBots) {
|
||||
@@ -489,6 +491,7 @@ async function processMessageWithPipeline(params: {
|
||||
senderId,
|
||||
senderEmail,
|
||||
groupUsers.map((v) => String(v)),
|
||||
allowNameMatching,
|
||||
);
|
||||
if (!ok) {
|
||||
logVerbose(core, runtime, `drop group message (sender not allowed, ${senderId})`);
|
||||
@@ -508,7 +511,12 @@ async function processMessageWithPipeline(params: {
|
||||
warnDeprecatedUsersEmailEntries(core, runtime, effectiveAllowFrom);
|
||||
const commandAllowFrom = isGroup ? groupUsers.map((v) => String(v)) : effectiveAllowFrom;
|
||||
const useAccessGroups = config.commands?.useAccessGroups !== false;
|
||||
const senderAllowedForCommands = isSenderAllowed(senderId, senderEmail, commandAllowFrom);
|
||||
const senderAllowedForCommands = isSenderAllowed(
|
||||
senderId,
|
||||
senderEmail,
|
||||
commandAllowFrom,
|
||||
allowNameMatching,
|
||||
);
|
||||
const commandAuthorized = shouldComputeAuth
|
||||
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
|
||||
@@ -46,6 +46,7 @@ export const IrcAccountSchemaBase = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
dangerouslyAllowNameMatching: z.boolean().optional(),
|
||||
host: z.string().optional(),
|
||||
port: z.number().int().min(1).max(65535).optional(),
|
||||
tls: z.boolean().optional(),
|
||||
|
||||
@@ -78,6 +78,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 dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
const defaultGroupPolicy = resolveDefaultGroupPolicy(config);
|
||||
@@ -132,6 +133,7 @@ export async function handleIrcInbound(params: {
|
||||
const senderAllowedForCommands = resolveIrcAllowlistMatch({
|
||||
allowFrom: message.isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom,
|
||||
message,
|
||||
allowNameMatching,
|
||||
}).allowed;
|
||||
const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig);
|
||||
const commandGate = resolveControlCommandGate({
|
||||
@@ -153,6 +155,7 @@ export async function handleIrcInbound(params: {
|
||||
message,
|
||||
outerAllowFrom: effectiveGroupAllowFrom,
|
||||
innerAllowFrom: groupAllowFrom,
|
||||
allowNameMatching,
|
||||
});
|
||||
if (!senderAllowed) {
|
||||
runtime.log?.(`irc: drop group sender ${senderDisplay} (policy=${groupPolicy})`);
|
||||
@@ -167,6 +170,7 @@ export async function handleIrcInbound(params: {
|
||||
const dmAllowed = resolveIrcAllowlistMatch({
|
||||
allowFrom: effectiveAllowFrom,
|
||||
message,
|
||||
allowNameMatching,
|
||||
}).allowed;
|
||||
if (!dmAllowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
|
||||
@@ -30,6 +30,8 @@ describe("irc normalize", () => {
|
||||
};
|
||||
|
||||
expect(buildIrcAllowlistCandidates(message)).toContain("alice!ident@example.org");
|
||||
expect(buildIrcAllowlistCandidates(message)).not.toContain("alice");
|
||||
expect(buildIrcAllowlistCandidates(message, { allowNameMatching: true })).toContain("alice");
|
||||
expect(
|
||||
resolveIrcAllowlistMatch({
|
||||
allowFrom: ["alice!ident@example.org"],
|
||||
@@ -38,9 +40,16 @@ describe("irc normalize", () => {
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolveIrcAllowlistMatch({
|
||||
allowFrom: ["bob"],
|
||||
allowFrom: ["alice"],
|
||||
message,
|
||||
}).allowed,
|
||||
).toBe(false);
|
||||
expect(
|
||||
resolveIrcAllowlistMatch({
|
||||
allowFrom: ["alice"],
|
||||
message,
|
||||
allowNameMatching: true,
|
||||
}).allowed,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,12 +77,15 @@ export function formatIrcSenderId(message: IrcInboundMessage): string {
|
||||
return base;
|
||||
}
|
||||
|
||||
export function buildIrcAllowlistCandidates(message: IrcInboundMessage): string[] {
|
||||
export function buildIrcAllowlistCandidates(
|
||||
message: IrcInboundMessage,
|
||||
params?: { allowNameMatching?: boolean },
|
||||
): string[] {
|
||||
const nick = message.senderNick.trim().toLowerCase();
|
||||
const user = message.senderUser?.trim().toLowerCase();
|
||||
const host = message.senderHost?.trim().toLowerCase();
|
||||
const candidates = new Set<string>();
|
||||
if (nick) {
|
||||
if (nick && params?.allowNameMatching === true) {
|
||||
candidates.add(nick);
|
||||
}
|
||||
if (nick && user) {
|
||||
@@ -100,6 +103,7 @@ export function buildIrcAllowlistCandidates(message: IrcInboundMessage): string[
|
||||
export function resolveIrcAllowlistMatch(params: {
|
||||
allowFrom: string[];
|
||||
message: IrcInboundMessage;
|
||||
allowNameMatching?: boolean;
|
||||
}): { allowed: boolean; source?: string } {
|
||||
const allowFrom = new Set(
|
||||
params.allowFrom.map((entry) => entry.trim().toLowerCase()).filter(Boolean),
|
||||
@@ -107,7 +111,9 @@ export function resolveIrcAllowlistMatch(params: {
|
||||
if (allowFrom.has("*")) {
|
||||
return { allowed: true, source: "wildcard" };
|
||||
}
|
||||
const candidates = buildIrcAllowlistCandidates(params.message);
|
||||
const candidates = buildIrcAllowlistCandidates(params.message, {
|
||||
allowNameMatching: params.allowNameMatching,
|
||||
});
|
||||
for (const candidate of candidates) {
|
||||
if (allowFrom.has(candidate)) {
|
||||
return { allowed: true, source: candidate };
|
||||
|
||||
@@ -50,6 +50,14 @@ describe("irc policy", () => {
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
resolveIrcGroupSenderAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
message,
|
||||
outerAllowFrom: ["alice!ident@example.org"],
|
||||
innerAllowFrom: [],
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolveIrcGroupSenderAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
@@ -57,6 +65,15 @@ describe("irc policy", () => {
|
||||
outerAllowFrom: ["alice"],
|
||||
innerAllowFrom: [],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
resolveIrcGroupSenderAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
message,
|
||||
outerAllowFrom: ["alice"],
|
||||
innerAllowFrom: [],
|
||||
allowNameMatching: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -142,16 +142,25 @@ export function resolveIrcGroupSenderAllowed(params: {
|
||||
message: IrcInboundMessage;
|
||||
outerAllowFrom: string[];
|
||||
innerAllowFrom: string[];
|
||||
allowNameMatching?: boolean;
|
||||
}): boolean {
|
||||
const policy = params.groupPolicy ?? "allowlist";
|
||||
const inner = normalizeIrcAllowlist(params.innerAllowFrom);
|
||||
const outer = normalizeIrcAllowlist(params.outerAllowFrom);
|
||||
|
||||
if (inner.length > 0) {
|
||||
return resolveIrcAllowlistMatch({ allowFrom: inner, message: params.message }).allowed;
|
||||
return resolveIrcAllowlistMatch({
|
||||
allowFrom: inner,
|
||||
message: params.message,
|
||||
allowNameMatching: params.allowNameMatching,
|
||||
}).allowed;
|
||||
}
|
||||
if (outer.length > 0) {
|
||||
return resolveIrcAllowlistMatch({ allowFrom: outer, message: params.message }).allowed;
|
||||
return resolveIrcAllowlistMatch({
|
||||
allowFrom: outer,
|
||||
message: params.message,
|
||||
allowNameMatching: params.allowNameMatching,
|
||||
}).allowed;
|
||||
}
|
||||
return policy === "open";
|
||||
}
|
||||
|
||||
@@ -32,6 +32,11 @@ export type IrcNickServConfig = {
|
||||
export type IrcAccountConfig = {
|
||||
name?: string;
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* Break-glass override: allow nick-only allowlist matching.
|
||||
* Default behavior requires host/user-qualified identities.
|
||||
*/
|
||||
dangerouslyAllowNameMatching?: boolean;
|
||||
host?: string;
|
||||
port?: number;
|
||||
tls?: boolean;
|
||||
|
||||
@@ -11,6 +11,7 @@ const MattermostAccountSchemaBase = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
capabilities: z.array(z.string()).optional(),
|
||||
dangerouslyAllowNameMatching: z.boolean().optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
enabled: z.boolean().optional(),
|
||||
configWrites: z.boolean().optional(),
|
||||
|
||||
@@ -152,6 +152,7 @@ function isSenderAllowed(params: {
|
||||
senderId: string;
|
||||
senderName?: string;
|
||||
allowFrom: string[];
|
||||
allowNameMatching?: boolean;
|
||||
}): boolean {
|
||||
const allowFrom = params.allowFrom;
|
||||
if (allowFrom.length === 0) {
|
||||
@@ -162,10 +163,15 @@ function isSenderAllowed(params: {
|
||||
}
|
||||
const normalizedSenderId = normalizeAllowEntry(params.senderId);
|
||||
const normalizedSenderName = params.senderName ? normalizeAllowEntry(params.senderName) : "";
|
||||
return allowFrom.some(
|
||||
(entry) =>
|
||||
entry === normalizedSenderId || (normalizedSenderName && entry === normalizedSenderName),
|
||||
);
|
||||
return allowFrom.some((entry) => {
|
||||
if (entry === normalizedSenderId) {
|
||||
return true;
|
||||
}
|
||||
if (params.allowNameMatching !== true) {
|
||||
return false;
|
||||
}
|
||||
return normalizedSenderName ? entry === normalizedSenderName : false;
|
||||
});
|
||||
}
|
||||
|
||||
type MattermostMediaInfo = {
|
||||
@@ -206,6 +212,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const allowNameMatching = account.config.dangerouslyAllowNameMatching === true;
|
||||
const botToken = opts.botToken?.trim() || account.botToken?.trim();
|
||||
if (!botToken) {
|
||||
throw new Error(
|
||||
@@ -416,11 +423,13 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
senderId,
|
||||
senderName,
|
||||
allowFrom: effectiveAllowFrom,
|
||||
allowNameMatching,
|
||||
});
|
||||
const groupAllowedForCommands = isSenderAllowed({
|
||||
senderId,
|
||||
senderName,
|
||||
allowFrom: effectiveGroupAllowFrom,
|
||||
allowNameMatching,
|
||||
});
|
||||
const commandGate = resolveControlCommandGate({
|
||||
useAccessGroups,
|
||||
@@ -892,6 +901,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
senderId: userId,
|
||||
senderName,
|
||||
allowFrom: effectiveAllowFrom,
|
||||
allowNameMatching,
|
||||
});
|
||||
if (!allowed) {
|
||||
logVerboseMessage(
|
||||
@@ -927,6 +937,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
senderId: userId,
|
||||
senderName,
|
||||
allowFrom: effectiveGroupAllowFrom,
|
||||
allowNameMatching,
|
||||
});
|
||||
if (!allowed) {
|
||||
logVerboseMessage(`mattermost: drop reaction (groupPolicy=allowlist sender=${userId})`);
|
||||
|
||||
@@ -7,6 +7,11 @@ export type MattermostAccountConfig = {
|
||||
name?: string;
|
||||
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||
capabilities?: string[];
|
||||
/**
|
||||
* Break-glass override: allow mutable identity matching (@username/display name) in allowlists.
|
||||
* Default behavior is ID-only matching.
|
||||
*/
|
||||
dangerouslyAllowNameMatching?: boolean;
|
||||
/** Allow channel-initiated config writes (default: true). */
|
||||
configWrites?: boolean;
|
||||
/** If false, do not start this Mattermost account. Default: true. */
|
||||
|
||||
@@ -145,10 +145,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
|
||||
if (dmPolicy !== "open") {
|
||||
const effectiveAllowFrom = [...allowFrom.map((v) => String(v)), ...storedAllowFrom];
|
||||
const allowNameMatching = msteamsCfg.dangerouslyAllowNameMatching === true;
|
||||
const allowMatch = resolveMSTeamsAllowlistMatch({
|
||||
allowFrom: effectiveAllowFrom,
|
||||
senderId,
|
||||
senderName,
|
||||
allowNameMatching,
|
||||
});
|
||||
|
||||
if (!allowMatch.allowed) {
|
||||
@@ -226,10 +228,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
return;
|
||||
}
|
||||
if (effectiveGroupAllowFrom.length > 0) {
|
||||
const allowNameMatching = msteamsCfg.dangerouslyAllowNameMatching === true;
|
||||
const allowMatch = resolveMSTeamsAllowlistMatch({
|
||||
allowFrom: effectiveGroupAllowFrom,
|
||||
senderId,
|
||||
senderName,
|
||||
allowNameMatching,
|
||||
});
|
||||
if (!allowMatch.allowed) {
|
||||
log.debug?.("dropping group message (not in groupAllowFrom)", {
|
||||
@@ -248,12 +252,14 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
allowFrom: effectiveDmAllowFrom,
|
||||
senderId,
|
||||
senderName,
|
||||
allowNameMatching: msteamsCfg?.dangerouslyAllowNameMatching === true,
|
||||
});
|
||||
const groupAllowedForCommands = isMSTeamsGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: effectiveGroupAllowFrom,
|
||||
senderId,
|
||||
senderName,
|
||||
allowNameMatching: msteamsCfg?.dangerouslyAllowNameMatching === true,
|
||||
});
|
||||
const hasControlCommandInMessage = core.channel.text.hasControlCommand(text, cfg);
|
||||
const commandGate = resolveControlCommandGate({
|
||||
|
||||
@@ -209,6 +209,7 @@ export function resolveMSTeamsAllowlistMatch(params: {
|
||||
allowFrom: Array<string | number>;
|
||||
senderId: string;
|
||||
senderName?: string | null;
|
||||
allowNameMatching?: boolean;
|
||||
}): MSTeamsAllowlistMatch {
|
||||
return resolveAllowlistMatchSimple(params);
|
||||
}
|
||||
@@ -245,6 +246,7 @@ export function isMSTeamsGroupAllowed(params: {
|
||||
allowFrom: Array<string | number>;
|
||||
senderId: string;
|
||||
senderName?: string | null;
|
||||
allowNameMatching?: boolean;
|
||||
}): boolean {
|
||||
const { groupPolicy } = params;
|
||||
if (groupPolicy === "disabled") {
|
||||
|
||||
Reference in New Issue
Block a user