Skip to content

Commit 08e2aa4

Browse files
committed
fix(commands): restrict commands.allowFrom to sender principals
1 parent 223d7dc commit 08e2aa4

File tree

3 files changed

+111
-1
lines changed

3 files changed

+111
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
1010

1111
### Fixes
1212

13+
- Security/Commands: enforce sender-only matching for `commands.allowFrom` by blocking conversation-shaped `From` identities (`channel:`, `group:`, `thread:`, `@g.us`) while preserving direct-message fallback when sender fields are missing. Ships in the next npm release. Thanks @jiseoung.
1314
- Security/Config writes: block reserved prototype keys in account-id normalization and route account config resolution through own-key lookups, hardening `/allowlist` and account-scoped config paths against prototype-chain pollution.
1415
- Security/Exec: harden `safeBins` long-option validation by rejecting unknown/ambiguous GNU long-option abbreviations and denying sort filesystem-dependent flags (`--random-source`, `--temporary-directory`, `-T`), closing safe-bin denylist bypasses. Thanks @jiseoung.
1516
- Security/Channels: unify dangerous name-matching policy checks (`dangerouslyAllowNameMatching`) across core and extension channels, share mutable-allowlist detectors between `openclaw doctor` and `openclaw security audit`, and scan all configured accounts (not only the default account) in channel security audit findings.

src/auto-reply/command-auth.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,35 @@ function resolveCommandsAllowFromList(params: {
176176
});
177177
}
178178

179+
function isConversationLikeIdentity(value: string): boolean {
180+
const normalized = value.trim().toLowerCase();
181+
if (!normalized) {
182+
return false;
183+
}
184+
if (normalized.includes("@g.us")) {
185+
return true;
186+
}
187+
if (normalized.startsWith("chat_id:")) {
188+
return true;
189+
}
190+
return /(^|:)(channel|group|thread|topic|room|space|spaces):/.test(normalized);
191+
}
192+
193+
function shouldUseFromAsSenderFallback(params: {
194+
from?: string | null;
195+
chatType?: string | null;
196+
}): boolean {
197+
const from = (params.from ?? "").trim();
198+
if (!from) {
199+
return false;
200+
}
201+
const chatType = (params.chatType ?? "").trim().toLowerCase();
202+
if (chatType && chatType !== "direct") {
203+
return false;
204+
}
205+
return !isConversationLikeIdentity(from);
206+
}
207+
179208
function resolveSenderCandidates(params: {
180209
dock?: ChannelDock;
181210
providerId?: ChannelId;
@@ -184,6 +213,7 @@ function resolveSenderCandidates(params: {
184213
senderId?: string | null;
185214
senderE164?: string | null;
186215
from?: string | null;
216+
chatType?: string | null;
187217
}): string[] {
188218
const { dock, cfg, accountId } = params;
189219
const candidates: string[] = [];
@@ -201,7 +231,12 @@ function resolveSenderCandidates(params: {
201231
pushCandidate(params.senderId);
202232
pushCandidate(params.senderE164);
203233
}
204-
pushCandidate(params.from);
234+
if (
235+
candidates.length === 0 &&
236+
shouldUseFromAsSenderFallback({ from: params.from, chatType: params.chatType })
237+
) {
238+
pushCandidate(params.from);
239+
}
205240

206241
const normalized: string[] = [];
207242
for (const sender of candidates) {
@@ -295,6 +330,7 @@ export function resolveCommandAuthorization(params: {
295330
senderId: ctx.SenderId,
296331
senderE164: ctx.SenderE164,
297332
from,
333+
chatType: ctx.ChatType,
298334
});
299335
const matchedSender = ownerList.length
300336
? senderCandidates.find((candidate) => ownerList.includes(candidate))

src/auto-reply/command-control.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,79 @@ describe("resolveCommandAuthorization", () => {
343343
expect(auth.isAuthorizedSender).toBe(true);
344344
});
345345

346+
it("does not treat conversation ids in From as sender identities", () => {
347+
const cfg = {
348+
commands: {
349+
allowFrom: {
350+
discord: ["channel:123456789012345678"],
351+
},
352+
},
353+
} as OpenClawConfig;
354+
355+
const auth = resolveCommandAuthorization({
356+
ctx: {
357+
Provider: "discord",
358+
Surface: "discord",
359+
ChatType: "channel",
360+
From: "discord:channel:123456789012345678",
361+
SenderId: "999999999999999999",
362+
} as MsgContext,
363+
cfg,
364+
commandAuthorized: false,
365+
});
366+
367+
expect(auth.isAuthorizedSender).toBe(false);
368+
});
369+
370+
it("still falls back to From for direct messages when sender fields are absent", () => {
371+
const cfg = {
372+
commands: {
373+
allowFrom: {
374+
discord: ["123456789012345678"],
375+
},
376+
},
377+
} as OpenClawConfig;
378+
379+
const auth = resolveCommandAuthorization({
380+
ctx: {
381+
Provider: "discord",
382+
Surface: "discord",
383+
ChatType: "direct",
384+
From: "discord:123456789012345678",
385+
SenderId: " ",
386+
SenderE164: " ",
387+
} as MsgContext,
388+
cfg,
389+
commandAuthorized: false,
390+
});
391+
392+
expect(auth.isAuthorizedSender).toBe(true);
393+
});
394+
395+
it("does not fall back to conversation-shaped From when chat type is missing", () => {
396+
const cfg = {
397+
commands: {
398+
allowFrom: {
399+
"*": ["120363411111111111@g.us"],
400+
},
401+
},
402+
} as OpenClawConfig;
403+
404+
const auth = resolveCommandAuthorization({
405+
ctx: {
406+
Provider: "whatsapp",
407+
Surface: "whatsapp",
408+
From: "120363411111111111@g.us",
409+
SenderId: " ",
410+
SenderE164: " ",
411+
} as MsgContext,
412+
cfg,
413+
commandAuthorized: false,
414+
});
415+
416+
expect(auth.isAuthorizedSender).toBe(false);
417+
});
418+
346419
it("normalizes Discord commands.allowFrom prefixes and mentions", () => {
347420
const cfg = {
348421
commands: {

0 commit comments

Comments
 (0)