fix(security): centralize dm/group allowlist auth composition

This commit is contained in:
Peter Steinberger
2026-02-26 16:35:18 +01:00
parent 7f863e22b0
commit 051fdcc428
8 changed files with 428 additions and 108 deletions

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { isAllowedBlueBubblesSender } from "../../extensions/bluebubbles/src/targets.js";
import { isMattermostSenderAllowed } from "../../extensions/mattermost/src/mattermost/monitor-auth.js";
import { isSignalSenderAllowed, type SignalSender } from "../signal/identity.js";
import { resolveDmGroupAccessWithLists } from "./dm-policy-shared.js";
type ChannelSmokeCase = {
name: string;
storeAllowFrom: string[];
isSenderAllowed: (allowFrom: string[]) => boolean;
};
const signalSender: SignalSender = {
kind: "phone",
raw: "+15550001111",
e164: "+15550001111",
};
const cases: ChannelSmokeCase[] = [
{
name: "bluebubbles",
storeAllowFrom: ["attacker-user"],
isSenderAllowed: (allowFrom) =>
isAllowedBlueBubblesSender({
allowFrom,
sender: "attacker-user",
chatId: 101,
}),
},
{
name: "signal",
storeAllowFrom: [signalSender.e164],
isSenderAllowed: (allowFrom) => isSignalSenderAllowed(signalSender, allowFrom),
},
{
name: "mattermost",
storeAllowFrom: ["user:attacker-user"],
isSenderAllowed: (allowFrom) =>
isMattermostSenderAllowed({
senderId: "attacker-user",
senderName: "Attacker",
allowFrom,
}),
},
];
describe("security/dm-policy-shared channel smoke", () => {
for (const testCase of cases) {
for (const ingress of ["message", "reaction"] as const) {
it(`[${testCase.name}] blocks group ${ingress} when sender is only in pairing store`, () => {
const access = resolveDmGroupAccessWithLists({
isGroup: true,
dmPolicy: "pairing",
groupPolicy: "allowlist",
allowFrom: ["owner-user"],
groupAllowFrom: ["group-owner"],
storeAllowFrom: testCase.storeAllowFrom,
isSenderAllowed: testCase.isSenderAllowed,
});
expect(access.decision).toBe("block");
expect(access.reason).toBe("groupPolicy=allowlist (not allowlisted)");
});
}
}
});

View File

@@ -133,56 +133,88 @@ describe("security/dm-policy-shared", () => {
const cases = [
{
name: "dmPolicy=open",
isGroup: false,
dmPolicy: "open" as const,
groupPolicy: "allowlist" as const,
allowFrom: [] as string[],
senderAllowed: false,
groupAllowFrom: [] as string[],
storeAllowFrom: [] as string[],
isSenderAllowed: () => false,
expectedDecision: "allow" as const,
expectedReactionAllowed: true,
},
{
name: "dmPolicy=disabled",
isGroup: false,
dmPolicy: "disabled" as const,
groupPolicy: "allowlist" as const,
allowFrom: [] as string[],
senderAllowed: false,
groupAllowFrom: [] as string[],
storeAllowFrom: [] as string[],
isSenderAllowed: () => false,
expectedDecision: "block" as const,
expectedReactionAllowed: false,
},
{
name: "dmPolicy=allowlist unauthorized",
isGroup: false,
dmPolicy: "allowlist" as const,
groupPolicy: "allowlist" as const,
allowFrom: ["owner"],
senderAllowed: false,
groupAllowFrom: [] as string[],
storeAllowFrom: [] as string[],
isSenderAllowed: () => false,
expectedDecision: "block" as const,
expectedReactionAllowed: false,
},
{
name: "dmPolicy=allowlist authorized",
isGroup: false,
dmPolicy: "allowlist" as const,
groupPolicy: "allowlist" as const,
allowFrom: ["owner"],
senderAllowed: true,
groupAllowFrom: [] as string[],
storeAllowFrom: [] as string[],
isSenderAllowed: () => true,
expectedDecision: "allow" as const,
expectedReactionAllowed: true,
},
{
name: "dmPolicy=pairing unauthorized",
isGroup: false,
dmPolicy: "pairing" as const,
groupPolicy: "allowlist" as const,
allowFrom: [] as string[],
senderAllowed: false,
groupAllowFrom: [] as string[],
storeAllowFrom: [] as string[],
isSenderAllowed: () => false,
expectedDecision: "pairing" as const,
expectedReactionAllowed: false,
},
{
name: "groupPolicy=allowlist rejects DM-paired sender not in explicit group list",
isGroup: true,
dmPolicy: "pairing" as const,
groupPolicy: "allowlist" as const,
allowFrom: ["owner"] as string[],
groupAllowFrom: ["group-owner"] as string[],
storeAllowFrom: ["paired-user"] as string[],
isSenderAllowed: (allowFrom: string[]) => allowFrom.includes("paired-user"),
expectedDecision: "block" as const,
expectedReactionAllowed: false,
},
];
for (const channel of channels) {
for (const testCase of cases) {
const access = resolveDmGroupAccessWithLists({
isGroup: false,
isGroup: testCase.isGroup,
dmPolicy: testCase.dmPolicy,
groupPolicy: "allowlist",
groupPolicy: testCase.groupPolicy,
allowFrom: testCase.allowFrom,
groupAllowFrom: [],
storeAllowFrom: [],
isSenderAllowed: () => testCase.senderAllowed,
groupAllowFrom: testCase.groupAllowFrom,
storeAllowFrom: testCase.storeAllowFrom,
isSenderAllowed: testCase.isSenderAllowed,
});
const reactionAllowed = access.decision === "allow";
expect(access.decision, `[${channel}] ${testCase.name}`).toBe(testCase.expectedDecision);