fix: command auth SecretRef resolution (#52791) (thanks @Lukavyi)

* fix(command-auth): handle unresolved SecretRef in resolveAllowFrom

* fix(command-auth): fall back to config allowlists

* fix(command-auth): avoid duplicate resolution fallback

* fix(command-auth): fail closed on invalid allowlists

* fix(command-auth): isolate fallback resolution errors

* fix: record command auth SecretRef landing notes (#52791) (thanks @Lukavyi)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
Taras Lukavyi 2026-03-24 03:51:30 +01:00 committed by GitHub
parent 5cd8d43af9
commit d4e3babdcc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 471 additions and 23 deletions

View File

@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai
- Infra/exec trust: preserve shell-multiplexer wrapper binaries for policy checks without breaking approved-command reconstruction, so BusyBox/ToyBox allowlist and audit flows bind to the real wrapper while execution plans stay coherent. (#53134) Thanks @vincentkoc.
- LINE/runtime-api: pre-export overlapping runtime symbols before the `line-runtime` star export so jiti no longer throws `TypeError: Cannot redefine property` on startup. (#53221) Thanks @Drickon.
- CLI/cron: make `openclaw cron add|edit --at ... --tz <iana>` honor the requested local wall-clock time for offset-less one-shot datetimes, including DST boundaries, and keep `--tz` rejected for `--every`. (#53224) Thanks @RolfHegr.
- Commands/auth: stop slash-command authorization from crashing or dropping valid allowlists when channel `allowFrom` resolution hits unresolved SecretRef-backed accounts, and fail closed only for the affected provider inference path. (#52791) Thanks @Lukavyi.
## 2026.3.23

View File

@ -20,13 +20,16 @@ export type CommandAuthorization = {
to?: string;
};
function resolveProviderFromContext(ctx: MsgContext, cfg: OpenClawConfig): ChannelId | undefined {
function resolveProviderFromContext(
ctx: MsgContext,
cfg: OpenClawConfig,
): { providerId: ChannelId | undefined; hadResolutionError: boolean } {
const explicitMessageChannel =
normalizeMessageChannel(ctx.Provider) ??
normalizeMessageChannel(ctx.Surface) ??
normalizeMessageChannel(ctx.OriginatingChannel);
if (explicitMessageChannel === INTERNAL_MESSAGE_CHANNEL) {
return undefined;
return { providerId: undefined, hadResolutionError: false };
}
const direct =
normalizeAnyChannelId(explicitMessageChannel ?? undefined) ??
@ -35,7 +38,7 @@ function resolveProviderFromContext(ctx: MsgContext, cfg: OpenClawConfig): Chann
normalizeAnyChannelId(ctx.Surface) ??
normalizeAnyChannelId(ctx.OriginatingChannel);
if (direct) {
return direct;
return { providerId: direct, hadResolutionError: false };
}
const candidates = [ctx.From, ctx.To]
.filter((value): value is string => Boolean(value?.trim()))
@ -43,35 +46,52 @@ function resolveProviderFromContext(ctx: MsgContext, cfg: OpenClawConfig): Chann
for (const candidate of candidates) {
const normalizedCandidateChannel = normalizeMessageChannel(candidate);
if (normalizedCandidateChannel === INTERNAL_MESSAGE_CHANNEL) {
return undefined;
return { providerId: undefined, hadResolutionError: false };
}
const normalized =
normalizeAnyChannelId(normalizedCandidateChannel ?? undefined) ??
(normalizedCandidateChannel as ChannelId | undefined) ??
normalizeAnyChannelId(candidate);
if (normalized) {
return normalized;
return { providerId: normalized, hadResolutionError: false };
}
}
const configured = listChannelPlugins()
.map((plugin) => {
if (!plugin.config?.resolveAllowFrom) {
return null;
}
const allowFrom = plugin.config.resolveAllowFrom({
const resolvedAllowFrom = resolveProviderAllowFrom({
plugin,
cfg,
accountId: ctx.AccountId,
});
if (!Array.isArray(allowFrom) || allowFrom.length === 0) {
const allowFrom = formatAllowFromList({
plugin,
cfg,
accountId: ctx.AccountId,
allowFrom: resolvedAllowFrom.allowFrom,
});
if (allowFrom.length === 0) {
return null;
}
return plugin.id;
return {
providerId: plugin.id,
hadResolutionError: resolvedAllowFrom.hadResolutionError,
};
})
.filter((value): value is ChannelId => Boolean(value));
.filter(
(
value,
): value is {
providerId: ChannelId;
hadResolutionError: boolean;
} => Boolean(value),
);
if (configured.length === 1) {
return configured[0];
}
return undefined;
return {
providerId: undefined,
hadResolutionError: configured.some((entry) => entry.hadResolutionError),
};
}
function formatAllowFromList(params: {
@ -105,6 +125,63 @@ function normalizeAllowFromEntry(params: {
return normalized.filter((entry) => entry.trim().length > 0);
}
function resolveProviderAllowFrom(params: {
plugin?: ChannelPlugin;
cfg: OpenClawConfig;
accountId?: string | null;
}): {
allowFrom: Array<string | number>;
hadResolutionError: boolean;
} {
const { plugin, cfg, accountId } = params;
const providerId = plugin?.id;
if (!plugin?.config?.resolveAllowFrom) {
return {
allowFrom: resolveFallbackAllowFrom({ cfg, providerId, accountId }),
hadResolutionError: false,
};
}
try {
const allowFrom = plugin.config.resolveAllowFrom({ cfg, accountId });
if (allowFrom == null) {
return {
allowFrom: [],
hadResolutionError: false,
};
}
if (!Array.isArray(allowFrom)) {
console.warn(
`[command-auth] resolveAllowFrom returned an invalid allowFrom for provider "${providerId}", falling back to config allowFrom: invalid_result`,
);
return {
allowFrom: resolveFallbackAllowFrom({ cfg, providerId, accountId }),
hadResolutionError: true,
};
}
return {
allowFrom,
hadResolutionError: false,
};
} catch (err) {
console.warn(
`[command-auth] resolveAllowFrom threw for provider "${providerId}", falling back to config allowFrom: ${describeAllowFromResolutionError(err)}`,
);
return {
allowFrom: resolveFallbackAllowFrom({ cfg, providerId, accountId }),
hadResolutionError: true,
};
}
}
function describeAllowFromResolutionError(err: unknown): string {
if (err instanceof Error) {
const name = err.name.trim();
return name || "Error";
}
return "unknown_error";
}
function resolveOwnerAllowFromList(params: {
plugin?: ChannelPlugin;
cfg: OpenClawConfig;
@ -283,7 +360,9 @@ function resolveFallbackAllowFrom(params: {
>
| undefined;
const channelCfg = channels?.[providerId];
const accountCfg = params.accountId ? channelCfg?.accounts?.[params.accountId] : undefined;
const accountCfg =
resolveFallbackAccountConfig(channelCfg?.accounts, params.accountId) ??
resolveFallbackDefaultAccountConfig(channelCfg);
const allowFrom =
accountCfg?.allowFrom ??
accountCfg?.dm?.allowFrom ??
@ -292,6 +371,64 @@ function resolveFallbackAllowFrom(params: {
return Array.isArray(allowFrom) ? allowFrom : [];
}
function resolveFallbackAccountConfig(
accounts:
| Record<
string,
| {
allowFrom?: Array<string | number>;
dm?: { allowFrom?: Array<string | number> };
}
| undefined
>
| undefined,
accountId?: string | null,
) {
const normalizedAccountId = accountId?.trim().toLowerCase();
if (!accounts || !normalizedAccountId) {
return undefined;
}
const direct = accounts[normalizedAccountId];
if (direct) {
return direct;
}
const matchKey = Object.keys(accounts).find(
(key) => key.trim().toLowerCase() === normalizedAccountId,
);
return matchKey ? accounts[matchKey] : undefined;
}
function resolveFallbackDefaultAccountConfig(
channelCfg:
| {
allowFrom?: Array<string | number>;
dm?: { allowFrom?: Array<string | number> };
defaultAccount?: string;
accounts?: Record<
string,
| {
allowFrom?: Array<string | number>;
dm?: { allowFrom?: Array<string | number> };
}
| undefined
>;
}
| undefined,
) {
const accounts = channelCfg?.accounts;
if (!accounts) {
return undefined;
}
const preferred =
resolveFallbackAccountConfig(accounts, channelCfg?.defaultAccount) ??
resolveFallbackAccountConfig(accounts, "default");
if (preferred) {
return preferred;
}
const definedAccounts = Object.values(accounts).filter(Boolean);
return definedAccounts.length === 1 ? definedAccounts[0] : undefined;
}
function resolveFallbackCommandOptions(providerId?: ChannelId): {
enforceOwnerForCommands: boolean;
} {
@ -306,7 +443,10 @@ export function resolveCommandAuthorization(params: {
commandAuthorized: boolean;
}): CommandAuthorization {
const { ctx, cfg, commandAuthorized } = params;
const providerId = resolveProviderFromContext(ctx, cfg);
const { providerId, hadResolutionError: providerResolutionError } = resolveProviderFromContext(
ctx,
cfg,
);
const plugin = providerId ? getChannelPlugin(providerId) : undefined;
const from = (ctx.From ?? "").trim();
const to = (ctx.To ?? "").trim();
@ -319,18 +459,25 @@ export function resolveCommandAuthorization(params: {
providerId,
});
const allowFromRaw = plugin?.config?.resolveAllowFrom
? plugin.config.resolveAllowFrom({ cfg, accountId: ctx.AccountId })
: resolveFallbackAllowFrom({
const resolvedAllowFrom = providerResolutionError
? {
allowFrom: resolveFallbackAllowFrom({
cfg,
providerId,
accountId: ctx.AccountId,
}),
hadResolutionError: true,
}
: resolveProviderAllowFrom({
plugin,
cfg,
providerId,
accountId: ctx.AccountId,
});
const allowFromList = formatAllowFromList({
plugin,
cfg,
accountId: ctx.AccountId,
allowFrom: Array.isArray(allowFromRaw) ? allowFromRaw : [],
allowFrom: resolvedAllowFrom.allowFrom,
});
const configOwnerAllowFromList = resolveOwnerAllowFromList({
plugin,
@ -347,7 +494,8 @@ export function resolveCommandAuthorization(params: {
allowFrom: ctx.OwnerAllowFrom,
});
const allowAll =
allowFromList.length === 0 || allowFromList.some((entry) => entry.trim() === "*");
!resolvedAllowFrom.hadResolutionError &&
(allowFromList.length === 0 || allowFromList.some((entry) => entry.trim() === "*"));
const ownerCandidatesForCommands = allowAll ? [] : allowFromList.filter((entry) => entry !== "*");
if (!allowAll && ownerCandidatesForCommands.length === 0 && to) {
@ -423,7 +571,8 @@ export function resolveCommandAuthorization(params: {
const matchedCommandsAllowFrom = commandsAllowFromList.length
? senderCandidates.find((candidate) => commandsAllowFromList.includes(candidate))
: undefined;
isAuthorizedSender = commandsAllowAll || Boolean(matchedCommandsAllowFrom);
isAuthorizedSender =
!providerResolutionError && (commandsAllowAll || Boolean(matchedCommandsAllowFrom));
} else {
// Fall back to existing behavior
isAuthorizedSender = commandAuthorized && isOwnerForCommands;

View File

@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js";
@ -181,6 +181,50 @@ describe("resolveCommandAuthorization", () => {
expect(auth.isAuthorizedSender).toBe(true);
});
it("falls back to channel allowFrom when provider allowlist resolution throws", () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
plugin: {
...createOutboundTestPlugin({
id: "telegram",
outbound: { deliveryMode: "direct" },
}),
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
resolveAllowFrom: () => {
throw new Error("channels.telegram.botToken: unresolved SecretRef");
},
formatAllowFrom: ({ allowFrom }: { allowFrom: Array<string | number> }) =>
allowFrom.map((entry) => String(entry).trim()).filter(Boolean),
},
},
source: "test",
},
]),
);
const cfg = {
channels: { telegram: { allowFrom: ["123"] } },
} as OpenClawConfig;
const auth = resolveCommandAuthorization({
ctx: {
Provider: "telegram",
Surface: "telegram",
From: "telegram:123",
SenderId: "123",
} as MsgContext,
cfg,
commandAuthorized: true,
});
expect(auth.ownerList).toEqual(["123"]);
expect(auth.senderIsOwner).toBe(true);
expect(auth.isAuthorizedSender).toBe(true);
});
describe("commands.allowFrom", () => {
const commandsAllowFromConfig = {
commands: {
@ -443,6 +487,260 @@ describe("resolveCommandAuthorization", () => {
expect(deniedAuth.isAuthorizedSender).toBe(false);
});
it("fails closed when provider inference hits unresolved SecretRef allowlists", () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
plugin: {
...createOutboundTestPlugin({
id: "telegram",
outbound: { deliveryMode: "direct" },
}),
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
resolveAllowFrom: () => {
throw new Error("channels.telegram.botToken: unresolved SecretRef");
},
formatAllowFrom: ({ allowFrom }: { allowFrom: Array<string | number> }) =>
allowFrom.map((entry) => String(entry).trim()).filter(Boolean),
},
},
source: "test",
},
]),
);
const cfg = {
commands: {
allowFrom: {
telegram: ["123"],
},
},
channels: {
telegram: {
allowFrom: ["123"],
},
},
} as OpenClawConfig;
const auth = resolveCommandAuthorization({
ctx: {
SenderId: "123",
} as MsgContext,
cfg,
commandAuthorized: false,
});
expect(auth.providerId).toBe("telegram");
expect(auth.isAuthorizedSender).toBe(false);
});
it("does not let an unrelated provider resolution error poison inferred commands.allowFrom", () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
plugin: {
...createOutboundTestPlugin({
id: "telegram",
outbound: { deliveryMode: "direct" },
}),
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
resolveAllowFrom: () => ["123"],
formatAllowFrom: ({ allowFrom }: { allowFrom: Array<string | number> }) =>
allowFrom.map((entry) => String(entry).trim()).filter(Boolean),
},
},
source: "test",
},
{
pluginId: "slack",
plugin: {
...createOutboundTestPlugin({
id: "slack",
outbound: { deliveryMode: "direct" },
}),
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
resolveAllowFrom: () => {
throw new Error("channels.slack.token: unresolved SecretRef");
},
formatAllowFrom: ({ allowFrom }: { allowFrom: Array<string | number> }) =>
allowFrom.map((entry) => String(entry).trim()).filter(Boolean),
},
},
source: "test",
},
]),
);
const auth = resolveCommandAuthorization({
ctx: {
SenderId: "123",
} as MsgContext,
cfg: {
commands: {
allowFrom: {
telegram: ["123"],
},
},
channels: {
telegram: {
allowFrom: ["123"],
},
},
} as OpenClawConfig,
commandAuthorized: false,
});
expect(auth.providerId).toBe("telegram");
expect(auth.isAuthorizedSender).toBe(true);
});
it("preserves default-account allowFrom on SecretRef fallback", () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
plugin: {
...createOutboundTestPlugin({
id: "telegram",
outbound: { deliveryMode: "direct" },
}),
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
resolveAllowFrom: () => {
throw new Error("channels.telegram.botToken: unresolved SecretRef");
},
formatAllowFrom: ({ allowFrom }: { allowFrom: Array<string | number> }) =>
allowFrom.map((entry) => String(entry).trim()).filter(Boolean),
},
},
source: "test",
},
]),
);
const auth = resolveCommandAuthorization({
ctx: {
Provider: "telegram",
Surface: "telegram",
SenderId: "123",
} as MsgContext,
cfg: {
channels: {
telegram: {
accounts: {
default: {
allowFrom: ["123"],
},
},
},
},
} as OpenClawConfig,
commandAuthorized: true,
});
expect(auth.ownerList).toEqual(["123"]);
expect(auth.isAuthorizedSender).toBe(true);
});
it("treats undefined allowFrom as an open channel, not a resolution failure", () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "discord",
plugin: {
...createOutboundTestPlugin({
id: "discord",
outbound: { deliveryMode: "direct" },
}),
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
resolveAllowFrom: () => undefined,
formatAllowFrom: ({ allowFrom }: { allowFrom: Array<string | number> }) =>
allowFrom.map((entry) => String(entry).trim()).filter(Boolean),
},
},
source: "test",
},
]),
);
const auth = resolveCommandAuthorization({
ctx: {
Provider: "discord",
Surface: "discord",
SenderId: "123",
} as MsgContext,
cfg: {
channels: {
discord: {},
},
} as OpenClawConfig,
commandAuthorized: true,
});
expect(auth.isAuthorizedSender).toBe(true);
});
it("does not log raw resolution messages from thrown allowFrom errors", () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
plugin: {
...createOutboundTestPlugin({
id: "telegram",
outbound: { deliveryMode: "direct" },
}),
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
resolveAllowFrom: () => {
throw new Error("SECRET-TOKEN-123");
},
formatAllowFrom: ({ allowFrom }: { allowFrom: Array<string | number> }) =>
allowFrom.map((entry) => String(entry).trim()).filter(Boolean),
},
},
source: "test",
},
]),
);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
resolveCommandAuthorization({
ctx: {
Provider: "telegram",
Surface: "telegram",
SenderId: "123",
} as MsgContext,
cfg: {
channels: {
telegram: {
allowFrom: ["123"],
},
},
} as OpenClawConfig,
commandAuthorized: true,
});
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0]?.[0] ?? "")).toContain("Error");
expect(String(warn.mock.calls[0]?.[0] ?? "")).not.toContain("SECRET-TOKEN-123");
} finally {
warn.mockRestore();
}
});
});
it("grants senderIsOwner for internal channel with operator.admin scope", () => {