@openclaw/msteams 2026.2.25 → 2026.3.2
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 +29 -44
- package/src/attachments/graph.ts +105 -66
- package/src/attachments/remote-media.ts +3 -0
- package/src/attachments/shared.test.ts +138 -41
- package/src/attachments/shared.ts +106 -51
- package/src/attachments.test.ts +241 -47
- package/src/errors.test.ts +25 -0
- package/src/errors.ts +15 -0
- package/src/messenger.test.ts +78 -4
- package/src/messenger.ts +68 -28
- package/src/monitor-handler/message-handler.authz.test.ts +4 -1
- package/src/monitor-handler/message-handler.ts +94 -62
- package/src/monitor-handler.file-consent.test.ts +21 -13
- package/src/monitor-handler.ts +17 -5
- package/src/monitor.lifecycle.test.ts +208 -0
- package/src/monitor.test.ts +85 -0
- package/src/monitor.ts +49 -9
- package/src/onboarding.ts +10 -11
- package/src/outbound.ts +10 -3
- package/src/reply-dispatcher.ts +29 -1
- package/src/revoked-context.test.ts +39 -0
- package/src/revoked-context.ts +17 -0
- package/src/secret-input.ts +7 -0
- package/src/send.test.ts +109 -0
- package/src/send.ts +8 -8
- package/src/token.test.ts +72 -0
- package/src/token.ts +24 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2026.3.2
|
|
4
|
+
|
|
5
|
+
### Changes
|
|
6
|
+
|
|
7
|
+
- Version alignment with core OpenClaw release numbers.
|
|
8
|
+
|
|
9
|
+
## 2026.3.1
|
|
10
|
+
|
|
11
|
+
### Changes
|
|
12
|
+
|
|
13
|
+
- Version alignment with core OpenClaw release numbers.
|
|
14
|
+
|
|
15
|
+
## 2026.2.26
|
|
16
|
+
|
|
17
|
+
### Changes
|
|
18
|
+
|
|
19
|
+
- Version alignment with core OpenClaw release numbers.
|
|
20
|
+
|
|
3
21
|
## 2026.2.25
|
|
4
22
|
|
|
5
23
|
### Changes
|
package/package.json
CHANGED
|
@@ -6,11 +6,12 @@ import {
|
|
|
6
6
|
isDownloadableAttachment,
|
|
7
7
|
isRecord,
|
|
8
8
|
isUrlAllowed,
|
|
9
|
+
type MSTeamsAttachmentFetchPolicy,
|
|
9
10
|
normalizeContentType,
|
|
11
|
+
resolveMediaSsrfPolicy,
|
|
12
|
+
resolveAttachmentFetchPolicy,
|
|
10
13
|
resolveRequestUrl,
|
|
11
|
-
|
|
12
|
-
resolveAllowedHosts,
|
|
13
|
-
safeFetch,
|
|
14
|
+
safeFetchWithPolicy,
|
|
14
15
|
} from "./shared.js";
|
|
15
16
|
import type {
|
|
16
17
|
MSTeamsAccessTokenProvider,
|
|
@@ -85,25 +86,22 @@ function scopeCandidatesForUrl(url: string): string[] {
|
|
|
85
86
|
}
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
function isRedirectStatus(status: number): boolean {
|
|
90
|
+
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
|
91
|
+
}
|
|
92
|
+
|
|
88
93
|
async function fetchWithAuthFallback(params: {
|
|
89
94
|
url: string;
|
|
90
95
|
tokenProvider?: MSTeamsAccessTokenProvider;
|
|
91
96
|
fetchFn?: typeof fetch;
|
|
92
97
|
requestInit?: RequestInit;
|
|
93
|
-
|
|
94
|
-
authAllowHosts: string[];
|
|
95
|
-
resolveFn?: (hostname: string) => Promise<{ address: string }>;
|
|
98
|
+
policy: MSTeamsAttachmentFetchPolicy;
|
|
96
99
|
}): Promise<Response> {
|
|
97
|
-
const
|
|
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({
|
|
100
|
+
const firstAttempt = await safeFetchWithPolicy({
|
|
102
101
|
url: params.url,
|
|
103
|
-
|
|
104
|
-
fetchFn,
|
|
102
|
+
policy: params.policy,
|
|
103
|
+
fetchFn: params.fetchFn,
|
|
105
104
|
requestInit: params.requestInit,
|
|
106
|
-
resolveFn: params.resolveFn,
|
|
107
105
|
});
|
|
108
106
|
if (firstAttempt.ok) {
|
|
109
107
|
return firstAttempt;
|
|
@@ -114,51 +112,37 @@ async function fetchWithAuthFallback(params: {
|
|
|
114
112
|
if (firstAttempt.status !== 401 && firstAttempt.status !== 403) {
|
|
115
113
|
return firstAttempt;
|
|
116
114
|
}
|
|
117
|
-
if (!isUrlAllowed(params.url, params.authAllowHosts)) {
|
|
115
|
+
if (!isUrlAllowed(params.url, params.policy.authAllowHosts)) {
|
|
118
116
|
return firstAttempt;
|
|
119
117
|
}
|
|
120
118
|
|
|
121
119
|
const scopes = scopeCandidatesForUrl(params.url);
|
|
120
|
+
const fetchFn = params.fetchFn ?? fetch;
|
|
122
121
|
for (const scope of scopes) {
|
|
123
122
|
try {
|
|
124
123
|
const token = await params.tokenProvider.getAccessToken(scope);
|
|
125
124
|
const authHeaders = new Headers(params.requestInit?.headers);
|
|
126
125
|
authHeaders.set("Authorization", `Bearer ${token}`);
|
|
127
|
-
const authAttempt = await
|
|
126
|
+
const authAttempt = await safeFetchWithPolicy({
|
|
128
127
|
url: params.url,
|
|
129
|
-
|
|
128
|
+
policy: params.policy,
|
|
130
129
|
fetchFn,
|
|
131
130
|
requestInit: {
|
|
132
131
|
...params.requestInit,
|
|
133
132
|
headers: authHeaders,
|
|
134
133
|
},
|
|
135
|
-
resolveFn: params.resolveFn,
|
|
136
134
|
});
|
|
137
135
|
if (authAttempt.ok) {
|
|
138
136
|
return authAttempt;
|
|
139
137
|
}
|
|
140
|
-
if (authAttempt.status
|
|
141
|
-
|
|
138
|
+
if (isRedirectStatus(authAttempt.status)) {
|
|
139
|
+
// Redirects in guarded fetch mode must propagate to the outer guard.
|
|
140
|
+
return authAttempt;
|
|
142
141
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
typeof authAttempt.url === "string" && authAttempt.url ? authAttempt.url : "";
|
|
146
|
-
if (!finalUrl || finalUrl === params.url || !isUrlAllowed(finalUrl, params.authAllowHosts)) {
|
|
142
|
+
if (authAttempt.status !== 401 && authAttempt.status !== 403) {
|
|
143
|
+
// Preserve scope fallback semantics for non-auth failures.
|
|
147
144
|
continue;
|
|
148
145
|
}
|
|
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
146
|
} catch {
|
|
163
147
|
// Try the next scope.
|
|
164
148
|
}
|
|
@@ -180,15 +164,17 @@ export async function downloadMSTeamsAttachments(params: {
|
|
|
180
164
|
fetchFn?: typeof fetch;
|
|
181
165
|
/** When true, embeds original filename in stored path for later extraction. */
|
|
182
166
|
preserveFilenames?: boolean;
|
|
183
|
-
/** Override DNS resolver for testing (anti-SSRF IP validation). */
|
|
184
|
-
resolveFn?: (hostname: string) => Promise<{ address: string }>;
|
|
185
167
|
}): Promise<MSTeamsInboundMedia[]> {
|
|
186
168
|
const list = Array.isArray(params.attachments) ? params.attachments : [];
|
|
187
169
|
if (list.length === 0) {
|
|
188
170
|
return [];
|
|
189
171
|
}
|
|
190
|
-
const
|
|
191
|
-
|
|
172
|
+
const policy = resolveAttachmentFetchPolicy({
|
|
173
|
+
allowHosts: params.allowHosts,
|
|
174
|
+
authAllowHosts: params.authAllowHosts,
|
|
175
|
+
});
|
|
176
|
+
const allowHosts = policy.allowHosts;
|
|
177
|
+
const ssrfPolicy = resolveMediaSsrfPolicy(allowHosts);
|
|
192
178
|
|
|
193
179
|
// Download ANY downloadable attachment (not just images)
|
|
194
180
|
const downloadable = list.filter(isDownloadableAttachment);
|
|
@@ -257,15 +243,14 @@ export async function downloadMSTeamsAttachments(params: {
|
|
|
257
243
|
contentTypeHint: candidate.contentTypeHint,
|
|
258
244
|
placeholder: candidate.placeholder,
|
|
259
245
|
preserveFilenames: params.preserveFilenames,
|
|
246
|
+
ssrfPolicy,
|
|
260
247
|
fetchImpl: (input, init) =>
|
|
261
248
|
fetchWithAuthFallback({
|
|
262
249
|
url: resolveRequestUrl(input),
|
|
263
250
|
tokenProvider: params.tokenProvider,
|
|
264
251
|
fetchFn: params.fetchFn,
|
|
265
252
|
requestInit: init,
|
|
266
|
-
|
|
267
|
-
authAllowHosts,
|
|
268
|
-
resolveFn: params.resolveFn,
|
|
253
|
+
policy,
|
|
269
254
|
}),
|
|
270
255
|
});
|
|
271
256
|
out.push(media);
|
package/src/attachments/graph.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
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";
|
|
4
5
|
import {
|
|
6
|
+
applyAuthorizationHeaderForUrl,
|
|
5
7
|
GRAPH_ROOT,
|
|
6
8
|
inferPlaceholder,
|
|
7
9
|
isRecord,
|
|
8
10
|
isUrlAllowed,
|
|
11
|
+
type MSTeamsAttachmentFetchPolicy,
|
|
9
12
|
normalizeContentType,
|
|
13
|
+
resolveMediaSsrfPolicy,
|
|
14
|
+
resolveAttachmentFetchPolicy,
|
|
10
15
|
resolveRequestUrl,
|
|
11
|
-
|
|
12
|
-
safeFetch,
|
|
16
|
+
safeFetchWithPolicy,
|
|
13
17
|
} from "./shared.js";
|
|
14
18
|
import type {
|
|
15
19
|
MSTeamsAccessTokenProvider,
|
|
@@ -119,20 +123,31 @@ async function fetchGraphCollection<T>(params: {
|
|
|
119
123
|
url: string;
|
|
120
124
|
accessToken: string;
|
|
121
125
|
fetchFn?: typeof fetch;
|
|
126
|
+
ssrfPolicy?: SsrFPolicy;
|
|
122
127
|
}): Promise<{ status: number; items: T[] }> {
|
|
123
128
|
const fetchFn = params.fetchFn ?? fetch;
|
|
124
|
-
const
|
|
125
|
-
|
|
129
|
+
const { response, release } = await fetchWithSsrFGuard({
|
|
130
|
+
url: params.url,
|
|
131
|
+
fetchImpl: fetchFn,
|
|
132
|
+
init: {
|
|
133
|
+
headers: { Authorization: `Bearer ${params.accessToken}` },
|
|
134
|
+
},
|
|
135
|
+
policy: params.ssrfPolicy,
|
|
136
|
+
auditContext: "msteams.graph.collection",
|
|
126
137
|
});
|
|
127
|
-
const status = res.status;
|
|
128
|
-
if (!res.ok) {
|
|
129
|
-
return { status, items: [] };
|
|
130
|
-
}
|
|
131
138
|
try {
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
139
|
+
const status = response.status;
|
|
140
|
+
if (!response.ok) {
|
|
141
|
+
return { status, items: [] };
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
const data = (await response.json()) as { value?: T[] };
|
|
145
|
+
return { status, items: Array.isArray(data.value) ? data.value : [] };
|
|
146
|
+
} catch {
|
|
147
|
+
return { status, items: [] };
|
|
148
|
+
}
|
|
149
|
+
} finally {
|
|
150
|
+
await release();
|
|
136
151
|
}
|
|
137
152
|
}
|
|
138
153
|
|
|
@@ -164,11 +179,13 @@ async function downloadGraphHostedContent(params: {
|
|
|
164
179
|
maxBytes: number;
|
|
165
180
|
fetchFn?: typeof fetch;
|
|
166
181
|
preserveFilenames?: boolean;
|
|
182
|
+
ssrfPolicy?: SsrFPolicy;
|
|
167
183
|
}): Promise<{ media: MSTeamsInboundMedia[]; status: number; count: number }> {
|
|
168
184
|
const hosted = await fetchGraphCollection<GraphHostedContent>({
|
|
169
185
|
url: `${params.messageUrl}/hostedContents`,
|
|
170
186
|
accessToken: params.accessToken,
|
|
171
187
|
fetchFn: params.fetchFn,
|
|
188
|
+
ssrfPolicy: params.ssrfPolicy,
|
|
172
189
|
});
|
|
173
190
|
if (hosted.items.length === 0) {
|
|
174
191
|
return { media: [], status: hosted.status, count: 0 };
|
|
@@ -227,7 +244,11 @@ export async function downloadMSTeamsGraphMedia(params: {
|
|
|
227
244
|
if (!params.messageUrl || !params.tokenProvider) {
|
|
228
245
|
return { media: [] };
|
|
229
246
|
}
|
|
230
|
-
const
|
|
247
|
+
const policy: MSTeamsAttachmentFetchPolicy = resolveAttachmentFetchPolicy({
|
|
248
|
+
allowHosts: params.allowHosts,
|
|
249
|
+
authAllowHosts: params.authAllowHosts,
|
|
250
|
+
});
|
|
251
|
+
const ssrfPolicy = resolveMediaSsrfPolicy(policy.allowHosts);
|
|
231
252
|
const messageUrl = params.messageUrl;
|
|
232
253
|
let accessToken: string;
|
|
233
254
|
try {
|
|
@@ -241,64 +262,80 @@ export async function downloadMSTeamsGraphMedia(params: {
|
|
|
241
262
|
const sharePointMedia: MSTeamsInboundMedia[] = [];
|
|
242
263
|
const downloadedReferenceUrls = new Set<string>();
|
|
243
264
|
try {
|
|
244
|
-
const msgRes = await
|
|
245
|
-
|
|
265
|
+
const { response: msgRes, release } = await fetchWithSsrFGuard({
|
|
266
|
+
url: messageUrl,
|
|
267
|
+
fetchImpl: fetchFn,
|
|
268
|
+
init: {
|
|
269
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
270
|
+
},
|
|
271
|
+
policy: ssrfPolicy,
|
|
272
|
+
auditContext: "msteams.graph.message",
|
|
246
273
|
});
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
274
|
+
try {
|
|
275
|
+
if (msgRes.ok) {
|
|
276
|
+
const msgData = (await msgRes.json()) as {
|
|
277
|
+
body?: { content?: string; contentType?: string };
|
|
278
|
+
attachments?: Array<{
|
|
279
|
+
id?: string;
|
|
280
|
+
contentUrl?: string;
|
|
281
|
+
contentType?: string;
|
|
282
|
+
name?: string;
|
|
283
|
+
}>;
|
|
284
|
+
};
|
|
257
285
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
286
|
+
// Extract SharePoint file attachments (contentType: "reference")
|
|
287
|
+
// Download any file type, not just images
|
|
288
|
+
const spAttachments = (msgData.attachments ?? []).filter(
|
|
289
|
+
(a) => a.contentType === "reference" && a.contentUrl && a.name,
|
|
290
|
+
);
|
|
291
|
+
for (const att of spAttachments) {
|
|
292
|
+
const name = att.name ?? "file";
|
|
265
293
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
294
|
+
try {
|
|
295
|
+
// SharePoint URLs need to be accessed via Graph shares API
|
|
296
|
+
const shareUrl = att.contentUrl!;
|
|
297
|
+
if (!isUrlAllowed(shareUrl, policy.allowHosts)) {
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
const encodedUrl = Buffer.from(shareUrl).toString("base64url");
|
|
301
|
+
const sharesUrl = `${GRAPH_ROOT}/shares/u!${encodedUrl}/driveItem/content`;
|
|
274
302
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
url: requestUrl,
|
|
287
|
-
allowHosts,
|
|
288
|
-
fetchFn,
|
|
289
|
-
requestInit: {
|
|
290
|
-
...init,
|
|
303
|
+
const media = await downloadAndStoreMSTeamsRemoteMedia({
|
|
304
|
+
url: sharesUrl,
|
|
305
|
+
filePathHint: name,
|
|
306
|
+
maxBytes: params.maxBytes,
|
|
307
|
+
contentTypeHint: "application/octet-stream",
|
|
308
|
+
preserveFilenames: params.preserveFilenames,
|
|
309
|
+
ssrfPolicy,
|
|
310
|
+
fetchImpl: async (input, init) => {
|
|
311
|
+
const requestUrl = resolveRequestUrl(input);
|
|
312
|
+
const headers = new Headers(init?.headers);
|
|
313
|
+
applyAuthorizationHeaderForUrl({
|
|
291
314
|
headers,
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
315
|
+
url: requestUrl,
|
|
316
|
+
authAllowHosts: policy.authAllowHosts,
|
|
317
|
+
bearerToken: accessToken,
|
|
318
|
+
});
|
|
319
|
+
return await safeFetchWithPolicy({
|
|
320
|
+
url: requestUrl,
|
|
321
|
+
policy,
|
|
322
|
+
fetchFn,
|
|
323
|
+
requestInit: {
|
|
324
|
+
...init,
|
|
325
|
+
headers,
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
sharePointMedia.push(media);
|
|
331
|
+
downloadedReferenceUrls.add(shareUrl);
|
|
332
|
+
} catch {
|
|
333
|
+
// Ignore SharePoint download failures.
|
|
334
|
+
}
|
|
300
335
|
}
|
|
301
336
|
}
|
|
337
|
+
} finally {
|
|
338
|
+
await release();
|
|
302
339
|
}
|
|
303
340
|
} catch {
|
|
304
341
|
// Ignore message fetch failures.
|
|
@@ -310,12 +347,14 @@ export async function downloadMSTeamsGraphMedia(params: {
|
|
|
310
347
|
maxBytes: params.maxBytes,
|
|
311
348
|
fetchFn: params.fetchFn,
|
|
312
349
|
preserveFilenames: params.preserveFilenames,
|
|
350
|
+
ssrfPolicy,
|
|
313
351
|
});
|
|
314
352
|
|
|
315
353
|
const attachments = await fetchGraphCollection<GraphAttachment>({
|
|
316
354
|
url: `${messageUrl}/attachments`,
|
|
317
355
|
accessToken,
|
|
318
356
|
fetchFn: params.fetchFn,
|
|
357
|
+
ssrfPolicy,
|
|
319
358
|
});
|
|
320
359
|
|
|
321
360
|
const normalizedAttachments = attachments.items.map(normalizeGraphAttachment);
|
|
@@ -337,8 +376,8 @@ export async function downloadMSTeamsGraphMedia(params: {
|
|
|
337
376
|
attachments: filteredAttachments,
|
|
338
377
|
maxBytes: params.maxBytes,
|
|
339
378
|
tokenProvider: params.tokenProvider,
|
|
340
|
-
allowHosts,
|
|
341
|
-
authAllowHosts:
|
|
379
|
+
allowHosts: policy.allowHosts,
|
|
380
|
+
authAllowHosts: policy.authAllowHosts,
|
|
342
381
|
fetchFn: params.fetchFn,
|
|
343
382
|
preserveFilenames: params.preserveFilenames,
|
|
344
383
|
});
|
|
@@ -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,
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
import {
|
|
3
|
+
applyAuthorizationHeaderForUrl,
|
|
4
|
+
isPrivateOrReservedIP,
|
|
5
|
+
isUrlAllowed,
|
|
6
|
+
resolveAndValidateIP,
|
|
7
|
+
resolveAttachmentFetchPolicy,
|
|
8
|
+
resolveAllowedHosts,
|
|
9
|
+
resolveAuthAllowedHosts,
|
|
10
|
+
resolveMediaSsrfPolicy,
|
|
11
|
+
safeFetch,
|
|
12
|
+
safeFetchWithPolicy,
|
|
13
|
+
} from "./shared.js";
|
|
5
14
|
|
|
6
15
|
const publicResolve = async () => ({ address: "13.107.136.10" });
|
|
7
16
|
const privateResolve = (ip: string) => async () => ({ address: ip });
|
|
@@ -22,47 +31,36 @@ function mockFetchWithRedirect(redirectMap: Record<string, string>, finalBody =
|
|
|
22
31
|
});
|
|
23
32
|
}
|
|
24
33
|
|
|
25
|
-
|
|
34
|
+
describe("msteams attachment allowlists", () => {
|
|
35
|
+
it("normalizes wildcard host lists", () => {
|
|
36
|
+
expect(resolveAllowedHosts(["*", "graph.microsoft.com"])).toEqual(["*"]);
|
|
37
|
+
expect(resolveAuthAllowedHosts(["*", "graph.microsoft.com"])).toEqual(["*"]);
|
|
38
|
+
});
|
|
26
39
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
["127.0.0.1", true],
|
|
38
|
-
["127.255.255.255", true],
|
|
39
|
-
["169.254.0.1", true],
|
|
40
|
-
["169.254.169.254", true],
|
|
41
|
-
["0.0.0.0", true],
|
|
42
|
-
["8.8.8.8", false],
|
|
43
|
-
["13.107.136.10", false],
|
|
44
|
-
["52.96.0.1", false],
|
|
45
|
-
] as const)("IPv4 %s → %s", (ip, expected) => {
|
|
46
|
-
expect(isPrivateOrReservedIP(ip)).toBe(expected);
|
|
40
|
+
it("resolves a normalized attachment fetch policy", () => {
|
|
41
|
+
expect(
|
|
42
|
+
resolveAttachmentFetchPolicy({
|
|
43
|
+
allowHosts: ["sharepoint.com"],
|
|
44
|
+
authAllowHosts: ["graph.microsoft.com"],
|
|
45
|
+
}),
|
|
46
|
+
).toEqual({
|
|
47
|
+
allowHosts: ["sharepoint.com"],
|
|
48
|
+
authAllowHosts: ["graph.microsoft.com"],
|
|
49
|
+
});
|
|
47
50
|
});
|
|
48
51
|
|
|
49
|
-
it
|
|
50
|
-
["
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
["
|
|
61
|
-
["::ffff:169.254.169.254", true],
|
|
62
|
-
["::ffff:8.8.8.8", false],
|
|
63
|
-
["::ffff:13.107.136.10", false],
|
|
64
|
-
] as const)("IPv6 %s → %s", (ip, expected) => {
|
|
65
|
-
expect(isPrivateOrReservedIP(ip)).toBe(expected);
|
|
52
|
+
it("requires https and host suffix match", () => {
|
|
53
|
+
const allowHosts = resolveAllowedHosts(["sharepoint.com"]);
|
|
54
|
+
expect(isUrlAllowed("https://contoso.sharepoint.com/file.png", allowHosts)).toBe(true);
|
|
55
|
+
expect(isUrlAllowed("http://contoso.sharepoint.com/file.png", allowHosts)).toBe(false);
|
|
56
|
+
expect(isUrlAllowed("https://evil.example.com/file.png", allowHosts)).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("builds shared SSRF policy from suffix allowlist", () => {
|
|
60
|
+
expect(resolveMediaSsrfPolicy(["sharepoint.com"])).toEqual({
|
|
61
|
+
hostnameAllowlist: ["sharepoint.com", "*.sharepoint.com"],
|
|
62
|
+
});
|
|
63
|
+
expect(resolveMediaSsrfPolicy(["*"])).toBeUndefined();
|
|
66
64
|
});
|
|
67
65
|
|
|
68
66
|
it.each([
|
|
@@ -149,6 +147,39 @@ describe("safeFetch", () => {
|
|
|
149
147
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
150
148
|
});
|
|
151
149
|
|
|
150
|
+
it("returns the redirect response when dispatcher is provided by an outer guard", async () => {
|
|
151
|
+
const redirectedTo = "https://cdn.sharepoint.com/storage/file.pdf";
|
|
152
|
+
const fetchMock = mockFetchWithRedirect({
|
|
153
|
+
"https://teams.sharepoint.com/file.pdf": redirectedTo,
|
|
154
|
+
});
|
|
155
|
+
const res = await safeFetch({
|
|
156
|
+
url: "https://teams.sharepoint.com/file.pdf",
|
|
157
|
+
allowHosts: ["sharepoint.com"],
|
|
158
|
+
fetchFn: fetchMock as unknown as typeof fetch,
|
|
159
|
+
requestInit: { dispatcher: {} } as RequestInit,
|
|
160
|
+
resolveFn: publicResolve,
|
|
161
|
+
});
|
|
162
|
+
expect(res.status).toBe(302);
|
|
163
|
+
expect(res.headers.get("location")).toBe(redirectedTo);
|
|
164
|
+
expect(fetchMock).toHaveBeenCalledOnce();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("still enforces allowlist checks before returning dispatcher-mode redirects", async () => {
|
|
168
|
+
const fetchMock = mockFetchWithRedirect({
|
|
169
|
+
"https://teams.sharepoint.com/file.pdf": "https://evil.example.com/steal",
|
|
170
|
+
});
|
|
171
|
+
await expect(
|
|
172
|
+
safeFetch({
|
|
173
|
+
url: "https://teams.sharepoint.com/file.pdf",
|
|
174
|
+
allowHosts: ["sharepoint.com"],
|
|
175
|
+
fetchFn: fetchMock as unknown as typeof fetch,
|
|
176
|
+
requestInit: { dispatcher: {} } as RequestInit,
|
|
177
|
+
resolveFn: publicResolve,
|
|
178
|
+
}),
|
|
179
|
+
).rejects.toThrow("blocked by allowlist");
|
|
180
|
+
expect(fetchMock).toHaveBeenCalledOnce();
|
|
181
|
+
});
|
|
182
|
+
|
|
152
183
|
it("blocks a redirect to a non-allowlisted host", async () => {
|
|
153
184
|
const fetchMock = mockFetchWithRedirect({
|
|
154
185
|
"https://teams.sharepoint.com/file.pdf": "https://evil.example.com/steal",
|
|
@@ -278,4 +309,70 @@ describe("safeFetch", () => {
|
|
|
278
309
|
}),
|
|
279
310
|
).rejects.toThrow("blocked by allowlist");
|
|
280
311
|
});
|
|
312
|
+
|
|
313
|
+
it("strips authorization across redirects outside auth allowlist", async () => {
|
|
314
|
+
const seenAuth: string[] = [];
|
|
315
|
+
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
|
316
|
+
const auth = new Headers(init?.headers).get("authorization") ?? "";
|
|
317
|
+
seenAuth.push(`${url}|${auth}`);
|
|
318
|
+
if (url === "https://teams.sharepoint.com/file.pdf") {
|
|
319
|
+
return new Response(null, {
|
|
320
|
+
status: 302,
|
|
321
|
+
headers: { location: "https://cdn.sharepoint.com/storage/file.pdf" },
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
return new Response("ok", { status: 200 });
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const headers = new Headers({ Authorization: "Bearer secret" });
|
|
328
|
+
const res = await safeFetch({
|
|
329
|
+
url: "https://teams.sharepoint.com/file.pdf",
|
|
330
|
+
allowHosts: ["sharepoint.com"],
|
|
331
|
+
authorizationAllowHosts: ["graph.microsoft.com"],
|
|
332
|
+
fetchFn: fetchMock as unknown as typeof fetch,
|
|
333
|
+
requestInit: { headers },
|
|
334
|
+
resolveFn: publicResolve,
|
|
335
|
+
});
|
|
336
|
+
expect(res.status).toBe(200);
|
|
337
|
+
expect(seenAuth[0]).toContain("Bearer secret");
|
|
338
|
+
expect(seenAuth[1]).toMatch(/\|$/);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
describe("attachment fetch auth helpers", () => {
|
|
343
|
+
it("sets and clears authorization header by auth allowlist", () => {
|
|
344
|
+
const headers = new Headers();
|
|
345
|
+
applyAuthorizationHeaderForUrl({
|
|
346
|
+
headers,
|
|
347
|
+
url: "https://graph.microsoft.com/v1.0/me",
|
|
348
|
+
authAllowHosts: ["graph.microsoft.com"],
|
|
349
|
+
bearerToken: "token-1",
|
|
350
|
+
});
|
|
351
|
+
expect(headers.get("authorization")).toBe("Bearer token-1");
|
|
352
|
+
|
|
353
|
+
applyAuthorizationHeaderForUrl({
|
|
354
|
+
headers,
|
|
355
|
+
url: "https://evil.example.com/collect",
|
|
356
|
+
authAllowHosts: ["graph.microsoft.com"],
|
|
357
|
+
bearerToken: "token-1",
|
|
358
|
+
});
|
|
359
|
+
expect(headers.get("authorization")).toBeNull();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("safeFetchWithPolicy forwards policy allowlists", async () => {
|
|
363
|
+
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => {
|
|
364
|
+
return new Response("ok", { status: 200 });
|
|
365
|
+
});
|
|
366
|
+
const res = await safeFetchWithPolicy({
|
|
367
|
+
url: "https://teams.sharepoint.com/file.pdf",
|
|
368
|
+
policy: resolveAttachmentFetchPolicy({
|
|
369
|
+
allowHosts: ["sharepoint.com"],
|
|
370
|
+
authAllowHosts: ["graph.microsoft.com"],
|
|
371
|
+
}),
|
|
372
|
+
fetchFn: fetchMock as unknown as typeof fetch,
|
|
373
|
+
resolveFn: publicResolve,
|
|
374
|
+
});
|
|
375
|
+
expect(res.status).toBe(200);
|
|
376
|
+
expect(fetchMock).toHaveBeenCalledOnce();
|
|
377
|
+
});
|
|
281
378
|
});
|