Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,609 @@
// Msteams tests cover bot framework plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { setMSTeamsRuntime } from "../runtime.js";
import {
downloadMSTeamsBotFrameworkAttachment,
downloadMSTeamsBotFrameworkAttachments,
isBotFrameworkPersonalChatId,
} from "./bot-framework.js";
import type { MSTeamsAccessTokenProvider } from "./types.js";
type SavedCall = {
buffer: Buffer;
contentType?: string;
direction: string;
maxBytes: number;
originalFilename?: string;
};
type MockRuntime = {
saveCalls: SavedCall[];
savePath: string;
savedContentType: string;
};
function installRuntime(): MockRuntime {
const state: MockRuntime = {
saveCalls: [],
savePath: "/tmp/bf-attachment.bin",
savedContentType: "application/pdf",
};
setMSTeamsRuntime({
media: {
detectMime: async ({ headerMime }: { headerMime?: string }) =>
headerMime ?? "application/pdf",
},
channel: {
media: {
saveMediaBuffer: async (
buffer: Buffer,
contentType: string | undefined,
direction: string,
maxBytes: number,
originalFilename?: string,
) => {
state.saveCalls.push({
buffer,
contentType,
direction,
maxBytes,
originalFilename,
});
return { path: state.savePath, contentType: state.savedContentType };
},
readRemoteMediaBuffer: async () => ({ buffer: Buffer.alloc(0), contentType: undefined }),
saveRemoteMedia: async () => ({
path: state.savePath,
contentType: state.savedContentType,
}),
saveResponseMedia: async (
response: Response,
options: {
fallbackContentType?: string;
subdir?: string;
maxBytes?: number;
originalFilename?: string;
},
) => {
const buffer = Buffer.from(await response.arrayBuffer());
state.saveCalls.push({
buffer,
contentType: options.fallbackContentType,
direction: options.subdir ?? "inbound",
maxBytes: options.maxBytes ?? 0,
originalFilename: options.originalFilename,
});
return { path: state.savePath, contentType: state.savedContentType };
},
},
},
} as unknown as Parameters<typeof setMSTeamsRuntime>[0]);
return state;
}
function createMockFetch(entries: Array<{ match: RegExp; response: Response }>): typeof fetch {
return vi.fn(async (input: RequestInfo | URL) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const entry = entries.find((e) => e.match.test(url));
if (!entry) {
return new Response("not found", { status: 404 });
}
return entry.response.clone();
}) as typeof fetch;
}
function buildTokenProvider(): MSTeamsAccessTokenProvider {
return {
getAccessToken: vi.fn(async (scope: string) => {
if (scope.includes("botframework.com")) {
return "bf-token";
}
return "graph-token";
}),
};
}
function firstMockCall(mock: ReturnType<typeof vi.fn>, label: string): unknown[] {
const [call] = mock.mock.calls;
if (!call) {
throw new Error(`expected ${label} call`);
}
return call;
}
async function resolvePublicHost(): Promise<{ address: string }> {
return { address: "93.184.216.34" };
}
describe("isBotFrameworkPersonalChatId", () => {
it("detects a: prefix personal chat IDs", () => {
expect(isBotFrameworkPersonalChatId("a:1dRsHCobZ1AxURzY05Dc")).toBe(true);
});
it("detects 8:orgid: prefix chat IDs", () => {
expect(isBotFrameworkPersonalChatId("8:orgid:12345678-1234-1234-1234-123456789abc")).toBe(true);
});
it("returns false for Graph-compatible 19: thread IDs", () => {
expect(isBotFrameworkPersonalChatId("19:abc@thread.tacv2")).toBe(false);
});
it("returns false for synthetic DM Graph IDs", () => {
expect(isBotFrameworkPersonalChatId("19:aad-user-id_bot-app-id@unq.gbl.spaces")).toBe(false);
});
it("returns false for null/undefined/empty", () => {
expect(isBotFrameworkPersonalChatId(null)).toBe(false);
expect(isBotFrameworkPersonalChatId(undefined)).toBe(false);
expect(isBotFrameworkPersonalChatId("")).toBe(false);
});
});
describe("downloadMSTeamsBotFrameworkAttachment", () => {
let runtime: MockRuntime;
beforeEach(() => {
runtime = installRuntime();
});
it("fetches attachment info then view and saves media", async () => {
const info = {
name: "report.pdf",
type: "application/pdf",
views: [{ viewId: "original", size: 1024 }],
};
const fileBytes = Buffer.from("PDFBYTES", "utf-8");
const fetchFn = createMockFetch([
{
match: /\/v3\/attachments\/att-1$/,
response: new Response(JSON.stringify(info), {
status: 200,
headers: { "content-type": "application/json" },
}),
},
{
match: /\/v3\/attachments\/att-1\/views\/original$/,
response: new Response(fileBytes, {
status: 200,
headers: { "content-length": String(fileBytes.byteLength) },
}),
},
]);
const media = await downloadMSTeamsBotFrameworkAttachment({
serviceUrl: "https://smba.trafficmanager.net/amer/",
attachmentId: "att-1",
tokenProvider: buildTokenProvider(),
maxBytes: 10_000_000,
fetchFn,
fetchFnSupportsDispatcher: true,
resolveFn: resolvePublicHost,
});
expect(media?.path).toBe(runtime.savePath);
expect(media?.contentType).toBe(runtime.savedContentType);
expect(runtime.saveCalls).toHaveLength(1);
expect(runtime.saveCalls[0].buffer.toString("utf-8")).toBe("PDFBYTES");
});
it("skips malformed attachment view content-length before saving media", async () => {
const info = {
name: "report.pdf",
type: "application/pdf",
views: [{ viewId: "original", size: 3 }],
};
const warn = vi.fn();
const fetchFn = createMockFetch([
{
match: /\/v3\/attachments\/att-1$/,
response: new Response(JSON.stringify(info), {
status: 200,
headers: { "content-type": "application/json" },
}),
},
{
match: /\/v3\/attachments\/att-1\/views\/original$/,
response: new Response("PDFBYTES", {
status: 200,
headers: { "content-length": "0x3" },
}),
},
]);
const media = await downloadMSTeamsBotFrameworkAttachment({
serviceUrl: "https://smba.trafficmanager.net/amer/",
attachmentId: "att-1",
tokenProvider: buildTokenProvider(),
maxBytes: 10_000_000,
fetchFn,
fetchFnSupportsDispatcher: true,
resolveFn: resolvePublicHost,
logger: { warn },
});
expect(media).toBeUndefined();
expect(runtime.saveCalls).toHaveLength(0);
expect(warn).toHaveBeenCalledWith(
"msteams botFramework attachmentView invalid content-length",
{ error: "invalid content-length header: 0x3" },
);
});
it("returns undefined when attachment info fetch fails", async () => {
const fetchFn = createMockFetch([
{
match: /\/v3\/attachments\//,
response: new Response("unauthorized", { status: 401 }),
},
]);
const media = await downloadMSTeamsBotFrameworkAttachment({
serviceUrl: "https://smba.trafficmanager.net/amer",
attachmentId: "att-1",
tokenProvider: buildTokenProvider(),
maxBytes: 10_000_000,
fetchFn,
fetchFnSupportsDispatcher: true,
resolveFn: resolvePublicHost,
});
expect(media).toBeUndefined();
expect(runtime.saveCalls).toHaveLength(0);
});
it("does not send Bot Framework service tokens to non-auth-allowlisted media hosts", async () => {
const seenAuth: Array<string | null> = [];
const fetchFn: typeof fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
seenAuth.push(new Headers(init?.headers).get("authorization"));
return new Response("unauthorized", { status: 401 });
}) as typeof fetch;
const media = await downloadMSTeamsBotFrameworkAttachment({
serviceUrl: "https://attacker.trafficmanager.net",
attachmentId: "att-1",
tokenProvider: buildTokenProvider(),
maxBytes: 10_000_000,
fetchFn,
fetchFnSupportsDispatcher: true,
resolveFn: resolvePublicHost,
});
expect(media).toBeUndefined();
expect(seenAuth).toEqual([null]);
expect(runtime.saveCalls).toHaveLength(0);
});
it("sends Bot Framework service tokens to auth-allowlisted service hosts", async () => {
const seenAuth: Array<string | null> = [];
const fileBytes = Buffer.from("BFBYTES", "utf-8");
const fetchFn: typeof fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
seenAuth.push(new Headers(init?.headers).get("authorization"));
if (url.endsWith("/v3/attachments/att-1")) {
return new Response(
JSON.stringify({
name: "doc.pdf",
type: "application/pdf",
views: [{ viewId: "original", size: fileBytes.byteLength }],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
if (url.endsWith("/v3/attachments/att-1/views/original")) {
return new Response(fileBytes, {
status: 200,
headers: { "content-length": String(fileBytes.byteLength) },
});
}
return new Response("not found", { status: 404 });
}) as typeof fetch;
const media = await downloadMSTeamsBotFrameworkAttachment({
serviceUrl: "https://smba.trafficmanager.net/amer",
attachmentId: "att-1",
tokenProvider: buildTokenProvider(),
maxBytes: 10_000_000,
fetchFn,
fetchFnSupportsDispatcher: true,
resolveFn: resolvePublicHost,
});
expect(media?.path).toBe(runtime.savePath);
expect(seenAuth).toEqual(["Bearer bf-token", "Bearer bf-token"]);
});
it("skips when attachment view size exceeds maxBytes", async () => {
const info = {
name: "huge.bin",
type: "application/octet-stream",
views: [{ viewId: "original", size: 50_000_000 }],
};
const fetchFn = createMockFetch([
{
match: /\/v3\/attachments\/big-1$/,
response: new Response(JSON.stringify(info), { status: 200 }),
},
]);
const media = await downloadMSTeamsBotFrameworkAttachment({
serviceUrl: "https://smba.trafficmanager.net/amer",
attachmentId: "big-1",
tokenProvider: buildTokenProvider(),
maxBytes: 10_000_000,
fetchFn,
resolveFn: resolvePublicHost,
});
expect(media).toBeUndefined();
expect(runtime.saveCalls).toHaveLength(0);
});
it("returns undefined when no views are returned", async () => {
const info = { name: "nothing", type: "application/pdf", views: [] };
const fetchFn = createMockFetch([
{
match: /\/v3\/attachments\/empty-1$/,
response: new Response(JSON.stringify(info), { status: 200 }),
},
]);
const media = await downloadMSTeamsBotFrameworkAttachment({
serviceUrl: "https://smba.trafficmanager.net/amer",
attachmentId: "empty-1",
tokenProvider: buildTokenProvider(),
maxBytes: 10_000_000,
fetchFn,
resolveFn: resolvePublicHost,
});
expect(media).toBeUndefined();
});
it("returns undefined without a tokenProvider", async () => {
const fetchFn = vi.fn();
const media = await downloadMSTeamsBotFrameworkAttachment({
serviceUrl: "https://smba.trafficmanager.net/amer",
attachmentId: "att-1",
tokenProvider: undefined,
maxBytes: 10_000_000,
fetchFn: fetchFn as unknown as typeof fetch,
});
expect(media).toBeUndefined();
expect(fetchFn).not.toHaveBeenCalled();
});
describe("guarded attachment fetches", () => {
it("drives dispatcher-aware caller fetchFn hooks through a pinned dispatcher", async () => {
const fileBytes = Buffer.from("BFBYTES", "utf-8");
const fetchCalls: Array<{ url: string; init?: RequestInit }> = [];
const fetchFn: typeof fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
fetchCalls.push({ url, init });
if (url.endsWith("/v3/attachments/att-1")) {
return new Response(
JSON.stringify({
name: "doc.pdf",
type: "application/pdf",
views: [{ viewId: "original", size: fileBytes.byteLength }],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
if (url.endsWith("/v3/attachments/att-1/views/original")) {
return new Response(fileBytes, {
status: 200,
headers: { "content-length": String(fileBytes.byteLength) },
});
}
return new Response("not found", { status: 404 });
}) as typeof fetch;
const media = await downloadMSTeamsBotFrameworkAttachment({
serviceUrl: "https://smba.trafficmanager.net/amer",
attachmentId: "att-1",
tokenProvider: buildTokenProvider(),
maxBytes: 10_000_000,
fetchFn,
fetchFnSupportsDispatcher: true,
resolveFn: resolvePublicHost,
});
expect(media?.path).toBe(runtime.savePath);
expect(media?.contentType).toBe(runtime.savedContentType);
// Both the attachment info call and the view call should be observed,
// confirming the guarded fetch path still preserves caller fetch hooks.
expect(fetchCalls).toHaveLength(2);
expect(fetchCalls[0].url.endsWith("/v3/attachments/att-1")).toBe(true);
expect(fetchCalls[1].url.endsWith("/v3/attachments/att-1/views/original")).toBe(true);
for (const call of fetchCalls) {
const init = call.init as RequestInit & { dispatcher?: unknown };
expect(init?.dispatcher).toBeDefined();
}
});
it("logs a warning when the attachmentInfo fetch throws (no longer silently swallowed)", async () => {
const warn = vi.fn();
const logger = { warn };
const error = new TypeError("fetch failed | invalid onRequestStart method");
const fetchFn: typeof fetch = (async () => {
throw error;
}) as typeof fetch;
const media = await downloadMSTeamsBotFrameworkAttachment({
serviceUrl: "https://smba.trafficmanager.net/amer",
attachmentId: "att-1",
tokenProvider: buildTokenProvider(),
maxBytes: 10_000_000,
fetchFn,
fetchFnSupportsDispatcher: true,
resolveFn: resolvePublicHost,
logger,
});
expect(media).toBeUndefined();
expect(warn).toHaveBeenCalledTimes(1);
expect(firstMockCall(warn, "logger.warn")).toStrictEqual([
"msteams botFramework attachmentInfo fetch failed",
{ error: "fetch failed | invalid onRequestStart method" },
]);
});
it("logs a warning when the attachmentView fetch throws", async () => {
const warn = vi.fn();
const logger = { warn };
const fetchFn: typeof fetch = (async (input: RequestInfo | URL) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.endsWith("/v3/attachments/att-1")) {
return new Response(
JSON.stringify({
name: "doc.pdf",
type: "application/pdf",
views: [{ viewId: "original", size: 10 }],
}),
{ status: 200 },
);
}
throw new TypeError("fetch failed");
}) as typeof fetch;
const media = await downloadMSTeamsBotFrameworkAttachment({
serviceUrl: "https://smba.trafficmanager.net/amer",
attachmentId: "att-1",
tokenProvider: buildTokenProvider(),
maxBytes: 10_000_000,
fetchFn,
fetchFnSupportsDispatcher: true,
resolveFn: resolvePublicHost,
logger,
});
expect(media).toBeUndefined();
expect(warn).toHaveBeenCalledTimes(1);
expect(firstMockCall(warn, "logger.warn")).toStrictEqual([
"msteams botFramework attachmentView fetch failed",
{ error: "fetch failed" },
]);
});
it("logs a warning on non-ok attachmentInfo response", async () => {
const warn = vi.fn();
const fetchFn = createMockFetch([
{
match: /\/v3\/attachments\/att-1$/,
response: new Response("server error", { status: 500 }),
},
]);
const media = await downloadMSTeamsBotFrameworkAttachment({
serviceUrl: "https://smba.trafficmanager.net/amer",
attachmentId: "att-1",
tokenProvider: buildTokenProvider(),
maxBytes: 10_000_000,
fetchFn,
resolveFn: resolvePublicHost,
logger: { warn },
});
expect(media).toBeUndefined();
expect(warn).toHaveBeenCalledTimes(1);
expect(firstMockCall(warn, "logger.warn")).toStrictEqual([
"msteams botFramework attachmentInfo non-ok",
{ status: 500 },
]);
});
});
});
describe("downloadMSTeamsBotFrameworkAttachments", () => {
beforeEach(() => {
installRuntime();
});
it("fetches every unique attachment id and returns combined media", async () => {
const mkInfo = (viewId: string) => ({
name: `file-${viewId}.pdf`,
type: "application/pdf",
views: [{ viewId, size: 10 }],
});
const fetchFn = createMockFetch([
{
match: /\/v3\/attachments\/att-1$/,
response: new Response(JSON.stringify(mkInfo("original")), { status: 200 }),
},
{
match: /\/v3\/attachments\/att-1\/views\/original$/,
response: new Response(Buffer.from("A"), { status: 200 }),
},
{
match: /\/v3\/attachments\/att-2$/,
response: new Response(JSON.stringify(mkInfo("original")), { status: 200 }),
},
{
match: /\/v3\/attachments\/att-2\/views\/original$/,
response: new Response(Buffer.from("B"), { status: 200 }),
},
]);
const result = await downloadMSTeamsBotFrameworkAttachments({
serviceUrl: "https://smba.trafficmanager.net/amer",
attachmentIds: ["att-1", "att-2", "att-1"],
tokenProvider: buildTokenProvider(),
maxBytes: 10_000,
fetchFn,
resolveFn: resolvePublicHost,
});
expect(result.media).toHaveLength(2);
expect(result.attachmentCount).toBe(2);
});
it("returns empty when no valid attachment ids", async () => {
const result = await downloadMSTeamsBotFrameworkAttachments({
serviceUrl: "https://smba.trafficmanager.net/amer",
attachmentIds: [],
tokenProvider: buildTokenProvider(),
maxBytes: 10_000,
fetchFn: vi.fn() as unknown as typeof fetch,
});
expect(result.media).toStrictEqual([]);
});
it("continues past a per-attachment failure", async () => {
const fetchFn = createMockFetch([
{
match: /\/v3\/attachments\/ok$/,
response: new Response(
JSON.stringify({
name: "ok.pdf",
type: "application/pdf",
views: [{ viewId: "original", size: 1 }],
}),
{ status: 200 },
),
},
{
match: /\/v3\/attachments\/ok\/views\/original$/,
response: new Response(Buffer.from("OK"), { status: 200 }),
},
{
match: /\/v3\/attachments\/bad$/,
response: new Response("nope", { status: 500 }),
},
]);
const result = await downloadMSTeamsBotFrameworkAttachments({
serviceUrl: "https://smba.trafficmanager.net/amer",
attachmentIds: ["bad", "ok"],
tokenProvider: buildTokenProvider(),
maxBytes: 10_000,
fetchFn,
resolveFn: resolvePublicHost,
});
expect(result.media).toHaveLength(1);
expect(result.attachmentCount).toBe(2);
});
});

View File

@@ -0,0 +1,386 @@
// Msteams plugin module implements bot framework behavior.
import { parseMediaContentLength } from "openclaw/plugin-sdk/media-runtime";
import { getMSTeamsRuntime } from "../runtime.js";
import { ensureUserAgentHeader } from "../user-agent.js";
import {
applyAuthorizationHeaderForUrl,
inferPlaceholder,
isUrlAllowed,
type MSTeamsAttachmentDownloadLogger,
type MSTeamsAttachmentFetchPolicy,
type MSTeamsAttachmentResolveFn,
resolveAttachmentFetchPolicy,
safeFetchWithPolicy,
} from "./shared.js";
import type {
MSTeamsAccessTokenProvider,
MSTeamsGraphMediaResult,
MSTeamsInboundMedia,
} from "./types.js";
/**
* Bot Framework Service token scope for requesting a token used against
* the Bot Connector (v3) REST endpoints such as `/v3/attachments/{id}`.
*/
const BOT_FRAMEWORK_SCOPE = "https://api.botframework.com";
/**
* Detect Bot Framework personal chat ("a:") and MSA orgid ("8:orgid:") conversation
* IDs. These identifiers are not recognized by Graph's `/chats/{id}` endpoint, so we
* must fetch media via the Bot Framework v3 attachments endpoint instead.
*
* Graph-compatible IDs start with `19:` and are left untouched by this detector.
*/
export function isBotFrameworkPersonalChatId(conversationId: string | null | undefined): boolean {
if (typeof conversationId !== "string") {
return false;
}
const trimmed = conversationId.trim();
return trimmed.startsWith("a:") || trimmed.startsWith("8:orgid:");
}
type BotFrameworkView = {
viewId?: string | null;
size?: number | null;
};
type BotFrameworkAttachmentInfo = {
name?: string | null;
type?: string | null;
views?: BotFrameworkView[] | null;
};
function normalizeServiceUrl(serviceUrl: string): string {
// Bot Framework service URLs sometimes carry a trailing slash; normalize so
// we can safely append `/v3/attachments/...` below.
return serviceUrl.replace(/\/+$/, "");
}
function buildBotFrameworkAttachmentHeaders(params: {
url: string;
accessToken: string;
policy: MSTeamsAttachmentFetchPolicy;
}): Headers {
const headers = ensureUserAgentHeader();
applyAuthorizationHeaderForUrl({
headers,
url: params.url,
authAllowHosts: params.policy.authAllowHosts,
bearerToken: params.accessToken,
});
return headers;
}
async function fetchBotFrameworkAttachmentInfo(params: {
serviceUrl: string;
attachmentId: string;
accessToken: string;
policy: MSTeamsAttachmentFetchPolicy;
fetchFn?: typeof fetch;
fetchFnSupportsDispatcher?: boolean;
resolveFn?: MSTeamsAttachmentResolveFn;
logger?: MSTeamsAttachmentDownloadLogger;
}): Promise<BotFrameworkAttachmentInfo | undefined> {
const url = `${normalizeServiceUrl(params.serviceUrl)}/v3/attachments/${encodeURIComponent(params.attachmentId)}`;
let response: Response;
try {
response = await safeFetchWithPolicy({
url,
policy: params.policy,
fetchFn: params.fetchFn,
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
resolveFn: params.resolveFn,
requestInit: {
headers: buildBotFrameworkAttachmentHeaders({
url,
accessToken: params.accessToken,
policy: params.policy,
}),
},
});
} catch (err) {
params.logger?.warn?.("msteams botFramework attachmentInfo fetch failed", {
error: err instanceof Error ? err.message : String(err),
});
return undefined;
}
if (!response.ok) {
await response.body?.cancel();
params.logger?.warn?.("msteams botFramework attachmentInfo non-ok", {
status: response.status,
});
return undefined;
}
try {
return (await response.json()) as BotFrameworkAttachmentInfo;
} catch (err) {
params.logger?.warn?.("msteams botFramework attachmentInfo parse failed", {
error: err instanceof Error ? err.message : String(err),
});
return undefined;
}
}
async function saveBotFrameworkAttachmentView(params: {
serviceUrl: string;
attachmentId: string;
viewId: string;
accessToken: string;
maxBytes: number;
fileNameHint?: string;
contentTypeHint?: string;
preserveFilenames?: boolean;
policy: MSTeamsAttachmentFetchPolicy;
fetchFn?: typeof fetch;
fetchFnSupportsDispatcher?: boolean;
resolveFn?: MSTeamsAttachmentResolveFn;
logger?: MSTeamsAttachmentDownloadLogger;
}): Promise<{ path: string; contentType?: string } | undefined> {
const url = `${normalizeServiceUrl(params.serviceUrl)}/v3/attachments/${encodeURIComponent(params.attachmentId)}/views/${encodeURIComponent(params.viewId)}`;
let response: Response;
try {
response = await safeFetchWithPolicy({
url,
policy: params.policy,
fetchFn: params.fetchFn,
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
resolveFn: params.resolveFn,
requestInit: {
headers: buildBotFrameworkAttachmentHeaders({
url,
accessToken: params.accessToken,
policy: params.policy,
}),
},
});
} catch (err) {
params.logger?.warn?.("msteams botFramework attachmentView fetch failed", {
error: err instanceof Error ? err.message : String(err),
});
return undefined;
}
if (!response.ok) {
await response.body?.cancel();
params.logger?.warn?.("msteams botFramework attachmentView non-ok", {
status: response.status,
});
return undefined;
}
let contentLength: number | null;
try {
contentLength = parseMediaContentLength(response.headers.get("content-length"));
} catch (err) {
await response.body?.cancel();
params.logger?.warn?.("msteams botFramework attachmentView invalid content-length", {
error: err instanceof Error ? err.message : String(err),
});
return undefined;
}
if (contentLength !== null && contentLength > params.maxBytes) {
await response.body?.cancel();
return undefined;
}
try {
return await getMSTeamsRuntime().channel.media.saveResponseMedia(response, {
sourceUrl: url,
filePathHint: params.fileNameHint,
maxBytes: params.maxBytes,
fallbackContentType: params.contentTypeHint,
subdir: "inbound",
originalFilename: params.preserveFilenames ? params.fileNameHint : undefined,
});
} catch (err) {
params.logger?.warn?.("msteams botFramework attachmentView save failed", {
error: err instanceof Error ? err.message : String(err),
});
return undefined;
}
}
/**
* Download media for a single attachment via the Bot Framework v3 attachments
* endpoint. Used for personal DM conversations where the Graph `/chats/{id}`
* path is not usable because the Bot Framework conversation ID (`a:...`) is
* not a valid Graph chat identifier.
*/
export async function downloadMSTeamsBotFrameworkAttachment(params: {
serviceUrl: string;
attachmentId: string;
tokenProvider?: MSTeamsAccessTokenProvider;
maxBytes: number;
allowHosts?: string[];
authAllowHosts?: string[];
fetchFn?: typeof fetch;
fetchFnSupportsDispatcher?: boolean;
resolveFn?: MSTeamsAttachmentResolveFn;
fileNameHint?: string | null;
contentTypeHint?: string | null;
preserveFilenames?: boolean;
logger?: MSTeamsAttachmentDownloadLogger;
}): Promise<MSTeamsInboundMedia | undefined> {
if (!params.serviceUrl || !params.attachmentId || !params.tokenProvider) {
return undefined;
}
const policy: MSTeamsAttachmentFetchPolicy = resolveAttachmentFetchPolicy({
allowHosts: params.allowHosts,
authAllowHosts: params.authAllowHosts,
});
const baseUrl = `${normalizeServiceUrl(params.serviceUrl)}/v3/attachments/${encodeURIComponent(params.attachmentId)}`;
if (!isUrlAllowed(baseUrl, policy.allowHosts)) {
return undefined;
}
let accessToken: string;
try {
accessToken = await params.tokenProvider.getAccessToken(BOT_FRAMEWORK_SCOPE);
} catch (err) {
params.logger?.warn?.("msteams botFramework token acquisition failed", {
error: err instanceof Error ? err.message : String(err),
});
return undefined;
}
if (!accessToken) {
return undefined;
}
const info = await fetchBotFrameworkAttachmentInfo({
serviceUrl: params.serviceUrl,
attachmentId: params.attachmentId,
accessToken,
policy,
fetchFn: params.fetchFn,
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
resolveFn: params.resolveFn,
logger: params.logger,
});
if (!info) {
return undefined;
}
const views = Array.isArray(info.views) ? info.views : [];
// Prefer the "original" view when present, otherwise fall back to the first
// view the Bot Framework service returned.
const original = views.find((view) => view?.viewId === "original");
const candidateView = original ?? views.find((view) => typeof view?.viewId === "string");
const viewId =
typeof candidateView?.viewId === "string" && candidateView.viewId
? candidateView.viewId
: undefined;
if (!viewId) {
return undefined;
}
if (
typeof candidateView?.size === "number" &&
candidateView.size > 0 &&
candidateView.size > params.maxBytes
) {
return undefined;
}
const fileNameHint =
(typeof params.fileNameHint === "string" && params.fileNameHint) ||
(typeof info.name === "string" && info.name) ||
undefined;
const contentTypeHint =
(typeof params.contentTypeHint === "string" && params.contentTypeHint) ||
(typeof info.type === "string" && info.type) ||
undefined;
const saved = await saveBotFrameworkAttachmentView({
serviceUrl: params.serviceUrl,
attachmentId: params.attachmentId,
viewId,
accessToken,
maxBytes: params.maxBytes,
fileNameHint,
contentTypeHint,
preserveFilenames: params.preserveFilenames,
policy,
fetchFn: params.fetchFn,
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
resolveFn: params.resolveFn,
logger: params.logger,
});
if (!saved) {
return undefined;
}
return {
path: saved.path,
contentType: saved.contentType,
placeholder: inferPlaceholder({ contentType: saved.contentType, fileName: fileNameHint }),
};
}
/**
* Download media for every attachment referenced by a Bot Framework personal
* chat activity. Returns all successfully fetched media along with diagnostics
* compatible with `downloadMSTeamsGraphMedia`'s result shape so callers can
* reuse the existing logging path.
*/
export async function downloadMSTeamsBotFrameworkAttachments(params: {
serviceUrl: string;
attachmentIds: string[];
tokenProvider?: MSTeamsAccessTokenProvider;
maxBytes: number;
allowHosts?: string[];
authAllowHosts?: string[];
fetchFn?: typeof fetch;
fetchFnSupportsDispatcher?: boolean;
resolveFn?: MSTeamsAttachmentResolveFn;
fileNameHint?: string | null;
contentTypeHint?: string | null;
preserveFilenames?: boolean;
logger?: MSTeamsAttachmentDownloadLogger;
}): Promise<MSTeamsGraphMediaResult> {
const seen = new Set<string>();
const unique: string[] = [];
for (const id of params.attachmentIds ?? []) {
if (typeof id !== "string") {
continue;
}
const trimmed = id.trim();
if (!trimmed || seen.has(trimmed)) {
continue;
}
seen.add(trimmed);
unique.push(trimmed);
}
if (unique.length === 0 || !params.serviceUrl || !params.tokenProvider) {
return { media: [], attachmentCount: unique.length };
}
const media: MSTeamsInboundMedia[] = [];
for (const attachmentId of unique) {
try {
const item = await downloadMSTeamsBotFrameworkAttachment({
serviceUrl: params.serviceUrl,
attachmentId,
tokenProvider: params.tokenProvider,
maxBytes: params.maxBytes,
allowHosts: params.allowHosts,
authAllowHosts: params.authAllowHosts,
fetchFn: params.fetchFn,
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
resolveFn: params.resolveFn,
fileNameHint: params.fileNameHint,
contentTypeHint: params.contentTypeHint,
preserveFilenames: params.preserveFilenames,
logger: params.logger,
});
if (item) {
media.push(item);
}
} catch (err) {
params.logger?.warn?.("msteams botFramework attachment download failed", {
error: err instanceof Error ? err.message : String(err),
attachmentId,
});
}
}
return {
media,
attachmentCount: unique.length,
};
}

View File

@@ -0,0 +1,335 @@
// Msteams plugin module implements download behavior.
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { getMSTeamsRuntime } from "../runtime.js";
import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
import {
extractInlineImageCandidates,
inferPlaceholder,
isDownloadableAttachment,
isRecord,
isUrlAllowed,
type MSTeamsAttachmentDownloadLogger,
type MSTeamsAttachmentFetchPolicy,
type MSTeamsAttachmentResolveFn,
normalizeContentType,
resolveMediaSsrfPolicy,
resolveAttachmentFetchPolicy,
resolveRequestUrl,
safeFetchWithPolicy,
tryBuildGraphSharesUrlForSharedLink,
} from "./shared.js";
import type {
MSTeamsAccessTokenProvider,
MSTeamsAttachmentLike,
MSTeamsInboundMedia,
} from "./types.js";
type DownloadCandidate = {
url: string;
fileHint?: string;
contentTypeHint?: string;
placeholder: string;
};
function resolveDownloadCandidate(att: MSTeamsAttachmentLike): DownloadCandidate | null {
const contentType = normalizeContentType(att.contentType);
const name = normalizeOptionalString(att.name) ?? "";
if (contentType === "application/vnd.microsoft.teams.file.download.info") {
if (!isRecord(att.content)) {
return null;
}
const downloadUrl = normalizeOptionalString(att.content.downloadUrl) ?? "";
if (!downloadUrl) {
return null;
}
const fileType = normalizeOptionalString(att.content.fileType) ?? "";
const uniqueId = normalizeOptionalString(att.content.uniqueId) ?? "";
const fileName = normalizeOptionalString(att.content.fileName) ?? "";
const fileHint = name || fileName || (uniqueId && fileType ? `${uniqueId}.${fileType}` : "");
return {
url: downloadUrl,
fileHint: fileHint || undefined,
contentTypeHint: undefined,
placeholder: inferPlaceholder({
contentType,
fileName: fileHint,
fileType,
}),
};
}
const contentUrl = normalizeOptionalString(att.contentUrl) ?? "";
if (!contentUrl) {
return null;
}
// OneDrive/SharePoint shared links (delivered in 1:1 DMs when the user
// picks "Attach > OneDrive") cannot be fetched directly — the URL returns
// an HTML landing page rather than the file bytes. Rewrite them to the
// Graph shares endpoint so the auth fallback attaches a Graph-scoped token
// and the response is the real file content.
const sharesUrl = tryBuildGraphSharesUrlForSharedLink(contentUrl);
const resolvedUrl = sharesUrl ?? contentUrl;
// Graph shares returns raw bytes without a declared content type we can
// trust for routing — let the downloader infer MIME from the buffer.
const resolvedContentTypeHint = sharesUrl ? undefined : contentType;
return {
url: resolvedUrl,
fileHint: name || undefined,
contentTypeHint: resolvedContentTypeHint,
placeholder: inferPlaceholder({ contentType, fileName: name }),
};
}
function scopeCandidatesForUrl(url: string): string[] {
try {
const host = normalizeLowercaseStringOrEmpty(new URL(url).hostname);
const looksLikeGraph =
host.endsWith("graph.microsoft.com") ||
host.endsWith("sharepoint.com") ||
host.endsWith("1drv.ms") ||
host.includes("sharepoint");
return looksLikeGraph
? ["https://graph.microsoft.com", "https://api.botframework.com"]
: ["https://api.botframework.com", "https://graph.microsoft.com"];
} catch {
return ["https://api.botframework.com", "https://graph.microsoft.com"];
}
}
function isRedirectStatus(status: number): boolean {
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
}
async function resolveInlineDataImageMime(inline: {
data: Buffer;
contentType?: string;
}): Promise<string | undefined> {
const detectedMime = await getMSTeamsRuntime().media.detectMime({
buffer: inline.data,
headerMime: inline.contentType,
});
const mime = normalizeOptionalLowercaseString(detectedMime ?? inline.contentType);
return mime?.startsWith("image/") ? mime : undefined;
}
async function fetchWithAuthFallback(params: {
url: string;
tokenProvider?: MSTeamsAccessTokenProvider;
fetchFn?: typeof fetch;
fetchFnSupportsDispatcher?: boolean;
requestInit?: RequestInit;
resolveFn?: MSTeamsAttachmentResolveFn;
policy: MSTeamsAttachmentFetchPolicy;
}): Promise<Response> {
const firstAttempt = await safeFetchWithPolicy({
url: params.url,
policy: params.policy,
fetchFn: params.fetchFn,
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
requestInit: params.requestInit,
resolveFn: params.resolveFn,
});
if (firstAttempt.ok) {
return firstAttempt;
}
if (!params.tokenProvider) {
return firstAttempt;
}
if (firstAttempt.status !== 401 && firstAttempt.status !== 403) {
return firstAttempt;
}
if (!isUrlAllowed(params.url, params.policy.authAllowHosts)) {
return firstAttempt;
}
await firstAttempt.body?.cancel();
const scopes = scopeCandidatesForUrl(params.url);
const fetchFn = params.fetchFn ?? fetch;
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 safeFetchWithPolicy({
url: params.url,
policy: params.policy,
fetchFn,
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
requestInit: {
...params.requestInit,
headers: authHeaders,
},
resolveFn: params.resolveFn,
});
if (authAttempt.ok) {
return authAttempt;
}
if (isRedirectStatus(authAttempt.status)) {
// Redirects in guarded fetch mode must propagate to the outer guard.
return authAttempt;
}
if (authAttempt.status !== 401 && authAttempt.status !== 403) {
// Preserve scope fallback semantics for non-auth failures.
await authAttempt.body?.cancel();
continue;
}
await authAttempt.body?.cancel();
} catch {
// Try the next scope.
}
}
return firstAttempt;
}
/**
* Download all file attachments from a Teams message (images, documents, etc.).
* Renamed from downloadMSTeamsImageAttachments to support all file types.
*/
export async function downloadMSTeamsAttachments(params: {
attachments: MSTeamsAttachmentLike[] | undefined;
maxBytes: number;
tokenProvider?: MSTeamsAccessTokenProvider;
allowHosts?: string[];
authAllowHosts?: string[];
fetchFn?: typeof fetch;
fetchFnSupportsDispatcher?: boolean;
resolveFn?: MSTeamsAttachmentResolveFn;
/** When true, embeds original filename in stored path for later extraction. */
preserveFilenames?: boolean;
/**
* Optional logger used to surface inline data decode failures and remote
* media download errors. Errors that are not logged here are invisible at
* INFO level and block diagnosis of issues like #63396.
*/
logger?: MSTeamsAttachmentDownloadLogger;
}): Promise<MSTeamsInboundMedia[]> {
const list = Array.isArray(params.attachments) ? params.attachments : [];
if (list.length === 0) {
return [];
}
const policy = resolveAttachmentFetchPolicy({
allowHosts: params.allowHosts,
authAllowHosts: params.authAllowHosts,
});
const allowHosts = policy.allowHosts;
const ssrfPolicy = resolveMediaSsrfPolicy(allowHosts);
// Download ANY downloadable attachment (not just images)
const downloadable = list.filter(isDownloadableAttachment);
const candidates: DownloadCandidate[] = downloadable
.map(resolveDownloadCandidate)
.filter(Boolean) as DownloadCandidate[];
const inlineCandidates = extractInlineImageCandidates(list, {
maxInlineBytes: params.maxBytes,
maxInlineTotalBytes: params.maxBytes,
});
const seenUrls = new Set<string>();
for (const inline of inlineCandidates) {
if (inline.kind === "url") {
if (!isUrlAllowed(inline.url, allowHosts)) {
continue;
}
if (seenUrls.has(inline.url)) {
continue;
}
seenUrls.add(inline.url);
candidates.push({
url: inline.url,
fileHint: inline.fileHint,
contentTypeHint: inline.contentType,
placeholder: inline.placeholder,
});
}
}
if (candidates.length === 0 && inlineCandidates.length === 0) {
return [];
}
const out: MSTeamsInboundMedia[] = [];
for (const inline of inlineCandidates) {
if (inline.kind !== "data") {
continue;
}
if (inline.data.byteLength > params.maxBytes) {
continue;
}
try {
const contentType = await resolveInlineDataImageMime(inline);
if (!contentType) {
continue;
}
// Data inline candidates (base64 data URLs) don't have original filenames
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
inline.data,
contentType,
"inbound",
params.maxBytes,
);
out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: inferPlaceholder({ contentType: saved.contentType ?? contentType }),
});
} catch (err) {
params.logger?.warn?.("msteams inline attachment decode failed", {
error: err instanceof Error ? err.message : String(err),
});
}
}
for (const candidate of candidates) {
if (!isUrlAllowed(candidate.url, allowHosts)) {
continue;
}
try {
const media = await downloadAndStoreMSTeamsRemoteMedia({
url: candidate.url,
filePathHint: candidate.fileHint ?? candidate.url,
maxBytes: params.maxBytes,
contentTypeHint: candidate.contentTypeHint,
placeholder: candidate.placeholder,
preserveFilenames: params.preserveFilenames,
ssrfPolicy,
// `fetchImpl` below owns Teams auth fallback and enforces the
// attachment fetch policy through `safeFetchWithPolicy`.
useDirectFetch: true,
fetchImpl: (input, init) =>
fetchWithAuthFallback({
url: resolveRequestUrl(input),
tokenProvider: params.tokenProvider,
fetchFn: params.fetchFn,
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
requestInit: init,
resolveFn: params.resolveFn,
policy,
}),
});
out.push(media);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
params.logger?.warn?.(
`msteams attachment download failed host=${safeHostForLog(candidate.url)} error=${msg}`,
);
}
}
return out;
}
function safeHostForLog(url: string): string {
try {
return new URL(url).host;
} catch {
return "invalid-url";
}
}

View File

@@ -0,0 +1,444 @@
// Msteams tests cover graph plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
// Mock shared.js to avoid transitive runtime-api imports that pull in uninstalled packages.
vi.mock("./shared.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./shared.js")>();
return {
...actual,
applyAuthorizationHeaderForUrl: vi.fn(),
GRAPH_ROOT: "https://graph.microsoft.com/v1.0",
inferPlaceholder: vi.fn(({ contentType }: { contentType?: string }) =>
contentType?.startsWith("image/") ? "[image]" : "[file]",
),
isRecord: (v: unknown) => typeof v === "object" && v !== null && !Array.isArray(v),
isUrlAllowed: vi.fn(() => true),
normalizeContentType: vi.fn((ct: string | null | undefined) => ct ?? undefined),
resolveMediaSsrfPolicy: vi.fn(() => undefined),
resolveAttachmentFetchPolicy: vi.fn(() => ({ allowHosts: ["*"], authAllowHosts: ["*"] })),
resolveRequestUrl: vi.fn((input: string) => input),
safeFetchWithPolicy: vi.fn(),
};
});
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
fetchWithSsrFGuard: vi.fn(),
}));
vi.mock("../runtime.js", () => ({
getMSTeamsRuntime: vi.fn(() => ({
media: {
detectMime: vi.fn(async () => "image/png"),
},
channel: {
media: {
saveResponseMedia: vi.fn(
async (
response: Response,
options?: { fallbackContentType?: string; maxBytes?: number },
) => {
const length = Number(response.headers.get("content-length"));
if (
Number.isFinite(length) &&
options?.maxBytes !== undefined &&
length > options.maxBytes
) {
throw new Error("content length exceeds maxBytes");
}
return {
path: "/tmp/saved.png",
contentType: options?.fallbackContentType ?? "image/png",
};
},
),
saveMediaBuffer: vi.fn(async (_buf: Buffer, ct: string) => ({
path: "/tmp/saved.png",
contentType: ct ?? "image/png",
})),
},
},
})),
}));
vi.mock("./download.js", () => ({
downloadMSTeamsAttachments: vi.fn(async () => []),
}));
vi.mock("./remote-media.js", () => ({
downloadAndStoreMSTeamsRemoteMedia: vi.fn(),
}));
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { downloadMSTeamsGraphMedia } from "./graph.js";
import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
import { safeFetchWithPolicy } from "./shared.js";
function mockFetchResponse(body: unknown, status = 200) {
const bodyStr = typeof body === "string" ? body : JSON.stringify(body);
return new Response(bodyStr, { status, headers: { "content-type": "application/json" } });
}
function mockBinaryResponse(data: Uint8Array, status = 200) {
return new Response(Buffer.from(data) as BodyInit, { status });
}
type GuardedFetchParams = { url: string; init?: RequestInit };
function guardedFetchResult(params: GuardedFetchParams, response: Response) {
return {
response,
release: async () => {},
finalUrl: params.url,
};
}
function requireFirstMockCall<TArgs extends unknown[]>(
mock: { mock: { calls: TArgs[] } },
label: string,
): TArgs {
const [call] = mock.mock.calls;
if (!call) {
throw new Error(`expected ${label}`);
}
return call;
}
function mockGraphMediaFetch(options: {
messageId: string;
messageResponse?: unknown;
hostedContents?: unknown[];
valueResponses?: Record<string, Response>;
fetchCalls?: string[];
}) {
vi.mocked(fetchWithSsrFGuard).mockImplementation(async (params: GuardedFetchParams) => {
options.fetchCalls?.push(params.url);
const url = params.url;
if (url.endsWith(`/messages/${options.messageId}`) && !url.includes("hostedContents")) {
return guardedFetchResult(
params,
mockFetchResponse(options.messageResponse ?? { body: {}, attachments: [] }),
);
}
if (url.endsWith("/hostedContents")) {
return guardedFetchResult(params, mockFetchResponse({ value: options.hostedContents ?? [] }));
}
for (const [fragment, response] of Object.entries(options.valueResponses ?? {})) {
if (url.includes(fragment)) {
return guardedFetchResult(params, response);
}
}
return guardedFetchResult(params, mockFetchResponse({}, 404));
});
}
describe("downloadMSTeamsGraphMedia hosted content $value fallback", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("fetches $value endpoint when contentBytes is null but item.id exists", async () => {
const imageBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); // PNG magic bytes
const fetchCalls: string[] = [];
mockGraphMediaFetch({
messageId: "msg-1",
hostedContents: [{ id: "hosted-123", contentType: "image/png", contentBytes: null }],
valueResponses: {
"/hostedContents/hosted-123/$value": mockBinaryResponse(imageBytes),
},
fetchCalls,
});
const result = await downloadMSTeamsGraphMedia({
messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-1",
tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
maxBytes: 10 * 1024 * 1024,
});
// Verify the $value endpoint was fetched
expect(fetchCalls).toContain(
"https://graph.microsoft.com/v1.0/chats/c/messages/msg-1/hostedContents/hosted-123/$value",
);
expect(result.media.length).toBeGreaterThan(0);
expect(result.hostedCount).toBe(1);
});
it("skips hosted content when contentBytes is null and id is missing", async () => {
mockGraphMediaFetch({
messageId: "msg-2",
hostedContents: [{ contentType: "image/png", contentBytes: null }],
});
const result = await downloadMSTeamsGraphMedia({
messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-2",
tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
maxBytes: 10 * 1024 * 1024,
});
// No media because there's no id to fetch $value from and no contentBytes
expect(result.media).toHaveLength(0);
});
it("skips $value content when Content-Length exceeds maxBytes", async () => {
const fetchCalls: string[] = [];
mockGraphMediaFetch({
messageId: "msg-cl",
hostedContents: [{ id: "hosted-big", contentType: "image/png", contentBytes: null }],
valueResponses: {
"/hostedContents/hosted-big/$value": new Response(
Buffer.from(new Uint8Array([0x89, 0x50, 0x4e, 0x47])) as BodyInit,
{
status: 200,
headers: { "content-length": "999999999" },
},
),
},
fetchCalls,
});
const result = await downloadMSTeamsGraphMedia({
messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-cl",
tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
maxBytes: 1024, // 1 KB limit
});
// $value was fetched but skipped due to Content-Length exceeding maxBytes
expect(fetchCalls).toContain(
"https://graph.microsoft.com/v1.0/chats/c/messages/msg-cl/hostedContents/hosted-big/$value",
);
expect(result.media).toHaveLength(0);
});
it("uses inline contentBytes when available instead of $value", async () => {
const fetchCalls: string[] = [];
const base64Png = Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString("base64");
mockGraphMediaFetch({
messageId: "msg-3",
hostedContents: [{ id: "hosted-456", contentType: "image/png", contentBytes: base64Png }],
fetchCalls,
});
const result = await downloadMSTeamsGraphMedia({
messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-3",
tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
maxBytes: 10 * 1024 * 1024,
});
// Should NOT have fetched $value since contentBytes was available
const valueCall = fetchCalls.find((u) => u.includes("/$value"));
expect(valueCall).toBeUndefined();
expect(result.media.length).toBeGreaterThan(0);
});
it("adds the OpenClaw User-Agent to guarded Graph attachment fetches", async () => {
mockGraphMediaFetch({ messageId: "msg-ua" });
await downloadMSTeamsGraphMedia({
messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-ua",
tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
maxBytes: 10 * 1024 * 1024,
});
const guardCalls = vi.mocked(fetchWithSsrFGuard).mock.calls;
for (const [call] of guardCalls) {
const headers = call.init?.headers;
expect(headers).toBeInstanceOf(Headers);
expect((headers as Headers).get("Authorization")).toBe("Bearer test-token");
expect((headers as Headers).get("User-Agent")).toMatch(
/^teams\.ts\[apps\]\/.+ OpenClaw\/.+$/,
);
}
});
it("adds the OpenClaw User-Agent to Graph shares downloads for reference attachments", async () => {
mockGraphMediaFetch({
messageId: "msg-share",
messageResponse: {
body: {},
attachments: [
{
contentType: "reference",
contentUrl: "https://tenant.sharepoint.com/file.docx",
name: "file.docx",
},
],
},
});
vi.mocked(safeFetchWithPolicy).mockResolvedValue(new Response(null, { status: 200 }));
vi.mocked(downloadAndStoreMSTeamsRemoteMedia).mockImplementation(async (params) => {
if (params.fetchImpl) {
await params.fetchImpl(params.url, {});
}
return {
path: "/tmp/file.docx",
contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
placeholder: "[file]",
};
});
await downloadMSTeamsGraphMedia({
messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-share",
tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
maxBytes: 10 * 1024 * 1024,
});
const [fetchParams] = requireFirstMockCall(
vi.mocked(safeFetchWithPolicy),
"safeFetchWithPolicy call",
);
expect(fetchParams.requestInit?.headers).toBeInstanceOf(Headers);
const requestInit = fetchParams.requestInit;
const headers = requestInit?.headers as Headers;
expect(headers.get("User-Agent")).toMatch(/^teams\.ts\[apps\]\/.+ OpenClaw\/.+$/);
});
});
describe("downloadMSTeamsGraphMedia attachment sourcing and error logging", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("does NOT call the nonexistent ${messageUrl}/attachments sub-resource", async () => {
// The Graph v1.0 API does not expose a `/attachments` sub-resource on
// channel or chat messages. Issue #58617 documented that the old code
// path called this endpoint and recorded a 404 in diagnostics. After
// this fix, the helper must source attachments from the main message
// resource's inline `attachments` array instead.
const fetchCalls: string[] = [];
mockGraphMediaFetch({
messageId: "msg-no-sub",
messageResponse: {
body: { content: "hi" },
attachments: [],
},
fetchCalls,
});
await downloadMSTeamsGraphMedia({
messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-no-sub",
tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
maxBytes: 10 * 1024 * 1024,
});
const calledSubResource = fetchCalls.some((u) =>
u.endsWith("/messages/msg-no-sub/attachments"),
);
expect(calledSubResource).toBe(false);
});
it("sources reference attachments from the message body's attachments array", async () => {
// Before the fix, the helper fetched `/attachments` and used that list.
// After the fix, it must use `msgData.attachments` from the main fetch.
mockGraphMediaFetch({
messageId: "msg-inline",
messageResponse: {
body: {},
attachments: [
{
contentType: "reference",
contentUrl: "https://tenant.sharepoint.com/inline.pdf",
name: "inline.pdf",
},
],
},
});
vi.mocked(safeFetchWithPolicy).mockResolvedValue(new Response(null, { status: 200 }));
vi.mocked(downloadAndStoreMSTeamsRemoteMedia).mockResolvedValue({
path: "/tmp/inline.pdf",
contentType: "application/pdf",
placeholder: "[file]",
});
const result = await downloadMSTeamsGraphMedia({
messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-inline",
tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
maxBytes: 10 * 1024 * 1024,
});
expect(result.media).toHaveLength(1);
expect(result.media[0]?.path).toBe("/tmp/inline.pdf");
// Regression guard: attachmentCount now reflects real inline attachments,
// not the imaginary `/attachments` sub-resource count.
expect(result.attachmentCount).toBe(1);
});
it("logs a debug event when the message fetch throws instead of swallowing it", async () => {
// Regression test for #51749: empty `catch {}` blocks used to hide the
// real error, producing misleading `graph media fetch empty` diagnostics
// without surfacing the underlying cause.
vi.mocked(fetchWithSsrFGuard).mockImplementation(async (params: GuardedFetchParams) => {
if (params.url.endsWith("/messages/msg-err")) {
throw new Error("network boom");
}
// hostedContents and any other paths succeed so the error branch under
// test is the only one that fires.
return guardedFetchResult(params, mockFetchResponse({ value: [] }));
});
const logger = { warn: vi.fn() };
const result = await downloadMSTeamsGraphMedia({
messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-err",
tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
maxBytes: 10 * 1024 * 1024,
logger,
});
expect(result.media).toHaveLength(0);
const [message, context] = requireFirstMockCall(logger.warn, "message fetch warning");
expect(message).toBe("msteams graph message fetch failed");
expect((context as { error?: unknown }).error).toBe("network boom");
});
it("logs a debug event when the message fetch returns non-ok", async () => {
// If the message endpoint returns 403/404, we want that recorded so
// operators can distinguish auth issues from empty result sets.
vi.mocked(fetchWithSsrFGuard).mockImplementation(async (params: GuardedFetchParams) => {
const url = params.url;
if (url.endsWith("/hostedContents")) {
return guardedFetchResult(params, mockFetchResponse({ value: [] }));
}
return guardedFetchResult(params, mockFetchResponse({ error: "forbidden" }, 403));
});
const log = { debug: vi.fn() };
const result = await downloadMSTeamsGraphMedia({
messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-403",
tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
maxBytes: 10 * 1024 * 1024,
log,
});
expect(result.media).toHaveLength(0);
expect(result.attachmentStatus).toBe(403);
const [message, context] = requireFirstMockCall(log.debug, "message fetch debug event");
expect(message).toBe("graph media message fetch not ok");
expect((context as { status?: unknown }).status).toBe(403);
});
it("logs a debug event when token acquisition fails", async () => {
vi.mocked(fetchWithSsrFGuard).mockImplementation(async (params: GuardedFetchParams) =>
guardedFetchResult(params, mockFetchResponse({})),
);
const logger = { warn: vi.fn() };
const result = await downloadMSTeamsGraphMedia({
messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-token",
tokenProvider: {
getAccessToken: vi.fn(async () => {
throw new Error("token expired");
}),
},
maxBytes: 10 * 1024 * 1024,
logger,
});
expect(result.tokenError).toBe(true);
const [message, context] = requireFirstMockCall(logger.warn, "token acquisition warning");
expect(message).toBe("msteams graph token acquisition failed");
expect((context as { error?: unknown }).error).toBe("token expired");
});
});

View File

@@ -0,0 +1,494 @@
// Msteams plugin module implements graph behavior.
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
normalizeOptionalString,
uniqueStrings,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { getMSTeamsRuntime } from "../runtime.js";
import { ensureUserAgentHeader } from "../user-agent.js";
import { downloadMSTeamsAttachments } from "./download.js";
import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
import {
applyAuthorizationHeaderForUrl,
encodeGraphShareId,
GRAPH_ROOT,
estimateBase64DecodedBytes,
inferPlaceholder,
readNestedString,
isUrlAllowed,
type MSTeamsAttachmentDownloadLogger,
type MSTeamsAttachmentFetchPolicy,
type MSTeamsAttachmentResolveFn,
normalizeContentType,
resolveMediaSsrfPolicy,
resolveAttachmentFetchPolicy,
resolveRequestUrl,
safeFetchWithPolicy,
} from "./shared.js";
import type {
MSTeamsAccessTokenProvider,
MSTeamsAttachmentLike,
MSTeamsGraphMediaLogger,
MSTeamsGraphMediaResult,
MSTeamsInboundMedia,
} from "./types.js";
type GraphHostedContent = {
id?: string | null;
contentType?: string | null;
contentBytes?: string | null;
};
type GraphAttachment = {
id?: string | null;
contentType?: string | null;
contentUrl?: string | null;
name?: string | null;
thumbnailUrl?: string | null;
content?: unknown;
};
export function buildMSTeamsGraphMessageUrls(params: {
conversationType?: string | null;
conversationId?: string | null;
messageId?: string | null;
replyToId?: string | null;
conversationMessageId?: string | null;
channelData?: unknown;
}): string[] {
const conversationType = normalizeLowercaseStringOrEmpty(params.conversationType ?? "");
const messageIdCandidates = new Set<string>();
const pushCandidate = (value: string | null | undefined) => {
const trimmed = normalizeOptionalString(value) ?? "";
if (trimmed) {
messageIdCandidates.add(trimmed);
}
};
pushCandidate(params.messageId);
pushCandidate(params.conversationMessageId);
pushCandidate(readNestedString(params.channelData, ["messageId"]));
pushCandidate(readNestedString(params.channelData, ["teamsMessageId"]));
const replyToId = normalizeOptionalString(params.replyToId) ?? "";
if (conversationType === "channel") {
const teamId =
readNestedString(params.channelData, ["team", "id"]) ??
readNestedString(params.channelData, ["teamId"]);
const channelId =
readNestedString(params.channelData, ["channel", "id"]) ??
readNestedString(params.channelData, ["channelId"]) ??
readNestedString(params.channelData, ["teamsChannelId"]);
if (!teamId || !channelId) {
return [];
}
const urls: string[] = [];
if (replyToId) {
for (const candidate of messageIdCandidates) {
if (candidate === replyToId) {
continue;
}
urls.push(
`${GRAPH_ROOT}/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(replyToId)}/replies/${encodeURIComponent(candidate)}`,
);
}
}
if (messageIdCandidates.size === 0 && replyToId) {
messageIdCandidates.add(replyToId);
}
for (const candidate of messageIdCandidates) {
urls.push(
`${GRAPH_ROOT}/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(candidate)}`,
);
}
return uniqueStrings(urls);
}
const chatId = params.conversationId?.trim() || readNestedString(params.channelData, ["chatId"]);
if (!chatId) {
return [];
}
if (messageIdCandidates.size === 0 && replyToId) {
messageIdCandidates.add(replyToId);
}
const urls = Array.from(messageIdCandidates).map(
(candidate) =>
`${GRAPH_ROOT}/chats/${encodeURIComponent(chatId)}/messages/${encodeURIComponent(candidate)}`,
);
return uniqueStrings(urls);
}
async function fetchGraphCollection(params: {
url: string;
accessToken: string;
fetchFn?: typeof fetch;
ssrfPolicy?: SsrFPolicy;
}): Promise<{ status: number; items: unknown[] }> {
const fetchFn = params.fetchFn ?? fetch;
const { response, release } = await fetchWithSsrFGuard({
url: params.url,
fetchImpl: fetchFn,
init: {
headers: ensureUserAgentHeader({ Authorization: `Bearer ${params.accessToken}` }),
},
policy: params.ssrfPolicy,
auditContext: "msteams.graph.collection",
});
try {
const status = response.status;
if (!response.ok) {
return { status, items: [] };
}
try {
const data = (await response.json()) as { value?: unknown[] };
return { status, items: Array.isArray(data.value) ? data.value : [] };
} catch {
return { status, items: [] };
}
} finally {
await release();
}
}
function normalizeGraphAttachment(att: GraphAttachment): MSTeamsAttachmentLike {
let content: unknown = att.content;
if (typeof content === "string") {
try {
content = JSON.parse(content);
} catch {
// Keep as raw string if it's not JSON.
}
}
return {
contentType: normalizeContentType(att.contentType) ?? undefined,
contentUrl: att.contentUrl ?? undefined,
name: att.name ?? undefined,
thumbnailUrl: att.thumbnailUrl ?? undefined,
content,
};
}
/**
* Download all hosted content from a Teams message (images, documents, etc.).
* Renamed from downloadGraphHostedImages to support all file types.
*/
async function downloadGraphHostedContent(params: {
accessToken: string;
messageUrl: string;
maxBytes: number;
fetchFn?: typeof fetch;
preserveFilenames?: boolean;
ssrfPolicy?: SsrFPolicy;
logger?: MSTeamsAttachmentDownloadLogger;
}): Promise<{ media: MSTeamsInboundMedia[]; status: number; count: number }> {
const hosted = (await fetchGraphCollection({
url: `${params.messageUrl}/hostedContents`,
accessToken: params.accessToken,
fetchFn: params.fetchFn,
ssrfPolicy: params.ssrfPolicy,
})) as { status: number; items: GraphHostedContent[] };
if (hosted.items.length === 0) {
return { media: [], status: hosted.status, count: 0 };
}
const out: MSTeamsInboundMedia[] = [];
for (const item of hosted.items) {
const contentBytes = typeof item.contentBytes === "string" ? item.contentBytes : "";
let buffer: Buffer;
if (contentBytes) {
if (estimateBase64DecodedBytes(contentBytes) > params.maxBytes) {
continue;
}
try {
buffer = Buffer.from(contentBytes, "base64");
} catch (err) {
params.logger?.warn?.("msteams graph hostedContent base64 decode failed", {
error: err instanceof Error ? err.message : String(err),
});
continue;
}
} else if (item.id) {
// contentBytes not inline — fetch from the individual $value endpoint.
try {
const valueUrl = `${params.messageUrl}/hostedContents/${encodeURIComponent(item.id)}/$value`;
const { response: valRes, release } = await fetchWithSsrFGuard({
url: valueUrl,
fetchImpl: params.fetchFn ?? fetch,
init: {
headers: ensureUserAgentHeader({ Authorization: `Bearer ${params.accessToken}` }),
},
policy: params.ssrfPolicy,
auditContext: "msteams.graph.hostedContent.value",
});
try {
if (!valRes.ok) {
continue;
}
const saved = await getMSTeamsRuntime().channel.media.saveResponseMedia(valRes, {
sourceUrl: valueUrl,
maxBytes: params.maxBytes,
fallbackContentType: item.contentType ?? undefined,
subdir: "inbound",
});
out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: inferPlaceholder({ contentType: saved.contentType }),
});
} finally {
await release();
}
} catch (err) {
params.logger?.warn?.("msteams graph hostedContent value fetch failed", {
error: err instanceof Error ? err.message : String(err),
});
continue;
}
continue;
} else {
continue;
}
if (buffer.byteLength > params.maxBytes) {
continue;
}
const mime = await getMSTeamsRuntime().media.detectMime({
buffer,
headerMime: item.contentType ?? undefined,
});
// Download any file type, not just images
try {
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
buffer,
mime ?? item.contentType ?? undefined,
"inbound",
params.maxBytes,
);
out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: inferPlaceholder({ contentType: saved.contentType }),
});
} catch (err) {
params.logger?.warn?.("msteams graph hostedContent save failed", {
error: err instanceof Error ? err.message : String(err),
});
}
}
return { media: out, status: hosted.status, count: hosted.items.length };
}
export async function downloadMSTeamsGraphMedia(params: {
messageUrl?: string | null;
tokenProvider?: MSTeamsAccessTokenProvider;
maxBytes: number;
allowHosts?: string[];
authAllowHosts?: string[];
fetchFn?: typeof fetch;
fetchFnSupportsDispatcher?: boolean;
resolveFn?: MSTeamsAttachmentResolveFn;
/** When true, embeds original filename in stored path for later extraction. */
preserveFilenames?: boolean;
/** Optional logger used to surface Graph/SharePoint fetch errors. */
logger?: MSTeamsAttachmentDownloadLogger;
/** Back-compat diagnostic logger used by older tests/callers. */
log?: MSTeamsGraphMediaLogger;
}): Promise<MSTeamsGraphMediaResult> {
if (!params.messageUrl || !params.tokenProvider) {
return { media: [] };
}
const policy: MSTeamsAttachmentFetchPolicy = resolveAttachmentFetchPolicy({
allowHosts: params.allowHosts,
authAllowHosts: params.authAllowHosts,
});
const ssrfPolicy = resolveMediaSsrfPolicy(policy.allowHosts);
const messageUrl = params.messageUrl;
const debugLog =
params.log ?? (params.logger as MSTeamsGraphMediaLogger | undefined) ?? undefined;
let accessToken: string;
try {
accessToken = await params.tokenProvider.getAccessToken("https://graph.microsoft.com");
} catch (err) {
debugLog?.debug?.("graph media token acquisition failed", {
messageUrl,
error: err instanceof Error ? err.message : String(err),
});
params.logger?.warn?.("msteams graph token acquisition failed", {
error: err instanceof Error ? err.message : String(err),
});
return { media: [], messageUrl, tokenError: true };
}
const fetchFn = params.fetchFn ?? fetch;
const sharePointMedia: MSTeamsInboundMedia[] = [];
const downloadedReferenceUrls = new Set<string>();
let messageAttachments: GraphAttachment[] = [];
let messageStatus: number | undefined;
try {
const { response: msgRes, release } = await fetchWithSsrFGuard({
url: messageUrl,
fetchImpl: fetchFn,
init: {
headers: ensureUserAgentHeader({ Authorization: `Bearer ${accessToken}` }),
},
policy: ssrfPolicy,
auditContext: "msteams.graph.message",
});
try {
messageStatus = msgRes.status;
if (msgRes.ok) {
let msgData: {
body?: { content?: string; contentType?: string };
attachments?: GraphAttachment[];
};
try {
msgData = (await msgRes.json()) as typeof msgData;
} catch (err) {
debugLog?.debug?.("graph media message parse failed", {
messageUrl,
error: err instanceof Error ? err.message : String(err),
});
params.logger?.warn?.("msteams graph message parse failed", {
error: err instanceof Error ? err.message : String(err),
messageUrl,
});
msgData = {};
}
messageAttachments = Array.isArray(msgData.attachments) ? msgData.attachments : [];
const spAttachments = messageAttachments.filter(
(a) => a.contentType === "reference" && a.contentUrl && a.name,
);
for (const att of spAttachments) {
const name = att.name ?? "file";
const shareUrl = att.contentUrl ?? "";
if (!shareUrl) {
continue;
}
try {
const sharesUrl = `${GRAPH_ROOT}/shares/${encodeGraphShareId(shareUrl)}/driveItem/content`;
if (!isUrlAllowed(sharesUrl, policy.allowHosts)) {
debugLog?.debug?.("graph media sharepoint url not in allowHosts", {
messageUrl,
sharesUrl,
});
continue;
}
const media = await downloadAndStoreMSTeamsRemoteMedia({
url: sharesUrl,
filePathHint: name,
maxBytes: params.maxBytes,
contentTypeHint: "application/octet-stream",
preserveFilenames: params.preserveFilenames,
ssrfPolicy,
useDirectFetch: true,
fetchImpl: async (input, init) => {
const requestUrl = resolveRequestUrl(input);
const headers = ensureUserAgentHeader(init?.headers);
applyAuthorizationHeaderForUrl({
headers,
url: requestUrl,
authAllowHosts: policy.authAllowHosts,
bearerToken: accessToken,
});
return await safeFetchWithPolicy({
url: requestUrl,
policy,
fetchFn,
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
requestInit: {
...init,
headers,
},
resolveFn: params.resolveFn,
});
},
});
sharePointMedia.push(media);
downloadedReferenceUrls.add(shareUrl);
} catch (err) {
params.logger?.warn?.("msteams SharePoint reference download failed", {
error: err instanceof Error ? err.message : String(err),
name,
});
}
}
} else {
debugLog?.debug?.("graph media message fetch not ok", {
messageUrl,
status: messageStatus,
});
}
} finally {
await release();
}
} catch (err) {
debugLog?.debug?.("graph media message fetch failed", {
messageUrl,
error: err instanceof Error ? err.message : String(err),
});
params.logger?.warn?.("msteams graph message fetch failed", {
error: err instanceof Error ? err.message : String(err),
});
}
const hosted = await downloadGraphHostedContent({
accessToken,
messageUrl,
maxBytes: params.maxBytes,
fetchFn: params.fetchFn,
preserveFilenames: params.preserveFilenames,
ssrfPolicy,
logger: params.logger,
});
const normalizedAttachments = messageAttachments.map(normalizeGraphAttachment);
const filteredAttachments =
sharePointMedia.length > 0
? normalizedAttachments.filter((att) => {
const contentType = normalizeOptionalLowercaseString(att.contentType);
if (contentType !== "reference") {
return true;
}
const url = typeof att.contentUrl === "string" ? att.contentUrl : "";
if (!url) {
return true;
}
return !downloadedReferenceUrls.has(url);
})
: normalizedAttachments;
let attachmentMedia: MSTeamsInboundMedia[] = [];
try {
attachmentMedia = await downloadMSTeamsAttachments({
attachments: filteredAttachments,
maxBytes: params.maxBytes,
tokenProvider: params.tokenProvider,
allowHosts: policy.allowHosts,
authAllowHosts: policy.authAllowHosts,
fetchFn: params.fetchFn,
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
resolveFn: params.resolveFn,
preserveFilenames: params.preserveFilenames,
logger: params.logger,
});
} catch (err) {
params.logger?.warn?.("msteams graph attachment download failed", {
error: err instanceof Error ? err.message : String(err),
messageUrl,
});
}
return {
media: [...sharePointMedia, ...hosted.media, ...attachmentMedia],
hostedCount: hosted.count,
attachmentCount: filteredAttachments.length + sharePointMedia.length,
hostedStatus: hosted.status,
attachmentStatus: messageStatus,
messageUrl,
};
}

View File

@@ -0,0 +1,220 @@
// Msteams plugin module implements html behavior.
import {
ATTACHMENT_TAG_RE,
extractHtmlFromAttachment,
extractInlineImageCandidates,
IMG_SRC_RE,
isDownloadableAttachment,
isLikelyImageAttachment,
normalizeContentType,
safeHostForUrl,
} from "./shared.js";
import type { MSTeamsAttachmentLike, MSTeamsHtmlAttachmentSummary } from "./types.js";
/**
* Extract every `<attachment id="...">` reference from the HTML attachments in
* the inbound activity. Returns the complete (non-sliced) list; callers that
* need a capped diagnostic summary can truncate after calling this helper.
*/
export function extractMSTeamsHtmlAttachmentIds(
attachments: MSTeamsAttachmentLike[] | undefined,
): string[] {
const list = Array.isArray(attachments) ? attachments : [];
if (list.length === 0) {
return [];
}
const ids = new Set<string>();
for (const att of list) {
const html = extractHtmlFromAttachment(att);
if (!html) {
continue;
}
ATTACHMENT_TAG_RE.lastIndex = 0;
let match: RegExpExecArray | null = ATTACHMENT_TAG_RE.exec(html);
while (match) {
const id = match[1]?.trim();
if (id) {
ids.add(id);
}
match = ATTACHMENT_TAG_RE.exec(html);
}
}
return Array.from(ids);
}
export function summarizeMSTeamsHtmlAttachments(
attachments: MSTeamsAttachmentLike[] | undefined,
): MSTeamsHtmlAttachmentSummary | undefined {
const list = Array.isArray(attachments) ? attachments : [];
if (list.length === 0) {
return undefined;
}
let htmlAttachments = 0;
let imgTags = 0;
let dataImages = 0;
let cidImages = 0;
const srcHosts = new Set<string>();
let attachmentTags = 0;
const attachmentIds = new Set<string>();
for (const att of list) {
const html = extractHtmlFromAttachment(att);
if (!html) {
continue;
}
htmlAttachments += 1;
IMG_SRC_RE.lastIndex = 0;
let match: RegExpExecArray | null = IMG_SRC_RE.exec(html);
while (match) {
imgTags += 1;
const src = match[1]?.trim();
if (src) {
if (src.startsWith("data:")) {
dataImages += 1;
} else if (src.startsWith("cid:")) {
cidImages += 1;
} else {
srcHosts.add(safeHostForUrl(src));
}
}
match = IMG_SRC_RE.exec(html);
}
ATTACHMENT_TAG_RE.lastIndex = 0;
let attachmentMatch: RegExpExecArray | null = ATTACHMENT_TAG_RE.exec(html);
while (attachmentMatch) {
attachmentTags += 1;
const id = attachmentMatch[1]?.trim();
if (id) {
attachmentIds.add(id);
}
attachmentMatch = ATTACHMENT_TAG_RE.exec(html);
}
}
if (htmlAttachments === 0) {
return undefined;
}
return {
htmlAttachments,
imgTags,
dataImages,
cidImages,
srcHosts: Array.from(srcHosts).slice(0, 5),
attachmentTags,
attachmentIds: Array.from(attachmentIds).slice(0, 5),
};
}
export function buildMSTeamsAttachmentPlaceholder(
attachments: MSTeamsAttachmentLike[] | undefined,
limits?: { maxInlineBytes?: number; maxInlineTotalBytes?: number },
): string {
return resolveMSTeamsInboundAttachmentPresentation(attachments, limits).placeholder;
}
function isAdvertisedFileAttachment(attachment: MSTeamsAttachmentLike): boolean {
const contentType = normalizeContentType(attachment.contentType) ?? "";
if (
contentType.startsWith("text/html") ||
contentType.startsWith("application/vnd.microsoft.card.") ||
contentType.startsWith("application/vnd.microsoft.teams.card.")
) {
return false;
}
return Boolean(
isDownloadableAttachment(attachment) ||
isLikelyImageAttachment(attachment) ||
attachment.name?.trim() ||
contentType,
);
}
function countDistinctInlineImages(attachments: MSTeamsAttachmentLike[]): number {
const seenReferences = new Set<string>();
let count = 0;
for (const attachment of attachments) {
const html = extractHtmlFromAttachment(attachment);
if (!html) {
continue;
}
IMG_SRC_RE.lastIndex = 0;
let match: RegExpExecArray | null = IMG_SRC_RE.exec(html);
while (match) {
const src = match[1]?.trim();
if (src?.startsWith("data:")) {
count += 1;
} else if (src && !seenReferences.has(src)) {
seenReferences.add(src);
count += 1;
}
match = IMG_SRC_RE.exec(html);
}
}
return count;
}
function countDistinctInlineCandidates(
attachments: MSTeamsAttachmentLike[],
limits?: { maxInlineBytes?: number; maxInlineTotalBytes?: number },
): number {
const seenUrls = new Set<string>();
let dataCount = 0;
for (const candidate of extractInlineImageCandidates(attachments, limits)) {
if (candidate.kind === "data") {
dataCount += 1;
} else {
seenUrls.add(candidate.url);
}
}
return dataCount + seenUrls.size;
}
function countUnrepresentedHtmlAttachmentIds(attachments: MSTeamsAttachmentLike[]): number {
const representedIds = new Set<string>();
for (const attachment of attachments) {
const contentType = normalizeContentType(attachment.contentType) ?? "";
if (contentType.startsWith("text/html")) {
continue;
}
const id = attachment.id?.trim();
if (id) {
representedIds.add(id);
}
}
return extractMSTeamsHtmlAttachmentIds(attachments).filter((id) => !representedIds.has(id))
.length;
}
export function resolveMSTeamsInboundAttachmentPresentation(
attachments: MSTeamsAttachmentLike[] | undefined,
limits?: { maxInlineBytes?: number; maxInlineTotalBytes?: number },
): { placeholder: string; expectedMediaCount: number } {
const list = Array.isArray(attachments) ? attachments : [];
if (list.length === 0) {
return { placeholder: "", expectedMediaCount: 0 };
}
const fileAttachments = list.filter(isAdvertisedFileAttachment);
const inlinePlaceholderCount = countDistinctInlineCandidates(list, limits);
const inlineExpectedCount = countDistinctInlineImages(list);
// Teams HTML uses <attachment> tags as references. A matching attachment
// entry is the same resource (and may be a card), so count only unmatched
// IDs that need the Graph/Bot Framework hosted-content fallback.
const htmlAttachmentCount = countUnrepresentedHtmlAttachmentIds(list);
const expectedMediaCount = fileAttachments.length + inlineExpectedCount + htmlAttachmentCount;
if (expectedMediaCount === 0) {
return { placeholder: "", expectedMediaCount: 0 };
}
const totalImages =
fileAttachments.filter(isLikelyImageAttachment).length + inlinePlaceholderCount;
if (totalImages > 0) {
return {
placeholder: `<media:image>${totalImages > 1 ? ` (${totalImages} images)` : ""}`,
expectedMediaCount,
};
}
return {
placeholder: `<media:document>${expectedMediaCount > 1 ? ` (${expectedMediaCount} files)` : ""}`,
expectedMediaCount,
};
}

View File

@@ -0,0 +1,15 @@
// Msteams plugin module implements payload behavior.
import { buildMediaPayload } from "../../runtime-api.js";
export function buildMSTeamsMediaPayload(
mediaList: Array<{ path: string; contentType?: string }>,
): {
MediaPath?: string;
MediaType?: string;
MediaUrl?: string;
MediaPaths?: string[];
MediaUrls?: string[];
MediaTypes?: string[];
} {
return buildMediaPayload(mediaList, { preserveMediaTypeCardinality: true });
}

View File

@@ -0,0 +1,188 @@
// Msteams tests cover remote media plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
// Mock the runtime so we can assert whether the strict-dispatcher path
// (`saveRemoteMedia`) was invoked versus the new direct-fetch path added
// for issue #63396 (Node 24+ / undici v7 compat).
const runtimeSaveRemoteMediaMock = vi.fn(
async (
_params: unknown,
): Promise<{
id: string;
path: string;
size: number;
contentType?: string;
fileName?: string;
}> => ({
id: "saved",
path: "/tmp/saved.png",
size: 42,
contentType: "image/png",
}),
);
const runtimeDetectMimeMock = vi.fn(async () => "image/png");
const runtimeSaveMediaBufferMock = vi.fn(async (_buf: Buffer, contentType?: string) => ({
id: "saved",
path: "/tmp/saved.png",
size: 42,
contentType: contentType ?? "image/png",
}));
const saveResponseMediaMock = vi.hoisted(() =>
vi.fn(async (response: Response, options: { maxBytes?: number }) => {
if (!response.ok) {
const statusText = response.statusText ? ` ${response.statusText}` : "";
throw new Error(`HTTP ${response.status}${statusText}`);
}
const contentLength = Number(response.headers.get("content-length"));
if (Number.isFinite(contentLength) && options.maxBytes && contentLength > options.maxBytes) {
throw new Error(`content length ${contentLength} exceeds maxBytes ${options.maxBytes}`);
}
return {
id: "saved",
path: "/tmp/saved.png",
size: 42,
contentType: response.headers.get("content-type") ?? "image/png",
};
}),
);
vi.mock("openclaw/plugin-sdk/media-runtime", async () => ({
saveResponseMedia: saveResponseMediaMock,
}));
vi.mock("../runtime.js", () => ({
getMSTeamsRuntime: () => ({
media: { detectMime: runtimeDetectMimeMock },
channel: {
media: {
saveRemoteMedia: runtimeSaveRemoteMediaMock,
saveMediaBuffer: runtimeSaveMediaBufferMock,
},
},
}),
}));
import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
const PNG_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
function jsonResponse(body: BodyInit, init?: ResponseInit): Response {
return new Response(body, init);
}
function requireFirstFetchUrl(mock: ReturnType<typeof vi.fn>): unknown {
const [call] = mock.mock.calls;
if (!call) {
throw new Error("expected direct fetch call");
}
return call[0];
}
describe("downloadAndStoreMSTeamsRemoteMedia", () => {
beforeEach(() => {
runtimeSaveRemoteMediaMock.mockClear();
saveResponseMediaMock.mockClear();
runtimeDetectMimeMock.mockClear();
runtimeSaveMediaBufferMock.mockClear();
});
describe("useDirectFetch: true (Node 24+ / undici v7 path for issue #63396)", () => {
it("bypasses readRemoteMediaBuffer and calls the supplied fetchImpl directly", async () => {
// `fetchImpl` here simulates the "pre-validated hostname" contract from
// `safeFetchWithPolicy`: the caller has already enforced the allowlist,
// so the strict SSRF dispatcher is not needed.
const fetchImpl = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
jsonResponse(PNG_BYTES, { status: 200, headers: { "content-type": "image/png" } }),
);
const result = await downloadAndStoreMSTeamsRemoteMedia({
url: "https://graph.microsoft.com/v1.0/shares/abc/driveItem/content",
filePathHint: "file.png",
maxBytes: 1024,
useDirectFetch: true,
fetchImpl,
});
expect(fetchImpl).toHaveBeenCalledTimes(1);
const calledUrl = requireFirstFetchUrl(fetchImpl);
expect(calledUrl).toBe("https://graph.microsoft.com/v1.0/shares/abc/driveItem/content");
expect(runtimeSaveRemoteMediaMock).not.toHaveBeenCalled();
expect(result.path).toBe("/tmp/saved.png");
});
it("surfaces HTTP errors as exceptions (no silent drop)", async () => {
const fetchImpl = vi.fn(async () => jsonResponse("nope", { status: 403 }));
await expect(
downloadAndStoreMSTeamsRemoteMedia({
url: "https://graph.microsoft.com/v1.0/shares/abc/driveItem/content",
filePathHint: "file.png",
maxBytes: 1024,
useDirectFetch: true,
fetchImpl,
}),
).rejects.toThrow(/HTTP 403/);
expect(runtimeSaveRemoteMediaMock).not.toHaveBeenCalled();
});
it("rejects a response whose Content-Length exceeds maxBytes", async () => {
const fetchImpl = vi.fn(async () =>
jsonResponse(PNG_BYTES, {
status: 200,
headers: { "content-length": "999999" },
}),
);
await expect(
downloadAndStoreMSTeamsRemoteMedia({
url: "https://graph.microsoft.com/v1.0/shares/abc/driveItem/content",
filePathHint: "file.png",
maxBytes: 1024,
useDirectFetch: true,
fetchImpl,
}),
).rejects.toThrow(/exceeds maxBytes/);
expect(runtimeSaveRemoteMediaMock).not.toHaveBeenCalled();
});
it("falls back to the runtime saveRemoteMedia path when useDirectFetch is omitted", async () => {
// Non-SharePoint caller, no pre-validated fetchImpl: make sure the strict
// SSRF dispatcher path is still used.
runtimeSaveRemoteMediaMock.mockResolvedValueOnce({
id: "saved",
path: "/tmp/saved.png",
size: 42,
contentType: "image/png",
fileName: "file.png",
});
await downloadAndStoreMSTeamsRemoteMedia({
url: "https://tenant.sharepoint.com/file.png",
filePathHint: "file.png",
maxBytes: 1024,
});
expect(runtimeSaveRemoteMediaMock).toHaveBeenCalledTimes(1);
});
it("does not use the direct path when useDirectFetch is true but fetchImpl is missing", async () => {
runtimeSaveRemoteMediaMock.mockResolvedValueOnce({
id: "saved",
path: "/tmp/saved.png",
size: 42,
contentType: "image/png",
});
await downloadAndStoreMSTeamsRemoteMedia({
url: "https://graph.microsoft.com/v1.0/shares/abc/driveItem/content",
filePathHint: "file.png",
maxBytes: 1024,
useDirectFetch: true,
});
// Without a fetchImpl to delegate to, we must fall back to the runtime
// path rather than crashing.
expect(runtimeSaveRemoteMediaMock).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,77 @@
// Msteams plugin module implements remote media behavior.
import { saveResponseMedia, type SavedRemoteMedia } from "openclaw/plugin-sdk/media-runtime";
import type { SsrFPolicy } from "../../runtime-api.js";
import { getMSTeamsRuntime } from "../runtime.js";
import { inferPlaceholder } from "./shared.js";
import type { MSTeamsInboundMedia } from "./types.js";
type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
/**
* Direct save path used when the caller supplies the already-guarded fetch
* implementation. This lets Teams-specific auth fallback own the request
* sequence while keeping redirect and DNS pinning inside `safeFetchWithPolicy`.
*/
async function saveRemoteMediaDirect(params: {
url: string;
filePathHint: string;
fetchImpl: FetchLike;
maxBytes: number;
contentTypeHint?: string;
originalFilename?: string;
}): Promise<SavedRemoteMedia> {
const response = await params.fetchImpl(params.url, { redirect: "follow" });
return await saveResponseMedia(response, {
sourceUrl: params.url,
filePathHint: params.filePathHint,
maxBytes: params.maxBytes,
fallbackContentType: params.contentTypeHint,
originalFilename: params.originalFilename,
});
}
export async function downloadAndStoreMSTeamsRemoteMedia(params: {
url: string;
filePathHint: string;
maxBytes: number;
fetchImpl?: FetchLike;
ssrfPolicy?: SsrFPolicy;
contentTypeHint?: string;
placeholder?: string;
preserveFilenames?: boolean;
/**
* Opt into the Teams-specific guarded fetch path. Only safe when the
* supplied `fetchImpl` enforces the attachment fetch policy itself.
*/
useDirectFetch?: boolean;
}): Promise<MSTeamsInboundMedia> {
const originalFilename = params.preserveFilenames ? params.filePathHint : undefined;
let saved: SavedRemoteMedia;
if (params.useDirectFetch && params.fetchImpl) {
saved = await saveRemoteMediaDirect({
url: params.url,
filePathHint: params.filePathHint,
fetchImpl: params.fetchImpl,
maxBytes: params.maxBytes,
contentTypeHint: params.contentTypeHint,
originalFilename,
});
} else {
saved = await getMSTeamsRuntime().channel.media.saveRemoteMedia({
url: params.url,
fetchImpl: params.fetchImpl,
filePathHint: params.filePathHint,
maxBytes: params.maxBytes,
ssrfPolicy: params.ssrfPolicy,
fallbackContentType: params.contentTypeHint,
originalFilename,
});
}
return {
path: saved.path,
contentType: saved.contentType,
placeholder:
params.placeholder ??
inferPlaceholder({ contentType: saved.contentType, fileName: params.filePathHint }),
};
}

View File

@@ -0,0 +1,680 @@
// Msteams tests cover shared plugin behavior.
import { describe, expect, it, vi } from "vitest";
import {
applyAuthorizationHeaderForUrl,
encodeGraphShareId,
extractInlineImageCandidates,
isGraphSharedLinkUrl,
isPrivateOrReservedIP,
isUrlAllowed,
resolveAndValidateIP,
resolveAttachmentFetchPolicy,
resolveAllowedHosts,
resolveAuthAllowedHosts,
resolveMediaSsrfPolicy,
safeFetch,
safeFetchWithPolicy,
tryBuildGraphSharesUrlForSharedLink,
} from "./shared.js";
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 });
});
}
function fetchInitAt(fetchMock: ReturnType<typeof vi.fn>, index: number): unknown {
const call = fetchMock.mock.calls[index];
if (!call) {
throw new Error(`expected fetch call ${index}`);
}
return call[1];
}
async function expectSafeFetchStatus(params: {
fetchMock: ReturnType<typeof vi.fn>;
url: string;
allowHosts: string[];
expectedStatus: number;
resolveFn?: typeof publicResolve;
}) {
const res = await safeFetch({
url: params.url,
allowHosts: params.allowHosts,
fetchFn: params.fetchMock as unknown as typeof fetch,
resolveFn: params.resolveFn ?? publicResolve,
});
expect(res.status).toBe(params.expectedStatus);
await res.body?.cancel();
return res;
}
describe("msteams attachment allowlists", () => {
it("normalizes wildcard host lists", () => {
expect(resolveAllowedHosts(["*", "graph.microsoft.com"])).toEqual(["*"]);
expect(resolveAuthAllowedHosts(["*", "graph.microsoft.com"])).toEqual(["*"]);
});
it("resolves a normalized attachment fetch policy", () => {
expect(
resolveAttachmentFetchPolicy({
allowHosts: ["sharepoint.com"],
authAllowHosts: ["graph.microsoft.com"],
}),
).toEqual({
allowHosts: ["sharepoint.com"],
authAllowHosts: ["graph.microsoft.com"],
});
});
it("allows Azure China Bot Framework attachment URLs with auth by default", () => {
const policy = resolveAttachmentFetchPolicy();
const url = "https://msteams.botframework.azure.cn/teams/v3/attachments/att-1/views/original";
const headers = new Headers();
expect(isUrlAllowed(url, policy.allowHosts)).toBe(true);
applyAuthorizationHeaderForUrl({
headers,
url,
authAllowHosts: policy.authAllowHosts,
bearerToken: "token-1",
});
expect(headers.get("Authorization")).toBe("Bearer token-1");
});
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("builds shared SSRF policy from suffix allowlist", () => {
expect(resolveMediaSsrfPolicy(["sharepoint.com"])).toEqual({
hostnameAllowlist: ["sharepoint.com", "*.sharepoint.com"],
});
expect(resolveMediaSsrfPolicy(["*"])).toBeUndefined();
});
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 });
});
await expectSafeFetchStatus({
fetchMock,
url: "https://teams.sharepoint.com/file.pdf",
allowHosts: ["sharepoint.com"],
expectedStatus: 200,
});
expect(fetchMock).toHaveBeenCalledOnce();
// Should have used redirect: "manual"
expect(fetchInitAt(fetchMock, 0)).toHaveProperty("redirect", "manual");
});
it("pins the validated DNS result into the request dispatcher", async () => {
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => {
return new Response("ok", { status: 200 });
});
await expectSafeFetchStatus({
fetchMock,
url: "https://teams.sharepoint.com/file.pdf",
allowHosts: ["sharepoint.com"],
expectedStatus: 200,
});
expect(fetchInitAt(fetchMock, 0)).toHaveProperty("dispatcher");
});
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",
});
await expectSafeFetchStatus({
fetchMock,
url: "https://teams.sharepoint.com/file.pdf",
allowHosts: ["sharepoint.com"],
expectedStatus: 200,
});
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("fails explicitly for custom fetch functions that cannot receive the pinned dispatcher", async () => {
let called = false;
const customFetch = async () => {
called = true;
return new Response("ok", { status: 200 });
};
await expect(
safeFetch({
url: "https://teams.sharepoint.com/file.pdf",
allowHosts: ["sharepoint.com"],
fetchFn: customFetch as typeof fetch,
resolveFn: publicResolve,
}),
).rejects.toThrow("fetchFnSupportsDispatcher");
expect(called).toBe(false);
});
it("returns the redirect response when dispatcher is provided by an outer guard", async () => {
const redirectedTo = "https://cdn.sharepoint.com/storage/file.pdf";
const fetchMock = mockFetchWithRedirect({
"https://teams.sharepoint.com/file.pdf": redirectedTo,
});
const res = await safeFetch({
url: "https://teams.sharepoint.com/file.pdf",
allowHosts: ["sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
requestInit: { dispatcher: {} } as RequestInit,
resolveFn: publicResolve,
});
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe(redirectedTo);
expect(fetchMock).toHaveBeenCalledOnce();
});
it("still enforces allowlist checks before returning dispatcher-mode redirects", 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,
requestInit: { dispatcher: {} } as RequestInit,
resolveFn: publicResolve,
}),
).rejects.toThrow("blocked by allowlist");
expect(fetchMock).toHaveBeenCalledOnce();
});
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("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/internal");
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("private/internal");
expect(fetchMock).not.toHaveBeenCalled();
});
it("blocks private hosts with the default resolver", async () => {
const fetchMock = vi.fn();
await expect(
safeFetch({
url: "https://localhost/file.pdf",
allowHosts: ["localhost"],
fetchFn: fetchMock as unknown as typeof fetch,
}),
).rejects.toThrow("private/internal");
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("DNS failure");
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);
await res.body?.cancel();
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("https");
});
it("strips authorization across redirects outside auth allowlist", async () => {
const seenAuth: string[] = [];
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
const auth = new Headers(init?.headers).get("authorization") ?? "";
seenAuth.push(`${url}|${auth}`);
if (url === "https://graph.microsoft.com/v1.0/me/photo") {
return new Response(null, {
status: 302,
headers: { location: "https://cdn.sharepoint.com/storage/file.pdf" },
});
}
return new Response("ok", { status: 200 });
});
const headers = new Headers({ Authorization: "Bearer secret" });
const res = await safeFetch({
url: "https://graph.microsoft.com/v1.0/me/photo",
allowHosts: ["graph.microsoft.com", "sharepoint.com"],
authorizationAllowHosts: ["graph.microsoft.com"],
fetchFn: fetchMock as unknown as typeof fetch,
requestInit: { headers },
resolveFn: publicResolve,
});
expect(res.status).toBe(200);
await res.body?.cancel();
expect(seenAuth[0]).toContain("Bearer secret");
expect(seenAuth[1]).toMatch(/\|$/);
});
it("keeps authorization across redirects inside auth allowlist", async () => {
const seenAuth: string[] = [];
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
const auth = new Headers(init?.headers).get("authorization") ?? "";
seenAuth.push(`${url}|${auth}`);
if (url === "https://graph.microsoft.com/file.pdf") {
return new Response(null, {
status: 302,
headers: { location: "https://cdn.sharepoint.com/storage/file.pdf" },
});
}
return new Response("ok", { status: 200 });
});
const headers = new Headers({ Authorization: "Bearer secret" });
const res = await safeFetch({
url: "https://graph.microsoft.com/file.pdf",
allowHosts: ["graph.microsoft.com", "sharepoint.com"],
authorizationAllowHosts: ["graph.microsoft.com", "sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
requestInit: { headers },
resolveFn: publicResolve,
});
expect(res.status).toBe(200);
await res.body?.cancel();
expect(seenAuth[0]).toContain("Bearer secret");
expect(seenAuth[1]).toContain("Bearer secret");
});
it("keeps authorization across HTTPS redirects when auth allowlist is wildcard", async () => {
const seenAuth: string[] = [];
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
const auth = new Headers(init?.headers).get("authorization") ?? "";
seenAuth.push(`${url}|${auth}`);
if (url === "https://graph.microsoft.com/file.pdf") {
return new Response(null, {
status: 302,
headers: { location: "https://cdn.example.com/storage/file.pdf" },
});
}
return new Response("ok", { status: 200 });
});
const headers = new Headers({ Authorization: "Bearer secret" });
const res = await safeFetch({
url: "https://graph.microsoft.com/file.pdf",
allowHosts: ["*"],
authorizationAllowHosts: ["*"],
fetchFn: fetchMock as unknown as typeof fetch,
requestInit: { headers },
resolveFn: publicResolve,
});
expect(res.status).toBe(200);
await res.body?.cancel();
expect(seenAuth[0]).toContain("Bearer secret");
expect(seenAuth[1]).toContain("Bearer secret");
});
it("strips authorization from the initial fetch outside auth allowlist", async () => {
const seenAuth: string[] = [];
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
seenAuth.push(new Headers(init?.headers).get("authorization") ?? "");
expect(url).toBe("https://attacker.trafficmanager.net/v3/attachments/att-1");
return new Response("ok", { status: 200 });
});
const res = await safeFetch({
url: "https://attacker.trafficmanager.net/v3/attachments/att-1",
allowHosts: ["trafficmanager.net"],
authorizationAllowHosts: ["smba.trafficmanager.net"],
fetchFn: fetchMock as unknown as typeof fetch,
requestInit: { headers: { Authorization: "Bearer secret" } },
resolveFn: publicResolve,
});
expect(res.status).toBe(200);
expect(seenAuth).toEqual([""]);
});
});
describe("attachment fetch auth helpers", () => {
it("sets and clears authorization header by auth allowlist", () => {
const headers = new Headers();
applyAuthorizationHeaderForUrl({
headers,
url: "https://graph.microsoft.com/v1.0/me",
authAllowHosts: ["graph.microsoft.com"],
bearerToken: "token-1",
});
expect(headers.get("authorization")).toBe("Bearer token-1");
applyAuthorizationHeaderForUrl({
headers,
url: "https://evil.example.com/collect",
authAllowHosts: ["graph.microsoft.com"],
bearerToken: "token-1",
});
expect(headers.get("authorization")).toBeNull();
});
it("safeFetchWithPolicy forwards policy allowlists", async () => {
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => {
return new Response("ok", { status: 200 });
});
const res = await safeFetchWithPolicy({
url: "https://teams.sharepoint.com/file.pdf",
policy: resolveAttachmentFetchPolicy({
allowHosts: ["sharepoint.com"],
authAllowHosts: ["graph.microsoft.com"],
}),
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolve,
});
expect(res.status).toBe(200);
await res.body?.cancel();
expect(fetchMock).toHaveBeenCalledOnce();
});
});
describe("Graph shared-link helpers", () => {
it.each([
["https://contoso.sharepoint.com/personal/user/Documents/report.pdf", true],
["https://contoso.sharepoint.us/sites/team/file.docx", true],
["https://contoso.sharepoint.cn/file", true],
["https://tenant-my.sharepoint.com/:b:/g/personal/file", true],
["https://1drv.ms/b/s!AkxYabc", true],
["https://onedrive.live.com/view.aspx?resid=ABC", true],
["https://onedrive.com/share/abc", true],
["https://graph.microsoft.com/v1.0/me", false],
["https://smba.trafficmanager.net/amer/v3", false],
["https://example.com/file.pdf", false],
["not-a-url", false],
])("isGraphSharedLinkUrl(%s) === %s", (url, expected) => {
expect(isGraphSharedLinkUrl(url)).toBe(expected);
});
it("encodeGraphShareId uses u! + base64url without padding", () => {
// Graph docs example: encoding "https://onedrive.live.com/redir?resid=..."
// should yield u!aHR0cHM6... (base64url, no '+', '/', or trailing '=').
const url = "https://contoso.sharepoint.com/sites/a/Shared Documents/file.pdf";
const shareId = encodeGraphShareId(url);
expect(shareId.startsWith("u!")).toBe(true);
const encoded = shareId.slice(2);
// base64url alphabet is A-Z, a-z, 0-9, '-', '_' (no padding).
expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/);
// Round-trip check: decoding yields the original URL.
const decoded = Buffer.from(encoded, "base64url").toString("utf8");
expect(decoded).toBe(url);
});
it("encodeGraphShareId swaps '+' and '/' for '-' and '_'", () => {
// A URL whose standard base64 contains '+' and '/' chars.
// Choose an input that base64 encodes with those characters.
const url = "https://host.sharepoint.com/sites/path?x=???";
const shareId = encodeGraphShareId(url);
const encoded = shareId.slice(2);
expect(encoded).not.toContain("+");
expect(encoded).not.toContain("/");
expect(encoded).not.toContain("=");
});
it("tryBuildGraphSharesUrlForSharedLink rewrites SharePoint URLs", () => {
const url = "https://contoso.sharepoint.com/personal/user/Documents/report.pdf";
const result = tryBuildGraphSharesUrlForSharedLink(url);
expect(result).toBe(
`https://graph.microsoft.com/v1.0/shares/${encodeGraphShareId(url)}/driveItem/content`,
);
});
it("tryBuildGraphSharesUrlForSharedLink rewrites OneDrive URLs", () => {
const url = "https://1drv.ms/b/s!AkxYabcdefg";
const result = tryBuildGraphSharesUrlForSharedLink(url);
expect(result).toBe(
`https://graph.microsoft.com/v1.0/shares/${encodeGraphShareId(url)}/driveItem/content`,
);
});
it("tryBuildGraphSharesUrlForSharedLink returns undefined for non-shared URLs", () => {
expect(
tryBuildGraphSharesUrlForSharedLink("https://graph.microsoft.com/v1.0/me"),
).toBeUndefined();
expect(tryBuildGraphSharesUrlForSharedLink("https://example.com/file.pdf")).toBeUndefined();
expect(tryBuildGraphSharesUrlForSharedLink("not-a-url")).toBeUndefined();
});
});
describe("msteams inline image limits", () => {
const smallPngDataUrl = "data:image/png;base64,aGVsbG8="; // "hello" (5 bytes)
it("rejects inline data images above per-image limit", () => {
const attachments = [
{
contentType: "text/html",
content: `<img src="${smallPngDataUrl}" />`,
},
];
const out = extractInlineImageCandidates(attachments, { maxInlineBytes: 4 });
expect(out).toStrictEqual([]);
});
it("accepts inline data images within limit", () => {
const attachments = [
{
contentType: "text/html",
content: `<img src="${smallPngDataUrl}" />`,
},
];
const out = extractInlineImageCandidates(attachments, { maxInlineBytes: 10 });
expect(out.length).toBe(1);
expect(out[0]?.kind).toBe("data");
if (out[0]?.kind === "data") {
expect(out[0].data.byteLength).toBeGreaterThan(0);
expect(out[0].contentType).toBe("image/png");
}
});
it("rejects inline data images with malformed base64 padding", () => {
const attachments = [
{
contentType: "text/html",
content: `<img src="data:image/png;base64,aGV=sbG8=" />`,
},
];
const out = extractInlineImageCandidates(attachments, { maxInlineBytes: 10 });
expect(out).toStrictEqual([]);
});
it("enforces cumulative inline size limit across attachments", () => {
const attachments = [
{
contentType: "text/html",
content: `<img src="${smallPngDataUrl}" />`,
},
{
contentType: "text/html",
content: `<img src="${smallPngDataUrl}" />`,
},
];
const out = extractInlineImageCandidates(attachments, {
maxInlineBytes: 10,
maxInlineTotalBytes: 6,
});
expect(out.length).toBe(1);
expect(out[0]?.kind).toBe("data");
});
});

View File

@@ -0,0 +1,730 @@
// Msteams plugin module implements shared behavior.
import { Buffer } from "node:buffer";
import { lookup } from "node:dns/promises";
import {
buildHostnameAllowlistPolicyFromSuffixAllowlist,
isHttpsUrlAllowedByHostnameSuffixAllowlist,
isPrivateIpAddress,
normalizeHostnameSuffixAllowlist,
type SsrFPolicy,
} from "openclaw/plugin-sdk/ssrf-policy";
import { fetchWithSsrFGuard, type LookupFn } from "openclaw/plugin-sdk/ssrf-runtime";
import {
isRecord,
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { responseWithRelease } from "../response-with-release.js";
import type { MSTeamsAttachmentLike } from "./types.js";
type InlineImageCandidate =
| {
kind: "data";
data: Buffer;
contentType?: string;
placeholder: string;
}
| {
kind: "url";
url: string;
contentType?: string;
fileHint?: string;
placeholder: string;
};
type InlineImageLimitOptions = {
maxInlineBytes?: number;
maxInlineTotalBytes?: number;
};
const IMAGE_EXT_RE = /\.(avif|bmp|gif|heic|heif|jpe?g|png|tiff?|webp)$/i;
export const IMG_SRC_RE = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
export const ATTACHMENT_TAG_RE = /<attachment[^>]+id=["']([^"']+)["'][^>]*>/gi;
const DEFAULT_MEDIA_HOST_ALLOWLIST = [
"graph.microsoft.com",
"graph.microsoft.us",
"graph.microsoft.de",
"graph.microsoft.cn",
"sharepoint.com",
"sharepoint.us",
"sharepoint.de",
"sharepoint.cn",
"sharepoint-df.com",
"1drv.ms",
"onedrive.com",
"teams.microsoft.com",
"teams.cdn.office.net",
"statics.teams.cdn.office.net",
"office.com",
"office.net",
// Azure Media Services / Skype CDN for clipboard-pasted images
"asm.skype.com",
"ams.skype.com",
"media.ams.skype.com",
// Bot Framework attachment URLs
"trafficmanager.net",
"botframework.azure.cn",
"blob.core.windows.net",
"azureedge.net",
"microsoft.com",
] as const;
const DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST = [
"api.botframework.com",
"botframework.com",
// Bot Framework Service URL (smba.trafficmanager.net) used for outbound
// replies and inbound attachment downloads (clipboard-pasted images).
"smba.trafficmanager.net",
"botframework.azure.cn",
"graph.microsoft.com",
"graph.microsoft.us",
"graph.microsoft.de",
"graph.microsoft.cn",
] as const;
export const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
export { isRecord };
// Keep this local; importing the broad media-runtime SDK barrel pulls image/audio runtimes into
// hot MSTeams attachment tests for one tiny estimator.
export function estimateBase64DecodedBytes(base64: string): number {
let effectiveLen = 0;
for (let i = 0; i < base64.length; i += 1) {
const code = base64.charCodeAt(i);
if (code <= 0x20) {
continue;
}
effectiveLen += 1;
}
if (effectiveLen === 0) {
return 0;
}
let padding = 0;
let end = base64.length - 1;
while (end >= 0 && base64.charCodeAt(end) <= 0x20) {
end -= 1;
}
if (end >= 0 && base64[end] === "=") {
padding = 1;
end -= 1;
while (end >= 0 && base64.charCodeAt(end) <= 0x20) {
end -= 1;
}
if (end >= 0 && base64[end] === "=") {
padding = 2;
}
}
const estimated = Math.floor((effectiveLen * 3) / 4) - padding;
return Math.max(0, estimated);
}
/**
* Host suffixes for SharePoint/OneDrive shared links that must be fetched via
* the Graph `/shares/{shareId}/driveItem/content` endpoint instead of directly.
*
* Direct fetches of SharePoint/OneDrive shared URLs return empty/HTML landing
* pages unless encoded as a Graph share id. See
* https://learn.microsoft.com/en-us/graph/api/shares-get for the encoding.
*/
const GRAPH_SHARED_LINK_HOST_SUFFIXES = [
".sharepoint.com",
".sharepoint.us",
".sharepoint.de",
".sharepoint.cn",
".sharepoint-df.com",
"1drv.ms",
"onedrive.live.com",
"onedrive.com",
] as const;
/**
* Returns true when the URL points at a SharePoint or OneDrive host whose
* shared-link content must be fetched through the Graph shares API rather
* than directly.
*/
export function isGraphSharedLinkUrl(url: string): boolean {
let host: string;
try {
host = normalizeLowercaseStringOrEmpty(new URL(url).hostname);
} catch {
return false;
}
if (!host) {
return false;
}
return GRAPH_SHARED_LINK_HOST_SUFFIXES.some((suffix) => host === suffix || host.endsWith(suffix));
}
/**
* Encode a SharePoint/OneDrive URL as a Graph shareId using the documented
* `u!` + base64url (no padding) scheme:
* https://learn.microsoft.com/en-us/graph/api/shares-get#encoding-sharing-urls
*/
export function encodeGraphShareId(url: string): string {
// Buffer.from(...).toString("base64url") already returns base64url without
// padding, matching the Graph spec exactly.
return `u!${Buffer.from(url, "utf8").toString("base64url")}`;
}
/**
* When `url` is a SharePoint/OneDrive shared link, return the matching
* `GET /shares/{shareId}/driveItem/content` URL that actually yields the file
* bytes. Returns `undefined` for non-shared-link URLs so callers can fall
* through to the existing fetch path.
*/
export function tryBuildGraphSharesUrlForSharedLink(url: string): string | undefined {
if (!isGraphSharedLinkUrl(url)) {
return undefined;
}
return `${GRAPH_ROOT}/shares/${encodeGraphShareId(url)}/driveItem/content`;
}
export function readNestedString(value: unknown, keys: Array<string | number>): string | undefined {
let current: unknown = value;
for (const key of keys) {
if (!isRecord(current)) {
return undefined;
}
current = current[key as keyof typeof current];
}
return normalizeOptionalString(current);
}
export function resolveRequestUrl(input: RequestInfo | URL): string {
if (typeof input === "string") {
return input;
}
if (input instanceof URL) {
return input.toString();
}
if (typeof input === "object" && input && "url" in input && typeof input.url === "string") {
return input.url;
}
try {
return JSON.stringify(input);
} catch {
return "";
}
}
export function normalizeContentType(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
export function inferPlaceholder(params: {
contentType?: string;
fileName?: string;
fileType?: string;
}): string {
const mime = normalizeLowercaseStringOrEmpty(params.contentType ?? "");
const name = normalizeLowercaseStringOrEmpty(params.fileName ?? "");
const fileType = normalizeLowercaseStringOrEmpty(params.fileType ?? "");
const looksLikeImage =
mime.startsWith("image/") || IMAGE_EXT_RE.test(name) || IMAGE_EXT_RE.test(`x.${fileType}`);
return looksLikeImage ? "<media:image>" : "<media:document>";
}
export function isLikelyImageAttachment(att: MSTeamsAttachmentLike): boolean {
const contentType = normalizeContentType(att.contentType) ?? "";
const name = typeof att.name === "string" ? att.name : "";
if (contentType.startsWith("image/")) {
return true;
}
if (IMAGE_EXT_RE.test(name)) {
return true;
}
if (
contentType === "application/vnd.microsoft.teams.file.download.info" &&
isRecord(att.content)
) {
const fileType = typeof att.content.fileType === "string" ? att.content.fileType : "";
if (fileType && IMAGE_EXT_RE.test(`x.${fileType}`)) {
return true;
}
const fileName = typeof att.content.fileName === "string" ? att.content.fileName : "";
if (fileName && IMAGE_EXT_RE.test(fileName)) {
return true;
}
}
return false;
}
/**
* Returns true if the attachment can be downloaded (any file type).
* Used when downloading all files, not just images.
*/
export function isDownloadableAttachment(att: MSTeamsAttachmentLike): boolean {
const contentType = normalizeContentType(att.contentType) ?? "";
// Teams file download info always has a downloadUrl
if (
contentType === "application/vnd.microsoft.teams.file.download.info" &&
isRecord(att.content) &&
typeof att.content.downloadUrl === "string"
) {
return true;
}
// Any attachment with a contentUrl can be downloaded
if (typeof att.contentUrl === "string" && att.contentUrl.trim()) {
return true;
}
return false;
}
function isHtmlAttachment(att: MSTeamsAttachmentLike): boolean {
const contentType = normalizeContentType(att.contentType) ?? "";
return contentType.startsWith("text/html");
}
export function extractHtmlFromAttachment(att: MSTeamsAttachmentLike): string | undefined {
if (!isHtmlAttachment(att)) {
return undefined;
}
if (typeof att.content === "string") {
return att.content;
}
if (!isRecord(att.content)) {
return undefined;
}
const text =
typeof att.content.text === "string"
? att.content.text
: typeof att.content.body === "string"
? att.content.body
: typeof att.content.content === "string"
? att.content.content
: undefined;
return text;
}
function canonicalizeInlineBase64Payload(value: string): string | undefined {
let cleaned = "";
let padding = 0;
let sawPadding = false;
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code <= 0x20) {
continue;
}
if (code === 0x3d) {
padding += 1;
if (padding > 2) {
return undefined;
}
sawPadding = true;
cleaned += "=";
continue;
}
const isDataChar =
(code >= 0x41 && code <= 0x5a) ||
(code >= 0x61 && code <= 0x7a) ||
(code >= 0x30 && code <= 0x39) ||
code === 0x2b ||
code === 0x2f;
if (sawPadding || !isDataChar) {
return undefined;
}
cleaned += value[index];
}
return cleaned && cleaned.length % 4 === 0 ? cleaned : undefined;
}
function decodeDataImageWithLimits(
src: string,
opts: { maxInlineBytes?: number },
): { candidate: InlineImageCandidate | null; estimatedBytes: number } {
const match = /^data:(image\/[a-z0-9.+-]+)?(;base64)?,(.*)$/i.exec(src);
if (!match) {
return { candidate: null, estimatedBytes: 0 };
}
const contentType = normalizeLowercaseStringOrEmpty(match[1] ?? "");
const isBase64 = Boolean(match[2]);
if (!isBase64) {
return { candidate: null, estimatedBytes: 0 };
}
const payload = match[3] ?? "";
const canonicalPayload = canonicalizeInlineBase64Payload(payload);
if (!canonicalPayload) {
return { candidate: null, estimatedBytes: 0 };
}
const estimatedBytes = estimateBase64DecodedBytes(canonicalPayload);
if (estimatedBytes <= 0) {
return { candidate: null, estimatedBytes: 0 };
}
if (typeof opts.maxInlineBytes === "number" && estimatedBytes > opts.maxInlineBytes) {
return { candidate: null, estimatedBytes };
}
try {
const data = Buffer.from(canonicalPayload, "base64");
return {
candidate: { kind: "data", data, contentType, placeholder: "<media:image>" },
estimatedBytes,
};
} catch {
return { candidate: null, estimatedBytes: 0 };
}
}
function fileHintFromUrl(src: string): string | undefined {
try {
const url = new URL(src);
const name = url.pathname.split("/").pop();
return name || undefined;
} catch {
return undefined;
}
}
export function extractInlineImageCandidates(
attachments: MSTeamsAttachmentLike[],
limits?: InlineImageLimitOptions,
): InlineImageCandidate[] {
const out: InlineImageCandidate[] = [];
let totalEstimatedInlineBytes = 0;
outerLoop: for (const att of attachments) {
const html = extractHtmlFromAttachment(att);
if (!html) {
continue;
}
IMG_SRC_RE.lastIndex = 0;
let match: RegExpExecArray | null = IMG_SRC_RE.exec(html);
while (match) {
const src = match[1]?.trim();
if (src && !src.startsWith("cid:")) {
if (src.startsWith("data:")) {
const { candidate: decoded, estimatedBytes } = decodeDataImageWithLimits(src, {
maxInlineBytes: limits?.maxInlineBytes,
});
if (decoded) {
const nextTotal = totalEstimatedInlineBytes + estimatedBytes;
if (
typeof limits?.maxInlineTotalBytes === "number" &&
nextTotal > limits.maxInlineTotalBytes
) {
break outerLoop;
}
totalEstimatedInlineBytes = nextTotal;
out.push(decoded);
}
} else {
out.push({
kind: "url",
url: src,
fileHint: fileHintFromUrl(src),
placeholder: "<media:image>",
});
}
}
match = IMG_SRC_RE.exec(html);
}
}
return out;
}
export function safeHostForUrl(url: string): string {
try {
return normalizeLowercaseStringOrEmpty(new URL(url).hostname);
} catch {
return "invalid-url";
}
}
export function resolveAllowedHosts(input?: string[]): string[] {
return normalizeHostnameSuffixAllowlist(input, DEFAULT_MEDIA_HOST_ALLOWLIST);
}
export function resolveAuthAllowedHosts(input?: string[]): string[] {
return normalizeHostnameSuffixAllowlist(input, DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST);
}
export type MSTeamsAttachmentFetchPolicy = {
allowHosts: string[];
authAllowHosts: string[];
};
/**
* Logger surface for attachment download errors. Structured so callers can
* pass `MSTeamsMonitorLogger` directly without adapters. Optional `warn`/
* `error` methods prevent silent swallowing of fetch failures — see issue
* #63396 where empty `catch {}` blocks hid a Node 24+ undici incompatibility.
*/
export type MSTeamsAttachmentDownloadLogger = {
warn?: (message: string, meta?: Record<string, unknown>) => void;
error?: (message: string, meta?: Record<string, unknown>) => void;
};
export type MSTeamsAttachmentResolveFn = (hostname: string) => Promise<{ address: string }>;
function isMockFetchFn(fetchFn: typeof fetch): boolean {
const candidate = fetchFn as unknown as { mock?: unknown };
return Boolean(candidate.mock || Object.hasOwn(candidate, "_isMockFunction"));
}
function resolveGuardedFetchImpl(params: {
fetchFn?: typeof fetch;
fetchFnSupportsDispatcher?: boolean;
}): typeof fetch | undefined {
if (!params.fetchFn) {
return undefined;
}
if (
params.fetchFnSupportsDispatcher === true ||
params.fetchFn === fetch ||
params.fetchFn === globalThis.fetch ||
isMockFetchFn(params.fetchFn)
) {
return params.fetchFn;
}
throw new Error(
"MSTeams attachment fetchFn must set fetchFnSupportsDispatcher to use guarded DNS pinning",
);
}
function resolveRetainedAuthorizationRedirectHostnameAllowlist(
input?: string[],
): string[] | undefined {
if (!input) {
return undefined;
}
if (input.includes("*")) {
return ["*"];
}
return resolveMediaSsrfPolicy(input)?.hostnameAllowlist;
}
export function resolveAttachmentFetchPolicy(params?: {
allowHosts?: string[];
authAllowHosts?: string[];
}): MSTeamsAttachmentFetchPolicy {
return {
allowHosts: resolveAllowedHosts(params?.allowHosts),
authAllowHosts: resolveAuthAllowedHosts(params?.authAllowHosts),
};
}
export function isUrlAllowed(url: string, allowlist: string[]): boolean {
return isHttpsUrlAllowedByHostnameSuffixAllowlist(url, allowlist);
}
export function applyAuthorizationHeaderForUrl(params: {
headers: Headers;
url: string;
authAllowHosts: string[];
bearerToken?: string;
}): void {
if (!params.bearerToken) {
params.headers.delete("Authorization");
return;
}
if (isUrlAllowed(params.url, params.authAllowHosts)) {
params.headers.set("Authorization", `Bearer ${params.bearerToken}`);
return;
}
params.headers.delete("Authorization");
}
export function resolveMediaSsrfPolicy(allowHosts: string[]): SsrFPolicy | undefined {
return buildHostnameAllowlistPolicyFromSuffixAllowlist(allowHosts);
}
/**
* 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?: MSTeamsAttachmentResolveFn,
): 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 optional DNS-resolved IP (anti-SSRF).
*
* This prevents:
* - Auto-following redirects to non-allowlisted hosts
* - DNS rebinding attacks when a lookup function is provided
*/
export async function safeFetch(params: {
url: string;
allowHosts: string[];
/**
* Optional allowlist for forwarding Authorization across redirects.
* When set, Authorization is stripped before following redirects to hosts
* outside this list.
*/
authorizationAllowHosts?: string[];
fetchFn?: typeof fetch;
fetchFnSupportsDispatcher?: boolean;
requestInit?: RequestInit;
resolveFn?: MSTeamsAttachmentResolveFn;
}): Promise<Response> {
const resolveFn = params.resolveFn ?? lookup;
const hasDispatcher = Boolean(
params.requestInit &&
typeof params.requestInit === "object" &&
"dispatcher" in (params.requestInit as Record<string, unknown>),
);
const currentHeaders = new Headers(params.requestInit?.headers);
let currentUrl = params.url;
if (!isUrlAllowed(currentUrl, params.allowHosts)) {
throw new Error(`Initial download URL blocked: ${currentUrl}`);
}
// Authorization is only allowed on explicitly auth-allowlisted hosts, including
// the first hop. Redirect hops apply the same rule below or in fetchWithSsrFGuard.
if (
currentHeaders.has("authorization") &&
params.authorizationAllowHosts &&
!isUrlAllowed(currentUrl, params.authorizationAllowHosts)
) {
currentHeaders.delete("authorization");
}
if (!hasDispatcher) {
const guarded = await fetchWithSsrFGuard({
url: currentUrl,
fetchImpl: resolveGuardedFetchImpl({
fetchFn: params.fetchFn,
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
}),
init: {
...params.requestInit,
headers: currentHeaders,
},
maxRedirects: MAX_SAFE_REDIRECTS,
requireHttps: true,
policy: resolveMediaSsrfPolicy(params.allowHosts),
lookupFn: resolveFn as LookupFn,
retainAuthorizationRedirectHostnameAllowlist:
resolveRetainedAuthorizationRedirectHostnameAllowlist(params.authorizationAllowHosts),
auditContext: "msteams.attachment",
});
return responseWithRelease(guarded.response, guarded.release);
}
if (resolveFn) {
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 (params.fetchFn ?? fetch)(currentUrl, {
...params.requestInit,
headers: currentHeaders,
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}`);
}
// Prevent credential bleed: only keep Authorization on redirect hops that
// are explicitly auth-allowlisted.
if (
currentHeaders.has("authorization") &&
params.authorizationAllowHosts &&
!isUrlAllowed(redirectUrl, params.authorizationAllowHosts)
) {
currentHeaders.delete("authorization");
}
// When a pinned dispatcher is already injected by an upstream guard
// (for example fetchWithSsrFGuard), let that guard own redirect handling
// after this allowlist validation step.
if (hasDispatcher) {
return res;
}
// Validate redirect target's resolved IP
if (resolveFn) {
const redirectHost = new URL(redirectUrl).hostname;
await resolveAndValidateIP(redirectHost, resolveFn);
}
currentUrl = redirectUrl;
}
throw new Error(`Too many redirects (>${MAX_SAFE_REDIRECTS})`);
}
export async function safeFetchWithPolicy(params: {
url: string;
policy: MSTeamsAttachmentFetchPolicy;
fetchFn?: typeof fetch;
fetchFnSupportsDispatcher?: boolean;
requestInit?: RequestInit;
resolveFn?: MSTeamsAttachmentResolveFn;
}): Promise<Response> {
return await safeFetch({
url: params.url,
allowHosts: params.policy.allowHosts,
authorizationAllowHosts: params.policy.authAllowHosts,
fetchFn: params.fetchFn,
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
requestInit: params.requestInit,
resolveFn: params.resolveFn,
});
}

View File

@@ -0,0 +1,49 @@
// Msteams type declarations define plugin contracts.
export type MSTeamsAttachmentLike = {
id?: string | null;
contentType?: string | null;
contentUrl?: string | null;
name?: string | null;
thumbnailUrl?: string | null;
content?: unknown;
};
export type MSTeamsAccessTokenProvider = {
getAccessToken: (scope: string) => Promise<string>;
};
export type MSTeamsInboundMedia = {
path: string;
contentType?: string;
placeholder: string;
};
export type MSTeamsHtmlAttachmentSummary = {
htmlAttachments: number;
imgTags: number;
dataImages: number;
cidImages: number;
srcHosts: string[];
attachmentTags: number;
attachmentIds: string[];
};
export type MSTeamsGraphMediaResult = {
media: MSTeamsInboundMedia[];
hostedCount?: number;
attachmentCount?: number;
hostedStatus?: number;
attachmentStatus?: number;
messageUrl?: string;
tokenError?: boolean;
};
/**
* Narrow logger surface used by `downloadMSTeamsGraphMedia` for diagnostic
* events. Accepting an optional callback keeps the helper testable without
* pulling in the full channel logger type, while still allowing the monitor
* handler to forward its plugin logger.
*/
export type MSTeamsGraphMediaLogger = {
debug?: (message: string, meta?: Record<string, unknown>) => void;
};