refactor: unify channel/plugin ssrf fetch policy and auth fallback

This commit is contained in:
Peter Steinberger
2026-02-26 16:43:44 +01:00
parent 2e97d0dd95
commit 57334cd7d8
13 changed files with 749 additions and 595 deletions

View File

@@ -1,4 +1,4 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
import type { PluginRuntime, SsrFPolicy } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
buildMSTeamsAttachmentPlaceholder,
@@ -9,16 +9,6 @@ import {
} from "./attachments.js";
import { setMSTeamsRuntime } from "./runtime.js";
vi.mock("openclaw/plugin-sdk", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk")>();
return {
...actual,
isPrivateIpAddress: () => false,
};
});
/** Mock DNS resolver that always returns a public IP (for anti-SSRF validation in tests). */
const publicResolveFn = async () => ({ address: "13.107.136.10" });
const GRAPH_HOST = "graph.microsoft.com";
const SHAREPOINT_HOST = "contoso.sharepoint.com";
const AZUREEDGE_HOST = "azureedge.net";
@@ -50,6 +40,7 @@ type RemoteMediaFetchParams = {
url: string;
maxBytes?: number;
filePathHint?: string;
ssrfPolicy?: SsrFPolicy;
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
};
@@ -75,10 +66,44 @@ const readRemoteMediaResponse = async (
fileName: params.filePathHint,
};
};
function isHostnameAllowedByPattern(hostname: string, pattern: string): boolean {
if (pattern.startsWith("*.")) {
const suffix = pattern.slice(2);
return suffix.length > 0 && hostname !== suffix && hostname.endsWith(`.${suffix}`);
}
return hostname === pattern;
}
function isUrlAllowedBySsrfPolicy(url: string, policy?: SsrFPolicy): boolean {
if (!policy?.hostnameAllowlist || policy.hostnameAllowlist.length === 0) {
return true;
}
const hostname = new URL(url).hostname.toLowerCase();
return policy.hostnameAllowlist.some((pattern) =>
isHostnameAllowedByPattern(hostname, pattern.toLowerCase()),
);
}
const fetchRemoteMediaMock = vi.fn(async (params: RemoteMediaFetchParams) => {
const fetchFn = params.fetchImpl ?? fetch;
const res = await fetchFn(params.url);
return readRemoteMediaResponse(res, params);
let currentUrl = params.url;
for (let i = 0; i <= MAX_REDIRECT_HOPS; i += 1) {
if (!isUrlAllowedBySsrfPolicy(currentUrl, params.ssrfPolicy)) {
throw new Error(`Blocked hostname (not in allowlist): ${currentUrl}`);
}
const res = await fetchFn(currentUrl, { redirect: "manual" });
if (REDIRECT_STATUS_CODES.includes(res.status)) {
const location = res.headers.get("location");
if (!location) {
throw new Error("redirect missing location");
}
currentUrl = new URL(location, currentUrl).toString();
continue;
}
return readRemoteMediaResponse(res, params);
}
throw new Error("too many redirects");
});
const runtimeStub = {
@@ -100,16 +125,13 @@ type DownloadGraphMediaParams = Parameters<typeof downloadMSTeamsGraphMedia>[0];
type DownloadedMedia = Awaited<ReturnType<typeof downloadMSTeamsAttachments>>;
type MSTeamsMediaPayload = ReturnType<typeof buildMSTeamsMediaPayload>;
type DownloadAttachmentsBuildOverrides = Partial<
Omit<DownloadAttachmentsParams, "attachments" | "maxBytes" | "allowHosts" | "resolveFn">
Omit<DownloadAttachmentsParams, "attachments" | "maxBytes" | "allowHosts">
> &
Pick<DownloadAttachmentsParams, "allowHosts" | "resolveFn">;
Pick<DownloadAttachmentsParams, "allowHosts">;
type DownloadAttachmentsNoFetchOverrides = Partial<
Omit<
DownloadAttachmentsParams,
"attachments" | "maxBytes" | "allowHosts" | "resolveFn" | "fetchFn"
>
Omit<DownloadAttachmentsParams, "attachments" | "maxBytes" | "allowHosts" | "fetchFn">
> &
Pick<DownloadAttachmentsParams, "allowHosts" | "resolveFn">;
Pick<DownloadAttachmentsParams, "allowHosts">;
type DownloadGraphMediaOverrides = Partial<
Omit<DownloadGraphMediaParams, "messageUrl" | "tokenProvider" | "maxBytes">
>;
@@ -210,7 +232,6 @@ const buildDownloadParams = (
attachments,
maxBytes: DEFAULT_MAX_BYTES,
allowHosts: DEFAULT_ALLOW_HOSTS,
resolveFn: publicResolveFn,
...overrides,
};
};
@@ -680,13 +701,37 @@ describe("msteams attachments", () => {
fetchMock,
{
allowHosts: [GRAPH_HOST],
resolveFn: undefined,
},
{ expectFetchCalled: false },
);
expectAttachmentMediaLength(media, 0);
});
it("blocks redirects to non-https URLs", async () => {
const insecureUrl = "http://x/insecure.png";
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : input.toString();
if (url === TEST_URL_IMAGE) {
return createRedirectResponse(insecureUrl);
}
if (url === insecureUrl) {
return createBufferResponse("insecure", CONTENT_TYPE_IMAGE_PNG);
}
return createNotFoundResponse();
});
const media = await downloadAttachmentsWithFetch(
createImageAttachments(TEST_URL_IMAGE),
fetchMock,
{
allowHosts: [TEST_HOST],
},
);
expectAttachmentMediaLength(media, 0);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
describe("buildMSTeamsGraphMessageUrls", () => {
@@ -701,24 +746,6 @@ describe("msteams attachments", () => {
it("blocks SharePoint redirects to hosts outside allowHosts", async () => {
const escapedUrl = "https://evil.example/internal.pdf";
fetchRemoteMediaMock.mockImplementationOnce(async (params) => {
const fetchFn = params.fetchImpl ?? fetch;
let currentUrl = params.url;
for (let i = 0; i < MAX_REDIRECT_HOPS; i += 1) {
const res = await fetchFn(currentUrl, { redirect: "manual" });
if (REDIRECT_STATUS_CODES.includes(res.status)) {
const location = res.headers.get("location");
if (!location) {
throw new Error("redirect missing location");
}
currentUrl = new URL(location, currentUrl).toString();
continue;
}
return readRemoteMediaResponse(res, params);
}
throw new Error("too many redirects");
});
const { fetchMock, media } = await downloadGraphMediaWithMockOptions(
{
...buildDefaultShareReferenceGraphFetchOptions({

View File

@@ -1,3 +1,4 @@
import { fetchWithBearerAuthScopeFallback } from "openclaw/plugin-sdk";
import { getMSTeamsRuntime } from "../runtime.js";
import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
import {
@@ -7,10 +8,10 @@ import {
isRecord,
isUrlAllowed,
normalizeContentType,
resolveMediaSsrfPolicy,
resolveRequestUrl,
resolveAuthAllowedHosts,
resolveAllowedHosts,
safeFetch,
} from "./shared.js";
import type {
MSTeamsAccessTokenProvider,
@@ -90,81 +91,17 @@ async function fetchWithAuthFallback(params: {
tokenProvider?: MSTeamsAccessTokenProvider;
fetchFn?: typeof fetch;
requestInit?: RequestInit;
allowHosts: string[];
authAllowHosts: string[];
resolveFn?: (hostname: string) => Promise<{ address: string }>;
}): Promise<Response> {
const fetchFn = params.fetchFn ?? fetch;
// Use safeFetch for the initial attempt — redirect: "manual" with
// allowlist + DNS/IP validation on every hop (prevents SSRF via redirect).
const firstAttempt = await safeFetch({
return await fetchWithBearerAuthScopeFallback({
url: params.url,
allowHosts: params.allowHosts,
fetchFn,
scopes: scopeCandidatesForUrl(params.url),
tokenProvider: params.tokenProvider,
fetchFn: params.fetchFn,
requestInit: params.requestInit,
resolveFn: params.resolveFn,
requireHttps: true,
shouldAttachAuth: (url) => isUrlAllowed(url, params.authAllowHosts),
});
if (firstAttempt.ok) {
return firstAttempt;
}
if (!params.tokenProvider) {
return firstAttempt;
}
if (firstAttempt.status !== 401 && firstAttempt.status !== 403) {
return firstAttempt;
}
if (!isUrlAllowed(params.url, params.authAllowHosts)) {
return firstAttempt;
}
const scopes = scopeCandidatesForUrl(params.url);
for (const scope of scopes) {
try {
const token = await params.tokenProvider.getAccessToken(scope);
const authHeaders = new Headers(params.requestInit?.headers);
authHeaders.set("Authorization", `Bearer ${token}`);
const authAttempt = await safeFetch({
url: params.url,
allowHosts: params.allowHosts,
fetchFn,
requestInit: {
...params.requestInit,
headers: authHeaders,
},
resolveFn: params.resolveFn,
});
if (authAttempt.ok) {
return authAttempt;
}
if (authAttempt.status !== 401 && authAttempt.status !== 403) {
continue;
}
const finalUrl =
typeof authAttempt.url === "string" && authAttempt.url ? authAttempt.url : "";
if (!finalUrl || finalUrl === params.url || !isUrlAllowed(finalUrl, params.authAllowHosts)) {
continue;
}
const redirectedAuthAttempt = await safeFetch({
url: finalUrl,
allowHosts: params.allowHosts,
fetchFn,
requestInit: {
...params.requestInit,
headers: authHeaders,
},
resolveFn: params.resolveFn,
});
if (redirectedAuthAttempt.ok) {
return redirectedAuthAttempt;
}
} catch {
// Try the next scope.
}
}
return firstAttempt;
}
/**
@@ -180,8 +117,6 @@ export async function downloadMSTeamsAttachments(params: {
fetchFn?: typeof fetch;
/** When true, embeds original filename in stored path for later extraction. */
preserveFilenames?: boolean;
/** Override DNS resolver for testing (anti-SSRF IP validation). */
resolveFn?: (hostname: string) => Promise<{ address: string }>;
}): Promise<MSTeamsInboundMedia[]> {
const list = Array.isArray(params.attachments) ? params.attachments : [];
if (list.length === 0) {
@@ -189,6 +124,7 @@ export async function downloadMSTeamsAttachments(params: {
}
const allowHosts = resolveAllowedHosts(params.allowHosts);
const authAllowHosts = resolveAuthAllowedHosts(params.authAllowHosts);
const ssrfPolicy = resolveMediaSsrfPolicy(allowHosts);
// Download ANY downloadable attachment (not just images)
const downloadable = list.filter(isDownloadableAttachment);
@@ -257,15 +193,14 @@ export async function downloadMSTeamsAttachments(params: {
contentTypeHint: candidate.contentTypeHint,
placeholder: candidate.placeholder,
preserveFilenames: params.preserveFilenames,
ssrfPolicy,
fetchImpl: (input, init) =>
fetchWithAuthFallback({
url: resolveRequestUrl(input),
tokenProvider: params.tokenProvider,
fetchFn: params.fetchFn,
requestInit: init,
allowHosts,
authAllowHosts,
resolveFn: params.resolveFn,
}),
});
out.push(media);

View File

@@ -1,3 +1,4 @@
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk";
import { getMSTeamsRuntime } from "../runtime.js";
import { downloadMSTeamsAttachments } from "./download.js";
import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
@@ -7,9 +8,9 @@ import {
isRecord,
isUrlAllowed,
normalizeContentType,
resolveMediaSsrfPolicy,
resolveRequestUrl,
resolveAllowedHosts,
safeFetch,
} from "./shared.js";
import type {
MSTeamsAccessTokenProvider,
@@ -119,20 +120,31 @@ async function fetchGraphCollection<T>(params: {
url: string;
accessToken: string;
fetchFn?: typeof fetch;
ssrfPolicy?: SsrFPolicy;
}): Promise<{ status: number; items: T[] }> {
const fetchFn = params.fetchFn ?? fetch;
const res = await fetchFn(params.url, {
headers: { Authorization: `Bearer ${params.accessToken}` },
const { response, release } = await fetchWithSsrFGuard({
url: params.url,
fetchImpl: fetchFn,
init: {
headers: { Authorization: `Bearer ${params.accessToken}` },
},
policy: params.ssrfPolicy,
auditContext: "msteams.graph.collection",
});
const status = res.status;
if (!res.ok) {
return { status, items: [] };
}
try {
const data = (await res.json()) as { value?: T[] };
return { status, items: Array.isArray(data.value) ? data.value : [] };
} catch {
return { status, items: [] };
const status = response.status;
if (!response.ok) {
return { status, items: [] };
}
try {
const data = (await response.json()) as { value?: T[] };
return { status, items: Array.isArray(data.value) ? data.value : [] };
} catch {
return { status, items: [] };
}
} finally {
await release();
}
}
@@ -164,11 +176,13 @@ async function downloadGraphHostedContent(params: {
maxBytes: number;
fetchFn?: typeof fetch;
preserveFilenames?: boolean;
ssrfPolicy?: SsrFPolicy;
}): Promise<{ media: MSTeamsInboundMedia[]; status: number; count: number }> {
const hosted = await fetchGraphCollection<GraphHostedContent>({
url: `${params.messageUrl}/hostedContents`,
accessToken: params.accessToken,
fetchFn: params.fetchFn,
ssrfPolicy: params.ssrfPolicy,
});
if (hosted.items.length === 0) {
return { media: [], status: hosted.status, count: 0 };
@@ -228,6 +242,7 @@ export async function downloadMSTeamsGraphMedia(params: {
return { media: [] };
}
const allowHosts = resolveAllowedHosts(params.allowHosts);
const ssrfPolicy = resolveMediaSsrfPolicy(allowHosts);
const messageUrl = params.messageUrl;
let accessToken: string;
try {
@@ -241,64 +256,67 @@ export async function downloadMSTeamsGraphMedia(params: {
const sharePointMedia: MSTeamsInboundMedia[] = [];
const downloadedReferenceUrls = new Set<string>();
try {
const msgRes = await fetchFn(messageUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
const { response: msgRes, release } = await fetchWithSsrFGuard({
url: messageUrl,
fetchImpl: fetchFn,
init: {
headers: { Authorization: `Bearer ${accessToken}` },
},
policy: ssrfPolicy,
auditContext: "msteams.graph.message",
});
if (msgRes.ok) {
const msgData = (await msgRes.json()) as {
body?: { content?: string; contentType?: string };
attachments?: Array<{
id?: string;
contentUrl?: string;
contentType?: string;
name?: string;
}>;
};
try {
if (msgRes.ok) {
const msgData = (await msgRes.json()) as {
body?: { content?: string; contentType?: string };
attachments?: Array<{
id?: string;
contentUrl?: string;
contentType?: string;
name?: string;
}>;
};
// Extract SharePoint file attachments (contentType: "reference")
// Download any file type, not just images
const spAttachments = (msgData.attachments ?? []).filter(
(a) => a.contentType === "reference" && a.contentUrl && a.name,
);
for (const att of spAttachments) {
const name = att.name ?? "file";
// Extract SharePoint file attachments (contentType: "reference")
// Download any file type, not just images
const spAttachments = (msgData.attachments ?? []).filter(
(a) => a.contentType === "reference" && a.contentUrl && a.name,
);
for (const att of spAttachments) {
const name = att.name ?? "file";
try {
// SharePoint URLs need to be accessed via Graph shares API
const shareUrl = att.contentUrl!;
if (!isUrlAllowed(shareUrl, allowHosts)) {
continue;
try {
// SharePoint URLs need to be accessed via Graph shares API
const shareUrl = att.contentUrl!;
if (!isUrlAllowed(shareUrl, allowHosts)) {
continue;
}
const encodedUrl = Buffer.from(shareUrl).toString("base64url");
const sharesUrl = `${GRAPH_ROOT}/shares/u!${encodedUrl}/driveItem/content`;
const media = await downloadAndStoreMSTeamsRemoteMedia({
url: sharesUrl,
filePathHint: name,
maxBytes: params.maxBytes,
contentTypeHint: "application/octet-stream",
preserveFilenames: params.preserveFilenames,
ssrfPolicy,
fetchImpl: async (input, init) => {
const requestUrl = resolveRequestUrl(input);
const headers = new Headers(init?.headers);
headers.set("Authorization", `Bearer ${accessToken}`);
return await fetchFn(requestUrl, { ...init, headers });
},
});
sharePointMedia.push(media);
downloadedReferenceUrls.add(shareUrl);
} catch {
// Ignore SharePoint download failures.
}
const encodedUrl = Buffer.from(shareUrl).toString("base64url");
const sharesUrl = `${GRAPH_ROOT}/shares/u!${encodedUrl}/driveItem/content`;
const media = await downloadAndStoreMSTeamsRemoteMedia({
url: sharesUrl,
filePathHint: name,
maxBytes: params.maxBytes,
contentTypeHint: "application/octet-stream",
preserveFilenames: params.preserveFilenames,
fetchImpl: async (input, init) => {
const requestUrl = resolveRequestUrl(input);
const headers = new Headers(init?.headers);
headers.set("Authorization", `Bearer ${accessToken}`);
return await safeFetch({
url: requestUrl,
allowHosts,
fetchFn,
requestInit: {
...init,
headers,
},
});
},
});
sharePointMedia.push(media);
downloadedReferenceUrls.add(shareUrl);
} catch {
// Ignore SharePoint download failures.
}
}
} finally {
await release();
}
} catch {
// Ignore message fetch failures.
@@ -310,12 +328,14 @@ export async function downloadMSTeamsGraphMedia(params: {
maxBytes: params.maxBytes,
fetchFn: params.fetchFn,
preserveFilenames: params.preserveFilenames,
ssrfPolicy,
});
const attachments = await fetchGraphCollection<GraphAttachment>({
url: `${messageUrl}/attachments`,
accessToken,
fetchFn: params.fetchFn,
ssrfPolicy,
});
const normalizedAttachments = attachments.items.map(normalizeGraphAttachment);

View File

@@ -1,3 +1,4 @@
import type { SsrFPolicy } from "openclaw/plugin-sdk";
import { getMSTeamsRuntime } from "../runtime.js";
import { inferPlaceholder } from "./shared.js";
import type { MSTeamsInboundMedia } from "./types.js";
@@ -9,6 +10,7 @@ export async function downloadAndStoreMSTeamsRemoteMedia(params: {
filePathHint: string;
maxBytes: number;
fetchImpl?: FetchLike;
ssrfPolicy?: SsrFPolicy;
contentTypeHint?: string;
placeholder?: string;
preserveFilenames?: boolean;
@@ -18,6 +20,7 @@ export async function downloadAndStoreMSTeamsRemoteMedia(params: {
fetchImpl: params.fetchImpl,
filePathHint: params.filePathHint,
maxBytes: params.maxBytes,
ssrfPolicy: params.ssrfPolicy,
});
const mime = await getMSTeamsRuntime().media.detectMime({
buffer: fetched.buffer,

View File

@@ -1,281 +1,28 @@
import { describe, expect, it, vi } from "vitest";
import { isPrivateOrReservedIP, resolveAndValidateIP, safeFetch } from "./shared.js";
import { describe, expect, it } from "vitest";
import {
isUrlAllowed,
resolveAllowedHosts,
resolveAuthAllowedHosts,
resolveMediaSsrfPolicy,
} from "./shared.js";
// ─── Helpers ─────────────────────────────────────────────────────────────────
const publicResolve = async () => ({ address: "13.107.136.10" });
const privateResolve = (ip: string) => async () => ({ address: ip });
const failingResolve = async () => {
throw new Error("DNS failure");
};
function mockFetchWithRedirect(redirectMap: Record<string, string>, finalBody = "ok") {
return vi.fn(async (url: string, init?: RequestInit) => {
const target = redirectMap[url];
if (target && init?.redirect === "manual") {
return new Response(null, {
status: 302,
headers: { location: target },
});
}
return new Response(finalBody, { status: 200 });
});
}
// ─── isPrivateOrReservedIP ───────────────────────────────────────────────────
describe("isPrivateOrReservedIP", () => {
it.each([
["10.0.0.1", true],
["10.255.255.255", true],
["172.16.0.1", true],
["172.31.255.255", true],
["172.15.0.1", false],
["172.32.0.1", false],
["192.168.0.1", true],
["192.168.255.255", true],
["127.0.0.1", true],
["127.255.255.255", true],
["169.254.0.1", true],
["169.254.169.254", true],
["0.0.0.0", true],
["8.8.8.8", false],
["13.107.136.10", false],
["52.96.0.1", false],
] as const)("IPv4 %s → %s", (ip, expected) => {
expect(isPrivateOrReservedIP(ip)).toBe(expected);
describe("msteams attachment allowlists", () => {
it("normalizes wildcard host lists", () => {
expect(resolveAllowedHosts(["*", "graph.microsoft.com"])).toEqual(["*"]);
expect(resolveAuthAllowedHosts(["*", "graph.microsoft.com"])).toEqual(["*"]);
});
it.each([
["::1", true],
["::", true],
["fe80::1", true],
["fc00::1", true],
["fd12:3456::1", true],
["2001:0db8::1", false],
["2620:1ec:c11::200", false],
// IPv4-mapped IPv6 addresses
["::ffff:127.0.0.1", true],
["::ffff:10.0.0.1", true],
["::ffff:192.168.1.1", true],
["::ffff:169.254.169.254", true],
["::ffff:8.8.8.8", false],
["::ffff:13.107.136.10", false],
] as const)("IPv6 %s → %s", (ip, expected) => {
expect(isPrivateOrReservedIP(ip)).toBe(expected);
it("requires https and host suffix match", () => {
const allowHosts = resolveAllowedHosts(["sharepoint.com"]);
expect(isUrlAllowed("https://contoso.sharepoint.com/file.png", allowHosts)).toBe(true);
expect(isUrlAllowed("http://contoso.sharepoint.com/file.png", allowHosts)).toBe(false);
expect(isUrlAllowed("https://evil.example.com/file.png", allowHosts)).toBe(false);
});
it.each([
["999.999.999.999", true],
["256.0.0.1", true],
["10.0.0.256", true],
["-1.0.0.1", false],
["1.2.3.4.5", false],
["0:0:0:0:0:0:0:1", true],
] as const)("malformed/expanded %s → %s (SDK fails closed)", (ip, expected) => {
expect(isPrivateOrReservedIP(ip)).toBe(expected);
});
});
// ─── resolveAndValidateIP ────────────────────────────────────────────────────
describe("resolveAndValidateIP", () => {
it("accepts a hostname resolving to a public IP", async () => {
const ip = await resolveAndValidateIP("teams.sharepoint.com", publicResolve);
expect(ip).toBe("13.107.136.10");
});
it("rejects a hostname resolving to 10.x.x.x", async () => {
await expect(resolveAndValidateIP("evil.test", privateResolve("10.0.0.1"))).rejects.toThrow(
"private/reserved IP",
);
});
it("rejects a hostname resolving to 169.254.169.254", async () => {
await expect(
resolveAndValidateIP("evil.test", privateResolve("169.254.169.254")),
).rejects.toThrow("private/reserved IP");
});
it("rejects a hostname resolving to loopback", async () => {
await expect(resolveAndValidateIP("evil.test", privateResolve("127.0.0.1"))).rejects.toThrow(
"private/reserved IP",
);
});
it("rejects a hostname resolving to IPv6 loopback", async () => {
await expect(resolveAndValidateIP("evil.test", privateResolve("::1"))).rejects.toThrow(
"private/reserved IP",
);
});
it("throws on DNS resolution failure", async () => {
await expect(resolveAndValidateIP("nonexistent.test", failingResolve)).rejects.toThrow(
"DNS resolution failed",
);
});
});
// ─── safeFetch ───────────────────────────────────────────────────────────────
describe("safeFetch", () => {
it("fetches a URL directly when no redirect occurs", async () => {
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => {
return new Response("ok", { status: 200 });
});
const res = await safeFetch({
url: "https://teams.sharepoint.com/file.pdf",
allowHosts: ["sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolve,
});
expect(res.status).toBe(200);
expect(fetchMock).toHaveBeenCalledOnce();
// Should have used redirect: "manual"
expect(fetchMock.mock.calls[0][1]).toHaveProperty("redirect", "manual");
});
it("follows a redirect to an allowlisted host with public IP", async () => {
const fetchMock = mockFetchWithRedirect({
"https://teams.sharepoint.com/file.pdf": "https://cdn.sharepoint.com/storage/file.pdf",
});
const res = await safeFetch({
url: "https://teams.sharepoint.com/file.pdf",
allowHosts: ["sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolve,
});
expect(res.status).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("blocks a redirect to a non-allowlisted host", async () => {
const fetchMock = mockFetchWithRedirect({
"https://teams.sharepoint.com/file.pdf": "https://evil.example.com/steal",
});
await expect(
safeFetch({
url: "https://teams.sharepoint.com/file.pdf",
allowHosts: ["sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolve,
}),
).rejects.toThrow("blocked by allowlist");
// Should not have fetched the evil URL
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("blocks a redirect to an allowlisted host that resolves to a private IP (DNS rebinding)", async () => {
let callCount = 0;
const rebindingResolve = async () => {
callCount++;
// First call (initial URL) resolves to public IP
if (callCount === 1) return { address: "13.107.136.10" };
// Second call (redirect target) resolves to private IP
return { address: "169.254.169.254" };
};
const fetchMock = mockFetchWithRedirect({
"https://teams.sharepoint.com/file.pdf": "https://evil.trafficmanager.net/metadata",
});
await expect(
safeFetch({
url: "https://teams.sharepoint.com/file.pdf",
allowHosts: ["sharepoint.com", "trafficmanager.net"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: rebindingResolve,
}),
).rejects.toThrow("private/reserved IP");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("blocks when the initial URL resolves to a private IP", async () => {
const fetchMock = vi.fn();
await expect(
safeFetch({
url: "https://evil.sharepoint.com/file.pdf",
allowHosts: ["sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: privateResolve("10.0.0.1"),
}),
).rejects.toThrow("Initial download URL blocked");
expect(fetchMock).not.toHaveBeenCalled();
});
it("blocks when initial URL DNS resolution fails", async () => {
const fetchMock = vi.fn();
await expect(
safeFetch({
url: "https://nonexistent.sharepoint.com/file.pdf",
allowHosts: ["sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: failingResolve,
}),
).rejects.toThrow("Initial download URL blocked");
expect(fetchMock).not.toHaveBeenCalled();
});
it("follows multiple redirects when all are valid", async () => {
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
if (url === "https://a.sharepoint.com/1" && init?.redirect === "manual") {
return new Response(null, {
status: 302,
headers: { location: "https://b.sharepoint.com/2" },
});
}
if (url === "https://b.sharepoint.com/2" && init?.redirect === "manual") {
return new Response(null, {
status: 302,
headers: { location: "https://c.sharepoint.com/3" },
});
}
return new Response("final", { status: 200 });
});
const res = await safeFetch({
url: "https://a.sharepoint.com/1",
allowHosts: ["sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolve,
});
expect(res.status).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it("throws on too many redirects", async () => {
let counter = 0;
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
if (init?.redirect === "manual") {
counter++;
return new Response(null, {
status: 302,
headers: { location: `https://loop${counter}.sharepoint.com/x` },
});
}
return new Response("ok", { status: 200 });
});
await expect(
safeFetch({
url: "https://start.sharepoint.com/x",
allowHosts: ["sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolve,
}),
).rejects.toThrow("Too many redirects");
});
it("blocks redirect to HTTP (non-HTTPS)", async () => {
const fetchMock = mockFetchWithRedirect({
"https://teams.sharepoint.com/file": "http://internal.sharepoint.com/file",
});
await expect(
safeFetch({
url: "https://teams.sharepoint.com/file",
allowHosts: ["sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolve,
}),
).rejects.toThrow("blocked by allowlist");
it("builds shared SSRF policy from suffix allowlist", () => {
expect(resolveMediaSsrfPolicy(["sharepoint.com"])).toEqual({
hostnameAllowlist: ["sharepoint.com", "*.sharepoint.com"],
});
expect(resolveMediaSsrfPolicy(["*"])).toBeUndefined();
});
});

View File

@@ -1,5 +1,9 @@
import { lookup } from "node:dns/promises";
import { isPrivateIpAddress } from "openclaw/plugin-sdk";
import {
buildHostnameAllowlistPolicyFromSuffixAllowlist,
isHttpsUrlAllowedByHostnameSuffixAllowlist,
normalizeHostnameSuffixAllowlist,
} from "openclaw/plugin-sdk";
import type { SsrFPolicy } from "openclaw/plugin-sdk";
import type { MSTeamsAttachmentLike } from "./types.js";
type InlineImageCandidate =
@@ -252,153 +256,18 @@ export function safeHostForUrl(url: string): string {
}
}
function normalizeAllowHost(value: string): string {
const trimmed = value.trim().toLowerCase();
if (!trimmed) {
return "";
}
if (trimmed === "*") {
return "*";
}
return trimmed.replace(/^\*\.?/, "");
}
export function resolveAllowedHosts(input?: string[]): string[] {
if (!Array.isArray(input) || input.length === 0) {
return DEFAULT_MEDIA_HOST_ALLOWLIST.slice();
}
const normalized = input.map(normalizeAllowHost).filter(Boolean);
if (normalized.includes("*")) {
return ["*"];
}
return normalized;
return normalizeHostnameSuffixAllowlist(input, DEFAULT_MEDIA_HOST_ALLOWLIST);
}
export function resolveAuthAllowedHosts(input?: string[]): string[] {
if (!Array.isArray(input) || input.length === 0) {
return DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST.slice();
}
const normalized = input.map(normalizeAllowHost).filter(Boolean);
if (normalized.includes("*")) {
return ["*"];
}
return normalized;
}
function isHostAllowed(host: string, allowlist: string[]): boolean {
if (allowlist.includes("*")) {
return true;
}
const normalized = host.toLowerCase();
return allowlist.some((entry) => normalized === entry || normalized.endsWith(`.${entry}`));
return normalizeHostnameSuffixAllowlist(input, DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST);
}
export function isUrlAllowed(url: string, allowlist: string[]): boolean {
try {
const parsed = new URL(url);
if (parsed.protocol !== "https:") {
return false;
}
return isHostAllowed(parsed.hostname, allowlist);
} catch {
return false;
}
return isHttpsUrlAllowedByHostnameSuffixAllowlist(url, allowlist);
}
/**
* Returns true if the given IPv4 or IPv6 address is in a private, loopback,
* or link-local range that must never be reached from media downloads.
*
* Delegates to the SDK's `isPrivateIpAddress` which handles IPv4-mapped IPv6,
* expanded notation, NAT64, 6to4, Teredo, octal IPv4, and fails closed on
* parse errors.
*/
export const isPrivateOrReservedIP: (ip: string) => boolean = isPrivateIpAddress;
/**
* Resolve a hostname via DNS and reject private/reserved IPs.
* Throws if the resolved IP is private or resolution fails.
*/
export async function resolveAndValidateIP(
hostname: string,
resolveFn?: (hostname: string) => Promise<{ address: string }>,
): Promise<string> {
const resolve = resolveFn ?? lookup;
let resolved: { address: string };
try {
resolved = await resolve(hostname);
} catch {
throw new Error(`DNS resolution failed for "${hostname}"`);
}
if (isPrivateOrReservedIP(resolved.address)) {
throw new Error(`Hostname "${hostname}" resolves to private/reserved IP (${resolved.address})`);
}
return resolved.address;
}
/** Maximum number of redirects to follow in safeFetch. */
const MAX_SAFE_REDIRECTS = 5;
/**
* Fetch a URL with redirect: "manual", validating each redirect target
* against the hostname allowlist and DNS-resolved IP (anti-SSRF).
*
* This prevents:
* - Auto-following redirects to non-allowlisted hosts
* - DNS rebinding attacks where an allowlisted domain resolves to a private IP
*/
export async function safeFetch(params: {
url: string;
allowHosts: string[];
fetchFn?: typeof fetch;
requestInit?: RequestInit;
resolveFn?: (hostname: string) => Promise<{ address: string }>;
}): Promise<Response> {
const fetchFn = params.fetchFn ?? fetch;
const resolveFn = params.resolveFn;
let currentUrl = params.url;
// Validate the initial URL's resolved IP
try {
const initialHost = new URL(currentUrl).hostname;
await resolveAndValidateIP(initialHost, resolveFn);
} catch {
throw new Error(`Initial download URL blocked: ${currentUrl}`);
}
for (let i = 0; i <= MAX_SAFE_REDIRECTS; i++) {
const res = await fetchFn(currentUrl, {
...params.requestInit,
redirect: "manual",
});
if (![301, 302, 303, 307, 308].includes(res.status)) {
return res;
}
const location = res.headers.get("location");
if (!location) {
return res;
}
let redirectUrl: string;
try {
redirectUrl = new URL(location, currentUrl).toString();
} catch {
throw new Error(`Invalid redirect URL: ${location}`);
}
// Validate redirect target against hostname allowlist
if (!isUrlAllowed(redirectUrl, params.allowHosts)) {
throw new Error(`Media redirect target blocked by allowlist: ${redirectUrl}`);
}
// Validate redirect target's resolved IP
const redirectHost = new URL(redirectUrl).hostname;
await resolveAndValidateIP(redirectHost, resolveFn);
currentUrl = redirectUrl;
}
throw new Error(`Too many redirects (>${MAX_SAFE_REDIRECTS})`);
export function resolveMediaSsrfPolicy(allowHosts: string[]): SsrFPolicy | undefined {
return buildHostnameAllowlistPolicyFromSuffixAllowlist(allowHosts);
}