fix(security): harden channel auth path checks and exec approval routing

This commit is contained in:
Peter Steinberger
2026-02-26 12:45:56 +01:00
parent b096ad267e
commit da0ba1b73a
18 changed files with 314 additions and 6 deletions

View File

@@ -13,6 +13,8 @@ Docs: https://docs.openclaw.ai
### Fixes
- Security/Plugin channel HTTP auth: normalize protected `/api/channels` path checks against canonicalized request paths (case + percent-decoding) so unauthenticated alternate-path variants cannot bypass gateway auth.
- Security/Exec approvals forwarding: prefer turn-source channel/account/thread metadata when resolving approval delivery targets so stale session routes do not misroute approval prompts.
- Auto-reply/Streaming: suppress only exact `NO_REPLY` final replies while still filtering streaming partial sentinel fragments (`NO_`, `NO_RE`, `HEARTBEAT_...`) so substantive replies ending with `NO_REPLY` are delivered and partial silent tokens do not leak during streaming. (#19576) Thanks @aldoeliacim.
- Doctor/State integrity: ignore metadata-only slash routing sessions when checking recent missing transcripts so `openclaw doctor` no longer reports false-positive transcript-missing warnings for `*:slash:*` keys. (#27375) thanks @gumadeiras.
- Channels/Multi-account config: when adding a non-default channel account to a single-account top-level channel setup, move existing account-scoped top-level single-account values into `channels.<channel>.accounts.default` before writing the new account so the original account keeps working without duplicated account values at channel root; `openclaw doctor --fix` now repairs previously mixed channel account shapes the same way. (#27334) thanks @gumadeiras.

View File

@@ -40,6 +40,10 @@ describe("requestExecApprovalDecision", () => {
agentId: "main",
resolvedPath: "/usr/bin/echo",
sessionKey: "session",
turnSourceChannel: "whatsapp",
turnSourceTo: "+15555550123",
turnSourceAccountId: "work",
turnSourceThreadId: "1739201675.123",
});
expect(result).toBe("allow-once");
@@ -57,6 +61,10 @@ describe("requestExecApprovalDecision", () => {
agentId: "main",
resolvedPath: "/usr/bin/echo",
sessionKey: "session",
turnSourceChannel: "whatsapp",
turnSourceTo: "+15555550123",
turnSourceAccountId: "work",
turnSourceThreadId: "1739201675.123",
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
twoPhase: true,
},

View File

@@ -17,6 +17,10 @@ export type RequestExecApprovalDecisionParams = {
agentId?: string;
resolvedPath?: string;
sessionKey?: string;
turnSourceChannel?: string;
turnSourceTo?: string;
turnSourceAccountId?: string;
turnSourceThreadId?: string | number;
};
type ParsedDecision = { present: boolean; value: string | null };
@@ -72,6 +76,10 @@ export async function registerExecApprovalRequest(
agentId: params.agentId,
resolvedPath: params.resolvedPath,
sessionKey: params.sessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
twoPhase: true,
},
@@ -127,6 +135,10 @@ export async function requestExecApprovalDecisionForHost(params: {
agentId?: string;
resolvedPath?: string;
sessionKey?: string;
turnSourceChannel?: string;
turnSourceTo?: string;
turnSourceAccountId?: string;
turnSourceThreadId?: string | number;
}): Promise<string | null> {
return await requestExecApprovalDecision({
id: params.approvalId,
@@ -140,6 +152,10 @@ export async function requestExecApprovalDecisionForHost(params: {
agentId: params.agentId,
resolvedPath: params.resolvedPath,
sessionKey: params.sessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
});
}
@@ -155,6 +171,10 @@ export async function registerExecApprovalRequestForHost(params: {
agentId?: string;
resolvedPath?: string;
sessionKey?: string;
turnSourceChannel?: string;
turnSourceTo?: string;
turnSourceAccountId?: string;
turnSourceThreadId?: string | number;
}): Promise<ExecApprovalRegistration> {
return await registerExecApprovalRequest({
id: params.approvalId,
@@ -168,5 +188,9 @@ export async function registerExecApprovalRequestForHost(params: {
agentId: params.agentId,
resolvedPath: params.resolvedPath,
sessionKey: params.sessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
});
}

View File

@@ -44,6 +44,10 @@ export type ProcessGatewayAllowlistParams = {
safeBinProfiles: Readonly<Record<string, SafeBinProfile>>;
agentId?: string;
sessionKey?: string;
turnSourceChannel?: string;
turnSourceTo?: string;
turnSourceAccountId?: string;
turnSourceThreadId?: string | number;
scopeKey?: string;
warnings: string[];
notifySessionKey?: string;
@@ -159,6 +163,10 @@ export async function processGatewayAllowlist(
agentId: params.agentId,
resolvedPath,
sessionKey: params.sessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
});
expiresAtMs = registration.expiresAtMs;
preResolvedDecision = registration.finalDecision;

View File

@@ -35,6 +35,10 @@ export type ExecuteNodeHostCommandParams = {
requestedNode?: string;
boundNode?: string;
sessionKey?: string;
turnSourceChannel?: string;
turnSourceTo?: string;
turnSourceAccountId?: string;
turnSourceThreadId?: string | number;
agentId?: string;
security: ExecSecurity;
ask: ExecAsk;
@@ -202,6 +206,10 @@ export async function executeNodeHostCommand(
ask: hostAsk,
agentId: params.agentId,
sessionKey: params.sessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
});
expiresAtMs = registration.expiresAtMs;
preResolvedDecision = registration.finalDecision;

View File

@@ -21,6 +21,9 @@ export type ExecToolDefaults = {
scopeKey?: string;
sessionKey?: string;
messageProvider?: string;
currentChannelId?: string;
currentThreadTs?: string;
accountId?: string;
notifyOnExit?: boolean;
notifyOnExitEmptySuccess?: boolean;
cwd?: string;

View File

@@ -407,6 +407,10 @@ export function createExecTool(
requestedNode: params.node?.trim(),
boundNode: defaults?.node?.trim(),
sessionKey: defaults?.sessionKey,
turnSourceChannel: defaults?.messageProvider,
turnSourceTo: defaults?.currentChannelId,
turnSourceAccountId: defaults?.accountId,
turnSourceThreadId: defaults?.currentThreadTs,
agentId,
security,
ask,
@@ -433,6 +437,10 @@ export function createExecTool(
safeBinProfiles,
agentId,
sessionKey: defaults?.sessionKey,
turnSourceChannel: defaults?.messageProvider,
turnSourceTo: defaults?.currentChannelId,
turnSourceAccountId: defaults?.accountId,
turnSourceThreadId: defaults?.currentThreadTs,
scopeKey: defaults?.scopeKey,
warnings,
notifySessionKey,

View File

@@ -116,6 +116,10 @@ export function createOpenClawTools(options?: {
createCanvasTool({ config: options?.config }),
createNodesTool({
agentSessionKey: options?.agentSessionKey,
agentChannel: options?.agentChannel,
agentAccountId: options?.agentAccountId,
currentChannelId: options?.currentChannelId,
currentThreadTs: options?.currentThreadTs,
config: options?.config,
}),
createCronTool({

View File

@@ -401,6 +401,9 @@ export function createOpenClawCodingTools(options?: {
scopeKey,
sessionKey: options?.sessionKey,
messageProvider: options?.messageProvider,
currentChannelId: options?.currentChannelId,
currentThreadTs: options?.currentThreadTs,
accountId: options?.agentAccountId,
backgroundMs: options?.exec?.backgroundMs ?? execConfig.backgroundMs,
timeoutSec: options?.exec?.timeoutSec ?? execConfig.timeoutSec,
approvalRunningNoticeMs:

View File

@@ -20,6 +20,7 @@ import { parseDurationMs } from "../../cli/parse-duration.js";
import type { OpenClawConfig } from "../../config/config.js";
import { formatExecCommand } from "../../infra/system-run-command.js";
import { imageMimeFromFormat } from "../../media/mime.js";
import type { GatewayMessageChannel } from "../../utils/message-channel.js";
import { resolveSessionAgentId } from "../agent-scope.js";
import { resolveImageSanitizationLimits } from "../image-sanitization.js";
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
@@ -128,9 +129,17 @@ const NodesToolSchema = Type.Object({
export function createNodesTool(options?: {
agentSessionKey?: string;
agentChannel?: GatewayMessageChannel;
agentAccountId?: string;
currentChannelId?: string;
currentThreadTs?: string | number;
config?: OpenClawConfig;
}): AnyAgentTool {
const sessionKey = options?.agentSessionKey?.trim() || undefined;
const turnSourceChannel = options?.agentChannel?.trim() || undefined;
const turnSourceTo = options?.currentChannelId?.trim() || undefined;
const turnSourceAccountId = options?.agentAccountId?.trim() || undefined;
const turnSourceThreadId = options?.currentThreadTs;
const agentId = resolveSessionAgentId({
sessionKey: options?.agentSessionKey,
config: options?.config,
@@ -512,6 +521,10 @@ export function createNodesTool(options?: {
host: "node",
agentId,
sessionKey,
turnSourceChannel,
turnSourceTo,
turnSourceAccountId,
turnSourceThreadId,
timeoutMs: APPROVAL_TIMEOUT_MS,
},
);

View File

@@ -98,6 +98,10 @@ export const ExecApprovalRequestParamsSchema = Type.Object(
agentId: Type.Optional(Type.Union([Type.String(), Type.Null()])),
resolvedPath: Type.Optional(Type.Union([Type.String(), Type.Null()])),
sessionKey: Type.Optional(Type.Union([Type.String(), Type.Null()])),
turnSourceChannel: Type.Optional(Type.Union([Type.String(), Type.Null()])),
turnSourceTo: Type.Optional(Type.Union([Type.String(), Type.Null()])),
turnSourceAccountId: Type.Optional(Type.Union([Type.String(), Type.Null()])),
turnSourceThreadId: Type.Optional(Type.Union([Type.String(), Type.Number(), Type.Null()])),
timeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
twoPhase: Type.Optional(Type.Boolean()),
},

View File

@@ -88,6 +88,19 @@ function isCanvasPath(pathname: string): boolean {
);
}
function decodePathnameOnce(pathname: string): string {
try {
return decodeURIComponent(pathname);
} catch {
return pathname;
}
}
function isProtectedPluginChannelPath(pathname: string): boolean {
const normalized = decodePathnameOnce(pathname).toLowerCase();
return normalized === "/api/channels" || normalized.startsWith("/api/channels/");
}
function isNodeWsClient(client: GatewayWsClient): boolean {
if (client.connect.role === "node") {
return true;
@@ -493,7 +506,7 @@ export function createGatewayHttpServer(opts: {
// Channel HTTP endpoints are gateway-auth protected by default.
// Non-channel plugin routes remain plugin-owned and must enforce
// their own auth when exposing sensitive functionality.
if (requestPath === "/api/channels" || requestPath.startsWith("/api/channels/")) {
if (isProtectedPluginChannelPath(requestPath)) {
const token = getBearerToken(req);
const authResult = await authorizeHttpGatewayConnect({
auth: resolvedAuth,

View File

@@ -52,6 +52,10 @@ export function createExecApprovalHandlers(
agentId?: string;
resolvedPath?: string;
sessionKey?: string;
turnSourceChannel?: string;
turnSourceTo?: string;
turnSourceAccountId?: string;
turnSourceThreadId?: string | number;
timeoutMs?: number;
twoPhase?: boolean;
};
@@ -91,6 +95,12 @@ export function createExecApprovalHandlers(
agentId: p.agentId ?? null,
resolvedPath: p.resolvedPath ?? null,
sessionKey: p.sessionKey ?? null,
turnSourceChannel:
typeof p.turnSourceChannel === "string" ? p.turnSourceChannel.trim() || null : null,
turnSourceTo: typeof p.turnSourceTo === "string" ? p.turnSourceTo.trim() || null : null,
turnSourceAccountId:
typeof p.turnSourceAccountId === "string" ? p.turnSourceAccountId.trim() || null : null,
turnSourceThreadId: p.turnSourceThreadId ?? null,
};
const record = manager.create(request, timeoutMs, explicitId);
record.requestedByConnId = client?.connId ?? null;

View File

@@ -493,6 +493,56 @@ describe("exec approval handlers", () => {
expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
});
it("forwards turn-source metadata to exec approval forwarding", async () => {
vi.useFakeTimers();
try {
const manager = new ExecApprovalManager();
const forwarder = {
handleRequested: vi.fn(async () => false),
handleResolved: vi.fn(async () => {}),
stop: vi.fn(),
};
const handlers = createExecApprovalHandlers(manager, { forwarder });
const respond = vi.fn();
const context = {
broadcast: (_event: string, _payload: unknown) => {},
hasExecApprovalClients: () => false,
};
const requestPromise = requestExecApproval({
handlers,
respond,
context,
params: {
timeoutMs: 60_000,
turnSourceChannel: "whatsapp",
turnSourceTo: "+15555550123",
turnSourceAccountId: "work",
turnSourceThreadId: "1739201675.123",
},
});
for (let idx = 0; idx < 20; idx += 1) {
await Promise.resolve();
}
expect(forwarder.handleRequested).toHaveBeenCalledTimes(1);
expect(forwarder.handleRequested).toHaveBeenCalledWith(
expect.objectContaining({
request: expect.objectContaining({
turnSourceChannel: "whatsapp",
turnSourceTo: "+15555550123",
turnSourceAccountId: "work",
turnSourceThreadId: "1739201675.123",
}),
}),
);
await vi.runOnlyPendingTimersAsync();
await requestPromise;
} finally {
vi.useRealTimers();
}
});
it("expires immediately when no approver clients and no forwarding targets", async () => {
vi.useFakeTimers();
try {

View File

@@ -242,6 +242,78 @@ describe("gateway plugin HTTP auth boundary", () => {
});
});
test("requires gateway auth for canonicalized /api/channels variants", async () => {
const resolvedAuth: ResolvedGatewayAuth = {
mode: "token",
token: "test-token",
password: undefined,
allowTailscale: false,
};
await withTempConfig({
cfg: { gateway: { trustedProxies: [] } },
prefix: "openclaw-plugin-http-auth-canonicalized-test-",
run: async () => {
const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
const canonicalPath = decodeURIComponent(pathname).toLowerCase();
if (canonicalPath === "/api/channels/nostr/default/profile") {
res.statusCode = 200;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify({ ok: true, route: "channel-canonicalized" }));
return true;
}
return false;
});
const server = createGatewayHttpServer({
canvasHost: null,
clients: new Set(),
controlUiEnabled: false,
controlUiBasePath: "/__control__",
openAiChatCompletionsEnabled: false,
openResponsesEnabled: false,
handleHooksRequest: async () => false,
handlePluginRequest,
resolvedAuth,
});
const unauthenticatedCaseVariant = createResponse();
await dispatchRequest(
server,
createRequest({ path: "/API/channels/nostr/default/profile" }),
unauthenticatedCaseVariant.res,
);
expect(unauthenticatedCaseVariant.res.statusCode).toBe(401);
expect(unauthenticatedCaseVariant.getBody()).toContain("Unauthorized");
expect(handlePluginRequest).not.toHaveBeenCalled();
const unauthenticatedEncodedSlash = createResponse();
await dispatchRequest(
server,
createRequest({ path: "/api/channels%2Fnostr%2Fdefault%2Fprofile" }),
unauthenticatedEncodedSlash.res,
);
expect(unauthenticatedEncodedSlash.res.statusCode).toBe(401);
expect(unauthenticatedEncodedSlash.getBody()).toContain("Unauthorized");
expect(handlePluginRequest).not.toHaveBeenCalled();
const authenticatedCaseVariant = createResponse();
await dispatchRequest(
server,
createRequest({
path: "/API/channels/nostr/default/profile",
authorization: "Bearer test-token",
}),
authenticatedCaseVariant.res,
);
expect(authenticatedCaseVariant.res.statusCode).toBe(200);
expect(authenticatedCaseVariant.getBody()).toContain('"route":"channel-canonicalized"');
expect(handlePluginRequest).toHaveBeenCalledTimes(1);
},
});
});
test.each(["0.0.0.0", "::"])(
"returns 404 (not 500) for non-hook routes with hooks enabled and bindHost=%s",
async (bindHost) => {

View File

@@ -1,3 +1,6 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { createExecApprovalForwarder } from "./exec-approval-forwarder.js";
@@ -40,14 +43,17 @@ function createForwarder(params: {
resolveSessionTarget?: () => { channel: string; to: string } | null;
}) {
const deliver = params.deliver ?? vi.fn().mockResolvedValue([]);
const forwarder = createExecApprovalForwarder({
const deps: NonNullable<Parameters<typeof createExecApprovalForwarder>[0]> = {
getConfig: () => params.cfg,
deliver: deliver as unknown as NonNullable<
NonNullable<Parameters<typeof createExecApprovalForwarder>[0]>["deliver"]
>,
nowMs: () => 1000,
resolveSessionTarget: params.resolveSessionTarget ?? (() => null),
});
};
if (params.resolveSessionTarget !== undefined) {
deps.resolveSessionTarget = params.resolveSessionTarget;
}
const forwarder = createExecApprovalForwarder(deps);
return { deliver, forwarder };
}
@@ -212,6 +218,58 @@ describe("exec approval forwarder", () => {
});
});
it("prefers turn-source routing over stale session last route", async () => {
vi.useFakeTimers();
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-exec-approval-forwarder-test-"));
try {
const storePath = path.join(tmpDir, "sessions.json");
fs.writeFileSync(
storePath,
JSON.stringify({
"agent:main:main": {
updatedAt: 1,
channel: "slack",
to: "U1",
lastChannel: "slack",
lastTo: "U1",
},
}),
"utf-8",
);
const cfg = {
session: { store: storePath },
approvals: { exec: { enabled: true, mode: "session" } },
} as OpenClawConfig;
const { deliver, forwarder } = createForwarder({ cfg });
await expect(
forwarder.handleRequested({
...baseRequest,
request: {
...baseRequest.request,
turnSourceChannel: "whatsapp",
turnSourceTo: "+15555550123",
turnSourceAccountId: "work",
turnSourceThreadId: "1739201675.123",
},
}),
).resolves.toBe(true);
expect(deliver).toHaveBeenCalledTimes(1);
expect(deliver).toHaveBeenCalledWith(
expect.objectContaining({
channel: "whatsapp",
to: "+15555550123",
accountId: "work",
threadId: "1739201675.123",
}),
);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("can forward resolved notices without pending cache when request payload is present", async () => {
vi.useFakeTimers();
const cfg = {

View File

@@ -8,7 +8,11 @@ import type {
import { createSubsystemLogger } from "../logging/subsystem.js";
import { normalizeAccountId, parseAgentSessionKey } from "../routing/session-key.js";
import { compileSafeRegex } from "../security/safe-regex.js";
import { isDeliverableMessageChannel, normalizeMessageChannel } from "../utils/message-channel.js";
import {
isDeliverableMessageChannel,
normalizeMessageChannel,
type DeliverableMessageChannel,
} from "../utils/message-channel.js";
import type {
ExecApprovalDecision,
ExecApprovalRequest,
@@ -209,6 +213,11 @@ function buildExpiredMessage(request: ExecApprovalRequest) {
return `⏱️ Exec approval expired. ID: ${request.id}`;
}
function normalizeTurnSourceChannel(value?: string | null): DeliverableMessageChannel | undefined {
const normalized = value ? normalizeMessageChannel(value) : undefined;
return normalized && isDeliverableMessageChannel(normalized) ? normalized : undefined;
}
function defaultResolveSessionTarget(params: {
cfg: OpenClawConfig;
request: ExecApprovalRequest;
@@ -225,7 +234,14 @@ function defaultResolveSessionTarget(params: {
if (!entry) {
return null;
}
const target = resolveSessionDeliveryTarget({ entry, requestedChannel: "last" });
const target = resolveSessionDeliveryTarget({
entry,
requestedChannel: "last",
turnSourceChannel: normalizeTurnSourceChannel(params.request.request.turnSourceChannel),
turnSourceTo: params.request.request.turnSourceTo?.trim() || undefined,
turnSourceAccountId: params.request.request.turnSourceAccountId?.trim() || undefined,
turnSourceThreadId: params.request.request.turnSourceThreadId ?? undefined,
});
if (!target.channel || !target.to) {
return null;
}

View File

@@ -22,6 +22,10 @@ export type ExecApprovalRequestPayload = {
agentId?: string | null;
resolvedPath?: string | null;
sessionKey?: string | null;
turnSourceChannel?: string | null;
turnSourceTo?: string | null;
turnSourceAccountId?: string | null;
turnSourceThreadId?: string | number | null;
};
export type ExecApprovalRequest = {