Mattermost: refresh slash callback command validation (#72923)

* fix(mattermost): refresh slash callback tokens

* fix(mattermost): reconcile slash callback method

* fix(mattermost): bound slash command lookups

* fix(mattermost): cache slash validation lookups

* fix(mattermost): refresh slash routing

* fix(mattermost): require slash callback secret

* fix(mattermost): rate limit slash validation

* fix(mattermost): throttle slash validation

* fix(mattermost): bound slash token cache

* fix(mattermost): sanitize slash callback logs

* fix(mattermost): avoid stale slash token cache

* fix(mattermost): scope slash token gate to command

* fix(mattermost): rate-limit slash validation

* fix(mattermost): redact slash validation errors

* fix(mattermost): satisfy slash sanitizer lint

* Move Mattermost slash refresh changelog entry to Unreleased Fixes

* Apply oxfmt accordion blank-line on Mattermost slash docs

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
This commit is contained in:
Agustin Rivera
2026-05-01 08:10:17 -07:00
committed by GitHub
parent a0035764b6
commit 9c0975c1c2
9 changed files with 1481 additions and 99 deletions

View File

@@ -148,6 +148,7 @@ Docs: https://docs.openclaw.ai
- macOS app: reserve layout space for exec approval command details so the allow dialog no longer overlaps the command, context, and action buttons. (#75470) Thanks @ngutman.
- Agents/failover: carry `sessionId`, `lane`, `provider`, `model`, and `profileId` attribution through `FailoverError` and `describeFailoverError`/`coerceToFailoverError` so structured error logs (e.g. `gateway.err.log` ingestion) can attribute exhausted-fallback wrapper errors to the originating session and last-attempted provider instead of dropping the metadata after the per-profile errors. Fixes #42713. (#73506) Thanks @wenxu007.
- Context Engine: treat assembled prompt as the default authority for preemptive overflow prechecks so engines that return a windowed, self-contained context no longer trigger false hard-fail compactions on huge raw history. Engines whose assembled view can hide overflow risk can opt back into the legacy behavior with `AssembleResult.promptAuthority: "preassembly_may_overflow"`. (#74255) Thanks @100yenadmin.
- Mattermost: refresh current native slash command registrations before accepting callbacks so stale tokens from deleted or regenerated commands stop being accepted without a gateway restart while failed validations stay briefly cached and lookup starts are rate-limited per command, gate each callback against the resolved command's own startup token so a token leaked for one slash command cannot poison another command's failure cache, redact slash validation lookup errors, and add a body read timeout to the multi-account routing path so slow callback senders cannot tie up the dispatcher. Thanks @feynman-hou and @eleqtrizit.
## 2026.4.29

View File

@@ -93,7 +93,9 @@ Native slash commands are opt-in. When enabled, OpenClaw registers `oc_*` slash
- If `callbackUrl` is omitted, OpenClaw derives one from gateway host/port + `callbackPath`.
- For multi-account setups, `commands` can be set at the top level or under `channels.mattermost.accounts.<id>.commands` (account values override top-level fields).
- Command callbacks are validated with the per-command tokens returned by Mattermost when OpenClaw registers `oc_*` commands.
- Slash callbacks fail closed when registration failed, startup was partial, or the callback token does not match one of the registered commands.
- OpenClaw refreshes current Mattermost command registration before accepting each callback so stale tokens from deleted or regenerated slash commands stop being accepted without a gateway restart.
- Callback validation fails closed if the Mattermost API cannot confirm the command is still current; failed validations are cached briefly, concurrent lookups are coalesced, and fresh lookup starts are rate-limited per command to bound replay pressure.
- Slash callbacks fail closed when registration failed, startup was partial, or the callback token does not match the resolved command's registered token (a token valid for one command cannot reach upstream validation for a different command).
</Accordion>
<Accordion title="Reachability requirement">

View File

@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import type { MattermostClient } from "./client.js";
import {
DEFAULT_COMMAND_SPECS,
MATTERMOST_SLASH_POST_METHOD,
parseSlashCommandPayload,
registerSlashCommands,
resolveCallbackUrl,
@@ -11,7 +12,7 @@ import {
describe("slash-commands", () => {
async function registerSingleStatusCommand(
requestImpl: (path: string, init?: { method?: string }) => Promise<unknown>,
requestImpl: (path: string, init?: RequestInit) => Promise<unknown>,
) {
const client: MattermostClient = {
baseUrl: "https://chat.example.com",
@@ -160,4 +161,53 @@ describe("slash-commands", () => {
expect(result).toHaveLength(0);
expect(request).toHaveBeenCalledTimes(1);
});
it("updates owned commands when callback method drifts from POST", async () => {
const request = vi.fn(async (path: string, init?: RequestInit) => {
if (path.startsWith("/commands?team_id=")) {
return [
{
id: "cmd-1",
token: "tok-old",
team_id: "team-1",
creator_id: "bot-user",
trigger: "oc_status",
method: "G",
url: "http://gateway/callback",
auto_complete: true,
},
];
}
if (path === "/commands/cmd-1" && init?.method === "PUT") {
expect(JSON.parse(typeof init.body === "string" ? init.body : "{}")).toMatchObject({
method: MATTERMOST_SLASH_POST_METHOD,
url: "http://gateway/callback",
});
return {
id: "cmd-1",
token: "tok-updated",
team_id: "team-1",
creator_id: "bot-user",
trigger: "oc_status",
method: MATTERMOST_SLASH_POST_METHOD,
url: "http://gateway/callback",
auto_complete: true,
};
}
throw new Error(`unexpected request path: ${path}`);
});
const result = await registerSingleStatusCommand(request);
expect(result).toEqual([
{
id: "cmd-1",
trigger: "oc_status",
teamId: "team-1",
token: "tok-updated",
url: "http://gateway/callback",
managed: false,
},
]);
expect(request).toHaveBeenCalledTimes(2);
});
});

View File

@@ -17,6 +17,8 @@ import type { MattermostClient } from "./client.js";
// ─── Types ───────────────────────────────────────────────────────────────────
export const MATTERMOST_SLASH_POST_METHOD = "P";
export type MattermostSlashCommandConfig = {
/** Enable native slash commands. "auto" resolves to false for now (opt-in). */
native: boolean | "auto";
@@ -45,6 +47,7 @@ export type MattermostRegisteredCommand = {
trigger: string;
teamId: string;
token: string;
url: string;
/** True when this process created the command and should delete it on shutdown. */
managed: boolean;
};
@@ -84,7 +87,7 @@ export type MattermostSlashCommandResponse = {
type MattermostCommandCreate = {
team_id: string;
trigger: string;
method: "P" | "G";
method: typeof MATTERMOST_SLASH_POST_METHOD | "G";
url: string;
description?: string;
auto_complete: boolean;
@@ -98,7 +101,7 @@ type MattermostCommandUpdate = {
id: string;
team_id: string;
trigger: string;
method: "P" | "G";
method: typeof MATTERMOST_SLASH_POST_METHOD | "G";
url: string;
description?: string;
auto_complete: boolean;
@@ -106,7 +109,7 @@ type MattermostCommandUpdate = {
auto_complete_hint?: string;
};
type MattermostCommandResponse = {
export type MattermostCommandResponse = {
id: string;
token: string;
team_id: string;
@@ -192,9 +195,25 @@ export const DEFAULT_COMMAND_SPECS: MattermostCommandSpec[] = [
export async function listMattermostCommands(
client: MattermostClient,
teamId: string,
init?: Pick<RequestInit, "signal">,
): Promise<MattermostCommandResponse[]> {
return await client.request<MattermostCommandResponse[]>(
`/commands?team_id=${encodeURIComponent(teamId)}&custom_only=true`,
init,
);
}
/**
* Get a custom slash command by id.
*/
export async function getMattermostCommand(
client: MattermostClient,
commandId: string,
init?: Pick<RequestInit, "signal">,
): Promise<MattermostCommandResponse> {
return await client.request<MattermostCommandResponse>(
`/commands/${encodeURIComponent(commandId)}`,
init,
);
}
@@ -303,31 +322,36 @@ export async function registerSlashCommands(params: {
const existingCmd = ownedCommands[0];
// Already registered with the correct callback URL
if (existingCmd && existingCmd.url === callbackUrl) {
const existingNeedsUpdate = existingCmd
? existingCmd.url !== callbackUrl || existingCmd.method !== MATTERMOST_SLASH_POST_METHOD
: false;
// Already registered with the correct callback URL and method.
if (existingCmd && !existingNeedsUpdate) {
log?.(`mattermost: command /${spec.trigger} already registered (id=${existingCmd.id})`);
registered.push({
id: existingCmd.id,
trigger: spec.trigger,
teamId,
token: existingCmd.token,
url: callbackUrl,
managed: false,
});
continue;
}
// Exists but points to a different URL: attempt to reconcile by updating
// (useful during callback URL migrations).
if (existingCmd && existingCmd.url !== callbackUrl) {
// Exists but has drifted critical callback fields: attempt to reconcile by
// updating (useful during callback URL migrations or method drift).
if (existingCmd && existingNeedsUpdate) {
log?.(
`mattermost: command /${spec.trigger} exists with different callback URL; updating (id=${existingCmd.id})`,
`mattermost: command /${spec.trigger} exists with different callback settings; updating (id=${existingCmd.id})`,
);
try {
const updated = await updateMattermostCommand(client, {
id: existingCmd.id,
team_id: teamId,
trigger: spec.trigger,
method: "P",
method: MATTERMOST_SLASH_POST_METHOD,
url: callbackUrl,
description: spec.description,
auto_complete: spec.autoComplete,
@@ -339,6 +363,7 @@ export async function registerSlashCommands(params: {
trigger: spec.trigger,
teamId,
token: updated.token,
url: callbackUrl,
managed: false,
});
continue;
@@ -365,7 +390,7 @@ export async function registerSlashCommands(params: {
const created = await createMattermostCommand(client, {
team_id: teamId,
trigger: spec.trigger,
method: "P",
method: MATTERMOST_SLASH_POST_METHOD,
url: callbackUrl,
description: spec.description,
auto_complete: spec.autoComplete,
@@ -378,6 +403,7 @@ export async function registerSlashCommands(params: {
trigger: spec.trigger,
teamId,
token: created.token,
url: callbackUrl,
managed: true,
});
} catch (err) {
@@ -499,6 +525,10 @@ export function resolveCommandText(
return args ? `/${commandName} ${args}` : `/${commandName}`;
}
export function normalizeSlashCommandTrigger(command: string): string {
return command.replace(/^\//, "").trim();
}
// ─── Config resolution ───────────────────────────────────────────────────────
const DEFAULT_CALLBACK_PATH = "/api/channels/mattermost/command";

View File

@@ -38,6 +38,16 @@ const mockState = vi.hoisted(() => ({
})),
sendMessageMattermost: vi.fn(async () => ({ messageId: "post-1", channelId: "chan-1" })),
normalizeMattermostAllowList: vi.fn((value: unknown) => value),
getMattermostCommand: vi.fn(async () => ({
id: "cmd-1",
token: "valid-token",
team_id: "team-1",
trigger: "oc_models",
method: "P",
url: "https://gateway.example.com/slash",
delete_at: 0,
})),
listMattermostCommands: vi.fn(async () => []),
}));
vi.mock("./runtime-api.js", () => {
@@ -120,16 +130,22 @@ vi.mock("./send.js", () => ({
}));
vi.mock("./slash-commands.js", () => ({
MATTERMOST_SLASH_POST_METHOD: "P",
getMattermostCommand: mockState.getMattermostCommand,
listMattermostCommands: mockState.listMattermostCommands,
normalizeSlashCommandTrigger: (command: string) => command.replace(/^\//, "").trim(),
parseSlashCommandPayload: mockState.parseSlashCommandPayload,
resolveCommandText: mockState.resolveCommandText,
}));
let createSlashCommandHttpHandler: typeof import("./slash-http.js").createSlashCommandHttpHandler;
const callbackUrlFixture = "https://gateway.example.com/slash";
function createRequest(body = "token=valid-token"): IncomingMessage {
const req = new PassThrough();
const incoming = req as PassThrough & IncomingMessage;
incoming.method = "POST";
incoming.url = "/slash";
incoming.headers = {
"content-type": "application/x-www-form-urlencoded",
};
@@ -205,6 +221,8 @@ describe("slash-http cfg threading", () => {
mockState.fetchMattermostChannel.mockClear();
mockState.sendMessageMattermost.mockClear();
mockState.normalizeMattermostAllowList.mockClear();
mockState.getMattermostCommand.mockClear();
mockState.listMattermostCommands.mockClear();
({ createSlashCommandHttpHandler } = await import("./slash-http.js"));
});
@@ -220,7 +238,16 @@ describe("slash-http cfg threading", () => {
account: accountFixture,
cfg,
runtime: {} as RuntimeEnv,
commandTokens: new Set(["valid-token"]),
registeredCommands: [
{
id: "cmd-1",
teamId: "team-1",
trigger: "oc_models",
token: "valid-token",
url: callbackUrlFixture,
managed: false,
},
],
});
const response = createResponse();
@@ -238,28 +265,129 @@ describe("slash-http cfg threading", () => {
);
});
it("does not rely on Set.has for command token validation", async () => {
const commandTokens = new Set(["valid-token"]);
const hasSpy = vi.fn(() => {
throw new Error("Set.has should not be used for slash token validation");
it("rejects a callback when Mattermost reports a different current command token", async () => {
mockState.parseSlashCommandPayload.mockReturnValueOnce({
token: "old-token",
command: "/oc_models",
text: "models",
channel_id: "chan-1",
user_id: "user-1",
user_name: "alice",
team_id: "team-1",
});
Object.defineProperty(commandTokens, "has", {
value: hasSpy,
configurable: true,
mockState.getMattermostCommand.mockResolvedValueOnce({
id: "cmd-1",
token: "new-token",
team_id: "team-1",
trigger: "oc_models",
method: "P",
url: callbackUrlFixture,
delete_at: 0,
});
const handler = createSlashCommandHttpHandler({
account: accountFixture,
cfg: {} as OpenClawConfig,
runtime: {} as RuntimeEnv,
commandTokens,
registeredCommands: [
{
id: "cmd-1",
teamId: "team-1",
trigger: "oc_models",
token: "old-token",
url: callbackUrlFixture,
managed: false,
},
],
});
const response = createResponse();
await handler(createRequest(), response.res);
await handler(createRequest("token=old-token"), response.res);
expect(response.res.statusCode).toBe(200);
expect(response.getBody()).toContain("Processing");
expect(hasSpy).not.toHaveBeenCalled();
expect(response.res.statusCode).toBe(401);
expect(response.getBody()).toContain("Unauthorized: invalid command token.");
expect(mockState.fetchMattermostChannel).not.toHaveBeenCalled();
expect(mockState.sendMessageMattermost).not.toHaveBeenCalled();
});
it("rejects unknown tokens before calling Mattermost", async () => {
mockState.parseSlashCommandPayload.mockReturnValueOnce({
token: "unknown-token",
command: "/oc_models",
text: "models",
channel_id: "chan-1",
user_id: "user-1",
user_name: "alice",
team_id: "team-1",
});
const handler = createSlashCommandHttpHandler({
account: accountFixture,
cfg: {} as OpenClawConfig,
runtime: {} as RuntimeEnv,
registeredCommands: [
{
id: "cmd-1",
teamId: "team-1",
trigger: "oc_models",
token: "valid-token",
url: callbackUrlFixture,
managed: false,
},
],
});
const response = createResponse();
await handler(createRequest("token=unknown-token"), response.res);
expect(response.res.statusCode).toBe(401);
expect(mockState.getMattermostCommand).not.toHaveBeenCalled();
expect(mockState.fetchMattermostChannel).not.toHaveBeenCalled();
expect(mockState.sendMessageMattermost).not.toHaveBeenCalled();
});
it("rejects a refreshed callback token before Mattermost lookup until local state updates", async () => {
mockState.parseSlashCommandPayload.mockReturnValueOnce({
token: "new-token",
command: "/oc_models",
text: "models",
channel_id: "chan-1",
user_id: "user-1",
user_name: "alice",
team_id: "team-1",
});
mockState.getMattermostCommand.mockResolvedValueOnce({
id: "cmd-1",
token: "new-token",
team_id: "team-1",
trigger: "oc_models",
method: "P",
url: callbackUrlFixture,
delete_at: 0,
});
const handler = createSlashCommandHttpHandler({
account: accountFixture,
cfg: {} as OpenClawConfig,
runtime: {} as RuntimeEnv,
registeredCommands: [
{
id: "cmd-1",
teamId: "team-1",
trigger: "oc_models",
token: "old-token",
url: callbackUrlFixture,
managed: false,
},
],
});
const response = createResponse();
await handler(createRequest("token=new-token"), response.res);
expect(response.res.statusCode).toBe(401);
expect(response.getBody()).toContain("Unauthorized: invalid command token.");
expect(mockState.getMattermostCommand).not.toHaveBeenCalled();
expect(mockState.fetchMattermostChannel).not.toHaveBeenCalled();
expect(mockState.sendMessageMattermost).not.toHaveBeenCalled();
});
});

View File

@@ -1,9 +1,19 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { PassThrough } from "node:stream";
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, RuntimeEnv } from "../../runtime-api.js";
import type { ResolvedMattermostAccount } from "./accounts.js";
import { createSlashCommandHttpHandler } from "./slash-http.js";
import type { MattermostClient } from "./client.js";
import {
MATTERMOST_SLASH_POST_METHOD,
type MattermostCommandResponse,
type MattermostRegisteredCommand,
} from "./slash-commands.js";
import {
createSlashCommandHttpHandler,
resetMattermostSlashCommandValidationCacheForTests,
validateMattermostSlashCommandToken,
} from "./slash-http.js";
function createRequest(params: {
method?: string;
@@ -61,8 +71,61 @@ const accountFixture: ResolvedMattermostAccount = {
config: {},
};
function createRegisteredCommand(params?: {
token?: string;
teamId?: string;
trigger?: string;
url?: string;
}): MattermostRegisteredCommand {
return {
id: "cmd-1",
teamId: params?.teamId ?? "t1",
trigger: params?.trigger ?? "oc_status",
token: params?.token ?? "valid-token",
url: params?.url ?? "https://gateway.example.com/slash",
managed: false,
};
}
function createCommandLookupClient(params: {
command?: MattermostCommandResponse | null | (() => MattermostCommandResponse | null);
commandLookupError?: Error;
listLookupError?: Error;
listCommands?: MattermostCommandResponse[];
}): MattermostClient & { requests: string[] } {
const requests: string[] = [];
return {
baseUrl: "https://chat.example.com",
apiBaseUrl: "https://chat.example.com/api/v4",
token: "bot-token",
request: async <T>(path: string) => {
requests.push(path);
if (path === "/commands/cmd-1") {
if (params.commandLookupError) {
throw params.commandLookupError;
}
const command = typeof params.command === "function" ? params.command() : params.command;
if (command) {
return command as T;
}
throw new Error("not found");
}
if (path.startsWith("/commands?team_id=")) {
if (params.listLookupError) {
throw params.listLookupError;
}
const command = typeof params.command === "function" ? params.command() : params.command;
return (params.listCommands ?? (command ? [command] : [])) as T;
}
throw new Error(`unexpected request path: ${path}`);
},
fetchImpl: vi.fn<typeof fetch>(),
requests,
};
}
async function runSlashRequest(params: {
commandTokens: Set<string>;
registeredCommands?: MattermostRegisteredCommand[];
body: string;
method?: string;
}) {
@@ -70,7 +133,7 @@ async function runSlashRequest(params: {
account: accountFixture,
cfg: {} as OpenClawConfig,
runtime: {} as RuntimeEnv,
commandTokens: params.commandTokens,
registeredCommands: params.registeredCommands ?? [],
});
const req = createRequest({ method: params.method, body: params.body });
const response = createResponse();
@@ -79,12 +142,16 @@ async function runSlashRequest(params: {
}
describe("slash-http", () => {
beforeEach(() => {
resetMattermostSlashCommandValidationCacheForTests();
});
it("rejects non-POST methods", async () => {
const handler = createSlashCommandHttpHandler({
account: accountFixture,
cfg: {} as OpenClawConfig,
runtime: {} as RuntimeEnv,
commandTokens: new Set(["valid-token"]),
registeredCommands: [createRegisteredCommand()],
});
const req = createRequest({ method: "GET", body: "" });
const response = createResponse();
@@ -101,7 +168,7 @@ describe("slash-http", () => {
account: accountFixture,
cfg: {} as OpenClawConfig,
runtime: {} as RuntimeEnv,
commandTokens: new Set(["valid-token"]),
registeredCommands: [createRegisteredCommand()],
});
const req = createRequest({ body: "token=abc&command=%2Foc_status" });
const response = createResponse();
@@ -112,9 +179,9 @@ describe("slash-http", () => {
expect(response.getBody()).toContain("Invalid slash command payload");
});
it("fails closed when no command tokens are registered", async () => {
it("fails closed when no commands are registered", async () => {
const response = await runSlashRequest({
commandTokens: new Set<string>(),
registeredCommands: [],
body: "token=tok1&team_id=t1&channel_id=c1&user_id=u1&command=%2Foc_status&text=",
});
@@ -122,10 +189,33 @@ describe("slash-http", () => {
expect(response.getBody()).toContain("Unauthorized: invalid command token.");
});
it("rejects unknown command tokens", async () => {
it("rejects unknown slash commands before upstream validation", async () => {
const response = await runSlashRequest({
commandTokens: new Set(["known-token"]),
body: "token=unknown&team_id=t1&channel_id=c1&user_id=u1&command=%2Foc_status&text=",
registeredCommands: [createRegisteredCommand({ token: "known-token" })],
body: "token=unknown&team_id=t1&channel_id=c1&user_id=u1&command=%2Foc_unknown&text=",
});
expect(response.res.statusCode).toBe(401);
expect(response.getBody()).toContain("Unauthorized: invalid command token.");
});
it("rejects a token valid for one command when used against another command", async () => {
// Cross-command spray DoS guard: a payload pointing at command B with the
// token for command A must fail at the per-command startup gate, before
// upstream validation runs and could poison the failure cache for B.
const response = await runSlashRequest({
registeredCommands: [
createRegisteredCommand({ token: "token-status", trigger: "oc_status" }),
{
id: "cmd-2",
teamId: "t1",
trigger: "oc_help",
token: "token-help",
url: "https://gateway.example.com/slash",
managed: false,
},
],
body: "token=token-status&team_id=t1&channel_id=c1&user_id=u1&command=%2Foc_help&text=",
});
expect(response.res.statusCode).toBe(401);
@@ -137,7 +227,7 @@ describe("slash-http", () => {
account: accountFixture,
cfg: {} as OpenClawConfig,
runtime: {} as RuntimeEnv,
commandTokens: new Set(["valid-token"]),
registeredCommands: [createRegisteredCommand()],
bodyTimeoutMs: 1,
});
const req = createRequest({ autoEnd: false });
@@ -148,4 +238,569 @@ describe("slash-http", () => {
expect(response.res.statusCode).toBe(408);
expect(response.getBody()).toBe("Request body timeout");
});
it("rejects the startup token when Mattermost has rotated the current command token", async () => {
const registeredCommand = createRegisteredCommand({ token: "old-token" });
const client = createCommandLookupClient({
command: {
id: "cmd-1",
token: "new-token",
team_id: "t1",
trigger: "oc_status",
method: MATTERMOST_SLASH_POST_METHOD,
url: "https://gateway.example.com/slash",
auto_complete: true,
delete_at: 0,
},
});
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload: {
token: "old-token",
team_id: "t1",
channel_id: "c1",
user_id: "u1",
command: "/oc_status",
text: "",
},
}),
).resolves.toBe(false);
expect(registeredCommand.token).toBe("old-token");
});
it("accepts the startup token while the current Mattermost command still matches", async () => {
const registeredCommand = createRegisteredCommand({ token: "valid-token" });
const client = createCommandLookupClient({
command: {
id: "cmd-1",
token: "valid-token",
team_id: "t1",
trigger: "oc_status",
method: MATTERMOST_SLASH_POST_METHOD,
url: "https://gateway.example.com/slash",
auto_complete: true,
delete_at: 0,
},
});
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload: {
token: "valid-token",
team_id: "t1",
channel_id: "c1",
user_id: "u1",
command: "/oc_status",
text: "",
},
}),
).resolves.toBe(true);
});
it("rate-limits sequential current-command lookups without caching successes", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-27T00:00:00Z"));
try {
const registeredCommand = createRegisteredCommand({ token: "valid-token" });
const command = {
id: "cmd-1",
token: "valid-token",
team_id: "t1",
trigger: "oc_status",
method: MATTERMOST_SLASH_POST_METHOD,
url: "https://gateway.example.com/slash",
auto_complete: true,
delete_at: 0,
};
const client = createCommandLookupClient({ command });
const payload = {
token: "valid-token",
team_id: "t1",
channel_id: "c1",
user_id: "u1",
command: "/oc_status",
text: "",
};
const log = vi.fn();
for (let i = 0; i < 20; i += 1) {
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload,
log,
}),
).resolves.toBe(true);
}
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload,
log,
}),
).resolves.toBe(false);
expect(client.requests).toHaveLength(20);
expect(log).toHaveBeenCalledWith(
"mattermost: slash command validation lookup rate-limited for /oc_status",
);
} finally {
vi.useRealTimers();
}
});
it("rechecks matching current commands so startup tokens are not accepted after rotation", async () => {
const registeredCommand = createRegisteredCommand({ token: "valid-token" });
let command = {
id: "cmd-1",
token: "valid-token",
team_id: "t1",
trigger: "oc_status",
method: MATTERMOST_SLASH_POST_METHOD,
url: "https://gateway.example.com/slash",
auto_complete: true,
delete_at: 0,
};
const client = createCommandLookupClient({
command: () => command,
});
const payload = {
token: "valid-token",
team_id: "t1",
channel_id: "c1",
user_id: "u1",
command: "/oc_status",
text: "",
};
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload,
}),
).resolves.toBe(true);
command = {
...command,
token: "new-token",
};
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload,
}),
).resolves.toBe(false);
expect(client.requests).toEqual(["/commands/cmd-1", "/commands/cmd-1"]);
});
it("briefly caches failed current command validation without accepting stale tokens", async () => {
const registeredCommand = createRegisteredCommand({ token: "old-token" });
const client = createCommandLookupClient({
command: {
id: "cmd-1",
token: "new-token",
team_id: "t1",
trigger: "oc_status",
method: MATTERMOST_SLASH_POST_METHOD,
url: "https://gateway.example.com/slash",
auto_complete: true,
delete_at: 0,
},
});
const payload = {
token: "old-token",
team_id: "t1",
channel_id: "c1",
user_id: "u1",
command: "/oc_status",
text: "",
};
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload,
}),
).resolves.toBe(false);
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload,
}),
).resolves.toBe(false);
expect(client.requests).toEqual(["/commands/cmd-1"]);
});
it("scopes validation cache entries by account", async () => {
const registeredCommand = createRegisteredCommand();
const clientA = createCommandLookupClient({
command: {
id: "cmd-1",
token: "token-a",
team_id: "t1",
trigger: "oc_status",
method: MATTERMOST_SLASH_POST_METHOD,
url: "https://gateway.example.com/slash",
auto_complete: true,
delete_at: 0,
},
});
const clientB = createCommandLookupClient({
command: {
id: "cmd-1",
token: "token-b",
team_id: "t1",
trigger: "oc_status",
method: MATTERMOST_SLASH_POST_METHOD,
url: "https://gateway.example.com/slash",
auto_complete: true,
delete_at: 0,
},
});
await expect(
validateMattermostSlashCommandToken({
accountId: "a1",
client: clientA,
registeredCommand,
payload: {
token: "token-a",
team_id: "t1",
channel_id: "c1",
user_id: "u1",
command: "/oc_status",
text: "",
},
}),
).resolves.toBe(true);
await expect(
validateMattermostSlashCommandToken({
accountId: "a2",
client: clientB,
registeredCommand,
payload: {
token: "token-b",
team_id: "t1",
channel_id: "c1",
user_id: "u1",
command: "/oc_status",
text: "",
},
}),
).resolves.toBe(true);
expect(clientA.requests).toEqual(["/commands/cmd-1"]);
expect(clientB.requests).toEqual(["/commands/cmd-1"]);
});
it("rejects a command that Mattermost reports as deleted", async () => {
const registeredCommand = createRegisteredCommand();
const client = createCommandLookupClient({
command: {
id: "cmd-1",
token: "valid-token",
team_id: "t1",
trigger: "oc_status",
method: MATTERMOST_SLASH_POST_METHOD,
url: "https://gateway.example.com/slash",
auto_complete: true,
delete_at: 123,
},
});
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload: {
token: "valid-token",
team_id: "t1",
channel_id: "c1",
user_id: "u1",
command: "/oc_status",
text: "",
},
}),
).resolves.toBe(false);
});
it("rejects a regenerated command when the current command id changed", async () => {
const registeredCommand = createRegisteredCommand({ token: "old-token" });
const oldDeletedCommand = {
id: "cmd-1",
token: "old-token",
team_id: "t1",
trigger: "oc_status",
method: MATTERMOST_SLASH_POST_METHOD,
url: "https://gateway.example.com/slash",
auto_complete: true,
delete_at: 123,
};
const newCommand = {
id: "cmd-2",
token: "new-token",
team_id: "t1",
trigger: "oc_status",
method: MATTERMOST_SLASH_POST_METHOD,
url: "https://gateway.example.com/slash",
auto_complete: true,
delete_at: 0,
};
const client = createCommandLookupClient({
command: oldDeletedCommand,
listCommands: [oldDeletedCommand, newCommand],
});
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload: {
token: "new-token",
team_id: "t1",
channel_id: "c1",
user_id: "u1",
command: "/oc_status",
text: "",
},
}),
).resolves.toBe(false);
expect(client.requests).toEqual(["/commands/cmd-1", "/commands?team_id=t1&custom_only=true"]);
});
it("logs when command lookup by id returns a deleted command before fallback", async () => {
const registeredCommand = createRegisteredCommand();
const command = {
id: "cmd-1\r\nspoofed",
token: "valid-token",
team_id: "t1",
trigger: "oc_status",
method: MATTERMOST_SLASH_POST_METHOD,
url: "https://gateway.example.com/slash",
auto_complete: true,
delete_at: 123,
};
const client = createCommandLookupClient({
command,
listCommands: [],
});
const log = vi.fn();
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload: {
token: "valid-token",
team_id: "t1",
channel_id: "c1",
user_id: "u1",
command: "/oc_status",
text: "",
},
log,
}),
).resolves.toBe(false);
expect(log).toHaveBeenCalledTimes(1);
const message = log.mock.calls[0]?.[0] ?? "";
expect(message).not.toMatch(/[\r\n\t]/u);
expect(message).toContain("deleted command cmd-1 spoofed");
expect(message).toContain("using team list fallback");
});
it("rejects current commands with a mismatched method or callback URL", async () => {
const registeredCommand = createRegisteredCommand();
for (const command of [
{
id: "cmd-1",
token: "valid-token",
team_id: "t1",
trigger: "oc_status",
method: "G",
url: "https://gateway.example.com/slash",
auto_complete: true,
delete_at: 0,
},
{
id: "cmd-1",
token: "valid-token",
team_id: "t1",
trigger: "oc_status",
method: MATTERMOST_SLASH_POST_METHOD,
url: "https://gateway.example.com/other",
auto_complete: true,
delete_at: 0,
},
]) {
resetMattermostSlashCommandValidationCacheForTests();
const client = createCommandLookupClient({ command });
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload: {
token: "valid-token",
team_id: "t1",
channel_id: "c1",
user_id: "u1",
command: "/oc_status",
text: "",
},
}),
).resolves.toBe(false);
}
});
it("falls back to the team command list when command lookup is unavailable", async () => {
const registeredCommand = createRegisteredCommand();
const command = {
id: "cmd-1",
token: "valid-token",
team_id: "t1",
trigger: "oc_status",
method: MATTERMOST_SLASH_POST_METHOD,
url: "https://gateway.example.com/slash",
auto_complete: true,
delete_at: 0,
};
const client = createCommandLookupClient({
commandLookupError: new Error("not implemented"),
listCommands: [command],
});
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload: {
token: "valid-token",
team_id: "t1",
channel_id: "c1",
user_id: "u1",
command: "/oc_status",
text: "",
},
}),
).resolves.toBe(true);
expect(client.requests).toEqual(["/commands/cmd-1", "/commands?team_id=t1&custom_only=true"]);
});
it("logs sanitized command lookup failures when falling back to the team command list", async () => {
const registeredCommand = createRegisteredCommand({ trigger: "oc_status\r\nspoofed" });
const command = {
id: "cmd-1",
token: "valid-token",
team_id: "t1",
trigger: "oc_status\r\nspoofed",
method: MATTERMOST_SLASH_POST_METHOD,
url: "https://gateway.example.com/slash",
auto_complete: true,
delete_at: 0,
};
const client = createCommandLookupClient({
commandLookupError: new Error(
"primary\ntoken=secret-token https://user:pass@chat.example.com/api?access_token=secret-access&client_secret=secret-client",
),
listCommands: [command],
});
const log = vi.fn();
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload: {
token: "valid-token",
team_id: "t1",
channel_id: "c1",
user_id: "u1",
command: "/oc_status",
text: "",
},
log,
}),
).resolves.toBe(true);
expect(log).toHaveBeenCalledTimes(1);
const message = log.mock.calls[0]?.[0] ?? "";
expect(message).not.toMatch(/[\r\n\t]/u);
expect(message).toContain("/oc_status spoofed");
expect(message).toContain("primary token=[redacted]");
expect(message).toContain("https://redacted:redacted@chat.example.com/api");
expect(message).not.toContain("secret-token");
expect(message).not.toContain("secret-access");
expect(message).not.toContain("secret-client");
expect(message).not.toContain("user:pass");
});
it("sanitizes upstream lookup errors before logging fallback failures", async () => {
const registeredCommand = createRegisteredCommand();
const client = createCommandLookupClient({
commandLookupError: new Error('primary\ntoken=secret-token refresh_token="secret-refresh"'),
listLookupError: new Error(
"fallback\r\nsecond-line botToken: secret-bot https://user:pass@chat.example.com/hooks?token=secret-query",
),
});
const log = vi.fn();
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload: {
token: "valid-token",
team_id: "t1",
channel_id: "c1",
user_id: "u1",
command: "/oc_status",
text: "",
},
log,
}),
).resolves.toBe(false);
expect(log).toHaveBeenCalledTimes(1);
const message = log.mock.calls[0]?.[0] ?? "";
expect(message).not.toMatch(/[\r\n\t]/u);
expect(message).toContain("fallback second-line");
expect(message).toContain("botToken: [redacted]");
expect(message).toContain("https://redacted:redacted@chat.example.com/hooks");
expect(message).toContain("primary token=[redacted]");
expect(message).not.toContain("secret-token");
expect(message).not.toContain("secret-refresh");
expect(message).not.toContain("secret-bot");
expect(message).not.toContain("secret-query");
expect(message).not.toContain("user:pass");
});
});

View File

@@ -40,17 +40,24 @@ import {
} from "./runtime-api.js";
import { sendMessageMattermost } from "./send.js";
import {
MATTERMOST_SLASH_POST_METHOD,
getMattermostCommand,
listMattermostCommands,
normalizeSlashCommandTrigger,
parseSlashCommandPayload,
resolveCommandText,
type MattermostRegisteredCommand,
type MattermostCommandResponse,
type MattermostSlashCommandResponse,
type MattermostSlashCommandPayload,
} from "./slash-commands.js";
type SlashHttpHandlerParams = {
account: ResolvedMattermostAccount;
cfg: OpenClawConfig;
runtime: RuntimeEnv;
/** Expected token from registered commands (for validation). */
commandTokens: Set<string>;
/** Commands registered or reconciled during monitor startup. */
registeredCommands: readonly MattermostRegisteredCommand[];
/** Map from trigger to original command name (for skill commands that start with oc_). */
triggerMap?: ReadonlyMap<string, string>;
log?: (msg: string) => void;
@@ -59,6 +66,34 @@ type SlashHttpHandlerParams = {
const MAX_BODY_BYTES = 64 * 1024;
const BODY_READ_TIMEOUT_MS = 5_000;
const COMMAND_LOOKUP_TIMEOUT_MS = 1_000;
const COMMAND_VALIDATION_FAILURE_CACHE_MS = 5_000;
const COMMAND_VALIDATION_FAILURE_CACHE_MAX_KEYS = 2_000;
const COMMAND_VALIDATION_LOOKUP_BURST = 20;
const COMMAND_VALIDATION_LOOKUP_REFILL_MS = 500;
const COMMAND_VALIDATION_LOOKUP_LIMIT_LOG_MS = 5_000;
const COMMAND_VALIDATION_LOOKUP_RATE_LIMIT_MAX_KEYS = 2_000;
type CommandLookupInflightEntry = {
accountId: string;
promise: Promise<MattermostCommandResponse | null>;
};
type CommandValidationRateLimitEntry = {
accountId: string;
tokens: number;
updatedAt: number;
lastLimitedLogAt: number;
};
const commandLookupInflight = new Map<string, CommandLookupInflightEntry>();
const commandValidationFailureCache = new Map<string, { accountId: string; expiresAt: number }>();
const commandValidationLookupRateLimit = new Map<string, CommandValidationRateLimitEntry>();
const SECRET_LOG_KEYS = new Set([
"access_token",
"authorization",
"bottoken",
"client_secret",
"refresh_token",
"token",
]);
/**
* Read the full request body as a string.
@@ -84,18 +119,302 @@ function sendJsonResponse(
res.end(JSON.stringify(body));
}
function matchesRegisteredCommandToken(
commandTokens: ReadonlySet<string>,
candidate: string,
): boolean {
for (const token of commandTokens) {
if (safeEqualSecret(candidate, token)) {
return true;
function findRegisteredCommandForPayload(params: {
registeredCommands: readonly MattermostRegisteredCommand[];
payload: MattermostSlashCommandPayload;
}): MattermostRegisteredCommand | undefined {
const trigger = normalizeSlashCommandTrigger(params.payload.command);
return params.registeredCommands.find(
(cmd) => cmd.teamId === params.payload.team_id && cmd.trigger === trigger,
);
}
function isDeletedMattermostCommand(command: { delete_at?: number }): boolean {
return typeof command.delete_at === "number" && command.delete_at > 0;
}
function sanitizeCommandLookupError(error: unknown): string {
const raw = error instanceof Error ? error.message : String(error);
return raw
.replace(/[\r\n\t]/gu, " ")
.replace(/https?:\/\/[^\s)\]}]+/giu, (urlText) => {
try {
const url = new URL(urlText);
if (url.username || url.password) {
url.username = "redacted";
url.password = "redacted";
}
for (const key of url.searchParams.keys()) {
if (SECRET_LOG_KEYS.has(key.toLowerCase())) {
url.searchParams.set(key, "redacted");
}
}
return url.toString();
} catch {
return urlText;
}
})
.replace(/(^|[^\w-])(Bearer|Token)\s+[A-Za-z0-9._~+/=-]+/giu, "$1$2 [redacted]")
.replace(
/\b(token|authorization|access_token|refresh_token|client_secret|botToken)\b(\s*["']?\s*(?:=|:)\s*["']?)[^"',\s;}]+/giu,
"$1$2[redacted]",
)
.slice(0, 300);
}
function sanitizeMattermostLogValue(value: string): string {
return value.replace(/[\r\n\t]/gu, " ").slice(0, 200);
}
async function withCommandLookupTimeout<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), COMMAND_LOOKUP_TIMEOUT_MS);
try {
return await task(controller.signal);
} finally {
clearTimeout(timeout);
}
}
function commandLookupKey(
client: ReturnType<typeof createMattermostClient>,
registered: MattermostRegisteredCommand,
accountId: string,
): string {
return `${client.apiBaseUrl}:${accountId}:${registered.teamId}:${registered.id}`;
}
export function resetMattermostSlashCommandValidationCacheForTests(): void {
commandLookupInflight.clear();
commandValidationFailureCache.clear();
commandValidationLookupRateLimit.clear();
}
export function clearMattermostSlashCommandValidationCacheForAccount(accountId: string): void {
for (const [key, entry] of commandValidationFailureCache) {
if (entry.accountId === accountId) {
commandValidationFailureCache.delete(key);
}
}
for (const [key, entry] of commandLookupInflight) {
if (entry.accountId === accountId) {
commandLookupInflight.delete(key);
}
}
for (const [key, entry] of commandValidationLookupRateLimit) {
if (entry.accountId === accountId) {
commandValidationLookupRateLimit.delete(key);
}
}
}
function sweepCommandValidationFailureCache(now = Date.now()): void {
for (const [key, entry] of commandValidationFailureCache) {
if (entry.expiresAt <= now) {
commandValidationFailureCache.delete(key);
}
}
while (commandValidationFailureCache.size > COMMAND_VALIDATION_FAILURE_CACHE_MAX_KEYS) {
const oldestKey = commandValidationFailureCache.keys().next().value;
if (!oldestKey) {
break;
}
commandValidationFailureCache.delete(oldestKey);
}
}
function hasCachedCommandValidationFailure(key: string, now = Date.now()): boolean {
sweepCommandValidationFailureCache(now);
const cached = commandValidationFailureCache.get(key);
if (!cached) {
return false;
}
if (cached.expiresAt > now) {
return true;
}
commandValidationFailureCache.delete(key);
return false;
}
function cacheCommandValidationFailure(key: string, accountId: string): void {
sweepCommandValidationFailureCache();
commandValidationFailureCache.set(key, {
accountId,
expiresAt: Date.now() + COMMAND_VALIDATION_FAILURE_CACHE_MS,
});
}
function sweepCommandValidationLookupRateLimit(now = Date.now()): void {
const staleAfterMs = COMMAND_VALIDATION_LOOKUP_REFILL_MS * COMMAND_VALIDATION_LOOKUP_BURST * 2;
for (const [key, entry] of commandValidationLookupRateLimit) {
if (now - entry.updatedAt > staleAfterMs) {
commandValidationLookupRateLimit.delete(key);
}
}
while (commandValidationLookupRateLimit.size > COMMAND_VALIDATION_LOOKUP_RATE_LIMIT_MAX_KEYS) {
const oldestKey = commandValidationLookupRateLimit.keys().next().value;
if (!oldestKey) {
break;
}
commandValidationLookupRateLimit.delete(oldestKey);
}
}
function reserveCommandValidationLookup(params: {
key: string;
accountId: string;
now?: number;
}): { allowed: true } | { allowed: false; shouldLog: boolean } {
const now = params.now ?? Date.now();
sweepCommandValidationLookupRateLimit(now);
const existing = commandValidationLookupRateLimit.get(params.key);
if (!existing) {
commandValidationLookupRateLimit.set(params.key, {
accountId: params.accountId,
tokens: COMMAND_VALIDATION_LOOKUP_BURST - 1,
updatedAt: now,
lastLimitedLogAt: 0,
});
return { allowed: true };
}
const refill = Math.floor((now - existing.updatedAt) / COMMAND_VALIDATION_LOOKUP_REFILL_MS);
if (refill > 0) {
existing.tokens = Math.min(COMMAND_VALIDATION_LOOKUP_BURST, existing.tokens + refill);
existing.updatedAt += refill * COMMAND_VALIDATION_LOOKUP_REFILL_MS;
}
if (existing.tokens <= 0) {
const shouldLog = now - existing.lastLimitedLogAt >= COMMAND_VALIDATION_LOOKUP_LIMIT_LOG_MS;
if (shouldLog) {
existing.lastLimitedLogAt = now;
}
return { allowed: false, shouldLog };
}
existing.tokens -= 1;
return { allowed: true };
}
async function fetchCurrentMattermostCommandUncached(params: {
client: ReturnType<typeof createMattermostClient>;
registered: MattermostRegisteredCommand;
log?: (msg: string) => void;
}): Promise<MattermostCommandResponse | null> {
let commandLookupResult: MattermostCommandResponse | null = null;
let commandLookupError: unknown;
let commandLookupFallbackDetail: string | undefined;
try {
commandLookupResult = await withCommandLookupTimeout((signal) =>
getMattermostCommand(params.client, params.registered.id, { signal }),
);
if (!isDeletedMattermostCommand(commandLookupResult)) {
return commandLookupResult;
}
commandLookupFallbackDetail = `command lookup by id returned deleted command ${sanitizeMattermostLogValue(commandLookupResult.id)}`;
} catch (err) {
commandLookupError = err;
// Older Mattermost servers may not expose GET /commands/{id}; fall back to
// the team command list, which registration already requires.
}
try {
const currentCommands = await withCommandLookupTimeout((signal) =>
listMattermostCommands(params.client, params.registered.teamId, { signal }),
);
if (commandLookupError) {
params.log?.(
`mattermost: slash command lookup by id failed for /${sanitizeMattermostLogValue(params.registered.trigger)}; using team list fallback: ${sanitizeCommandLookupError(commandLookupError)}`,
);
} else if (commandLookupFallbackDetail) {
params.log?.(
`mattermost: slash ${commandLookupFallbackDetail} for /${sanitizeMattermostLogValue(params.registered.trigger)}; using team list fallback`,
);
}
return currentCommands.find((cmd) => cmd.id === params.registered.id) ?? commandLookupResult;
} catch (err) {
const primaryDetail = commandLookupError
? `; command lookup: ${sanitizeCommandLookupError(commandLookupError)}`
: commandLookupFallbackDetail
? `; command lookup: ${commandLookupFallbackDetail}`
: "";
params.log?.(
`mattermost: slash command registration check failed for /${sanitizeMattermostLogValue(params.registered.trigger)}: ${sanitizeCommandLookupError(err)}${primaryDetail}`,
);
return null;
}
}
async function fetchCurrentMattermostCommand(params: {
accountId: string;
client: ReturnType<typeof createMattermostClient>;
registered: MattermostRegisteredCommand;
log?: (msg: string) => void;
}): Promise<MattermostCommandResponse | null> {
const key = commandLookupKey(params.client, params.registered, params.accountId);
const existing = commandLookupInflight.get(key);
if (existing) {
return await existing.promise;
}
const lookup = fetchCurrentMattermostCommandUncached(params).finally(() => {
commandLookupInflight.delete(key);
});
commandLookupInflight.set(key, { accountId: params.accountId, promise: lookup });
return await lookup;
}
export async function validateMattermostSlashCommandToken(params: {
accountId: string;
client: ReturnType<typeof createMattermostClient>;
registeredCommand: MattermostRegisteredCommand;
payload: MattermostSlashCommandPayload;
log?: (msg: string) => void;
}): Promise<boolean> {
const lookupKey = commandLookupKey(params.client, params.registeredCommand, params.accountId);
if (hasCachedCommandValidationFailure(lookupKey)) {
return false;
}
if (!commandLookupInflight.has(lookupKey)) {
const reservation = reserveCommandValidationLookup({
key: lookupKey,
accountId: params.accountId,
});
if (!reservation.allowed) {
if (reservation.shouldLog) {
params.log?.(
`mattermost: slash command validation lookup rate-limited for /${sanitizeMattermostLogValue(params.registeredCommand.trigger)}`,
);
}
return false;
}
}
const current = await fetchCurrentMattermostCommand({
accountId: params.accountId,
client: params.client,
registered: params.registeredCommand,
log: params.log,
});
if (!current || isDeletedMattermostCommand(current)) {
cacheCommandValidationFailure(lookupKey, params.accountId);
return false;
}
if (
current.id !== params.registeredCommand.id ||
current.team_id !== params.registeredCommand.teamId ||
current.trigger !== params.registeredCommand.trigger ||
current.method !== MATTERMOST_SLASH_POST_METHOD ||
current.url !== params.registeredCommand.url
) {
cacheCommandValidationFailure(lookupKey, params.accountId);
return false;
}
if (!current.token || !safeEqualSecret(params.payload.token, current.token)) {
cacheCommandValidationFailure(lookupKey, params.accountId);
return false;
}
commandValidationFailureCache.delete(lookupKey);
return true;
}
type SlashInvocationAuth = {
ok: boolean;
denyResponse?: MattermostSlashCommandResponse;
@@ -126,7 +445,9 @@ async function authorizeSlashInvocation(params: {
try {
channelInfo = await fetchMattermostChannel(client, channelId);
} catch (err) {
log?.(`mattermost: slash channel lookup failed for ${channelId}: ${String(err)}`);
log?.(
`mattermost: slash channel lookup failed for ${sanitizeMattermostLogValue(channelId)}: ${sanitizeCommandLookupError(err)}`,
);
}
if (!channelInfo) {
@@ -224,7 +545,7 @@ async function authorizeSlashInvocation(params: {
* from the Mattermost server when a user invokes a registered slash command.
*/
export function createSlashCommandHttpHandler(params: SlashHttpHandlerParams) {
const { account, cfg, runtime, commandTokens, triggerMap, log, bodyTimeoutMs } = params;
const { account, cfg, runtime, registeredCommands, triggerMap, log, bodyTimeoutMs } = params;
return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
if (req.method !== "POST") {
@@ -258,9 +579,20 @@ export function createSlashCommandHttpHandler(params: SlashHttpHandlerParams) {
return;
}
// Validate token — fail closed: reject when no tokens are registered
// (e.g. registration failed or startup was partial)
if (commandTokens.size === 0 || !matchesRegisteredCommandToken(commandTokens, payload.token)) {
const registeredCommand = findRegisteredCommandForPayload({ registeredCommands, payload });
// Fail closed when no commands are registered, the payload doesn't map to
// a registered (team, trigger), or the payload token doesn't equal the
// resolved command's startup token. Comparing against the resolved
// command's token (rather than any token in the account) prevents a token
// valid for command A from advancing to upstream validation for command B,
// which would otherwise let an attacker poison the per-command failure
// cache and DoS legitimate invocations of command B.
if (
registeredCommands.length === 0 ||
!registeredCommand ||
!safeEqualSecret(payload.token, registeredCommand.token)
) {
sendJsonResponse(res, 401, {
response_type: "ephemeral",
text: "Unauthorized: invalid command token.",
@@ -269,18 +601,34 @@ export function createSlashCommandHttpHandler(params: SlashHttpHandlerParams) {
}
// Extract command info
const trigger = payload.command.replace(/^\//, "").trim();
const commandText = resolveCommandText(trigger, payload.text, triggerMap);
const channelId = payload.channel_id;
const senderId = payload.user_id;
const senderName = payload.user_name ?? senderId;
const client = createMattermostClient({
baseUrl: account.baseUrl ?? "",
botToken: account.botToken ?? "",
allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config),
});
const tokenIsCurrent = await validateMattermostSlashCommandToken({
accountId: account.accountId,
client,
registeredCommand,
payload,
log,
});
if (!tokenIsCurrent) {
sendJsonResponse(res, 401, {
response_type: "ephemeral",
text: "Unauthorized: invalid command token.",
});
return;
}
// Extract command info
const trigger = normalizeSlashCommandTrigger(payload.command);
const commandText = resolveCommandText(trigger, payload.text, triggerMap);
const channelId = payload.channel_id;
const senderId = payload.user_id;
const senderName = payload.user_name ?? senderId;
const auth = await authorizeSlashInvocation({
account,
cfg,
@@ -301,7 +649,9 @@ export function createSlashCommandHttpHandler(params: SlashHttpHandlerParams) {
return;
}
log?.(`mattermost: slash command /${trigger} from ${senderName} in ${channelId}`);
log?.(
`mattermost: slash command /${sanitizeMattermostLogValue(trigger)} from ${sanitizeMattermostLogValue(senderName)} in ${sanitizeMattermostLogValue(channelId)}`,
);
// Acknowledge immediately — we'll send the actual reply asynchronously
sendJsonResponse(res, 200, {
@@ -331,7 +681,7 @@ export function createSlashCommandHttpHandler(params: SlashHttpHandlerParams) {
log,
});
} catch (err) {
log?.(`mattermost: slash command handler error: ${String(err)}`);
log?.(`mattermost: slash command handler error: ${sanitizeCommandLookupError(err)}`);
try {
const to = `channel:${channelId}`;
await sendMessageMattermost(to, "Sorry, something went wrong processing that command.", {
@@ -525,7 +875,9 @@ async function handleSlashCommandAsync(params: {
runtime.log?.(`delivered slash reply to ${to}`);
},
onError: (err, info) => {
runtime.error?.(`mattermost slash ${info.kind} reply failed: ${String(err)}`);
runtime.error?.(
`mattermost slash ${info.kind} reply failed: ${sanitizeCommandLookupError(err)}`,
);
},
onReplyStart: typingCallbacks?.onReplyStart,
});

View File

@@ -1,9 +1,11 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig, RuntimeEnv } from "../runtime-api.js";
import type { ResolvedMattermostAccount } from "./accounts.js";
import type { MattermostRegisteredCommand } from "./slash-commands.js";
import {
activateSlashCommands,
deactivateSlashCommands,
resolveSlashHandlerForCommand,
resolveSlashHandlerForToken,
} from "./slash-state.js";
@@ -17,6 +19,21 @@ function createResolvedMattermostAccount(accountId: string): ResolvedMattermostA
};
}
function createRegisteredCommand(params?: {
id?: string;
teamId?: string;
trigger?: string;
}): MattermostRegisteredCommand {
return {
id: params?.id ?? "cmd-1",
teamId: params?.teamId ?? "team-1",
trigger: params?.trigger ?? "oc_status",
token: "token-1",
url: "https://gateway.example.com/slash",
managed: false,
};
}
const slashApi = {
cfg: {},
runtime: {
@@ -40,8 +57,7 @@ describe("slash-state token routing", () => {
});
const match = resolveSlashHandlerForToken("tok-a");
expect(match.kind).toBe("single");
expect(match.accountIds).toEqual(["a1"]);
expect(match).toMatchObject({ kind: "single", source: "token", accountIds: ["a1"] });
});
it("returns ambiguous when same token exists in multiple accounts", () => {
@@ -61,6 +77,55 @@ describe("slash-state token routing", () => {
const match = resolveSlashHandlerForToken("tok-shared");
expect(match.kind).toBe("ambiguous");
expect(match.accountIds?.toSorted()).toEqual(["a1", "a2"]);
if (match.kind !== "ambiguous") {
throw new Error("expected ambiguous match");
}
expect(match.source).toBe("token");
expect(match.accountIds.toSorted()).toEqual(["a1", "a2"]);
});
it("routes by registered team and command when token lookup misses", () => {
deactivateSlashCommands();
activateSlashCommands({
account: createResolvedMattermostAccount("a1"),
commandTokens: ["old-token"],
registeredCommands: [createRegisteredCommand()],
api: slashApi,
});
const match = resolveSlashHandlerForCommand({
teamId: "team-1",
command: "/oc_status",
});
expect(match).toMatchObject({ kind: "single", source: "command", accountIds: ["a1"] });
});
it("returns ambiguous when registered team and command match multiple accounts", () => {
deactivateSlashCommands();
activateSlashCommands({
account: createResolvedMattermostAccount("a1"),
commandTokens: ["tok-a"],
registeredCommands: [createRegisteredCommand({ id: "cmd-a" })],
api: slashApi,
});
activateSlashCommands({
account: createResolvedMattermostAccount("a2"),
commandTokens: ["tok-b"],
registeredCommands: [createRegisteredCommand({ id: "cmd-b" })],
api: slashApi,
});
const match = resolveSlashHandlerForCommand({
teamId: "team-1",
command: "/oc_status",
});
expect(match.kind).toBe("ambiguous");
if (match.kind !== "ambiguous") {
throw new Error("expected ambiguous match");
}
expect(match.source).toBe("command");
expect(match.accountIds.toSorted()).toEqual(["a1", "a2"]);
});
});

View File

@@ -3,7 +3,8 @@
*
* Bridges the plugin registration phase (HTTP route) with the monitor phase
* (command registration with MM API). The HTTP handler needs to know which
* tokens are valid, and the monitor needs to store registered command IDs.
* tokens are known for fast-path routing, and the monitor needs to store
* registered command IDs.
*
* State is kept per-account so that multi-account deployments don't
* overwrite each other's tokens, registered commands, or handlers.
@@ -13,14 +14,43 @@ import type { IncomingMessage, ServerResponse } from "node:http";
import { Readable } from "node:stream";
import type { MattermostConfig } from "../types.js";
import type { ResolvedMattermostAccount } from "./accounts.js";
import type { OpenClawPluginApi } from "./runtime-api.js";
import { resolveSlashCommandConfig, type MattermostRegisteredCommand } from "./slash-commands.js";
import { createSlashCommandHttpHandler } from "./slash-http.js";
import {
isRequestBodyLimitError,
readRequestBodyWithLimit,
type OpenClawPluginApi,
} from "./runtime-api.js";
import {
normalizeSlashCommandTrigger,
parseSlashCommandPayload,
resolveSlashCommandConfig,
type MattermostRegisteredCommand,
} from "./slash-commands.js";
import {
clearMattermostSlashCommandValidationCacheForAccount,
createSlashCommandHttpHandler,
} from "./slash-http.js";
const MULTI_ACCOUNT_BODY_MAX_BYTES = 64 * 1024;
const MULTI_ACCOUNT_BODY_TIMEOUT_MS = 5_000;
type SlashHandlerMatchSource = "token" | "command";
type SlashHandlerMatch =
| { kind: "none" }
| {
kind: "single";
source: SlashHandlerMatchSource;
handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
accountIds: string[];
}
| {
kind: "ambiguous";
source: SlashHandlerMatchSource;
accountIds: string[];
};
// ─── Per-account state ───────────────────────────────────────────────────────
export type SlashCommandAccountState = {
/** Tokens from registered commands, used for validation. */
/** Tokens from registered/current commands, used for fast-path routing. */
commandTokens: Set<string>;
/** Registered command IDs for cleanup on shutdown. */
registeredCommands: MattermostRegisteredCommand[];
@@ -35,11 +65,7 @@ export type SlashCommandAccountState = {
/** Map from accountId → per-account slash command state. */
const accountStates = new Map<string, SlashCommandAccountState>();
export function resolveSlashHandlerForToken(token: string): {
kind: "none" | "single" | "ambiguous";
handler?: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
accountIds?: string[];
} {
export function resolveSlashHandlerForToken(token: string): SlashHandlerMatch {
const matches: Array<{
accountId: string;
handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
@@ -55,11 +81,61 @@ export function resolveSlashHandlerForToken(token: string): {
return { kind: "none" };
}
if (matches.length === 1) {
return { kind: "single", handler: matches[0].handler, accountIds: [matches[0].accountId] };
return {
kind: "single",
source: "token",
handler: matches[0].handler,
accountIds: [matches[0].accountId],
};
}
return {
kind: "ambiguous",
source: "token",
accountIds: matches.map((entry) => entry.accountId),
};
}
export function resolveSlashHandlerForCommand(params: {
teamId: string;
command: string;
}): SlashHandlerMatch {
const trigger = normalizeSlashCommandTrigger(params.command);
if (!trigger) {
return { kind: "none" };
}
const matches: Array<{
accountId: string;
handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
}> = [];
for (const [accountId, state] of accountStates) {
if (
state.handler &&
state.registeredCommands.some(
(cmd) => cmd.teamId === params.teamId && cmd.trigger === trigger,
)
) {
matches.push({ accountId, handler: state.handler });
}
}
if (matches.length === 0) {
return { kind: "none" };
}
if (matches.length === 1) {
return {
kind: "single",
source: "command",
handler: matches[0].handler,
accountIds: [matches[0].accountId],
};
}
return {
kind: "ambiguous",
source: "command",
accountIds: matches.map((entry) => entry.accountId),
};
}
@@ -95,7 +171,7 @@ export function activateSlashCommands(params: {
account,
cfg: api.cfg,
runtime: api.runtime,
commandTokens: tokenSet,
registeredCommands,
triggerMap,
log,
});
@@ -123,14 +199,16 @@ export function deactivateSlashCommands(accountId?: string) {
state.commandTokens.clear();
state.registeredCommands = [];
state.handler = null;
clearMattermostSlashCommandValidationCacheForAccount(accountId);
accountStates.delete(accountId);
}
} else {
// Deactivate all accounts (full shutdown)
for (const [, state] of accountStates) {
for (const [stateAccountId, state] of accountStates) {
state.commandTokens.clear();
state.registeredCommands = [];
state.handler = null;
clearMattermostSlashCommandValidationCacheForAccount(stateAccountId);
}
accountStates.clear();
}
@@ -140,8 +218,10 @@ export function deactivateSlashCommands(accountId?: string) {
* Register the HTTP route for slash command callbacks.
* Called during plugin registration.
*
* The single HTTP route dispatches to the correct per-account handler
* by matching the inbound token against each account's registered tokens.
* The single HTTP route dispatches to the correct per-account handler by
* matching the inbound token against each account's known tokens, falling back
* to registered team/trigger ownership so upstream validation can accept a
* rotated Mattermost token.
*/
export function registerSlashCommandRoute(api: OpenClawPluginApi) {
const mmConfig = api.config.channels?.mattermost as MattermostConfig | undefined;
@@ -194,9 +274,9 @@ export function registerSlashCommandRoute(api: OpenClawPluginApi) {
return;
}
// We need to peek at the token to route to the right account handler.
// Since each account handler also validates the token, we find the
// account whose token set contains the inbound token and delegate.
// We need to peek at the body to route to the right account handler. Each
// account handler still performs upstream token validation before running a
// command.
// If there's only one active account (common case), route directly.
if (accountStates.size === 1) {
@@ -216,23 +296,29 @@ export function registerSlashCommandRoute(api: OpenClawPluginApi) {
return;
}
// Multi-account: buffer the body, find the matching account by token,
// then replay the request to the correct handler.
const chunks: Buffer[] = [];
const MAX_BODY = 64 * 1024;
let size = 0;
for await (const chunk of req) {
size += (chunk as Buffer).length;
if (size > MAX_BODY) {
res.statusCode = 413;
res.end("Payload Too Large");
// Multi-account: buffer the body, find the matching account by token or
// registered team/trigger, then replay the request to the correct handler.
// Use the bounded helper so a slow/never-finishing client cannot tie up the
// routing handler indefinitely (Slowloris).
let bodyStr: string;
try {
bodyStr = await readRequestBodyWithLimit(req, {
maxBytes: MULTI_ACCOUNT_BODY_MAX_BYTES,
timeoutMs: MULTI_ACCOUNT_BODY_TIMEOUT_MS,
});
} catch (error) {
if (isRequestBodyLimitError(error, "REQUEST_BODY_TIMEOUT")) {
res.statusCode = 408;
res.end("Request body timeout");
return;
}
chunks.push(chunk as Buffer);
res.statusCode = 413;
res.end("Payload Too Large");
return;
}
const bodyStr = Buffer.concat(chunks).toString("utf8");
// Parse just the token to find the right account
// Parse the token for the fast path; if it misses, parse the full slash
// payload so rotated tokens can still route by registered team/trigger.
let token: string | null = null;
const ct = req.headers["content-type"] ?? "";
try {
@@ -245,7 +331,16 @@ export function registerSlashCommandRoute(api: OpenClawPluginApi) {
// parse failed — will be caught by handler
}
const match = token ? resolveSlashHandlerForToken(token) : { kind: "none" as const };
let match: SlashHandlerMatch = token ? resolveSlashHandlerForToken(token) : { kind: "none" };
if (match.kind === "none") {
const payload = parseSlashCommandPayload(bodyStr, ct);
if (payload) {
match = resolveSlashHandlerForCommand({
teamId: payload.team_id,
command: payload.command,
});
}
}
if (match.kind === "none") {
// No matching account — reject
@@ -262,20 +357,24 @@ export function registerSlashCommandRoute(api: OpenClawPluginApi) {
if (match.kind === "ambiguous") {
api.logger.warn?.(
`mattermost: slash callback token matched multiple accounts (${match.accountIds?.join(", ")})`,
`mattermost: slash callback matched multiple accounts via ${match.source} (${match.accountIds.join(", ")})`,
);
const conflictText =
match.source === "token"
? "Conflict: command token is not unique across accounts."
: "Conflict: slash command is not unique across accounts.";
res.statusCode = 409;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(
JSON.stringify({
response_type: "ephemeral",
text: "Conflict: command token is not unique across accounts.",
text: conflictText,
}),
);
return;
}
const matchedHandler = match.handler!;
const matchedHandler = match.handler;
// Replay: create a synthetic readable that re-emits the buffered body
const syntheticReq = new Readable({