fix(mattermost): harden slash command token validation

This commit is contained in:
Peter Steinberger 2026-04-04 19:51:08 +09:00
parent 53d3fbcef6
commit 2a65bfee96
No known key found for this signature in database
3 changed files with 40 additions and 1 deletions

View File

@ -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

View File

@ -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();
});
});

View File

@ -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<string>,
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.",