diff --git a/CHANGELOG.md b/CHANGELOG.md index fe91e11e353..8c398d321f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -180,6 +180,7 @@ Docs: https://docs.openclaw.ai - Providers/OpenRouter: gate Anthropic prompt-cache `cache_control` markers to native/default OpenRouter routes and preserve them for native OpenRouter hosts behind custom provider ids. Thanks @vincentkoc. - Browser/CDP: validate both initial and discovered CDP websocket endpoints before connect so strict SSRF policy blocks cross-host pivots and direct websocket targets. (#60469) Thanks @eleqtrizit. - Browser/profiles: reject remote browser profile `cdpUrl` values that violate strict SSRF policy before saving config, with clearer validation errors for blocked endpoints. (#60477) Thanks @eleqtrizit. +- Mattermost/slash commands: harden native slash-command callback token validation to use constant-time secret comparison, matching the existing interaction-token path. ## 2026.4.1 diff --git a/extensions/mattermost/src/mattermost/slash-http.send-config.test.ts b/extensions/mattermost/src/mattermost/slash-http.send-config.test.ts index a366572cd09..49877df3fb2 100644 --- a/extensions/mattermost/src/mattermost/slash-http.send-config.test.ts +++ b/extensions/mattermost/src/mattermost/slash-http.send-config.test.ts @@ -236,4 +236,29 @@ 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"); + }); + Object.defineProperty(commandTokens, "has", { + value: hasSpy, + configurable: true, + }); + + const handler = createSlashCommandHttpHandler({ + account: accountFixture, + cfg: {} as OpenClawConfig, + runtime: {} as RuntimeEnv, + commandTokens, + }); + const response = createResponse(); + + await handler(createRequest(), response.res); + + expect(response.res.statusCode).toBe(200); + expect(response.getBody()).toContain("Processing"); + expect(hasSpy).not.toHaveBeenCalled(); + }); }); diff --git a/extensions/mattermost/src/mattermost/slash-http.ts b/extensions/mattermost/src/mattermost/slash-http.ts index 7fccd8ea4e7..9527549c23a 100644 --- a/extensions/mattermost/src/mattermost/slash-http.ts +++ b/extensions/mattermost/src/mattermost/slash-http.ts @@ -6,6 +6,7 @@ */ import type { IncomingMessage, ServerResponse } from "node:http"; +import { safeEqualSecret } from "openclaw/plugin-sdk/browser-support"; import type { ResolvedMattermostAccount } from "../mattermost/accounts.js"; import { getMattermostRuntime } from "../runtime.js"; import { @@ -78,6 +79,18 @@ function sendJsonResponse( res.end(JSON.stringify(body)); } +function matchesRegisteredCommandToken( + commandTokens: ReadonlySet, + candidate: string, +): boolean { + for (const token of commandTokens) { + if (safeEqualSecret(candidate, token)) { + return true; + } + } + return false; +} + type SlashInvocationAuth = { ok: boolean; denyResponse?: MattermostSlashCommandResponse; @@ -242,7 +255,7 @@ export function createSlashCommandHttpHandler(params: SlashHttpHandlerParams) { // Validate token — fail closed: reject when no tokens are registered // (e.g. registration failed or startup was partial) - if (commandTokens.size === 0 || !commandTokens.has(payload.token)) { + if (commandTokens.size === 0 || !matchesRegisteredCommandToken(commandTokens, payload.token)) { sendJsonResponse(res, 401, { response_type: "ephemeral", text: "Unauthorized: invalid command token.",