@openclaw/msteams 2026.2.21 → 2026.2.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/package.json +1 -1
- package/src/attachments/download.ts +67 -68
- package/src/attachments/graph.ts +29 -28
- package/src/attachments/payload.ts +3 -11
- package/src/attachments/remote-media.ts +42 -0
- package/src/attachments/shared.test.ts +281 -0
- package/src/attachments/shared.ts +113 -0
- package/src/attachments.test.ts +696 -400
- package/src/channel.ts +8 -2
- package/src/directory-live.ts +2 -20
- package/src/graph-users.test.ts +66 -0
- package/src/graph-users.ts +29 -0
- package/src/graph.ts +1 -12
- package/src/messenger.test.ts +30 -48
- package/src/messenger.ts +10 -21
- package/src/monitor-handler/message-handler.ts +14 -5
- package/src/policy.test.ts +13 -1
- package/src/policy.ts +2 -0
- package/src/probe.ts +1 -12
- package/src/resolve-allowlist.ts +2 -20
- package/src/token-response.test.ts +23 -0
- package/src/token-response.ts +11 -0
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { lookup } from "node:dns/promises";
|
|
2
|
+
import { isPrivateIpAddress } from "openclaw/plugin-sdk";
|
|
1
3
|
import type { MSTeamsAttachmentLike } from "./types.js";
|
|
2
4
|
|
|
3
5
|
type InlineImageCandidate =
|
|
@@ -63,6 +65,19 @@ export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
63
65
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
export function resolveRequestUrl(input: RequestInfo | URL): string {
|
|
69
|
+
if (typeof input === "string") {
|
|
70
|
+
return input;
|
|
71
|
+
}
|
|
72
|
+
if (input instanceof URL) {
|
|
73
|
+
return input.toString();
|
|
74
|
+
}
|
|
75
|
+
if (typeof input === "object" && input && "url" in input && typeof input.url === "string") {
|
|
76
|
+
return input.url;
|
|
77
|
+
}
|
|
78
|
+
return String(input);
|
|
79
|
+
}
|
|
80
|
+
|
|
66
81
|
export function normalizeContentType(value: unknown): string | undefined {
|
|
67
82
|
if (typeof value !== "string") {
|
|
68
83
|
return undefined;
|
|
@@ -289,3 +304,101 @@ export function isUrlAllowed(url: string, allowlist: string[]): boolean {
|
|
|
289
304
|
return false;
|
|
290
305
|
}
|
|
291
306
|
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Returns true if the given IPv4 or IPv6 address is in a private, loopback,
|
|
310
|
+
* or link-local range that must never be reached from media downloads.
|
|
311
|
+
*
|
|
312
|
+
* Delegates to the SDK's `isPrivateIpAddress` which handles IPv4-mapped IPv6,
|
|
313
|
+
* expanded notation, NAT64, 6to4, Teredo, octal IPv4, and fails closed on
|
|
314
|
+
* parse errors.
|
|
315
|
+
*/
|
|
316
|
+
export const isPrivateOrReservedIP: (ip: string) => boolean = isPrivateIpAddress;
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Resolve a hostname via DNS and reject private/reserved IPs.
|
|
320
|
+
* Throws if the resolved IP is private or resolution fails.
|
|
321
|
+
*/
|
|
322
|
+
export async function resolveAndValidateIP(
|
|
323
|
+
hostname: string,
|
|
324
|
+
resolveFn?: (hostname: string) => Promise<{ address: string }>,
|
|
325
|
+
): Promise<string> {
|
|
326
|
+
const resolve = resolveFn ?? lookup;
|
|
327
|
+
let resolved: { address: string };
|
|
328
|
+
try {
|
|
329
|
+
resolved = await resolve(hostname);
|
|
330
|
+
} catch {
|
|
331
|
+
throw new Error(`DNS resolution failed for "${hostname}"`);
|
|
332
|
+
}
|
|
333
|
+
if (isPrivateOrReservedIP(resolved.address)) {
|
|
334
|
+
throw new Error(`Hostname "${hostname}" resolves to private/reserved IP (${resolved.address})`);
|
|
335
|
+
}
|
|
336
|
+
return resolved.address;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Maximum number of redirects to follow in safeFetch. */
|
|
340
|
+
const MAX_SAFE_REDIRECTS = 5;
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Fetch a URL with redirect: "manual", validating each redirect target
|
|
344
|
+
* against the hostname allowlist and DNS-resolved IP (anti-SSRF).
|
|
345
|
+
*
|
|
346
|
+
* This prevents:
|
|
347
|
+
* - Auto-following redirects to non-allowlisted hosts
|
|
348
|
+
* - DNS rebinding attacks where an allowlisted domain resolves to a private IP
|
|
349
|
+
*/
|
|
350
|
+
export async function safeFetch(params: {
|
|
351
|
+
url: string;
|
|
352
|
+
allowHosts: string[];
|
|
353
|
+
fetchFn?: typeof fetch;
|
|
354
|
+
requestInit?: RequestInit;
|
|
355
|
+
resolveFn?: (hostname: string) => Promise<{ address: string }>;
|
|
356
|
+
}): Promise<Response> {
|
|
357
|
+
const fetchFn = params.fetchFn ?? fetch;
|
|
358
|
+
const resolveFn = params.resolveFn;
|
|
359
|
+
let currentUrl = params.url;
|
|
360
|
+
|
|
361
|
+
// Validate the initial URL's resolved IP
|
|
362
|
+
try {
|
|
363
|
+
const initialHost = new URL(currentUrl).hostname;
|
|
364
|
+
await resolveAndValidateIP(initialHost, resolveFn);
|
|
365
|
+
} catch {
|
|
366
|
+
throw new Error(`Initial download URL blocked: ${currentUrl}`);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
for (let i = 0; i <= MAX_SAFE_REDIRECTS; i++) {
|
|
370
|
+
const res = await fetchFn(currentUrl, {
|
|
371
|
+
...params.requestInit,
|
|
372
|
+
redirect: "manual",
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
if (![301, 302, 303, 307, 308].includes(res.status)) {
|
|
376
|
+
return res;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const location = res.headers.get("location");
|
|
380
|
+
if (!location) {
|
|
381
|
+
return res;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
let redirectUrl: string;
|
|
385
|
+
try {
|
|
386
|
+
redirectUrl = new URL(location, currentUrl).toString();
|
|
387
|
+
} catch {
|
|
388
|
+
throw new Error(`Invalid redirect URL: ${location}`);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Validate redirect target against hostname allowlist
|
|
392
|
+
if (!isUrlAllowed(redirectUrl, params.allowHosts)) {
|
|
393
|
+
throw new Error(`Media redirect target blocked by allowlist: ${redirectUrl}`);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Validate redirect target's resolved IP
|
|
397
|
+
const redirectHost = new URL(redirectUrl).hostname;
|
|
398
|
+
await resolveAndValidateIP(redirectHost, resolveFn);
|
|
399
|
+
|
|
400
|
+
currentUrl = redirectUrl;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
throw new Error(`Too many redirects (>${MAX_SAFE_REDIRECTS})`);
|
|
404
|
+
}
|