@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 +6 -0
- package/package.json +1 -1
- package/src/attachments/download.ts +67 -68
- package/src/attachments/graph.ts +29 -28
- package/src/attachments/remote-media.ts +42 -0
- package/src/attachments/shared.test.ts +279 -0
- package/src/attachments/shared.ts +113 -0
- package/src/attachments.test.ts +126 -18
- package/src/channel.ts +8 -2
- package/src/directory-live.ts +2 -20
- package/src/graph-users.test.ts +66 -0
- package/src/graph-users.ts +29 -0
- package/src/graph.ts +1 -12
- package/src/messenger.ts +10 -21
- package/src/monitor-handler/message-handler.ts +7 -5
- package/src/probe.ts +1 -12
- package/src/resolve-allowlist.ts +2 -20
- package/src/token-response.test.ts +23 -0
- package/src/token-response.ts +11 -0
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
|
112
|
-
|
|
113
|
-
|
|
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 (
|
|
116
|
-
return
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
253
|
+
const media = await downloadAndStoreMSTeamsRemoteMedia({
|
|
242
254
|
url: candidate.url,
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
}
|
package/src/attachments/graph.ts
CHANGED
|
@@ -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
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
+
});
|