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
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:
730
extensions/msteams/src/attachments/shared.ts
Normal file
730
extensions/msteams/src/attachments/shared.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user