| import { lookup } from "node:dns/promises"; |
| import { |
| buildHostnameAllowlistPolicyFromSuffixAllowlist, |
| isHttpsUrlAllowedByHostnameSuffixAllowlist, |
| isPrivateIpAddress, |
| normalizeHostnameSuffixAllowlist, |
| } from "openclaw/plugin-sdk/msteams"; |
| import type { SsrFPolicy } from "openclaw/plugin-sdk/msteams"; |
| 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; |
| }; |
|
|
| export 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; |
|
|
| export 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", |
| |
| "asm.skype.com", |
| "ams.skype.com", |
| "media.ams.skype.com", |
| |
| "trafficmanager.net", |
| "blob.core.windows.net", |
| "azureedge.net", |
| "microsoft.com", |
| ] as const; |
|
|
| export const DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST = [ |
| "api.botframework.com", |
| "botframework.com", |
| "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 function isRecord(value: unknown): value is Record<string, unknown> { |
| return Boolean(value) && typeof value === "object" && !Array.isArray(value); |
| } |
|
|
| 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; |
| } |
| return String(input); |
| } |
|
|
| 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 = params.contentType?.toLowerCase() ?? ""; |
| const name = params.fileName?.toLowerCase() ?? ""; |
| const fileType = params.fileType?.toLowerCase() ?? ""; |
|
|
| 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; |
| } |
|
|
| |
| |
| |
| |
| export function isDownloadableAttachment(att: MSTeamsAttachmentLike): boolean { |
| const contentType = normalizeContentType(att.contentType) ?? ""; |
|
|
| |
| if ( |
| contentType === "application/vnd.microsoft.teams.file.download.info" && |
| isRecord(att.content) && |
| typeof att.content.downloadUrl === "string" |
| ) { |
| return true; |
| } |
|
|
| |
| 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 decodeDataImage(src: string): InlineImageCandidate | null { |
| const match = /^data:(image\/[a-z0-9.+-]+)?(;base64)?,(.*)$/i.exec(src); |
| if (!match) { |
| return null; |
| } |
| const contentType = match[1]?.toLowerCase(); |
| const isBase64 = Boolean(match[2]); |
| if (!isBase64) { |
| return null; |
| } |
| const payload = match[3] ?? ""; |
| if (!payload) { |
| return null; |
| } |
| try { |
| const data = Buffer.from(payload, "base64"); |
| return { kind: "data", data, contentType, placeholder: "<media:image>" }; |
| } catch { |
| return null; |
| } |
| } |
|
|
| 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[], |
| ): InlineImageCandidate[] { |
| const out: InlineImageCandidate[] = []; |
| 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 decoded = decodeDataImage(src); |
| if (decoded) { |
| 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 new URL(url).hostname.toLowerCase(); |
| } 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[]; |
| }; |
|
|
| 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); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export const isPrivateOrReservedIP: (ip: string) => boolean = isPrivateIpAddress; |
|
|
| |
| |
| |
| |
| export async function resolveAndValidateIP( |
| hostname: string, |
| resolveFn?: (hostname: string) => Promise<{ address: string }>, |
| ): Promise<string> { |
| const resolve = resolveFn ?? lookup; |
| let resolved: { address: string }; |
| try { |
| resolved = await resolve(hostname); |
| } catch { |
| throw new Error(`DNS resolution failed for "${hostname}"`); |
| } |
| if (isPrivateOrReservedIP(resolved.address)) { |
| throw new Error(`Hostname "${hostname}" resolves to private/reserved IP (${resolved.address})`); |
| } |
| return resolved.address; |
| } |
|
|
| |
| const MAX_SAFE_REDIRECTS = 5; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| 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; |
| requestInit?: RequestInit; |
| resolveFn?: (hostname: string) => Promise<{ address: string }>; |
| }): Promise<Response> { |
| const fetchFn = params.fetchFn ?? fetch; |
| const resolveFn = params.resolveFn; |
| 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}`); |
| } |
|
|
| 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 fetchFn(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}`); |
| } |
|
|
| |
| if (!isUrlAllowed(redirectUrl, params.allowHosts)) { |
| throw new Error(`Media redirect target blocked by allowlist: ${redirectUrl}`); |
| } |
|
|
| |
| |
| if ( |
| currentHeaders.has("authorization") && |
| params.authorizationAllowHosts && |
| !isUrlAllowed(redirectUrl, params.authorizationAllowHosts) |
| ) { |
| currentHeaders.delete("authorization"); |
| } |
|
|
| |
| |
| |
| if (hasDispatcher) { |
| return res; |
| } |
|
|
| |
| 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; |
| requestInit?: RequestInit; |
| resolveFn?: (hostname: string) => Promise<{ address: string }>; |
| }): Promise<Response> { |
| return await safeFetch({ |
| url: params.url, |
| allowHosts: params.policy.allowHosts, |
| authorizationAllowHosts: params.policy.authAllowHosts, |
| fetchFn: params.fetchFn, |
| requestInit: params.requestInit, |
| resolveFn: params.resolveFn, |
| }); |
| } |
|
|