@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.
@@ -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
+ }