mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-13 19:10:39 +00:00
fix(security): enforce auth for abort triggers and models
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user