From f03358edb0976dbaa52807762f2789d6ab4adee0 Mon Sep 17 00:00:00 2001 From: Echo Date: Wed, 18 Feb 2026 11:05:05 -0500 Subject: [PATCH] fix(mattermost): harden callback auth bypass and default callback port --- .../mattermost/src/mattermost/monitor.ts | 2 +- src/gateway/server-http.ts | 9 +- src/gateway/server.plugin-http-auth.test.ts | 88 +++++++++++++++++++ 3 files changed, 96 insertions(+), 3 deletions(-) diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index dc5973c75fe..17082d912aa 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -236,7 +236,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} const envPortRaw = process.env.OPENCLAW_GATEWAY_PORT?.trim(); const envPort = envPortRaw ? Number.parseInt(envPortRaw, 10) : NaN; const gatewayPort = - Number.isFinite(envPort) && envPort > 0 ? envPort : (cfg.gateway?.port ?? 3015); + Number.isFinite(envPort) && envPort > 0 ? envPort : (cfg.gateway?.port ?? 18789); const callbackUrl = resolveCallbackUrl({ config: slashConfig, diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 45056ed8e5a..fa3383b41c4 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -90,6 +90,8 @@ function resolveMattermostSlashCallbackPaths( configSnapshot: ReturnType, ): Set { const callbackPaths = new Set([MATTERMOST_SLASH_CALLBACK_PATH]); + const isMattermostCommandCallbackPath = (path: string): boolean => + path === MATTERMOST_SLASH_CALLBACK_PATH || path.startsWith("/api/channels/mattermost/"); const normalizeCallbackPath = (value: unknown): string => { const trimmed = typeof value === "string" ? value.trim() : ""; @@ -109,7 +111,7 @@ function resolveMattermostSlashCallbackPaths( } try { const pathname = new URL(trimmed).pathname; - if (pathname) { + if (pathname && isMattermostCommandCallbackPath(pathname)) { callbackPaths.add(pathname); } } catch { @@ -123,7 +125,10 @@ function resolveMattermostSlashCallbackPaths( return; } const commands = raw as Record; - callbackPaths.add(normalizeCallbackPath(commands.callbackPath)); + const callbackPath = normalizeCallbackPath(commands.callbackPath); + if (isMattermostCommandCallbackPath(callbackPath)) { + callbackPaths.add(callbackPath); + } tryAddCallbackUrlPath(commands.callbackUrl); }; diff --git a/src/gateway/server.plugin-http-auth.test.ts b/src/gateway/server.plugin-http-auth.test.ts index 46fdcacc57f..3c5afceaa35 100644 --- a/src/gateway/server.plugin-http-auth.test.ts +++ b/src/gateway/server.plugin-http-auth.test.ts @@ -17,6 +17,7 @@ import { withGatewayServer, withGatewayTempConfig, } from "./server-http.test-harness.js"; +import { withTempConfig } from "./test-temp-config.js"; type PluginRequestHandler = (req: IncomingMessage, res: ServerResponse) => Promise; @@ -216,6 +217,93 @@ describe("gateway plugin HTTP auth boundary", () => { }); }); + test("allows unauthenticated Mattermost slash callback routes while keeping other channel routes protected", async () => { + const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { + const pathname = new URL(req.url ?? "/", "http://localhost").pathname; + if (pathname === "/api/channels/mattermost/command") { + res.statusCode = 200; + res.end("ok:mm-callback"); + return true; + } + if (pathname === "/api/channels/nostr/default/profile") { + res.statusCode = 200; + res.end("ok:nostr"); + return true; + } + return false; + }); + + await withTempConfig({ + cfg: { + gateway: { trustedProxies: [] }, + channels: { + mattermost: { + commands: { callbackPath: "/api/channels/mattermost/command" }, + }, + }, + }, + prefix: "openclaw-plugin-http-auth-mm-callback-", + run: async () => { + const server = createTestGatewayServer({ + resolvedAuth: AUTH_TOKEN, + overrides: { handlePluginRequest }, + }); + + const slashCallback = await sendRequest(server, { + path: "/api/channels/mattermost/command", + method: "POST", + }); + expect(slashCallback.res.statusCode).toBe(200); + expect(slashCallback.getBody()).toBe("ok:mm-callback"); + + const otherChannelUnauthed = await sendRequest(server, { + path: "/api/channels/nostr/default/profile", + }); + expect(otherChannelUnauthed.res.statusCode).toBe(401); + expect(otherChannelUnauthed.getBody()).toContain("Unauthorized"); + }, + }); + }); + + test("does not bypass auth when mattermost callbackPath points to non-mattermost channel routes", async () => { + const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { + const pathname = new URL(req.url ?? "/", "http://localhost").pathname; + if (pathname === "/api/channels/nostr/default/profile") { + res.statusCode = 200; + res.end("ok:nostr"); + return true; + } + return false; + }); + + await withTempConfig({ + cfg: { + gateway: { trustedProxies: [] }, + channels: { + mattermost: { + commands: { callbackPath: "/api/channels/nostr/default/profile" }, + }, + }, + }, + prefix: "openclaw-plugin-http-auth-mm-misconfig-", + run: async () => { + const server = createTestGatewayServer({ + resolvedAuth: AUTH_TOKEN, + overrides: { handlePluginRequest }, + }); + + const unauthenticated = await sendRequest(server, { + path: "/api/channels/nostr/default/profile", + method: "POST", + }); + + expect(unauthenticated.res.statusCode).toBe(401); + expect(unauthenticated.getBody()).toContain("Unauthorized"); + expect(handlePluginRequest).not.toHaveBeenCalled(); + }, + }); + }); + test("keeps wildcard plugin handlers ungated when auth enforcement predicate excludes their paths", async () => { const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { const pathname = new URL(req.url ?? "/", "http://localhost").pathname;