diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 864dc26a4ae..6f3467f68a2 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -373,7 +373,12 @@ export const mattermostPlugin: ChannelPlugin = create if (!token || !baseUrl) { return { ok: false, error: "bot token or baseUrl missing" }; } - return await probeMattermost(baseUrl, token, timeoutMs); + return await probeMattermost( + baseUrl, + token, + timeoutMs, + account.config.allowPrivateNetwork === true, + ); }, resolveAccountSnapshot: ({ account, runtime }) => ({ accountId: account.accountId, diff --git a/extensions/mattermost/src/mattermost/probe.test.ts b/extensions/mattermost/src/mattermost/probe.test.ts index 887ac576a85..fc940b529d6 100644 --- a/extensions/mattermost/src/mattermost/probe.test.ts +++ b/extensions/mattermost/src/mattermost/probe.test.ts @@ -1,16 +1,24 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { probeMattermost } from "./probe.js"; -const mockFetch = vi.fn(); +const { mockFetchGuard, mockRelease } = vi.hoisted(() => ({ + mockFetchGuard: vi.fn(), + mockRelease: vi.fn(async () => {}), +})); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => { + const original = (await importOriginal()) as Record; + return { ...original, fetchWithSsrFGuard: mockFetchGuard }; +}); describe("probeMattermost", () => { beforeEach(() => { - vi.stubGlobal("fetch", mockFetch); - mockFetch.mockReset(); + mockFetchGuard.mockReset(); + mockRelease.mockClear(); }); afterEach(() => { - vi.unstubAllGlobals(); + vi.restoreAllMocks(); }); it("returns baseUrl missing for empty base URL", async () => { @@ -18,25 +26,28 @@ describe("probeMattermost", () => { ok: false, error: "baseUrl missing", }); - expect(mockFetch).not.toHaveBeenCalled(); + expect(mockFetchGuard).not.toHaveBeenCalled(); }); it("normalizes base URL and returns bot info", async () => { - mockFetch.mockResolvedValueOnce( - new Response(JSON.stringify({ id: "bot-1", username: "clawbot" }), { + mockFetchGuard.mockResolvedValueOnce({ + response: new Response(JSON.stringify({ id: "bot-1", username: "clawbot" }), { status: 200, headers: { "content-type": "application/json" }, }), - ); + release: mockRelease, + }); const result = await probeMattermost("https://mm.example.com/api/v4/", "bot-token"); - expect(mockFetch).toHaveBeenCalledWith( - "https://mm.example.com/api/v4/users/me", - expect.objectContaining({ + expect(mockFetchGuard).toHaveBeenCalledWith({ + url: "https://mm.example.com/api/v4/users/me", + init: expect.objectContaining({ headers: { Authorization: "Bearer bot-token" }, }), - ); + auditContext: "mattermost-probe", + policy: undefined, + }); expect(result).toEqual( expect.objectContaining({ ok: true, @@ -45,16 +56,36 @@ describe("probeMattermost", () => { }), ); expect(result.elapsedMs).toBeGreaterThanOrEqual(0); + expect(mockRelease).toHaveBeenCalledTimes(1); + }); + + it("forwards allowPrivateNetwork to the SSRF guard policy", async () => { + mockFetchGuard.mockResolvedValueOnce({ + response: new Response(JSON.stringify({ id: "bot-1" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + release: mockRelease, + }); + + await probeMattermost("https://mm.example.com", "bot-token", 2500, true); + + expect(mockFetchGuard).toHaveBeenCalledWith( + expect.objectContaining({ + policy: { allowPrivateNetwork: true }, + }), + ); }); it("returns API error details from JSON response", async () => { - mockFetch.mockResolvedValueOnce( - new Response(JSON.stringify({ message: "invalid auth token" }), { + mockFetchGuard.mockResolvedValueOnce({ + response: new Response(JSON.stringify({ message: "invalid auth token" }), { status: 401, statusText: "Unauthorized", headers: { "content-type": "application/json" }, }), - ); + release: mockRelease, + }); await expect(probeMattermost("https://mm.example.com", "bad-token")).resolves.toEqual( expect.objectContaining({ @@ -63,16 +94,18 @@ describe("probeMattermost", () => { error: "invalid auth token", }), ); + expect(mockRelease).toHaveBeenCalledTimes(1); }); it("falls back to statusText when error body is empty", async () => { - mockFetch.mockResolvedValueOnce( - new Response("", { + mockFetchGuard.mockResolvedValueOnce({ + response: new Response("", { status: 403, statusText: "Forbidden", headers: { "content-type": "text/plain" }, }), - ); + release: mockRelease, + }); await expect(probeMattermost("https://mm.example.com", "token")).resolves.toEqual( expect.objectContaining({ @@ -81,10 +114,11 @@ describe("probeMattermost", () => { error: "Forbidden", }), ); + expect(mockRelease).toHaveBeenCalledTimes(1); }); it("returns fetch error when request throws", async () => { - mockFetch.mockRejectedValueOnce(new Error("network down")); + mockFetchGuard.mockRejectedValueOnce(new Error("network down")); await expect(probeMattermost("https://mm.example.com", "token")).resolves.toEqual( expect.objectContaining({ diff --git a/extensions/mattermost/src/mattermost/probe.ts b/extensions/mattermost/src/mattermost/probe.ts index 139f1571f9e..c68b18bdd76 100644 --- a/extensions/mattermost/src/mattermost/probe.ts +++ b/extensions/mattermost/src/mattermost/probe.ts @@ -1,3 +1,4 @@ +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import { normalizeMattermostBaseUrl, readMattermostError, type MattermostUser } from "./client.js"; import type { BaseProbeResult } from "./runtime-api.js"; @@ -11,6 +12,7 @@ export async function probeMattermost( baseUrl: string, botToken: string, timeoutMs = 2500, + allowPrivateNetwork = false, ): Promise { const normalized = normalizeMattermostBaseUrl(baseUrl); if (!normalized) { @@ -24,27 +26,36 @@ export async function probeMattermost( timer = setTimeout(() => controller.abort(), timeoutMs); } try { - const res = await fetch(url, { - headers: { Authorization: `Bearer ${botToken}` }, - signal: controller?.signal, + const { response: res, release } = await fetchWithSsrFGuard({ + url, + init: { + headers: { Authorization: `Bearer ${botToken}` }, + signal: controller?.signal, + }, + auditContext: "mattermost-probe", + policy: allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined, }); - const elapsedMs = Date.now() - start; - if (!res.ok) { - const detail = await readMattermostError(res); + try { + const elapsedMs = Date.now() - start; + if (!res.ok) { + const detail = await readMattermostError(res); + return { + ok: false, + status: res.status, + error: detail || res.statusText, + elapsedMs, + }; + } + const bot = (await res.json()) as MattermostUser; return { - ok: false, + ok: true, status: res.status, - error: detail || res.statusText, elapsedMs, + bot, }; + } finally { + await release(); } - const bot = (await res.json()) as MattermostUser; - return { - ok: true, - status: res.status, - elapsedMs, - bot, - }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return {