fix(security): enforce auth for abort triggers and models

This commit is contained in:
Agent
2026-03-01 21:17:29 +00:00
parent c89836a251
commit 3a93a7bb1e
4 changed files with 71 additions and 8 deletions

View File

@@ -214,6 +214,7 @@ Docs: https://docs.openclaw.ai
- Agents/Models config: preserve agent-level provider `apiKey` and `baseUrl` during merge-mode `models.json` updates when agent values are present. (#27293) thanks @Sid-Qin.
- Azure OpenAI Responses: force `store=true` for `azure-openai-responses` direct responses API calls to avoid multi-turn 400 failures. Landed from contributor PR #27499 by @polarbear-Yang. (#27497)
- Security/Node exec approvals: require structured `commandArgv` approvals for `host=node`, enforce versioned `systemRunBindingV1` matching for argv/cwd/session/agent/env context with fail-closed behavior on missing/mismatched bindings, and add `GIT_EXTERNAL_DIFF` to blocked host env keys. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
- Security/Command authorization: enforce sender authorization for natural-language abort triggers (`stop`-like text) and `/models` listings, preventing unauthorized session aborts and model-auth metadata disclosure. This ships in the next npm release (`2026.2.27`). Thanks @tdjackey for reporting.
- Security/Plugin channel HTTP auth: normalize protected `/api/channels` path checks against canonicalized request paths (case + percent-decoding + slash normalization), resolve encoded dot-segment traversal variants, and fail closed on malformed `%`-encoded channel prefixes so alternate-path variants cannot bypass gateway auth. This ships in the next npm release (`2026.2.26`). Thanks @zpbrent for reporting.
- Security/Gateway node pairing: pin paired-device `platform`/`deviceFamily` metadata across reconnects and bind those fields into device-auth signatures, so reconnect metadata spoofing cannot expand node command allowlists without explicit repair pairing. This ships in the next npm release (`2026.2.26`). Thanks @76embiid21 for reporting.
- Security/Sandbox path alias guard: reject broken symlink targets by resolving through existing ancestors and failing closed on out-of-root targets, preventing workspace-only `apply_patch` writes from escaping sandbox/workspace boundaries via dangling symlinks. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.

View File

@@ -19,6 +19,7 @@ import {
type ProviderInfo,
} from "../../telegram/model-buttons.js";
import type { ReplyPayload } from "../types.js";
import { rejectUnauthorizedCommand } from "./command-gates.js";
import type { CommandHandler } from "./commands-types.js";
const PAGE_SIZE_DEFAULT = 20;
@@ -363,6 +364,14 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma
if (!allowTextCommands) {
return null;
}
const commandBodyNormalized = params.command.commandBodyNormalized.trim();
if (!commandBodyNormalized.startsWith("/models")) {
return null;
}
const unauthorized = rejectUnauthorizedCommand(params, "/models");
if (unauthorized) {
return unauthorized;
}
const modelsAgentId =
params.agentId ??
@@ -374,7 +383,7 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma
const reply = await resolveModelsCommandReply({
cfg: params.cfg,
commandBodyNormalized: params.command.commandBodyNormalized,
commandBodyNormalized,
surface: params.ctx.Surface,
currentModel: params.model ? `${params.provider}/${params.model}` : undefined,
agentDir: modelsAgentDir,

View File

@@ -14,6 +14,7 @@ import {
setAbortMemory,
stopSubagentsForRequester,
} from "./abort.js";
import { rejectUnauthorizedCommand } from "./command-gates.js";
import { persistAbortTargetEntry } from "./commands-session-store.js";
import type { CommandHandler } from "./commands-types.js";
import { clearSessionQueues } from "./queue.js";
@@ -92,11 +93,9 @@ export const handleStopCommand: CommandHandler = async (params, allowTextCommand
if (params.command.commandBodyNormalized !== "/stop") {
return null;
}
if (!params.command.isAuthorizedSender) {
logVerbose(
`Ignoring /stop from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
const unauthorizedStop = rejectUnauthorizedCommand(params, "/stop");
if (unauthorizedStop) {
return unauthorizedStop;
}
const abortTarget = resolveAbortTarget({
ctx: params.ctx,
@@ -151,6 +150,10 @@ export const handleAbortTrigger: CommandHandler = async (params, allowTextComman
if (!isAbortTrigger(params.command.rawBodyNormalized)) {
return null;
}
const unauthorizedAbortTrigger = rejectUnauthorizedCommand(params, "abort trigger");
if (unauthorizedAbortTrigger) {
return unauthorizedAbortTrigger;
}
const abortTarget = resolveAbortTarget({
ctx: params.ctx,
sessionKey: params.sessionKey,

View File

@@ -2,14 +2,14 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { compactEmbeddedPiSession } from "../../agents/pi-embedded.js";
import { abortEmbeddedPiRun, compactEmbeddedPiSession } from "../../agents/pi-embedded.js";
import {
addSubagentRunForTests,
listSubagentRunsForRequester,
resetSubagentRegistryForTests,
} from "../../agents/subagent-registry.js";
import type { OpenClawConfig } from "../../config/config.js";
import { updateSessionStore } from "../../config/sessions.js";
import { updateSessionStore, type SessionEntry } from "../../config/sessions.js";
import * as internalHooks from "../../hooks/internal-hooks.js";
import { clearPluginCommands, registerPluginCommand } from "../../plugins/commands.js";
import { typedCases } from "../../test-utils/typed-cases.js";
@@ -431,6 +431,43 @@ describe("/compact command", () => {
});
});
describe("abort trigger command", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("rejects unauthorized natural-language abort triggers", async () => {
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as OpenClawConfig;
const params = buildParams("stop", cfg);
const sessionEntry: SessionEntry = {
sessionId: "session-1",
updatedAt: Date.now(),
abortedLastRun: false,
};
const sessionStore: Record<string, SessionEntry> = {
[params.sessionKey]: sessionEntry,
};
const result = await handleCommands({
...params,
sessionEntry,
sessionStore,
command: {
...params.command,
isAuthorizedSender: false,
senderId: "unauthorized",
},
});
expect(result).toEqual({ shouldContinue: false });
expect(sessionStore[params.sessionKey]?.abortedLastRun).toBe(false);
expect(vi.mocked(abortEmbeddedPiRun)).not.toHaveBeenCalled();
});
});
describe("buildCommandsPaginationKeyboard", () => {
it("adds agent id to callback data when provided", () => {
const keyboard = buildCommandsPaginationKeyboard(2, 3, "agent-main");
@@ -739,6 +776,19 @@ describe("/models command", () => {
expect(result.reply?.text).toContain("Use: /models <provider>");
});
it("rejects unauthorized /models commands", async () => {
const params = buildPolicyParams("/models", cfg, { Provider: "discord", Surface: "discord" });
const result = await handleCommands({
...params,
command: {
...params.command,
isAuthorizedSender: false,
senderId: "unauthorized",
},
});
expect(result).toEqual({ shouldContinue: false });
});
it("lists providers on telegram (buttons)", async () => {
const params = buildPolicyParams("/models", cfg, { Provider: "telegram", Surface: "telegram" });
const result = await handleCommands(params);