Mattermost: guard probe fetches (#58529)

This commit is contained in:
mappel-nv
2026-04-02 05:30:33 -04:00
committed by GitHub
parent 2c45b06afd
commit 2eaf5a695e
3 changed files with 85 additions and 35 deletions

View File

@@ -373,7 +373,12 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = 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,

View File

@@ -1,16 +1,24 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { probeMattermost } from "./probe.js";
const mockFetch = vi.fn<typeof fetch>();
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<string, unknown>;
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({

View File

@@ -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<MattermostProbe> {
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 {