fix(mattermost): harden callback auth bypass and default callback port

This commit is contained in:
Echo 2026-02-18 11:05:05 -05:00 committed by Muhammed Mukhthar CM
parent 1a2fb8fc20
commit f03358edb0
3 changed files with 96 additions and 3 deletions

View File

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

View File

@ -90,6 +90,8 @@ function resolveMattermostSlashCallbackPaths(
configSnapshot: ReturnType<typeof loadConfig>,
): Set<string> {
const callbackPaths = new Set<string>([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<string, unknown>;
callbackPaths.add(normalizeCallbackPath(commands.callbackPath));
const callbackPath = normalizeCallbackPath(commands.callbackPath);
if (isMattermostCommandCallbackPath(callbackPath)) {
callbackPaths.add(callbackPath);
}
tryAddCallbackUrlPath(commands.callbackUrl);
};

View File

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