Require owner access for /allowlist writes (#59836)

* fix(allowlist): require owner access for writes

* docs(changelog): note allowlist owner gate fix

---------

Co-authored-by: Jacob Tomlinson <jtomlinson@nvidia.com>
This commit is contained in:
Agustin Rivera
2026-04-03 07:07:36 -07:00
committed by GitHub
parent 62b1fe0b85
commit 3cd9aac6bb
3 changed files with 48 additions and 0 deletions

View File

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

View File

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

View File

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