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:
Peter Steinberger
2026-02-24 01:01:51 +00:00
committed by GitHub
parent 41b0568b35
commit cfa44ea6b4
53 changed files with 852 additions and 100 deletions

View File

@@ -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,
);
});
});

View File

@@ -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,

View File

@@ -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(),

View File

@@ -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") {

View File

@@ -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);
});
});

View File

@@ -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 };

View File

@@ -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);
});

View File

@@ -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";
}

View File

@@ -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;

View File

@@ -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(),

View File

@@ -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})`);

View File

@@ -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. */

View File

@@ -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({

View File

@@ -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") {