@openclaw/msteams 2026.2.24 → 2026.3.1
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 +18 -0
- package/package.json +1 -1
- package/src/attachments/download.ts +10 -75
- package/src/attachments/graph.ts +83 -63
- package/src/attachments/remote-media.ts +3 -0
- package/src/attachments/shared.test.ts +20 -273
- package/src/attachments/shared.ts +11 -142
- package/src/attachments.test.ts +67 -40
- package/src/messenger.test.ts +2 -2
- package/src/monitor-handler/message-handler.authz.test.ts +99 -0
- package/src/monitor-handler/message-handler.ts +98 -66
- package/src/monitor-handler.file-consent.test.ts +234 -0
- package/src/monitor-handler.ts +28 -10
- package/src/outbound.ts +10 -3
- package/src/send.test.ts +109 -0
- package/src/send.ts +8 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2026.3.1
|
|
4
|
+
|
|
5
|
+
### Changes
|
|
6
|
+
|
|
7
|
+
- Version alignment with core OpenClaw release numbers.
|
|
8
|
+
|
|
9
|
+
## 2026.2.26
|
|
10
|
+
|
|
11
|
+
### Changes
|
|
12
|
+
|
|
13
|
+
- Version alignment with core OpenClaw release numbers.
|
|
14
|
+
|
|
15
|
+
## 2026.2.25
|
|
16
|
+
|
|
17
|
+
### Changes
|
|
18
|
+
|
|
19
|
+
- Version alignment with core OpenClaw release numbers.
|
|
20
|
+
|
|
3
21
|
## 2026.2.24
|
|
4
22
|
|
|
5
23
|
### Changes
|
package/package.json
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fetchWithBearerAuthScopeFallback } from "openclaw/plugin-sdk";
|
|
1
2
|
import { getMSTeamsRuntime } from "../runtime.js";
|
|
2
3
|
import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
|
|
3
4
|
import {
|
|
@@ -7,10 +8,10 @@ import {
|
|
|
7
8
|
isRecord,
|
|
8
9
|
isUrlAllowed,
|
|
9
10
|
normalizeContentType,
|
|
11
|
+
resolveMediaSsrfPolicy,
|
|
10
12
|
resolveRequestUrl,
|
|
11
13
|
resolveAuthAllowedHosts,
|
|
12
14
|
resolveAllowedHosts,
|
|
13
|
-
safeFetch,
|
|
14
15
|
} from "./shared.js";
|
|
15
16
|
import type {
|
|
16
17
|
MSTeamsAccessTokenProvider,
|
|
@@ -90,81 +91,17 @@ async function fetchWithAuthFallback(params: {
|
|
|
90
91
|
tokenProvider?: MSTeamsAccessTokenProvider;
|
|
91
92
|
fetchFn?: typeof fetch;
|
|
92
93
|
requestInit?: RequestInit;
|
|
93
|
-
allowHosts: string[];
|
|
94
94
|
authAllowHosts: string[];
|
|
95
|
-
resolveFn?: (hostname: string) => Promise<{ address: string }>;
|
|
96
95
|
}): Promise<Response> {
|
|
97
|
-
|
|
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({
|
|
96
|
+
return await fetchWithBearerAuthScopeFallback({
|
|
102
97
|
url: params.url,
|
|
103
|
-
|
|
104
|
-
|
|
98
|
+
scopes: scopeCandidatesForUrl(params.url),
|
|
99
|
+
tokenProvider: params.tokenProvider,
|
|
100
|
+
fetchFn: params.fetchFn,
|
|
105
101
|
requestInit: params.requestInit,
|
|
106
|
-
|
|
102
|
+
requireHttps: true,
|
|
103
|
+
shouldAttachAuth: (url) => isUrlAllowed(url, params.authAllowHosts),
|
|
107
104
|
});
|
|
108
|
-
if (firstAttempt.ok) {
|
|
109
|
-
return firstAttempt;
|
|
110
|
-
}
|
|
111
|
-
if (!params.tokenProvider) {
|
|
112
|
-
return firstAttempt;
|
|
113
|
-
}
|
|
114
|
-
if (firstAttempt.status !== 401 && firstAttempt.status !== 403) {
|
|
115
|
-
return firstAttempt;
|
|
116
|
-
}
|
|
117
|
-
if (!isUrlAllowed(params.url, params.authAllowHosts)) {
|
|
118
|
-
return firstAttempt;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const scopes = scopeCandidatesForUrl(params.url);
|
|
122
|
-
for (const scope of scopes) {
|
|
123
|
-
try {
|
|
124
|
-
const token = await params.tokenProvider.getAccessToken(scope);
|
|
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,
|
|
136
|
-
});
|
|
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;
|
|
148
|
-
}
|
|
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;
|
|
161
|
-
}
|
|
162
|
-
} catch {
|
|
163
|
-
// Try the next scope.
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
return firstAttempt;
|
|
168
105
|
}
|
|
169
106
|
|
|
170
107
|
/**
|
|
@@ -180,8 +117,6 @@ export async function downloadMSTeamsAttachments(params: {
|
|
|
180
117
|
fetchFn?: typeof fetch;
|
|
181
118
|
/** When true, embeds original filename in stored path for later extraction. */
|
|
182
119
|
preserveFilenames?: boolean;
|
|
183
|
-
/** Override DNS resolver for testing (anti-SSRF IP validation). */
|
|
184
|
-
resolveFn?: (hostname: string) => Promise<{ address: string }>;
|
|
185
120
|
}): Promise<MSTeamsInboundMedia[]> {
|
|
186
121
|
const list = Array.isArray(params.attachments) ? params.attachments : [];
|
|
187
122
|
if (list.length === 0) {
|
|
@@ -189,6 +124,7 @@ export async function downloadMSTeamsAttachments(params: {
|
|
|
189
124
|
}
|
|
190
125
|
const allowHosts = resolveAllowedHosts(params.allowHosts);
|
|
191
126
|
const authAllowHosts = resolveAuthAllowedHosts(params.authAllowHosts);
|
|
127
|
+
const ssrfPolicy = resolveMediaSsrfPolicy(allowHosts);
|
|
192
128
|
|
|
193
129
|
// Download ANY downloadable attachment (not just images)
|
|
194
130
|
const downloadable = list.filter(isDownloadableAttachment);
|
|
@@ -257,15 +193,14 @@ export async function downloadMSTeamsAttachments(params: {
|
|
|
257
193
|
contentTypeHint: candidate.contentTypeHint,
|
|
258
194
|
placeholder: candidate.placeholder,
|
|
259
195
|
preserveFilenames: params.preserveFilenames,
|
|
196
|
+
ssrfPolicy,
|
|
260
197
|
fetchImpl: (input, init) =>
|
|
261
198
|
fetchWithAuthFallback({
|
|
262
199
|
url: resolveRequestUrl(input),
|
|
263
200
|
tokenProvider: params.tokenProvider,
|
|
264
201
|
fetchFn: params.fetchFn,
|
|
265
202
|
requestInit: init,
|
|
266
|
-
allowHosts,
|
|
267
203
|
authAllowHosts,
|
|
268
|
-
resolveFn: params.resolveFn,
|
|
269
204
|
}),
|
|
270
205
|
});
|
|
271
206
|
out.push(media);
|
package/src/attachments/graph.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk";
|
|
1
2
|
import { getMSTeamsRuntime } from "../runtime.js";
|
|
2
3
|
import { downloadMSTeamsAttachments } from "./download.js";
|
|
3
4
|
import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
|
|
@@ -7,9 +8,9 @@ import {
|
|
|
7
8
|
isRecord,
|
|
8
9
|
isUrlAllowed,
|
|
9
10
|
normalizeContentType,
|
|
11
|
+
resolveMediaSsrfPolicy,
|
|
10
12
|
resolveRequestUrl,
|
|
11
13
|
resolveAllowedHosts,
|
|
12
|
-
safeFetch,
|
|
13
14
|
} from "./shared.js";
|
|
14
15
|
import type {
|
|
15
16
|
MSTeamsAccessTokenProvider,
|
|
@@ -119,20 +120,31 @@ async function fetchGraphCollection<T>(params: {
|
|
|
119
120
|
url: string;
|
|
120
121
|
accessToken: string;
|
|
121
122
|
fetchFn?: typeof fetch;
|
|
123
|
+
ssrfPolicy?: SsrFPolicy;
|
|
122
124
|
}): Promise<{ status: number; items: T[] }> {
|
|
123
125
|
const fetchFn = params.fetchFn ?? fetch;
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
+
const { response, release } = await fetchWithSsrFGuard({
|
|
127
|
+
url: params.url,
|
|
128
|
+
fetchImpl: fetchFn,
|
|
129
|
+
init: {
|
|
130
|
+
headers: { Authorization: `Bearer ${params.accessToken}` },
|
|
131
|
+
},
|
|
132
|
+
policy: params.ssrfPolicy,
|
|
133
|
+
auditContext: "msteams.graph.collection",
|
|
126
134
|
});
|
|
127
|
-
const status = res.status;
|
|
128
|
-
if (!res.ok) {
|
|
129
|
-
return { status, items: [] };
|
|
130
|
-
}
|
|
131
135
|
try {
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
+
const status = response.status;
|
|
137
|
+
if (!response.ok) {
|
|
138
|
+
return { status, items: [] };
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
const data = (await response.json()) as { value?: T[] };
|
|
142
|
+
return { status, items: Array.isArray(data.value) ? data.value : [] };
|
|
143
|
+
} catch {
|
|
144
|
+
return { status, items: [] };
|
|
145
|
+
}
|
|
146
|
+
} finally {
|
|
147
|
+
await release();
|
|
136
148
|
}
|
|
137
149
|
}
|
|
138
150
|
|
|
@@ -164,11 +176,13 @@ async function downloadGraphHostedContent(params: {
|
|
|
164
176
|
maxBytes: number;
|
|
165
177
|
fetchFn?: typeof fetch;
|
|
166
178
|
preserveFilenames?: boolean;
|
|
179
|
+
ssrfPolicy?: SsrFPolicy;
|
|
167
180
|
}): Promise<{ media: MSTeamsInboundMedia[]; status: number; count: number }> {
|
|
168
181
|
const hosted = await fetchGraphCollection<GraphHostedContent>({
|
|
169
182
|
url: `${params.messageUrl}/hostedContents`,
|
|
170
183
|
accessToken: params.accessToken,
|
|
171
184
|
fetchFn: params.fetchFn,
|
|
185
|
+
ssrfPolicy: params.ssrfPolicy,
|
|
172
186
|
});
|
|
173
187
|
if (hosted.items.length === 0) {
|
|
174
188
|
return { media: [], status: hosted.status, count: 0 };
|
|
@@ -228,6 +242,7 @@ export async function downloadMSTeamsGraphMedia(params: {
|
|
|
228
242
|
return { media: [] };
|
|
229
243
|
}
|
|
230
244
|
const allowHosts = resolveAllowedHosts(params.allowHosts);
|
|
245
|
+
const ssrfPolicy = resolveMediaSsrfPolicy(allowHosts);
|
|
231
246
|
const messageUrl = params.messageUrl;
|
|
232
247
|
let accessToken: string;
|
|
233
248
|
try {
|
|
@@ -241,64 +256,67 @@ export async function downloadMSTeamsGraphMedia(params: {
|
|
|
241
256
|
const sharePointMedia: MSTeamsInboundMedia[] = [];
|
|
242
257
|
const downloadedReferenceUrls = new Set<string>();
|
|
243
258
|
try {
|
|
244
|
-
const msgRes = await
|
|
245
|
-
|
|
259
|
+
const { response: msgRes, release } = await fetchWithSsrFGuard({
|
|
260
|
+
url: messageUrl,
|
|
261
|
+
fetchImpl: fetchFn,
|
|
262
|
+
init: {
|
|
263
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
264
|
+
},
|
|
265
|
+
policy: ssrfPolicy,
|
|
266
|
+
auditContext: "msteams.graph.message",
|
|
246
267
|
});
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
268
|
+
try {
|
|
269
|
+
if (msgRes.ok) {
|
|
270
|
+
const msgData = (await msgRes.json()) as {
|
|
271
|
+
body?: { content?: string; contentType?: string };
|
|
272
|
+
attachments?: Array<{
|
|
273
|
+
id?: string;
|
|
274
|
+
contentUrl?: string;
|
|
275
|
+
contentType?: string;
|
|
276
|
+
name?: string;
|
|
277
|
+
}>;
|
|
278
|
+
};
|
|
257
279
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
280
|
+
// Extract SharePoint file attachments (contentType: "reference")
|
|
281
|
+
// Download any file type, not just images
|
|
282
|
+
const spAttachments = (msgData.attachments ?? []).filter(
|
|
283
|
+
(a) => a.contentType === "reference" && a.contentUrl && a.name,
|
|
284
|
+
);
|
|
285
|
+
for (const att of spAttachments) {
|
|
286
|
+
const name = att.name ?? "file";
|
|
265
287
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
288
|
+
try {
|
|
289
|
+
// SharePoint URLs need to be accessed via Graph shares API
|
|
290
|
+
const shareUrl = att.contentUrl!;
|
|
291
|
+
if (!isUrlAllowed(shareUrl, allowHosts)) {
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
const encodedUrl = Buffer.from(shareUrl).toString("base64url");
|
|
295
|
+
const sharesUrl = `${GRAPH_ROOT}/shares/u!${encodedUrl}/driveItem/content`;
|
|
274
296
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
},
|
|
295
|
-
});
|
|
296
|
-
sharePointMedia.push(media);
|
|
297
|
-
downloadedReferenceUrls.add(shareUrl);
|
|
298
|
-
} catch {
|
|
299
|
-
// Ignore SharePoint download failures.
|
|
297
|
+
const media = await downloadAndStoreMSTeamsRemoteMedia({
|
|
298
|
+
url: sharesUrl,
|
|
299
|
+
filePathHint: name,
|
|
300
|
+
maxBytes: params.maxBytes,
|
|
301
|
+
contentTypeHint: "application/octet-stream",
|
|
302
|
+
preserveFilenames: params.preserveFilenames,
|
|
303
|
+
ssrfPolicy,
|
|
304
|
+
fetchImpl: async (input, init) => {
|
|
305
|
+
const requestUrl = resolveRequestUrl(input);
|
|
306
|
+
const headers = new Headers(init?.headers);
|
|
307
|
+
headers.set("Authorization", `Bearer ${accessToken}`);
|
|
308
|
+
return await fetchFn(requestUrl, { ...init, headers });
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
sharePointMedia.push(media);
|
|
312
|
+
downloadedReferenceUrls.add(shareUrl);
|
|
313
|
+
} catch {
|
|
314
|
+
// Ignore SharePoint download failures.
|
|
315
|
+
}
|
|
300
316
|
}
|
|
301
317
|
}
|
|
318
|
+
} finally {
|
|
319
|
+
await release();
|
|
302
320
|
}
|
|
303
321
|
} catch {
|
|
304
322
|
// Ignore message fetch failures.
|
|
@@ -310,12 +328,14 @@ export async function downloadMSTeamsGraphMedia(params: {
|
|
|
310
328
|
maxBytes: params.maxBytes,
|
|
311
329
|
fetchFn: params.fetchFn,
|
|
312
330
|
preserveFilenames: params.preserveFilenames,
|
|
331
|
+
ssrfPolicy,
|
|
313
332
|
});
|
|
314
333
|
|
|
315
334
|
const attachments = await fetchGraphCollection<GraphAttachment>({
|
|
316
335
|
url: `${messageUrl}/attachments`,
|
|
317
336
|
accessToken,
|
|
318
337
|
fetchFn: params.fetchFn,
|
|
338
|
+
ssrfPolicy,
|
|
319
339
|
});
|
|
320
340
|
|
|
321
341
|
const normalizedAttachments = attachments.items.map(normalizeGraphAttachment);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SsrFPolicy } from "openclaw/plugin-sdk";
|
|
1
2
|
import { getMSTeamsRuntime } from "../runtime.js";
|
|
2
3
|
import { inferPlaceholder } from "./shared.js";
|
|
3
4
|
import type { MSTeamsInboundMedia } from "./types.js";
|
|
@@ -9,6 +10,7 @@ export async function downloadAndStoreMSTeamsRemoteMedia(params: {
|
|
|
9
10
|
filePathHint: string;
|
|
10
11
|
maxBytes: number;
|
|
11
12
|
fetchImpl?: FetchLike;
|
|
13
|
+
ssrfPolicy?: SsrFPolicy;
|
|
12
14
|
contentTypeHint?: string;
|
|
13
15
|
placeholder?: string;
|
|
14
16
|
preserveFilenames?: boolean;
|
|
@@ -18,6 +20,7 @@ export async function downloadAndStoreMSTeamsRemoteMedia(params: {
|
|
|
18
20
|
fetchImpl: params.fetchImpl,
|
|
19
21
|
filePathHint: params.filePathHint,
|
|
20
22
|
maxBytes: params.maxBytes,
|
|
23
|
+
ssrfPolicy: params.ssrfPolicy,
|
|
21
24
|
});
|
|
22
25
|
const mime = await getMSTeamsRuntime().media.detectMime({
|
|
23
26
|
buffer: fetched.buffer,
|