mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-20 21:51:28 +00:00
285 lines
8.0 KiB
TypeScript
285 lines
8.0 KiB
TypeScript
/**
|
|
* FileConsentCard utilities for MS Teams large file uploads (>4MB) in personal chats.
|
|
*
|
|
* Teams requires user consent before the bot can upload large files. This module provides
|
|
* utilities for:
|
|
* - Building FileConsentCard attachments (to request upload permission)
|
|
* - Building FileInfoCard attachments (to confirm upload completion)
|
|
* - Parsing fileConsent/invoke activities
|
|
*/
|
|
|
|
import { lookup } from "node:dns/promises";
|
|
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
|
import { buildUserAgent } from "./user-agent.js";
|
|
|
|
/**
|
|
* Allowlist of domains that are valid targets for file consent uploads.
|
|
* These are the Microsoft/SharePoint domains that Teams legitimately provides
|
|
* as upload destinations in the FileConsentCard flow.
|
|
*/
|
|
export const CONSENT_UPLOAD_HOST_ALLOWLIST = [
|
|
"sharepoint.com",
|
|
"sharepoint.us",
|
|
"sharepoint.de",
|
|
"sharepoint.cn",
|
|
"sharepoint-df.com",
|
|
"storage.live.com",
|
|
"onedrive.com",
|
|
"1drv.ms",
|
|
"graph.microsoft.com",
|
|
"graph.microsoft.us",
|
|
"graph.microsoft.de",
|
|
"graph.microsoft.cn",
|
|
] as const;
|
|
|
|
/**
|
|
* Returns true if the given IPv4 or IPv6 address is in a private, loopback,
|
|
* or link-local range that must never be reached via consent uploads.
|
|
*/
|
|
export function isPrivateOrReservedIP(ip: string): boolean {
|
|
// Handle IPv4-mapped IPv6 first (e.g., ::ffff:127.0.0.1, ::ffff:10.0.0.1)
|
|
const ipv4MappedMatch = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(ip);
|
|
if (ipv4MappedMatch) {
|
|
return isPrivateOrReservedIP(ipv4MappedMatch[1]);
|
|
}
|
|
|
|
// IPv4 checks
|
|
const v4Parts = ip.split(".");
|
|
if (v4Parts.length === 4) {
|
|
const octets = v4Parts.map(Number);
|
|
// Validate all octets are integers in 0-255
|
|
if (octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) {
|
|
return false;
|
|
}
|
|
const [a, b] = octets;
|
|
// 10.0.0.0/8
|
|
if (a === 10) {
|
|
return true;
|
|
}
|
|
// 172.16.0.0/12
|
|
if (a === 172 && b >= 16 && b <= 31) {
|
|
return true;
|
|
}
|
|
// 192.168.0.0/16
|
|
if (a === 192 && b === 168) {
|
|
return true;
|
|
}
|
|
// 127.0.0.0/8 (loopback)
|
|
if (a === 127) {
|
|
return true;
|
|
}
|
|
// 169.254.0.0/16 (link-local)
|
|
if (a === 169 && b === 254) {
|
|
return true;
|
|
}
|
|
// 0.0.0.0/8
|
|
if (a === 0) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// IPv6 checks
|
|
const normalized = normalizeLowercaseStringOrEmpty(ip);
|
|
// ::1 loopback
|
|
if (normalized === "::1") {
|
|
return true;
|
|
}
|
|
// fe80::/10 link-local
|
|
if (normalized.startsWith("fe80:") || normalized.startsWith("fe80")) {
|
|
return true;
|
|
}
|
|
// fc00::/7 unique-local (fc00:: and fd00::)
|
|
if (normalized.startsWith("fc") || normalized.startsWith("fd")) {
|
|
return true;
|
|
}
|
|
// :: unspecified
|
|
if (normalized === "::") {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Validate that a consent upload URL is safe to PUT to.
|
|
* Checks:
|
|
* 1. Protocol is HTTPS
|
|
* 2. Hostname matches the consent upload allowlist
|
|
* 3. Resolved IP is not in a private/reserved range (anti-SSRF)
|
|
*
|
|
* @throws Error if the URL fails validation
|
|
*/
|
|
export async function validateConsentUploadUrl(
|
|
url: string,
|
|
opts?: {
|
|
allowlist?: readonly string[];
|
|
resolveFn?: (hostname: string) => Promise<{ address: string } | { address: string }[]>;
|
|
},
|
|
): Promise<void> {
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(url);
|
|
} catch {
|
|
throw new Error("Consent upload URL is not a valid URL");
|
|
}
|
|
|
|
// 1. Protocol check
|
|
if (parsed.protocol !== "https:") {
|
|
throw new Error(`Consent upload URL must use HTTPS, got ${parsed.protocol}`);
|
|
}
|
|
|
|
// 2. Hostname allowlist check
|
|
const hostname = normalizeLowercaseStringOrEmpty(parsed.hostname);
|
|
const allowlist = opts?.allowlist ?? CONSENT_UPLOAD_HOST_ALLOWLIST;
|
|
const hostAllowed = allowlist.some(
|
|
(entry) => hostname === entry || hostname.endsWith(`.${entry}`),
|
|
);
|
|
if (!hostAllowed) {
|
|
throw new Error(`Consent upload URL hostname "${hostname}" is not in the allowed domains`);
|
|
}
|
|
|
|
// 3. DNS resolution — reject private/reserved IPs.
|
|
// Check all resolved addresses to avoid SSRF bypass via mixed public/private answers.
|
|
const resolveFn = opts?.resolveFn ?? ((name: string) => lookup(name, { all: true }));
|
|
let resolved: { address: string }[];
|
|
try {
|
|
const result = await resolveFn(hostname);
|
|
resolved = Array.isArray(result) ? result : [result];
|
|
} catch {
|
|
throw new Error(`Failed to resolve consent upload URL hostname "${hostname}"`);
|
|
}
|
|
|
|
for (const entry of resolved) {
|
|
if (isPrivateOrReservedIP(entry.address)) {
|
|
throw new Error(`Consent upload URL resolves to a private/reserved IP (${entry.address})`);
|
|
}
|
|
}
|
|
}
|
|
|
|
export interface FileConsentCardParams {
|
|
filename: string;
|
|
description?: string;
|
|
sizeInBytes: number;
|
|
/** Custom context data to include in the card (passed back in the invoke) */
|
|
context?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface FileInfoCardParams {
|
|
filename: string;
|
|
contentUrl: string;
|
|
uniqueId: string;
|
|
fileType: string;
|
|
}
|
|
|
|
/**
|
|
* Build a FileConsentCard attachment for requesting upload permission.
|
|
* Use this for files >= 4MB in personal (1:1) chats.
|
|
*/
|
|
export function buildFileConsentCard(params: FileConsentCardParams) {
|
|
return {
|
|
contentType: "application/vnd.microsoft.teams.card.file.consent",
|
|
name: params.filename,
|
|
content: {
|
|
description: params.description ?? `File: ${params.filename}`,
|
|
sizeInBytes: params.sizeInBytes,
|
|
acceptContext: { filename: params.filename, ...params.context },
|
|
declineContext: { filename: params.filename, ...params.context },
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build a FileInfoCard attachment for confirming upload completion.
|
|
* Send this after successfully uploading the file to the consent URL.
|
|
*/
|
|
export function buildFileInfoCard(params: FileInfoCardParams) {
|
|
return {
|
|
contentType: "application/vnd.microsoft.teams.card.file.info",
|
|
contentUrl: params.contentUrl,
|
|
name: params.filename,
|
|
content: {
|
|
uniqueId: params.uniqueId,
|
|
fileType: params.fileType,
|
|
},
|
|
};
|
|
}
|
|
|
|
export interface FileConsentUploadInfo {
|
|
name: string;
|
|
uploadUrl: string;
|
|
contentUrl: string;
|
|
uniqueId: string;
|
|
fileType: string;
|
|
}
|
|
|
|
export interface FileConsentResponse {
|
|
action: "accept" | "decline";
|
|
uploadInfo?: FileConsentUploadInfo;
|
|
context?: Record<string, unknown>;
|
|
}
|
|
|
|
/**
|
|
* Parse a fileConsent/invoke activity.
|
|
* Returns null if the activity is not a file consent invoke.
|
|
*/
|
|
export function parseFileConsentInvoke(activity: {
|
|
name?: string;
|
|
value?: unknown;
|
|
}): FileConsentResponse | null {
|
|
if (activity.name !== "fileConsent/invoke") {
|
|
return null;
|
|
}
|
|
|
|
const value = activity.value as {
|
|
type?: string;
|
|
action?: string;
|
|
uploadInfo?: FileConsentUploadInfo;
|
|
context?: Record<string, unknown>;
|
|
};
|
|
|
|
if (value?.type !== "fileUpload") {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
action: value.action === "accept" ? "accept" : "decline",
|
|
uploadInfo: value.uploadInfo,
|
|
context: value.context,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Upload a file to the consent URL provided by Teams.
|
|
* The URL is provided in the fileConsent/invoke response after user accepts.
|
|
*
|
|
* @throws Error if the URL fails SSRF validation (non-HTTPS, disallowed host, private IP)
|
|
*/
|
|
export async function uploadToConsentUrl(params: {
|
|
url: string;
|
|
buffer: Buffer;
|
|
contentType?: string;
|
|
fetchFn?: typeof fetch;
|
|
/** Override for testing — custom allowlist and DNS resolver */
|
|
validationOpts?: {
|
|
allowlist?: readonly string[];
|
|
resolveFn?: (hostname: string) => Promise<{ address: string } | { address: string }[]>;
|
|
};
|
|
}): Promise<void> {
|
|
await validateConsentUploadUrl(params.url, params.validationOpts);
|
|
|
|
const fetchFn = params.fetchFn ?? fetch;
|
|
const res = await fetchFn(params.url, {
|
|
method: "PUT",
|
|
headers: {
|
|
"User-Agent": buildUserAgent(),
|
|
"Content-Type": params.contentType ?? "application/octet-stream",
|
|
"Content-Range": `bytes 0-${params.buffer.length - 1}/${params.buffer.length}`,
|
|
},
|
|
body: new Uint8Array(params.buffer),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
throw new Error(`File upload to consent URL failed: ${res.status} ${res.statusText}`);
|
|
}
|
|
}
|