mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-04 03:40:21 +00:00
refactor(security): unify config write target policy
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { resolveAccountEntry } from "../../routing/account-lookup.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
|
||||
import { normalizeAccountId } from "../../routing/session-key.js";
|
||||
import { isInternalMessageChannel } from "../../utils/message-channel.js";
|
||||
import type { ChannelId } from "./types.js";
|
||||
|
||||
type ChannelConfigWithAccounts = {
|
||||
@@ -17,6 +19,12 @@ export type ConfigWriteScope = {
|
||||
accountId?: string | null;
|
||||
};
|
||||
|
||||
export type ConfigWriteTarget =
|
||||
| { kind: "global" }
|
||||
| { kind: "channel"; scope: { channelId: ChannelId } }
|
||||
| { kind: "account"; scope: { channelId: ChannelId; accountId: string } }
|
||||
| { kind: "ambiguous"; scopes: ConfigWriteScope[] };
|
||||
|
||||
export type ConfigWriteAuthorizationResult =
|
||||
| { allowed: true }
|
||||
| {
|
||||
@@ -47,14 +55,13 @@ export function resolveChannelConfigWrites(params: {
|
||||
export function authorizeConfigWrite(params: {
|
||||
cfg: OpenClawConfig;
|
||||
origin?: ConfigWriteScope;
|
||||
targets?: ConfigWriteScope[];
|
||||
target?: ConfigWriteTarget;
|
||||
allowBypass?: boolean;
|
||||
hasAmbiguousTarget?: boolean;
|
||||
}): ConfigWriteAuthorizationResult {
|
||||
if (params.allowBypass) {
|
||||
return { allowed: true };
|
||||
}
|
||||
if (params.hasAmbiguousTarget) {
|
||||
if (params.target?.kind === "ambiguous") {
|
||||
return { allowed: false, reason: "ambiguous-target" };
|
||||
}
|
||||
if (
|
||||
@@ -72,7 +79,7 @@ export function authorizeConfigWrite(params: {
|
||||
};
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
for (const target of params.targets ?? []) {
|
||||
for (const target of listConfigWriteTargetScopes(params.target)) {
|
||||
if (!target.channelId) {
|
||||
continue;
|
||||
}
|
||||
@@ -98,31 +105,79 @@ export function authorizeConfigWrite(params: {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
export function resolveConfigWriteScopesFromPath(path: string[]): {
|
||||
targets: ConfigWriteScope[];
|
||||
hasAmbiguousTarget: boolean;
|
||||
} {
|
||||
export function resolveExplicitConfigWriteTarget(scope: ConfigWriteScope): ConfigWriteTarget {
|
||||
if (!scope.channelId) {
|
||||
return { kind: "global" };
|
||||
}
|
||||
const accountId = normalizeAccountId(scope.accountId);
|
||||
if (!accountId || accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return { kind: "channel", scope: { channelId: scope.channelId } };
|
||||
}
|
||||
return { kind: "account", scope: { channelId: scope.channelId, accountId } };
|
||||
}
|
||||
|
||||
export function resolveConfigWriteTargetFromPath(path: string[]): ConfigWriteTarget {
|
||||
if (path[0] !== "channels") {
|
||||
return { targets: [], hasAmbiguousTarget: false };
|
||||
return { kind: "global" };
|
||||
}
|
||||
if (path.length < 2) {
|
||||
return { targets: [], hasAmbiguousTarget: true };
|
||||
return { kind: "ambiguous", scopes: [] };
|
||||
}
|
||||
const channelId = path[1].trim().toLowerCase() as ChannelId;
|
||||
if (!channelId) {
|
||||
return { targets: [], hasAmbiguousTarget: true };
|
||||
return { kind: "ambiguous", scopes: [] };
|
||||
}
|
||||
if (path.length === 2) {
|
||||
return { targets: [{ channelId }], hasAmbiguousTarget: true };
|
||||
return { kind: "ambiguous", scopes: [{ channelId }] };
|
||||
}
|
||||
if (path[2] !== "accounts") {
|
||||
return { targets: [{ channelId }], hasAmbiguousTarget: false };
|
||||
return { kind: "channel", scope: { channelId } };
|
||||
}
|
||||
if (path.length < 4) {
|
||||
return { targets: [{ channelId }], hasAmbiguousTarget: true };
|
||||
return { kind: "ambiguous", scopes: [{ channelId }] };
|
||||
}
|
||||
return {
|
||||
targets: [{ channelId, accountId: normalizeAccountId(path[3]) }],
|
||||
hasAmbiguousTarget: false,
|
||||
};
|
||||
return resolveExplicitConfigWriteTarget({
|
||||
channelId,
|
||||
accountId: normalizeAccountId(path[3]),
|
||||
});
|
||||
}
|
||||
|
||||
export function canBypassConfigWritePolicy(params: {
|
||||
channel?: string | null;
|
||||
gatewayClientScopes?: string[] | null;
|
||||
}): boolean {
|
||||
return (
|
||||
isInternalMessageChannel(params.channel) &&
|
||||
params.gatewayClientScopes?.includes("operator.admin") === true
|
||||
);
|
||||
}
|
||||
|
||||
export function formatConfigWriteDeniedMessage(params: {
|
||||
result: Exclude<ConfigWriteAuthorizationResult, { allowed: true }>;
|
||||
fallbackChannelId?: ChannelId | null;
|
||||
}): string {
|
||||
if (params.result.reason === "ambiguous-target") {
|
||||
return "⚠️ Channel-initiated /config writes cannot replace channels, channel roots, or accounts collections. Use a more specific path or gateway operator.admin.";
|
||||
}
|
||||
|
||||
const blocked = params.result.blockedScope?.scope;
|
||||
const channelLabel = blocked?.channelId ?? params.fallbackChannelId ?? "this channel";
|
||||
const hint = blocked?.channelId
|
||||
? blocked.accountId
|
||||
? `channels.${blocked.channelId}.accounts.${blocked.accountId}.configWrites=true`
|
||||
: `channels.${blocked.channelId}.configWrites=true`
|
||||
: params.fallbackChannelId
|
||||
? `channels.${params.fallbackChannelId}.configWrites=true`
|
||||
: "channels.<channel>.configWrites=true";
|
||||
return `⚠️ Config writes are disabled for ${channelLabel}. Set ${hint} to enable.`;
|
||||
}
|
||||
|
||||
function listConfigWriteTargetScopes(target?: ConfigWriteTarget): ConfigWriteScope[] {
|
||||
if (!target || target.kind === "global") {
|
||||
return [];
|
||||
}
|
||||
if (target.kind === "ambiguous") {
|
||||
return target.scopes;
|
||||
}
|
||||
return [target.scope];
|
||||
}
|
||||
|
||||
@@ -19,11 +19,15 @@ import {
|
||||
createTestRegistry,
|
||||
} from "../../test-utils/channel-plugins.js";
|
||||
import { withEnvAsync } from "../../test-utils/env.js";
|
||||
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
|
||||
import { getChannelPluginCatalogEntry, listChannelPluginCatalogEntries } from "./catalog.js";
|
||||
import {
|
||||
authorizeConfigWrite,
|
||||
canBypassConfigWritePolicy,
|
||||
formatConfigWriteDeniedMessage,
|
||||
resolveExplicitConfigWriteTarget,
|
||||
resolveChannelConfigWrites,
|
||||
resolveConfigWriteScopesFromPath,
|
||||
resolveConfigWriteTargetFromPath,
|
||||
} from "./config-writes.js";
|
||||
import {
|
||||
listDiscordDirectoryGroupsFromConfig,
|
||||
@@ -336,7 +340,7 @@ describe("authorizeConfigWrite", () => {
|
||||
authorizeConfigWrite({
|
||||
cfg,
|
||||
origin: { channelId: "slack", accountId: "default" },
|
||||
targets: [{ channelId: "slack", accountId: "work" }],
|
||||
target: resolveExplicitConfigWriteTarget({ channelId: "slack", accountId: "work" }),
|
||||
}),
|
||||
).toEqual({
|
||||
allowed: false,
|
||||
@@ -345,16 +349,80 @@ describe("authorizeConfigWrite", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks when the origin account disables writes", () => {
|
||||
const cfg = makeSlackConfigWritesCfg("default");
|
||||
expect(
|
||||
authorizeConfigWrite({
|
||||
cfg,
|
||||
origin: { channelId: "slack", accountId: "default" },
|
||||
target: resolveExplicitConfigWriteTarget({ channelId: "slack", accountId: "work" }),
|
||||
}),
|
||||
).toEqual({
|
||||
allowed: false,
|
||||
reason: "origin-disabled",
|
||||
blockedScope: { kind: "origin", scope: { channelId: "slack", accountId: "default" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("allows bypass for internal operator.admin writes", () => {
|
||||
const cfg = makeSlackConfigWritesCfg("work");
|
||||
expect(
|
||||
authorizeConfigWrite({
|
||||
cfg,
|
||||
origin: { channelId: "slack", accountId: "default" },
|
||||
target: resolveExplicitConfigWriteTarget({ channelId: "slack", accountId: "work" }),
|
||||
allowBypass: canBypassConfigWritePolicy({
|
||||
channel: INTERNAL_MESSAGE_CHANNEL,
|
||||
gatewayClientScopes: ["operator.admin"],
|
||||
}),
|
||||
}),
|
||||
).toEqual({ allowed: true });
|
||||
});
|
||||
|
||||
it("treats non-channel config paths as global writes", () => {
|
||||
const cfg = makeSlackConfigWritesCfg("work");
|
||||
expect(
|
||||
authorizeConfigWrite({
|
||||
cfg,
|
||||
origin: { channelId: "slack", accountId: "default" },
|
||||
target: resolveConfigWriteTargetFromPath(["messages", "ackReaction"]),
|
||||
}),
|
||||
).toEqual({ allowed: true });
|
||||
});
|
||||
|
||||
it("rejects ambiguous channel collection writes", () => {
|
||||
expect(resolveConfigWriteScopesFromPath(["channels", "telegram"])).toEqual({
|
||||
targets: [{ channelId: "telegram" }],
|
||||
hasAmbiguousTarget: true,
|
||||
expect(resolveConfigWriteTargetFromPath(["channels", "telegram"])).toEqual({
|
||||
kind: "ambiguous",
|
||||
scopes: [{ channelId: "telegram" }],
|
||||
});
|
||||
expect(resolveConfigWriteScopesFromPath(["channels", "telegram", "accounts"])).toEqual({
|
||||
targets: [{ channelId: "telegram" }],
|
||||
hasAmbiguousTarget: true,
|
||||
expect(resolveConfigWriteTargetFromPath(["channels", "telegram", "accounts"])).toEqual({
|
||||
kind: "ambiguous",
|
||||
scopes: [{ channelId: "telegram" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves explicit channel and account targets", () => {
|
||||
expect(resolveExplicitConfigWriteTarget({ channelId: "slack" })).toEqual({
|
||||
kind: "channel",
|
||||
scope: { channelId: "slack" },
|
||||
});
|
||||
expect(resolveExplicitConfigWriteTarget({ channelId: "slack", accountId: "work" })).toEqual({
|
||||
kind: "account",
|
||||
scope: { channelId: "slack", accountId: "work" },
|
||||
});
|
||||
});
|
||||
|
||||
it("formats denied messages consistently", () => {
|
||||
expect(
|
||||
formatConfigWriteDeniedMessage({
|
||||
result: {
|
||||
allowed: false,
|
||||
reason: "target-disabled",
|
||||
blockedScope: { kind: "target", scope: { channelId: "slack", accountId: "work" } },
|
||||
},
|
||||
}),
|
||||
).toContain("channels.slack.accounts.work.configWrites=true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("directory (config-backed)", () => {
|
||||
|
||||
Reference in New Issue
Block a user