mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-03 05:12:15 +00:00
Mattermost: guard probe fetches (#58529)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user