Skip to content

Commit 8bdda7a

Browse files
committed
fix(security): keep DM pairing allowlists out of group auth
1 parent d08dafb commit 8bdda7a

15 files changed

Lines changed: 194 additions & 54 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ Docs: https://docs.openclaw.ai
9595
- Security/Slack member + message subtype events: gate `member_*` plus `message_changed`/`message_deleted`/`thread_broadcast` system-event enqueue through shared sender authorization so DM `dmPolicy`/`allowFrom` and channel `users` allowlists are enforced consistently for non-message ingress; message subtype system events now fail closed when sender identity is missing, with regression coverage. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
9696
- Security/Telegram reactions: enforce `dmPolicy`/`allowFrom` and group allowlist authorization on `message_reaction` events before enqueueing reaction system events, preventing unauthorized reaction-triggered input in DMs and groups; ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
9797
- Security/Telegram group allowlist: fail closed for group sender authorization by removing DM pairing-store fallback from group allowlist evaluation; group sender access now requires explicit `groupAllowFrom` or per-group/per-topic `allowFrom`. (#25988) Thanks @bmendonca3.
98+
- Security/DM-group allowlist boundaries: keep DM pairing-store approvals DM-only by removing pairing-store inheritance from group sender authorization in LINE and Mattermost message preflight, and by centralizing shared DM/group allowlist composition so group checks never include pairing-store entries. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
9899
- Security/Slack interactions: enforce channel/DM authorization and modal actor binding (`private_metadata.userId`) before enqueueing `block_action`/`view_submission`/`view_closed` system events, with regression coverage for unauthorized senders and missing/mismatched actor metadata. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
99100
- Security/Nextcloud Talk: drop replayed signed webhook events with persistent per-account replay dedupe across restarts, and reject unexpected webhook backend origins when account base URL is configured. Thanks @aristorechina for reporting.
100101
- Security/Nextcloud Talk: reject unsigned webhook traffic before full body reads, reducing unauthenticated request-body exposure, with auth-order regression coverage. (#26118) Thanks @bmendonca3.

docs/channels/groups.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ Notes:
184184

185185
- `groupPolicy` is separate from mention-gating (which requires @mentions).
186186
- WhatsApp/Telegram/Signal/iMessage/Microsoft Teams/Zalo: use `groupAllowFrom` (fallback: explicit `allowFrom`).
187+
- DM pairing approvals (`*-allowFrom` store entries) apply to DM access only; group sender authorization stays explicit to group allowlists.
187188
- Discord: allowlist uses `channels.discord.guilds.<id>.channels`.
188189
- Slack: allowlist uses `channels.slack.channels`.
189190
- Matrix: allowlist uses `channels.matrix.groups` (room IDs, aliases, or names). Use `channels.matrix.groupAllowFrom` to restrict senders; per-room `users` allowlists are also supported.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, expect, it } from "vitest";
2+
import { resolveMattermostEffectiveAllowFromLists } from "./monitor.js";
3+
4+
describe("mattermost monitor authz", () => {
5+
it("keeps DM allowlist merged with pairing-store entries", () => {
6+
const resolved = resolveMattermostEffectiveAllowFromLists({
7+
dmPolicy: "pairing",
8+
allowFrom: ["@trusted-user"],
9+
groupAllowFrom: ["@group-owner"],
10+
storeAllowFrom: ["user:attacker"],
11+
});
12+
13+
expect(resolved.effectiveAllowFrom).toEqual(["trusted-user", "attacker"]);
14+
});
15+
16+
it("uses explicit groupAllowFrom without pairing-store inheritance", () => {
17+
const resolved = resolveMattermostEffectiveAllowFromLists({
18+
dmPolicy: "pairing",
19+
allowFrom: ["@trusted-user"],
20+
groupAllowFrom: ["@group-owner"],
21+
storeAllowFrom: ["user:attacker"],
22+
});
23+
24+
expect(resolved.effectiveGroupAllowFrom).toEqual(["group-owner"]);
25+
});
26+
27+
it("does not inherit pairing-store entries into group allowlist", () => {
28+
const resolved = resolveMattermostEffectiveAllowFromLists({
29+
dmPolicy: "pairing",
30+
allowFrom: ["@trusted-user"],
31+
storeAllowFrom: ["user:attacker"],
32+
});
33+
34+
expect(resolved.effectiveAllowFrom).toEqual(["trusted-user", "attacker"]);
35+
expect(resolved.effectiveGroupAllowFrom).toEqual(["trusted-user"]);
36+
});
37+
});

extensions/mattermost/src/mattermost/monitor.ts

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
isDangerousNameMatchingEnabled,
1919
resolveControlCommandGate,
2020
resolveDmGroupAccessWithLists,
21+
resolveEffectiveAllowFromLists,
2122
resolveAllowlistProviderRuntimeGroupPolicy,
2223
resolveDefaultGroupPolicy,
2324
resolveChannelMediaMaxBytes,
@@ -150,6 +151,23 @@ function normalizeAllowList(entries: Array<string | number>): string[] {
150151
return Array.from(new Set(normalized));
151152
}
152153

154+
export function resolveMattermostEffectiveAllowFromLists(params: {
155+
allowFrom?: Array<string | number> | null;
156+
groupAllowFrom?: Array<string | number> | null;
157+
storeAllowFrom?: Array<string | number> | null;
158+
dmPolicy?: string | null;
159+
}): {
160+
effectiveAllowFrom: string[];
161+
effectiveGroupAllowFrom: string[];
162+
} {
163+
return resolveEffectiveAllowFromLists({
164+
allowFrom: normalizeAllowList(params.allowFrom ?? []),
165+
groupAllowFrom: normalizeAllowList(params.groupAllowFrom ?? []),
166+
storeAllowFrom: normalizeAllowList(params.storeAllowFrom ?? []),
167+
dmPolicy: params.dmPolicy,
168+
});
169+
}
170+
153171
function isSenderAllowed(params: {
154172
senderId: string;
155173
senderName?: string;
@@ -400,20 +418,18 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
400418
senderId;
401419
const rawText = post.message?.trim() || "";
402420
const dmPolicy = account.config.dmPolicy ?? "pairing";
403-
const configAllowFrom = normalizeAllowList(account.config.allowFrom ?? []);
404-
const configGroupAllowFrom = normalizeAllowList(account.config.groupAllowFrom ?? []);
405421
const storeAllowFrom = normalizeAllowList(
406422
dmPolicy === "allowlist"
407423
? []
408424
: await core.channel.pairing.readAllowFromStore("mattermost").catch(() => []),
409425
);
410-
const effectiveAllowFrom = Array.from(new Set([...configAllowFrom, ...storeAllowFrom]));
411-
const effectiveGroupAllowFrom = Array.from(
412-
new Set([
413-
...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom),
414-
...storeAllowFrom,
415-
]),
416-
);
426+
const { effectiveAllowFrom, effectiveGroupAllowFrom } =
427+
resolveMattermostEffectiveAllowFromLists({
428+
dmPolicy,
429+
allowFrom: account.config.allowFrom,
430+
groupAllowFrom: account.config.groupAllowFrom,
431+
storeAllowFrom,
432+
});
417433
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
418434
cfg,
419435
surface: "mattermost",

src/channels/allow-from.test.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import { describe, expect, it } from "vitest";
2-
import { firstDefined, isSenderIdAllowed, mergeAllowFromSources } from "./allow-from.js";
2+
import {
3+
firstDefined,
4+
isSenderIdAllowed,
5+
mergeDmAllowFromSources,
6+
resolveGroupAllowFromSources,
7+
} from "./allow-from.js";
38

4-
describe("mergeAllowFromSources", () => {
9+
describe("mergeDmAllowFromSources", () => {
510
it("merges, trims, and filters empty values", () => {
611
expect(
7-
mergeAllowFromSources({
12+
mergeDmAllowFromSources({
813
allowFrom: [" line:user:abc ", "", 123],
914
storeAllowFrom: [" ", "telegram:456"],
1015
}),
@@ -13,7 +18,7 @@ describe("mergeAllowFromSources", () => {
1318

1419
it("excludes pairing-store entries when dmPolicy is allowlist", () => {
1520
expect(
16-
mergeAllowFromSources({
21+
mergeDmAllowFromSources({
1722
allowFrom: ["+1111"],
1823
storeAllowFrom: ["+2222", "+3333"],
1924
dmPolicy: "allowlist",
@@ -23,7 +28,7 @@ describe("mergeAllowFromSources", () => {
2328

2429
it("keeps pairing-store entries for non-allowlist policies", () => {
2530
expect(
26-
mergeAllowFromSources({
31+
mergeDmAllowFromSources({
2732
allowFrom: ["+1111"],
2833
storeAllowFrom: ["+2222"],
2934
dmPolicy: "pairing",
@@ -32,6 +37,26 @@ describe("mergeAllowFromSources", () => {
3237
});
3338
});
3439

40+
describe("resolveGroupAllowFromSources", () => {
41+
it("prefers explicit group allowlist", () => {
42+
expect(
43+
resolveGroupAllowFromSources({
44+
allowFrom: ["owner"],
45+
groupAllowFrom: ["group-owner", " group-admin "],
46+
}),
47+
).toEqual(["group-owner", "group-admin"]);
48+
});
49+
50+
it("falls back to DM allowlist when group allowlist is unset/empty", () => {
51+
expect(
52+
resolveGroupAllowFromSources({
53+
allowFrom: [" owner ", "", "owner2"],
54+
groupAllowFrom: [],
55+
}),
56+
).toEqual(["owner", "owner2"]);
57+
});
58+
});
59+
3560
describe("firstDefined", () => {
3661
it("returns the first non-undefined value", () => {
3762
expect(firstDefined(undefined, undefined, "x", "y")).toBe("x");

src/channels/allow-from.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
export function mergeAllowFromSources(params: {
1+
export function mergeDmAllowFromSources(params: {
22
allowFrom?: Array<string | number>;
3-
storeAllowFrom?: string[];
3+
storeAllowFrom?: Array<string | number>;
44
dmPolicy?: string;
55
}): string[] {
66
const storeEntries = params.dmPolicy === "allowlist" ? [] : (params.storeAllowFrom ?? []);
@@ -9,6 +9,17 @@ export function mergeAllowFromSources(params: {
99
.filter(Boolean);
1010
}
1111

12+
export function resolveGroupAllowFromSources(params: {
13+
allowFrom?: Array<string | number>;
14+
groupAllowFrom?: Array<string | number>;
15+
}): string[] {
16+
const scoped =
17+
params.groupAllowFrom && params.groupAllowFrom.length > 0
18+
? params.groupAllowFrom
19+
: (params.allowFrom ?? []);
20+
return scoped.map((value) => String(value).trim()).filter(Boolean);
21+
}
22+
1223
export function firstDefined<T>(...values: Array<T | undefined>) {
1324
for (const value of values) {
1425
if (typeof value !== "undefined") {

src/line/bot-access.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { firstDefined, isSenderIdAllowed, mergeAllowFromSources } from "../channels/allow-from.js";
1+
import {
2+
firstDefined,
3+
isSenderIdAllowed,
4+
mergeDmAllowFromSources,
5+
} from "../channels/allow-from.js";
26

37
export type NormalizedAllowFrom = {
48
entries: string[];
@@ -27,11 +31,11 @@ export const normalizeAllowFrom = (list?: Array<string | number>): NormalizedAll
2731
};
2832
};
2933

30-
export const normalizeAllowFromWithStore = (params: {
34+
export const normalizeDmAllowFromWithStore = (params: {
3135
allowFrom?: Array<string | number>;
3236
storeAllowFrom?: string[];
3337
dmPolicy?: string;
34-
}): NormalizedAllowFrom => normalizeAllowFrom(mergeAllowFromSources(params));
38+
}): NormalizedAllowFrom => normalizeAllowFrom(mergeDmAllowFromSources(params));
3539

3640
export const isSenderAllowed = (params: {
3741
allow: NormalizedAllowFrom;

src/line/bot-handlers.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,41 @@ describe("handleLineWebhookEvents", () => {
182182
expect(processMessage).toHaveBeenCalledTimes(1);
183183
});
184184

185+
it("blocks group sender that is only present in pairing-store allowlist", async () => {
186+
const processMessage = vi.fn();
187+
readAllowFromStoreMock.mockResolvedValueOnce(["user-paired"]);
188+
const event = {
189+
type: "message",
190+
message: { id: "m3b", type: "text", text: "hi" },
191+
replyToken: "reply-token",
192+
timestamp: Date.now(),
193+
source: { type: "group", groupId: "group-1", userId: "user-paired" },
194+
mode: "active",
195+
webhookEventId: "evt-3b",
196+
deliveryContext: { isRedelivery: false },
197+
} as MessageEvent;
198+
199+
await handleLineWebhookEvents([event], {
200+
cfg: {
201+
channels: { line: { groupPolicy: "allowlist", groupAllowFrom: ["user-owner"] } },
202+
},
203+
account: {
204+
accountId: "default",
205+
enabled: true,
206+
channelAccessToken: "token",
207+
channelSecret: "secret",
208+
tokenSource: "config",
209+
config: { groupPolicy: "allowlist", groupAllowFrom: ["user-owner"] },
210+
},
211+
runtime: createRuntime(),
212+
mediaMaxBytes: 1,
213+
processMessage,
214+
});
215+
216+
expect(buildLineMessageContextMock).not.toHaveBeenCalled();
217+
expect(processMessage).not.toHaveBeenCalled();
218+
});
219+
185220
it("blocks group messages when wildcard group config disables groups", async () => {
186221
const processMessage = vi.fn();
187222
const event = {

src/line/bot-handlers.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@ import {
2121
upsertChannelPairingRequest,
2222
} from "../pairing/pairing-store.js";
2323
import type { RuntimeEnv } from "../runtime.js";
24-
import { firstDefined, isSenderAllowed, normalizeAllowFromWithStore } from "./bot-access.js";
24+
import {
25+
firstDefined,
26+
isSenderAllowed,
27+
normalizeAllowFrom,
28+
normalizeDmAllowFromWithStore,
29+
} from "./bot-access.js";
2530
import {
2631
getLineSourceInfo,
2732
buildLineMessageContext,
@@ -117,7 +122,7 @@ async function shouldProcessLineEvent(
117122
const dmPolicy = account.config.dmPolicy ?? "pairing";
118123

119124
const storeAllowFrom = await readChannelAllowFromStore("line").catch(() => []);
120-
const effectiveDmAllow = normalizeAllowFromWithStore({
125+
const effectiveDmAllow = normalizeDmAllowFromWithStore({
121126
allowFrom: account.config.allowFrom,
122127
storeAllowFrom,
123128
dmPolicy,
@@ -132,11 +137,9 @@ async function shouldProcessLineEvent(
132137
account.config.groupAllowFrom,
133138
fallbackGroupAllowFrom,
134139
);
135-
const effectiveGroupAllow = normalizeAllowFromWithStore({
136-
allowFrom: groupAllowFrom,
137-
storeAllowFrom,
138-
dmPolicy,
139-
});
140+
// Group authorization stays explicit to group allowlists and must not
141+
// inherit DM pairing-store identities.
142+
const effectiveGroupAllow = normalizeAllowFrom(groupAllowFrom);
140143
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
141144
const { groupPolicy, providerMissingFallbackApplied } =
142145
resolveAllowlistProviderRuntimeGroupPolicy({

src/security/dm-policy-shared.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ describe("security/dm-policy-shared", () => {
4141
storeAllowFrom: [" owner3 ", ""],
4242
});
4343
expect(lists.effectiveAllowFrom).toEqual(["owner", "owner2", "owner3"]);
44-
expect(lists.effectiveGroupAllowFrom).toEqual(["group:abc", "owner3"]);
44+
expect(lists.effectiveGroupAllowFrom).toEqual(["group:abc"]);
4545
});
4646

4747
it("falls back to DM allowlist for groups when groupAllowFrom is empty", () => {
@@ -51,7 +51,7 @@ describe("security/dm-policy-shared", () => {
5151
storeAllowFrom: [" owner2 "],
5252
});
5353
expect(lists.effectiveAllowFrom).toEqual(["owner", "owner2"]);
54-
expect(lists.effectiveGroupAllowFrom).toEqual(["owner", "owner2"]);
54+
expect(lists.effectiveGroupAllowFrom).toEqual(["owner"]);
5555
});
5656

5757
it("excludes storeAllowFrom when dmPolicy is allowlist", () => {
@@ -65,15 +65,15 @@ describe("security/dm-policy-shared", () => {
6565
expect(lists.effectiveGroupAllowFrom).toEqual(["group:abc"]);
6666
});
6767

68-
it("includes storeAllowFrom when dmPolicy is pairing", () => {
68+
it("keeps group allowlist explicit when dmPolicy is pairing", () => {
6969
const lists = resolveEffectiveAllowFromLists({
7070
allowFrom: ["+1111"],
7171
groupAllowFrom: [],
7272
storeAllowFrom: ["+2222"],
7373
dmPolicy: "pairing",
7474
});
7575
expect(lists.effectiveAllowFrom).toEqual(["+1111", "+2222"]);
76-
expect(lists.effectiveGroupAllowFrom).toEqual(["+1111", "+2222"]);
76+
expect(lists.effectiveGroupAllowFrom).toEqual(["+1111"]);
7777
});
7878

7979
it("resolves access + effective allowlists in one shared call", () => {
@@ -89,7 +89,7 @@ describe("security/dm-policy-shared", () => {
8989
expect(resolved.decision).toBe("allow");
9090
expect(resolved.reason).toBe("dmPolicy=pairing (allowlisted)");
9191
expect(resolved.effectiveAllowFrom).toEqual(["owner", "paired-user"]);
92-
expect(resolved.effectiveGroupAllowFrom).toEqual(["group:room", "paired-user"]);
92+
expect(resolved.effectiveGroupAllowFrom).toEqual(["group:room"]);
9393
});
9494

9595
it("keeps allowlist mode strict in shared resolver (no pairing-store fallback)", () => {

0 commit comments

Comments
 (0)