Skip to content

Commit 2a65bfe

Browse files
committed
fix(mattermost): harden slash command token validation
1 parent 53d3fbc commit 2a65bfe

3 files changed

Lines changed: 40 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ Docs: https://docs.openclaw.ai
180180
- 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.
181181
- 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.
182182
- 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.
183+
- Mattermost/slash commands: harden native slash-command callback token validation to use constant-time secret comparison, matching the existing interaction-token path.
183184

184185
## 2026.4.1
185186

extensions/mattermost/src/mattermost/slash-http.send-config.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,4 +236,29 @@ describe("slash-http cfg threading", () => {
236236
}),
237237
);
238238
});
239+
240+
it("does not rely on Set.has for command token validation", async () => {
241+
const commandTokens = new Set(["valid-token"]);
242+
const hasSpy = vi.fn(() => {
243+
throw new Error("Set.has should not be used for slash token validation");
244+
});
245+
Object.defineProperty(commandTokens, "has", {
246+
value: hasSpy,
247+
configurable: true,
248+
});
249+
250+
const handler = createSlashCommandHttpHandler({
251+
account: accountFixture,
252+
cfg: {} as OpenClawConfig,
253+
runtime: {} as RuntimeEnv,
254+
commandTokens,
255+
});
256+
const response = createResponse();
257+
258+
await handler(createRequest(), response.res);
259+
260+
expect(response.res.statusCode).toBe(200);
261+
expect(response.getBody()).toContain("Processing");
262+
expect(hasSpy).not.toHaveBeenCalled();
263+
});
239264
});

extensions/mattermost/src/mattermost/slash-http.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
import type { IncomingMessage, ServerResponse } from "node:http";
9+
import { safeEqualSecret } from "openclaw/plugin-sdk/browser-support";
910
import type { ResolvedMattermostAccount } from "../mattermost/accounts.js";
1011
import { getMattermostRuntime } from "../runtime.js";
1112
import {
@@ -78,6 +79,18 @@ function sendJsonResponse(
7879
res.end(JSON.stringify(body));
7980
}
8081

82+
function matchesRegisteredCommandToken(
83+
commandTokens: ReadonlySet<string>,
84+
candidate: string,
85+
): boolean {
86+
for (const token of commandTokens) {
87+
if (safeEqualSecret(candidate, token)) {
88+
return true;
89+
}
90+
}
91+
return false;
92+
}
93+
8194
type SlashInvocationAuth = {
8295
ok: boolean;
8396
denyResponse?: MattermostSlashCommandResponse;
@@ -242,7 +255,7 @@ export function createSlashCommandHttpHandler(params: SlashHttpHandlerParams) {
242255

243256
// Validate token — fail closed: reject when no tokens are registered
244257
// (e.g. registration failed or startup was partial)
245-
if (commandTokens.size === 0 || !commandTokens.has(payload.token)) {
258+
if (commandTokens.size === 0 || !matchesRegisteredCommandToken(commandTokens, payload.token)) {
246259
sendJsonResponse(res, 401, {
247260
response_type: "ephemeral",
248261
text: "Unauthorized: invalid command token.",

0 commit comments

Comments
 (0)