diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index f8099b5bfb4..e3de4dfe5d1 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -254,7 +254,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 b9f58f60d8c..4c71fb54e58 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -505,6 +505,9 @@ export function createGatewayHttpServer(opts: { const mattermostCallbackPaths = new Set(); const defaultMmCallbackPath = "/api/channels/mattermost/command"; + const isMattermostCommandCallbackPath = (path: string): boolean => + path === defaultMmCallbackPath || path.startsWith("/api/channels/mattermost/"); + const normalizeCallbackPath = (value: unknown): string => { const trimmed = typeof value === "string" ? value.trim() : ""; if (!trimmed) { @@ -513,6 +516,12 @@ export function createGatewayHttpServer(opts: { return trimmed.startsWith("/") ? trimmed : `/${trimmed}`; }; + const addMattermostCallbackPathIfValid = (path: string) => { + if (isMattermostCommandCallbackPath(path)) { + mattermostCallbackPaths.add(path); + } + }; + const tryAddCallbackUrlPath = (rawUrl: unknown) => { if (typeof rawUrl !== "string") { return; @@ -524,7 +533,7 @@ export function createGatewayHttpServer(opts: { try { const pathname = new URL(trimmed).pathname; if (pathname) { - mattermostCallbackPaths.add(pathname); + addMattermostCallbackPathIfValid(pathname); } } catch { // ignore @@ -537,7 +546,7 @@ export function createGatewayHttpServer(opts: { return; } const commands = raw as Record; - mattermostCallbackPaths.add(normalizeCallbackPath(commands?.callbackPath)); + addMattermostCallbackPathIfValid(normalizeCallbackPath(commands?.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 1a5ec95176b..898df4babee 100644 --- a/src/gateway/server.plugin-http-auth.test.ts +++ b/src/gateway/server.plugin-http-auth.test.ts @@ -142,4 +142,126 @@ describe("gateway plugin HTTP auth boundary", () => { }, }); }); + + test("allows unauthenticated Mattermost slash callback routes while keeping other channel routes protected", async () => { + const resolvedAuth: ResolvedGatewayAuth = { + mode: "token", + token: "test-token", + password: undefined, + allowTailscale: false, + }; + + await withTempConfig({ + cfg: { + gateway: { trustedProxies: [] }, + channels: { + mattermost: { + commands: { callbackPath: "/api/channels/mattermost/command" }, + }, + }, + }, + prefix: "openclaw-plugin-http-auth-mm-callback-", + run: 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; + }); + + const server = createGatewayHttpServer({ + canvasHost: null, + clients: new Set(), + controlUiEnabled: false, + controlUiBasePath: "/__control__", + openAiChatCompletionsEnabled: false, + openResponsesEnabled: false, + handleHooksRequest: async () => false, + handlePluginRequest, + resolvedAuth, + }); + + const slashCallback = createResponse(); + await dispatchRequest( + server, + createRequest({ path: "/api/channels/mattermost/command", method: "POST" }), + slashCallback.res, + ); + expect(slashCallback.res.statusCode).toBe(200); + expect(slashCallback.getBody()).toBe("ok:mm-callback"); + + const otherChannelUnauthed = createResponse(); + await dispatchRequest( + server, + createRequest({ path: "/api/channels/nostr/default/profile" }), + otherChannelUnauthed.res, + ); + 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 resolvedAuth: ResolvedGatewayAuth = { + mode: "token", + token: "test-token", + password: undefined, + allowTailscale: 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 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; + }); + + const server = createGatewayHttpServer({ + canvasHost: null, + clients: new Set(), + controlUiEnabled: false, + controlUiBasePath: "/__control__", + openAiChatCompletionsEnabled: false, + openResponsesEnabled: false, + handleHooksRequest: async () => false, + handlePluginRequest, + resolvedAuth, + }); + + const unauthenticated = createResponse(); + await dispatchRequest( + server, + createRequest({ path: "/api/channels/nostr/default/profile", method: "POST" }), + unauthenticated.res, + ); + + expect(unauthenticated.res.statusCode).toBe(401); + expect(unauthenticated.getBody()).toContain("Unauthorized"); + expect(handlePluginRequest).not.toHaveBeenCalled(); + }, + }); + }); });