mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-30 11:26:46 +00:00
fix(mattermost): harden callback auth bypass and default callback port
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user