@openclaw/msteams 2026.2.21 → 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,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.2.22
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
3
9
  ## 2026.1.15
4
10
 
5
11
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/msteams",
3
- "version": "2026.2.21",
3
+ "version": "2026.2.22",
4
4
  "description": "OpenClaw Microsoft Teams channel plugin",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -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
+ }
@@ -0,0 +1,279 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { isPrivateOrReservedIP, resolveAndValidateIP, safeFetch } from "./shared.js";
3
+
4
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
5
+
6
+ const publicResolve = async () => ({ address: "13.107.136.10" });
7
+ const privateResolve = (ip: string) => async () => ({ address: ip });
8
+ const failingResolve = async () => {
9
+ throw new Error("DNS failure");
10
+ };
11
+
12
+ function mockFetchWithRedirect(redirectMap: Record<string, string>, finalBody = "ok") {
13
+ return vi.fn(async (url: string, init?: RequestInit) => {
14
+ const target = redirectMap[url];
15
+ if (target && init?.redirect === "manual") {
16
+ return new Response(null, {
17
+ status: 302,
18
+ headers: { location: target },
19
+ });
20
+ }
21
+ return new Response(finalBody, { status: 200 });
22
+ });
23
+ }
24
+
25
+ // ─── isPrivateOrReservedIP ───────────────────────────────────────────────────
26
+
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);
47
+ });
48
+
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);
66
+ });
67
+
68
+ it.each([
69
+ ["999.999.999.999", true],
70
+ ["256.0.0.1", true],
71
+ ["10.0.0.256", true],
72
+ ["-1.0.0.1", false],
73
+ ["1.2.3.4.5", false],
74
+ ["0:0:0:0:0:0:0:1", true],
75
+ ] as const)("malformed/expanded %s → %s (SDK fails closed)", (ip, expected) => {
76
+ expect(isPrivateOrReservedIP(ip)).toBe(expected);
77
+ });
78
+ });
79
+
80
+ // ─── resolveAndValidateIP ────────────────────────────────────────────────────
81
+
82
+ describe("resolveAndValidateIP", () => {
83
+ it("accepts a hostname resolving to a public IP", async () => {
84
+ const ip = await resolveAndValidateIP("teams.sharepoint.com", publicResolve);
85
+ expect(ip).toBe("13.107.136.10");
86
+ });
87
+
88
+ it("rejects a hostname resolving to 10.x.x.x", async () => {
89
+ await expect(resolveAndValidateIP("evil.test", privateResolve("10.0.0.1"))).rejects.toThrow(
90
+ "private/reserved IP",
91
+ );
92
+ });
93
+
94
+ it("rejects a hostname resolving to 169.254.169.254", async () => {
95
+ await expect(
96
+ resolveAndValidateIP("evil.test", privateResolve("169.254.169.254")),
97
+ ).rejects.toThrow("private/reserved IP");
98
+ });
99
+
100
+ it("rejects a hostname resolving to loopback", async () => {
101
+ await expect(resolveAndValidateIP("evil.test", privateResolve("127.0.0.1"))).rejects.toThrow(
102
+ "private/reserved IP",
103
+ );
104
+ });
105
+
106
+ it("rejects a hostname resolving to IPv6 loopback", async () => {
107
+ await expect(resolveAndValidateIP("evil.test", privateResolve("::1"))).rejects.toThrow(
108
+ "private/reserved IP",
109
+ );
110
+ });
111
+
112
+ it("throws on DNS resolution failure", async () => {
113
+ await expect(resolveAndValidateIP("nonexistent.test", failingResolve)).rejects.toThrow(
114
+ "DNS resolution failed",
115
+ );
116
+ });
117
+ });
118
+
119
+ // ─── safeFetch ───────────────────────────────────────────────────────────────
120
+
121
+ describe("safeFetch", () => {
122
+ it("fetches a URL directly when no redirect occurs", async () => {
123
+ const fetchMock = vi.fn<typeof fetch>(async () => new Response("ok", { status: 200 }));
124
+ const res = await safeFetch({
125
+ url: "https://teams.sharepoint.com/file.pdf",
126
+ allowHosts: ["sharepoint.com"],
127
+ fetchFn: fetchMock,
128
+ resolveFn: publicResolve,
129
+ });
130
+ expect(res.status).toBe(200);
131
+ expect(fetchMock).toHaveBeenCalledOnce();
132
+ // Should have used redirect: "manual"
133
+ expect(fetchMock.mock.calls[0][1]).toHaveProperty("redirect", "manual");
134
+ });
135
+
136
+ it("follows a redirect to an allowlisted host with public IP", async () => {
137
+ const fetchMock = mockFetchWithRedirect({
138
+ "https://teams.sharepoint.com/file.pdf": "https://cdn.sharepoint.com/storage/file.pdf",
139
+ });
140
+ const res = await safeFetch({
141
+ url: "https://teams.sharepoint.com/file.pdf",
142
+ allowHosts: ["sharepoint.com"],
143
+ fetchFn: fetchMock as unknown as typeof fetch,
144
+ resolveFn: publicResolve,
145
+ });
146
+ expect(res.status).toBe(200);
147
+ expect(fetchMock).toHaveBeenCalledTimes(2);
148
+ });
149
+
150
+ it("blocks a redirect to a non-allowlisted host", async () => {
151
+ const fetchMock = mockFetchWithRedirect({
152
+ "https://teams.sharepoint.com/file.pdf": "https://evil.example.com/steal",
153
+ });
154
+ await expect(
155
+ safeFetch({
156
+ url: "https://teams.sharepoint.com/file.pdf",
157
+ allowHosts: ["sharepoint.com"],
158
+ fetchFn: fetchMock as unknown as typeof fetch,
159
+ resolveFn: publicResolve,
160
+ }),
161
+ ).rejects.toThrow("blocked by allowlist");
162
+ // Should not have fetched the evil URL
163
+ expect(fetchMock).toHaveBeenCalledTimes(1);
164
+ });
165
+
166
+ it("blocks a redirect to an allowlisted host that resolves to a private IP (DNS rebinding)", async () => {
167
+ let callCount = 0;
168
+ const rebindingResolve = async () => {
169
+ callCount++;
170
+ // First call (initial URL) resolves to public IP
171
+ if (callCount === 1) return { address: "13.107.136.10" };
172
+ // Second call (redirect target) resolves to private IP
173
+ return { address: "169.254.169.254" };
174
+ };
175
+
176
+ const fetchMock = mockFetchWithRedirect({
177
+ "https://teams.sharepoint.com/file.pdf": "https://evil.trafficmanager.net/metadata",
178
+ });
179
+ await expect(
180
+ safeFetch({
181
+ url: "https://teams.sharepoint.com/file.pdf",
182
+ allowHosts: ["sharepoint.com", "trafficmanager.net"],
183
+ fetchFn: fetchMock as unknown as typeof fetch,
184
+ resolveFn: rebindingResolve,
185
+ }),
186
+ ).rejects.toThrow("private/reserved IP");
187
+ expect(fetchMock).toHaveBeenCalledTimes(1);
188
+ });
189
+
190
+ it("blocks when the initial URL resolves to a private IP", async () => {
191
+ const fetchMock = vi.fn();
192
+ await expect(
193
+ safeFetch({
194
+ url: "https://evil.sharepoint.com/file.pdf",
195
+ allowHosts: ["sharepoint.com"],
196
+ fetchFn: fetchMock as unknown as typeof fetch,
197
+ resolveFn: privateResolve("10.0.0.1"),
198
+ }),
199
+ ).rejects.toThrow("Initial download URL blocked");
200
+ expect(fetchMock).not.toHaveBeenCalled();
201
+ });
202
+
203
+ it("blocks when initial URL DNS resolution fails", async () => {
204
+ const fetchMock = vi.fn();
205
+ await expect(
206
+ safeFetch({
207
+ url: "https://nonexistent.sharepoint.com/file.pdf",
208
+ allowHosts: ["sharepoint.com"],
209
+ fetchFn: fetchMock as unknown as typeof fetch,
210
+ resolveFn: failingResolve,
211
+ }),
212
+ ).rejects.toThrow("Initial download URL blocked");
213
+ expect(fetchMock).not.toHaveBeenCalled();
214
+ });
215
+
216
+ it("follows multiple redirects when all are valid", async () => {
217
+ const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
218
+ if (url === "https://a.sharepoint.com/1" && init?.redirect === "manual") {
219
+ return new Response(null, {
220
+ status: 302,
221
+ headers: { location: "https://b.sharepoint.com/2" },
222
+ });
223
+ }
224
+ if (url === "https://b.sharepoint.com/2" && init?.redirect === "manual") {
225
+ return new Response(null, {
226
+ status: 302,
227
+ headers: { location: "https://c.sharepoint.com/3" },
228
+ });
229
+ }
230
+ return new Response("final", { status: 200 });
231
+ });
232
+
233
+ const res = await safeFetch({
234
+ url: "https://a.sharepoint.com/1",
235
+ allowHosts: ["sharepoint.com"],
236
+ fetchFn: fetchMock as unknown as typeof fetch,
237
+ resolveFn: publicResolve,
238
+ });
239
+ expect(res.status).toBe(200);
240
+ expect(fetchMock).toHaveBeenCalledTimes(3);
241
+ });
242
+
243
+ it("throws on too many redirects", async () => {
244
+ let counter = 0;
245
+ const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
246
+ if (init?.redirect === "manual") {
247
+ counter++;
248
+ return new Response(null, {
249
+ status: 302,
250
+ headers: { location: `https://loop${counter}.sharepoint.com/x` },
251
+ });
252
+ }
253
+ return new Response("ok", { status: 200 });
254
+ });
255
+
256
+ await expect(
257
+ safeFetch({
258
+ url: "https://start.sharepoint.com/x",
259
+ allowHosts: ["sharepoint.com"],
260
+ fetchFn: fetchMock as unknown as typeof fetch,
261
+ resolveFn: publicResolve,
262
+ }),
263
+ ).rejects.toThrow("Too many redirects");
264
+ });
265
+
266
+ it("blocks redirect to HTTP (non-HTTPS)", async () => {
267
+ const fetchMock = mockFetchWithRedirect({
268
+ "https://teams.sharepoint.com/file": "http://internal.sharepoint.com/file",
269
+ });
270
+ await expect(
271
+ safeFetch({
272
+ url: "https://teams.sharepoint.com/file",
273
+ allowHosts: ["sharepoint.com"],
274
+ fetchFn: fetchMock as unknown as typeof fetch,
275
+ resolveFn: publicResolve,
276
+ }),
277
+ ).rejects.toThrow("blocked by allowlist");
278
+ });
279
+ });