diff --git a/CHANGELOG.md b/CHANGELOG.md index 002a63ed7ab..5d21ae61707 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ Docs: https://docs.openclaw.ai - Sessions/resolution: collapse alias-duplicate session-id matches before scoring, keep distinct structural ties ambiguous, and prefer current-store reuse when resolving equal cross-store duplicates so follow-up turns stop dropping or duplicating sessions on timestamp ties. - Mobile pairing/bootstrap: keep setup bootstrap tokens alive through the initial node auto-pair so the same QR bootstrap token can finish operator approval, then revoke it after the full issued profile connects successfully. (#60221) Thanks @obviyus. - Plugins/allowlists: let explicit bundled chat channel enablement bypass `plugins.allow`, while keeping auto-enabled channel activation and startup sidecars behind restrictive allowlists. (#60233) Thanks @dorukardahan. +- Allowlist/commands: require owner access for `/allowlist add` and `/allowlist remove` so command-authorized non-owners cannot mutate persisted allowlists. (#59836) Thanks @eleqtrizit. ## 2026.4.2 diff --git a/src/auto-reply/reply/commands-allowlist.ts b/src/auto-reply/reply/commands-allowlist.ts index d0416a31ecc..d9568f080b1 100644 --- a/src/auto-reply/reply/commands-allowlist.ts +++ b/src/auto-reply/reply/commands-allowlist.ts @@ -19,6 +19,7 @@ import { } from "../../routing/session-key.js"; import { normalizeStringEntries } from "../../shared/string-normalization.js"; import { + rejectNonOwnerCommand, rejectUnauthorizedCommand, requireCommandFlagEnabled, requireGatewayClientScopeForInternalChannel, @@ -387,6 +388,11 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo return { shouldContinue: false, reply: { text: lines.join("\n") } }; } + const nonOwner = rejectNonOwnerCommand(params, "/allowlist"); + if (nonOwner) { + return nonOwner; + } + const missingAdminScope = requireGatewayClientScopeForInternalChannel(params, { label: "/allowlist write", allowedScopes: ["operator.admin"], diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 0eeacf97ade..5f874f6982f 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -2011,6 +2011,7 @@ describe("handleCommands /allowlist", () => { commands: { text: true, config: true }, channels: { telegram: { allowFrom: ["123"] } }, } as OpenClawConfig); + params.command.senderIsOwner = true; const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); @@ -2053,6 +2054,7 @@ describe("handleCommands /allowlist", () => { AccountId: "work", }, ); + params.command.senderIsOwner = true; const result = await handleCommands(params); expect(result.shouldContinue, "selected account scope").toBe(false); @@ -2092,6 +2094,7 @@ describe("handleCommands /allowlist", () => { Provider: "telegram", Surface: "telegram", }); + params.command.senderIsOwner = true; const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); @@ -2099,6 +2102,41 @@ describe("handleCommands /allowlist", () => { expect(writeConfigFileMock.mock.calls.length).toBe(previousWriteCount); }); + it("blocks allowlist writes from authorized non-owner senders, including cross-channel targets", async () => { + const cfg = { + commands: { + text: true, + config: true, + allowFrom: { telegram: ["*"] }, + ownerAllowFrom: ["discord:owner-discord-id"], + }, + channels: { + telegram: { allowFrom: ["*"], configWrites: true }, + discord: { allowFrom: ["owner-discord-id"], configWrites: true }, + }, + } as OpenClawConfig; + const params = buildPolicyParams( + "/allowlist add dm --channel discord attacker-discord-id", + cfg, + { + Provider: "telegram", + Surface: "telegram", + SenderId: "telegram-attacker", + From: "telegram-attacker", + }, + ); + + expect(params.command.isAuthorizedSender).toBe(true); + expect(params.command.senderIsOwner).toBe(false); + + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(result.reply).toBeUndefined(); + expect(writeConfigFileMock).not.toHaveBeenCalled(); + expect(addChannelAllowFromStoreEntryMock).not.toHaveBeenCalled(); + }); + it("removes default-account entries from scoped and legacy pairing stores", async () => { removeChannelAllowFromStoreEntryMock .mockResolvedValueOnce({ @@ -2115,6 +2153,7 @@ describe("handleCommands /allowlist", () => { channels: { telegram: { allowFrom: ["123"] } }, } as OpenClawConfig; const params = buildPolicyParams("/allowlist remove dm --store 789", cfg); + params.command.senderIsOwner = true; const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); @@ -2137,6 +2176,7 @@ describe("handleCommands /allowlist", () => { channels: { telegram: { allowFrom: ["123"] } }, } as OpenClawConfig; const params = buildPolicyParams("/allowlist add dm --account __proto__ 789", cfg); + params.command.senderIsOwner = true; const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); @@ -2196,6 +2236,7 @@ describe("handleCommands /allowlist", () => { Provider: testCase.provider, Surface: testCase.provider, }); + params.command.senderIsOwner = true; const result = await handleCommands(params); expect(result.shouldContinue).toBe(false);