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

This commit is contained in:
Echo
2026-02-18 11:05:05 -05:00
parent 7be68ecfc5
commit f8e1f1a699
3 changed files with 134 additions and 3 deletions

View File

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

View File

@@ -505,6 +505,9 @@ export function createGatewayHttpServer(opts: {
const mattermostCallbackPaths = new Set<string>();
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<string, unknown>;
mattermostCallbackPaths.add(normalizeCallbackPath(commands?.callbackPath));
addMattermostCallbackPathIfValid(normalizeCallbackPath(commands?.callbackPath));
tryAddCallbackUrlPath(commands?.callbackUrl);
};

View File

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