@openclaw/msteams 2026.2.19 → 2026.2.22

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 CHANGED
@@ -1,120 +1,6 @@
1
1
  # Changelog
2
2
 
3
- ## 2026.2.19
4
-
5
- ### Changes
6
-
7
- - Version alignment with core OpenClaw release numbers.
8
-
9
- ## 2026.2.16
10
-
11
- ### Changes
12
-
13
- - Version alignment with core OpenClaw release numbers.
14
-
15
- ## 2026.2.15
16
-
17
- ### Changes
18
-
19
- - Version alignment with core OpenClaw release numbers.
20
-
21
- ## 2026.2.14
22
-
23
- ### Changes
24
-
25
- - Version alignment with core OpenClaw release numbers.
26
-
27
- ## 2026.2.13
28
-
29
- ### Changes
30
-
31
- - Version alignment with core OpenClaw release numbers.
32
-
33
- ## 2026.2.6-3
34
-
35
- ### Changes
36
-
37
- - Version alignment with core OpenClaw release numbers.
38
-
39
- ## 2026.2.6-2
40
-
41
- ### Changes
42
-
43
- - Version alignment with core OpenClaw release numbers.
44
-
45
- ## 2026.2.6
46
-
47
- ### Changes
48
-
49
- - Version alignment with core OpenClaw release numbers.
50
-
51
- ## 2026.2.4
52
-
53
- ### Changes
54
-
55
- - Version alignment with core OpenClaw release numbers.
56
-
57
- ## 2026.2.2
58
-
59
- ### Changes
60
-
61
- - Version alignment with core OpenClaw release numbers.
62
-
63
- ## 2026.1.31
64
-
65
- ### Changes
66
-
67
- - Version alignment with core OpenClaw release numbers.
68
-
69
- ## 2026.1.30
70
-
71
- ### Changes
72
-
73
- - Version alignment with core OpenClaw release numbers.
74
-
75
- ## 2026.1.29
76
-
77
- ### Changes
78
-
79
- - Version alignment with core OpenClaw release numbers.
80
-
81
- ## 2026.1.23
82
-
83
- ### Changes
84
-
85
- - Version alignment with core OpenClaw release numbers.
86
-
87
- ## 2026.1.22
88
-
89
- ### Changes
90
-
91
- - Version alignment with core OpenClaw release numbers.
92
-
93
- ## 2026.1.21
94
-
95
- ### Changes
96
-
97
- - Version alignment with core OpenClaw release numbers.
98
-
99
- ## 2026.1.20
100
-
101
- ### Changes
102
-
103
- - Version alignment with core OpenClaw release numbers.
104
-
105
- ## 2026.1.17-1
106
-
107
- ### Changes
108
-
109
- - Version alignment with core OpenClaw release numbers.
110
-
111
- ## 2026.1.17
112
-
113
- ### Changes
114
-
115
- - Version alignment with core OpenClaw release numbers.
116
-
117
- ## 2026.1.16
3
+ ## 2026.2.22
118
4
 
119
5
  ### Changes
120
6
 
package/package.json CHANGED
@@ -1,12 +1,10 @@
1
1
  {
2
2
  "name": "@openclaw/msteams",
3
- "version": "2026.2.19",
3
+ "version": "2026.2.22",
4
4
  "description": "OpenClaw Microsoft Teams channel plugin",
5
5
  "type": "module",
6
6
  "dependencies": {
7
- "@microsoft/agents-hosting": "^1.2.3",
8
- "@microsoft/agents-hosting-express": "^1.2.3",
9
- "@microsoft/agents-hosting-extensions-teams": "^1.2.3",
7
+ "@microsoft/agents-hosting": "^1.3.1",
10
8
  "express": "^5.2.1"
11
9
  },
12
10
  "devDependencies": {
@@ -1,4 +1,5 @@
1
1
  import { getMSTeamsRuntime } from "../runtime.js";
2
+ import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
2
3
  import {
3
4
  extractInlineImageCandidates,
4
5
  inferPlaceholder,
@@ -6,8 +7,10 @@ import {
6
7
  isRecord,
7
8
  isUrlAllowed,
8
9
  normalizeContentType,
10
+ resolveRequestUrl,
9
11
  resolveAuthAllowedHosts,
10
12
  resolveAllowedHosts,
13
+ safeFetch,
11
14
  } from "./shared.js";
12
15
  import type {
13
16
  MSTeamsAccessTokenProvider,
@@ -86,11 +89,22 @@ async function fetchWithAuthFallback(params: {
86
89
  url: string;
87
90
  tokenProvider?: MSTeamsAccessTokenProvider;
88
91
  fetchFn?: typeof fetch;
92
+ requestInit?: RequestInit;
89
93
  allowHosts: string[];
90
94
  authAllowHosts: string[];
95
+ resolveFn?: (hostname: string) => Promise<{ address: string }>;
91
96
  }): Promise<Response> {
92
97
  const fetchFn = params.fetchFn ?? fetch;
93
- const firstAttempt = await fetchFn(params.url);
98
+
99
+ // Use safeFetch for the initial attempt — redirect: "manual" with
100
+ // allowlist + DNS/IP validation on every hop (prevents SSRF via redirect).
101
+ const firstAttempt = await safeFetch({
102
+ url: params.url,
103
+ allowHosts: params.allowHosts,
104
+ fetchFn,
105
+ requestInit: params.requestInit,
106
+ resolveFn: params.resolveFn,
107
+ });
94
108
  if (firstAttempt.ok) {
95
109
  return firstAttempt;
96
110
  }
@@ -108,31 +122,42 @@ async function fetchWithAuthFallback(params: {
108
122
  for (const scope of scopes) {
109
123
  try {
110
124
  const token = await params.tokenProvider.getAccessToken(scope);
111
- const res = await fetchFn(params.url, {
112
- headers: { Authorization: `Bearer ${token}` },
113
- redirect: "manual",
125
+ const authHeaders = new Headers(params.requestInit?.headers);
126
+ authHeaders.set("Authorization", `Bearer ${token}`);
127
+ const authAttempt = await safeFetch({
128
+ url: params.url,
129
+ allowHosts: params.allowHosts,
130
+ fetchFn,
131
+ requestInit: {
132
+ ...params.requestInit,
133
+ headers: authHeaders,
134
+ },
135
+ resolveFn: params.resolveFn,
114
136
  });
115
- if (res.ok) {
116
- return res;
137
+ if (authAttempt.ok) {
138
+ return authAttempt;
139
+ }
140
+ if (authAttempt.status !== 401 && authAttempt.status !== 403) {
141
+ continue;
142
+ }
143
+
144
+ const finalUrl =
145
+ typeof authAttempt.url === "string" && authAttempt.url ? authAttempt.url : "";
146
+ if (!finalUrl || finalUrl === params.url || !isUrlAllowed(finalUrl, params.authAllowHosts)) {
147
+ continue;
117
148
  }
118
- const redirectUrl = readRedirectUrl(params.url, res);
119
- if (redirectUrl && isUrlAllowed(redirectUrl, params.allowHosts)) {
120
- const redirectRes = await fetchFn(redirectUrl);
121
- if (redirectRes.ok) {
122
- return redirectRes;
123
- }
124
- if (
125
- (redirectRes.status === 401 || redirectRes.status === 403) &&
126
- isUrlAllowed(redirectUrl, params.authAllowHosts)
127
- ) {
128
- const redirectAuthRes = await fetchFn(redirectUrl, {
129
- headers: { Authorization: `Bearer ${token}` },
130
- redirect: "manual",
131
- });
132
- if (redirectAuthRes.ok) {
133
- return redirectAuthRes;
134
- }
135
- }
149
+ const redirectedAuthAttempt = await safeFetch({
150
+ url: finalUrl,
151
+ allowHosts: params.allowHosts,
152
+ fetchFn,
153
+ requestInit: {
154
+ ...params.requestInit,
155
+ headers: authHeaders,
156
+ },
157
+ resolveFn: params.resolveFn,
158
+ });
159
+ if (redirectedAuthAttempt.ok) {
160
+ return redirectedAuthAttempt;
136
161
  }
137
162
  } catch {
138
163
  // Try the next scope.
@@ -142,21 +167,6 @@ async function fetchWithAuthFallback(params: {
142
167
  return firstAttempt;
143
168
  }
144
169
 
145
- function readRedirectUrl(baseUrl: string, res: Response): string | null {
146
- if (![301, 302, 303, 307, 308].includes(res.status)) {
147
- return null;
148
- }
149
- const location = res.headers.get("location");
150
- if (!location) {
151
- return null;
152
- }
153
- try {
154
- return new URL(location, baseUrl).toString();
155
- } catch {
156
- return null;
157
- }
158
- }
159
-
160
170
  /**
161
171
  * Download all file attachments from a Teams message (images, documents, etc.).
162
172
  * Renamed from downloadMSTeamsImageAttachments to support all file types.
@@ -170,6 +180,8 @@ export async function downloadMSTeamsAttachments(params: {
170
180
  fetchFn?: typeof fetch;
171
181
  /** When true, embeds original filename in stored path for later extraction. */
172
182
  preserveFilenames?: boolean;
183
+ /** Override DNS resolver for testing (anti-SSRF IP validation). */
184
+ resolveFn?: (hostname: string) => Promise<{ address: string }>;
173
185
  }): Promise<MSTeamsInboundMedia[]> {
174
186
  const list = Array.isArray(params.attachments) ? params.attachments : [];
175
187
  if (list.length === 0) {
@@ -238,38 +250,25 @@ export async function downloadMSTeamsAttachments(params: {
238
250
  continue;
239
251
  }
240
252
  try {
241
- const res = await fetchWithAuthFallback({
253
+ const media = await downloadAndStoreMSTeamsRemoteMedia({
242
254
  url: candidate.url,
243
- tokenProvider: params.tokenProvider,
244
- fetchFn: params.fetchFn,
245
- allowHosts,
246
- authAllowHosts,
247
- });
248
- if (!res.ok) {
249
- continue;
250
- }
251
- const buffer = Buffer.from(await res.arrayBuffer());
252
- if (buffer.byteLength > params.maxBytes) {
253
- continue;
254
- }
255
- const mime = await getMSTeamsRuntime().media.detectMime({
256
- buffer,
257
- headerMime: res.headers.get("content-type"),
258
- filePath: candidate.fileHint ?? candidate.url,
259
- });
260
- const originalFilename = params.preserveFilenames ? candidate.fileHint : undefined;
261
- const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
262
- buffer,
263
- mime ?? candidate.contentTypeHint,
264
- "inbound",
265
- params.maxBytes,
266
- originalFilename,
267
- );
268
- out.push({
269
- path: saved.path,
270
- contentType: saved.contentType,
255
+ filePathHint: candidate.fileHint ?? candidate.url,
256
+ maxBytes: params.maxBytes,
257
+ contentTypeHint: candidate.contentTypeHint,
271
258
  placeholder: candidate.placeholder,
259
+ preserveFilenames: params.preserveFilenames,
260
+ fetchImpl: (input, init) =>
261
+ fetchWithAuthFallback({
262
+ url: resolveRequestUrl(input),
263
+ tokenProvider: params.tokenProvider,
264
+ fetchFn: params.fetchFn,
265
+ requestInit: init,
266
+ allowHosts,
267
+ authAllowHosts,
268
+ resolveFn: params.resolveFn,
269
+ }),
272
270
  });
271
+ out.push(media);
273
272
  } catch {
274
273
  // Ignore download failures and continue with next candidate.
275
274
  }
@@ -1,11 +1,15 @@
1
1
  import { getMSTeamsRuntime } from "../runtime.js";
2
2
  import { downloadMSTeamsAttachments } from "./download.js";
3
+ import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
3
4
  import {
4
5
  GRAPH_ROOT,
5
6
  inferPlaceholder,
6
7
  isRecord,
8
+ isUrlAllowed,
7
9
  normalizeContentType,
10
+ resolveRequestUrl,
8
11
  resolveAllowedHosts,
12
+ safeFetch,
9
13
  } from "./shared.js";
10
14
  import type {
11
15
  MSTeamsAccessTokenProvider,
@@ -262,38 +266,35 @@ export async function downloadMSTeamsGraphMedia(params: {
262
266
  try {
263
267
  // SharePoint URLs need to be accessed via Graph shares API
264
268
  const shareUrl = att.contentUrl!;
269
+ if (!isUrlAllowed(shareUrl, allowHosts)) {
270
+ continue;
271
+ }
265
272
  const encodedUrl = Buffer.from(shareUrl).toString("base64url");
266
273
  const sharesUrl = `${GRAPH_ROOT}/shares/u!${encodedUrl}/driveItem/content`;
267
274
 
268
- const spRes = await fetchFn(sharesUrl, {
269
- headers: { Authorization: `Bearer ${accessToken}` },
270
- redirect: "follow",
271
- });
272
-
273
- if (spRes.ok) {
274
- const buffer = Buffer.from(await spRes.arrayBuffer());
275
- if (buffer.byteLength <= params.maxBytes) {
276
- const mime = await getMSTeamsRuntime().media.detectMime({
277
- buffer,
278
- headerMime: spRes.headers.get("content-type") ?? undefined,
279
- filePath: name,
275
+ const media = await downloadAndStoreMSTeamsRemoteMedia({
276
+ url: sharesUrl,
277
+ filePathHint: name,
278
+ maxBytes: params.maxBytes,
279
+ contentTypeHint: "application/octet-stream",
280
+ preserveFilenames: params.preserveFilenames,
281
+ fetchImpl: async (input, init) => {
282
+ const requestUrl = resolveRequestUrl(input);
283
+ const headers = new Headers(init?.headers);
284
+ headers.set("Authorization", `Bearer ${accessToken}`);
285
+ return await safeFetch({
286
+ url: requestUrl,
287
+ allowHosts,
288
+ fetchFn,
289
+ requestInit: {
290
+ ...init,
291
+ headers,
292
+ },
280
293
  });
281
- const originalFilename = params.preserveFilenames ? name : undefined;
282
- const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
283
- buffer,
284
- mime ?? "application/octet-stream",
285
- "inbound",
286
- params.maxBytes,
287
- originalFilename,
288
- );
289
- sharePointMedia.push({
290
- path: saved.path,
291
- contentType: saved.contentType,
292
- placeholder: inferPlaceholder({ contentType: saved.contentType, fileName: name }),
293
- });
294
- downloadedReferenceUrls.add(shareUrl);
295
- }
296
- }
294
+ },
295
+ });
296
+ sharePointMedia.push(media);
297
+ downloadedReferenceUrls.add(shareUrl);
297
298
  } catch {
298
299
  // Ignore SharePoint download failures.
299
300
  }
@@ -0,0 +1,42 @@
1
+ import { getMSTeamsRuntime } from "../runtime.js";
2
+ import { inferPlaceholder } from "./shared.js";
3
+ import type { MSTeamsInboundMedia } from "./types.js";
4
+
5
+ type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
6
+
7
+ export async function downloadAndStoreMSTeamsRemoteMedia(params: {
8
+ url: string;
9
+ filePathHint: string;
10
+ maxBytes: number;
11
+ fetchImpl?: FetchLike;
12
+ contentTypeHint?: string;
13
+ placeholder?: string;
14
+ preserveFilenames?: boolean;
15
+ }): Promise<MSTeamsInboundMedia> {
16
+ const fetched = await getMSTeamsRuntime().channel.media.fetchRemoteMedia({
17
+ url: params.url,
18
+ fetchImpl: params.fetchImpl,
19
+ filePathHint: params.filePathHint,
20
+ maxBytes: params.maxBytes,
21
+ });
22
+ const mime = await getMSTeamsRuntime().media.detectMime({
23
+ buffer: fetched.buffer,
24
+ headerMime: fetched.contentType ?? params.contentTypeHint,
25
+ filePath: params.filePathHint,
26
+ });
27
+ const originalFilename = params.preserveFilenames ? params.filePathHint : undefined;
28
+ const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
29
+ fetched.buffer,
30
+ mime ?? params.contentTypeHint,
31
+ "inbound",
32
+ params.maxBytes,
33
+ originalFilename,
34
+ );
35
+ return {
36
+ path: saved.path,
37
+ contentType: saved.contentType,
38
+ placeholder:
39
+ params.placeholder ??
40
+ inferPlaceholder({ contentType: saved.contentType, fileName: params.filePathHint }),
41
+ };
42
+ }