@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/msteams",
3
- "version": "2026.2.25",
3
+ "version": "2026.3.2",
4
4
  "description": "OpenClaw Microsoft Teams channel plugin",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -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
- resolveAuthAllowedHosts,
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
- allowHosts: string[];
94
- authAllowHosts: string[];
95
- resolveFn?: (hostname: string) => Promise<{ address: string }>;
98
+ policy: MSTeamsAttachmentFetchPolicy;
96
99
  }): Promise<Response> {
97
- const fetchFn = params.fetchFn ?? fetch;
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
- allowHosts: params.allowHosts,
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 safeFetch({
126
+ const authAttempt = await safeFetchWithPolicy({
128
127
  url: params.url,
129
- allowHosts: params.allowHosts,
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 !== 401 && authAttempt.status !== 403) {
141
- continue;
138
+ if (isRedirectStatus(authAttempt.status)) {
139
+ // Redirects in guarded fetch mode must propagate to the outer guard.
140
+ return authAttempt;
142
141
  }
143
-
144
- const finalUrl =
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 allowHosts = resolveAllowedHosts(params.allowHosts);
191
- const authAllowHosts = resolveAuthAllowedHosts(params.authAllowHosts);
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
- allowHosts,
267
- authAllowHosts,
268
- resolveFn: params.resolveFn,
253
+ policy,
269
254
  }),
270
255
  });
271
256
  out.push(media);
@@ -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
- resolveAllowedHosts,
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 res = await fetchFn(params.url, {
125
- headers: { Authorization: `Bearer ${params.accessToken}` },
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 data = (await res.json()) as { value?: T[] };
133
- return { status, items: Array.isArray(data.value) ? data.value : [] };
134
- } catch {
135
- return { status, items: [] };
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 allowHosts = resolveAllowedHosts(params.allowHosts);
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 fetchFn(messageUrl, {
245
- headers: { Authorization: `Bearer ${accessToken}` },
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
- if (msgRes.ok) {
248
- const msgData = (await msgRes.json()) as {
249
- body?: { content?: string; contentType?: string };
250
- attachments?: Array<{
251
- id?: string;
252
- contentUrl?: string;
253
- contentType?: string;
254
- name?: string;
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
- // Extract SharePoint file attachments (contentType: "reference")
259
- // Download any file type, not just images
260
- const spAttachments = (msgData.attachments ?? []).filter(
261
- (a) => a.contentType === "reference" && a.contentUrl && a.name,
262
- );
263
- for (const att of spAttachments) {
264
- const name = att.name ?? "file";
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
- try {
267
- // SharePoint URLs need to be accessed via Graph shares API
268
- const shareUrl = att.contentUrl!;
269
- if (!isUrlAllowed(shareUrl, allowHosts)) {
270
- continue;
271
- }
272
- const encodedUrl = Buffer.from(shareUrl).toString("base64url");
273
- const sharesUrl = `${GRAPH_ROOT}/shares/u!${encodedUrl}/driveItem/content`;
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
- 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,
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
- sharePointMedia.push(media);
297
- downloadedReferenceUrls.add(shareUrl);
298
- } catch {
299
- // Ignore SharePoint download failures.
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: params.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 { isPrivateOrReservedIP, resolveAndValidateIP, safeFetch } from "./shared.js";
3
-
4
- // ─── Helpers ─────────────────────────────────────────────────────────────────
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
- // ─── isPrivateOrReservedIP ───────────────────────────────────────────────────
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
- describe("isPrivateOrReservedIP", () => {
28
- it.each([
29
- ["10.0.0.1", true],
30
- ["10.255.255.255", true],
31
- ["172.16.0.1", true],
32
- ["172.31.255.255", true],
33
- ["172.15.0.1", false],
34
- ["172.32.0.1", false],
35
- ["192.168.0.1", true],
36
- ["192.168.255.255", true],
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.each([
50
- ["::1", true],
51
- ["::", true],
52
- ["fe80::1", true],
53
- ["fc00::1", true],
54
- ["fd12:3456::1", true],
55
- ["2001:0db8::1", false],
56
- ["2620:1ec:c11::200", false],
57
- // IPv4-mapped IPv6 addresses
58
- ["::ffff:127.0.0.1", true],
59
- ["::ffff:10.0.0.1", true],
60
- ["::ffff:192.168.1.1", true],
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
  });