mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 04:37:40 +00:00
refactor: unify channel/plugin ssrf fetch policy and auth fallback
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user