@openclaw/msteams 2026.3.1 → 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 +6 -0
- package/package.json +1 -1
- package/src/attachments/download.ts +62 -12
- package/src/attachments/graph.ts +27 -8
- package/src/attachments/shared.test.ts +351 -1
- package/src/attachments/shared.ts +186 -0
- package/src/attachments.test.ts +175 -8
- package/src/errors.test.ts +25 -0
- package/src/errors.ts +15 -0
- package/src/messenger.test.ts +76 -2
- package/src/messenger.ts +68 -28
- package/src/monitor-handler.file-consent.test.ts +4 -10
- package/src/monitor-handler.ts +13 -3
- 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/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/token.test.ts +72 -0
- package/src/token.ts +24 -3
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { lookup } from "node:dns/promises";
|
|
1
2
|
import {
|
|
2
3
|
buildHostnameAllowlistPolicyFromSuffixAllowlist,
|
|
3
4
|
isHttpsUrlAllowedByHostnameSuffixAllowlist,
|
|
5
|
+
isPrivateIpAddress,
|
|
4
6
|
normalizeHostnameSuffixAllowlist,
|
|
5
7
|
} from "openclaw/plugin-sdk";
|
|
6
8
|
import type { SsrFPolicy } from "openclaw/plugin-sdk";
|
|
@@ -264,10 +266,194 @@ export function resolveAuthAllowedHosts(input?: string[]): string[] {
|
|
|
264
266
|
return normalizeHostnameSuffixAllowlist(input, DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST);
|
|
265
267
|
}
|
|
266
268
|
|
|
269
|
+
export type MSTeamsAttachmentFetchPolicy = {
|
|
270
|
+
allowHosts: string[];
|
|
271
|
+
authAllowHosts: string[];
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
export function resolveAttachmentFetchPolicy(params?: {
|
|
275
|
+
allowHosts?: string[];
|
|
276
|
+
authAllowHosts?: string[];
|
|
277
|
+
}): MSTeamsAttachmentFetchPolicy {
|
|
278
|
+
return {
|
|
279
|
+
allowHosts: resolveAllowedHosts(params?.allowHosts),
|
|
280
|
+
authAllowHosts: resolveAuthAllowedHosts(params?.authAllowHosts),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
267
284
|
export function isUrlAllowed(url: string, allowlist: string[]): boolean {
|
|
268
285
|
return isHttpsUrlAllowedByHostnameSuffixAllowlist(url, allowlist);
|
|
269
286
|
}
|
|
270
287
|
|
|
288
|
+
export function applyAuthorizationHeaderForUrl(params: {
|
|
289
|
+
headers: Headers;
|
|
290
|
+
url: string;
|
|
291
|
+
authAllowHosts: string[];
|
|
292
|
+
bearerToken?: string;
|
|
293
|
+
}): void {
|
|
294
|
+
if (!params.bearerToken) {
|
|
295
|
+
params.headers.delete("Authorization");
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (isUrlAllowed(params.url, params.authAllowHosts)) {
|
|
299
|
+
params.headers.set("Authorization", `Bearer ${params.bearerToken}`);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
params.headers.delete("Authorization");
|
|
303
|
+
}
|
|
304
|
+
|
|
271
305
|
export function resolveMediaSsrfPolicy(allowHosts: string[]): SsrFPolicy | undefined {
|
|
272
306
|
return buildHostnameAllowlistPolicyFromSuffixAllowlist(allowHosts);
|
|
273
307
|
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Returns true if the given IPv4 or IPv6 address is in a private, loopback,
|
|
311
|
+
* or link-local range that must never be reached from media downloads.
|
|
312
|
+
*
|
|
313
|
+
* Delegates to the SDK's `isPrivateIpAddress` which handles IPv4-mapped IPv6,
|
|
314
|
+
* expanded notation, NAT64, 6to4, Teredo, octal IPv4, and fails closed on
|
|
315
|
+
* parse errors.
|
|
316
|
+
*/
|
|
317
|
+
export const isPrivateOrReservedIP: (ip: string) => boolean = isPrivateIpAddress;
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Resolve a hostname via DNS and reject private/reserved IPs.
|
|
321
|
+
* Throws if the resolved IP is private or resolution fails.
|
|
322
|
+
*/
|
|
323
|
+
export async function resolveAndValidateIP(
|
|
324
|
+
hostname: string,
|
|
325
|
+
resolveFn?: (hostname: string) => Promise<{ address: string }>,
|
|
326
|
+
): Promise<string> {
|
|
327
|
+
const resolve = resolveFn ?? lookup;
|
|
328
|
+
let resolved: { address: string };
|
|
329
|
+
try {
|
|
330
|
+
resolved = await resolve(hostname);
|
|
331
|
+
} catch {
|
|
332
|
+
throw new Error(`DNS resolution failed for "${hostname}"`);
|
|
333
|
+
}
|
|
334
|
+
if (isPrivateOrReservedIP(resolved.address)) {
|
|
335
|
+
throw new Error(`Hostname "${hostname}" resolves to private/reserved IP (${resolved.address})`);
|
|
336
|
+
}
|
|
337
|
+
return resolved.address;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Maximum number of redirects to follow in safeFetch. */
|
|
341
|
+
const MAX_SAFE_REDIRECTS = 5;
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Fetch a URL with redirect: "manual", validating each redirect target
|
|
345
|
+
* against the hostname allowlist and optional DNS-resolved IP (anti-SSRF).
|
|
346
|
+
*
|
|
347
|
+
* This prevents:
|
|
348
|
+
* - Auto-following redirects to non-allowlisted hosts
|
|
349
|
+
* - DNS rebinding attacks when a lookup function is provided
|
|
350
|
+
*/
|
|
351
|
+
export async function safeFetch(params: {
|
|
352
|
+
url: string;
|
|
353
|
+
allowHosts: string[];
|
|
354
|
+
/**
|
|
355
|
+
* Optional allowlist for forwarding Authorization across redirects.
|
|
356
|
+
* When set, Authorization is stripped before following redirects to hosts
|
|
357
|
+
* outside this list.
|
|
358
|
+
*/
|
|
359
|
+
authorizationAllowHosts?: string[];
|
|
360
|
+
fetchFn?: typeof fetch;
|
|
361
|
+
requestInit?: RequestInit;
|
|
362
|
+
resolveFn?: (hostname: string) => Promise<{ address: string }>;
|
|
363
|
+
}): Promise<Response> {
|
|
364
|
+
const fetchFn = params.fetchFn ?? fetch;
|
|
365
|
+
const resolveFn = params.resolveFn;
|
|
366
|
+
const hasDispatcher = Boolean(
|
|
367
|
+
params.requestInit &&
|
|
368
|
+
typeof params.requestInit === "object" &&
|
|
369
|
+
"dispatcher" in (params.requestInit as Record<string, unknown>),
|
|
370
|
+
);
|
|
371
|
+
const currentHeaders = new Headers(params.requestInit?.headers);
|
|
372
|
+
let currentUrl = params.url;
|
|
373
|
+
|
|
374
|
+
if (!isUrlAllowed(currentUrl, params.allowHosts)) {
|
|
375
|
+
throw new Error(`Initial download URL blocked: ${currentUrl}`);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (resolveFn) {
|
|
379
|
+
try {
|
|
380
|
+
const initialHost = new URL(currentUrl).hostname;
|
|
381
|
+
await resolveAndValidateIP(initialHost, resolveFn);
|
|
382
|
+
} catch {
|
|
383
|
+
throw new Error(`Initial download URL blocked: ${currentUrl}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
for (let i = 0; i <= MAX_SAFE_REDIRECTS; i++) {
|
|
388
|
+
const res = await fetchFn(currentUrl, {
|
|
389
|
+
...params.requestInit,
|
|
390
|
+
headers: currentHeaders,
|
|
391
|
+
redirect: "manual",
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
if (![301, 302, 303, 307, 308].includes(res.status)) {
|
|
395
|
+
return res;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const location = res.headers.get("location");
|
|
399
|
+
if (!location) {
|
|
400
|
+
return res;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
let redirectUrl: string;
|
|
404
|
+
try {
|
|
405
|
+
redirectUrl = new URL(location, currentUrl).toString();
|
|
406
|
+
} catch {
|
|
407
|
+
throw new Error(`Invalid redirect URL: ${location}`);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Validate redirect target against hostname allowlist
|
|
411
|
+
if (!isUrlAllowed(redirectUrl, params.allowHosts)) {
|
|
412
|
+
throw new Error(`Media redirect target blocked by allowlist: ${redirectUrl}`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Prevent credential bleed: only keep Authorization on redirect hops that
|
|
416
|
+
// are explicitly auth-allowlisted.
|
|
417
|
+
if (
|
|
418
|
+
currentHeaders.has("authorization") &&
|
|
419
|
+
params.authorizationAllowHosts &&
|
|
420
|
+
!isUrlAllowed(redirectUrl, params.authorizationAllowHosts)
|
|
421
|
+
) {
|
|
422
|
+
currentHeaders.delete("authorization");
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// When a pinned dispatcher is already injected by an upstream guard
|
|
426
|
+
// (for example fetchWithSsrFGuard), let that guard own redirect handling
|
|
427
|
+
// after this allowlist validation step.
|
|
428
|
+
if (hasDispatcher) {
|
|
429
|
+
return res;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Validate redirect target's resolved IP
|
|
433
|
+
if (resolveFn) {
|
|
434
|
+
const redirectHost = new URL(redirectUrl).hostname;
|
|
435
|
+
await resolveAndValidateIP(redirectHost, resolveFn);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
currentUrl = redirectUrl;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
throw new Error(`Too many redirects (>${MAX_SAFE_REDIRECTS})`);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export async function safeFetchWithPolicy(params: {
|
|
445
|
+
url: string;
|
|
446
|
+
policy: MSTeamsAttachmentFetchPolicy;
|
|
447
|
+
fetchFn?: typeof fetch;
|
|
448
|
+
requestInit?: RequestInit;
|
|
449
|
+
resolveFn?: (hostname: string) => Promise<{ address: string }>;
|
|
450
|
+
}): Promise<Response> {
|
|
451
|
+
return await safeFetch({
|
|
452
|
+
url: params.url,
|
|
453
|
+
allowHosts: params.policy.allowHosts,
|
|
454
|
+
authorizationAllowHosts: params.policy.authAllowHosts,
|
|
455
|
+
fetchFn: params.fetchFn,
|
|
456
|
+
requestInit: params.requestInit,
|
|
457
|
+
resolveFn: params.resolveFn,
|
|
458
|
+
});
|
|
459
|
+
}
|
package/src/attachments.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { PluginRuntime, SsrFPolicy } from "openclaw/plugin-sdk";
|
|
2
2
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
|
|
3
4
|
import {
|
|
4
5
|
buildMSTeamsAttachmentPlaceholder,
|
|
5
6
|
buildMSTeamsGraphMessageUrls,
|
|
@@ -46,7 +47,9 @@ type RemoteMediaFetchParams = {
|
|
|
46
47
|
|
|
47
48
|
const detectMimeMock = vi.fn(async () => CONTENT_TYPE_IMAGE_PNG);
|
|
48
49
|
const saveMediaBufferMock = vi.fn(async () => ({
|
|
50
|
+
id: "saved.png",
|
|
49
51
|
path: SAVED_PNG_PATH,
|
|
52
|
+
size: Buffer.byteLength(PNG_BUFFER),
|
|
50
53
|
contentType: CONTENT_TYPE_IMAGE_PNG,
|
|
51
54
|
}));
|
|
52
55
|
const readRemoteMediaResponse = async (
|
|
@@ -106,19 +109,17 @@ const fetchRemoteMediaMock = vi.fn(async (params: RemoteMediaFetchParams) => {
|
|
|
106
109
|
throw new Error("too many redirects");
|
|
107
110
|
});
|
|
108
111
|
|
|
109
|
-
const runtimeStub = {
|
|
112
|
+
const runtimeStub: PluginRuntime = createPluginRuntimeMock({
|
|
110
113
|
media: {
|
|
111
|
-
detectMime: detectMimeMock
|
|
114
|
+
detectMime: detectMimeMock,
|
|
112
115
|
},
|
|
113
116
|
channel: {
|
|
114
117
|
media: {
|
|
115
|
-
fetchRemoteMedia:
|
|
116
|
-
|
|
117
|
-
saveMediaBuffer:
|
|
118
|
-
saveMediaBufferMock as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
|
|
118
|
+
fetchRemoteMedia: fetchRemoteMediaMock,
|
|
119
|
+
saveMediaBuffer: saveMediaBufferMock,
|
|
119
120
|
},
|
|
120
121
|
},
|
|
121
|
-
}
|
|
122
|
+
});
|
|
122
123
|
|
|
123
124
|
type DownloadAttachmentsParams = Parameters<typeof downloadMSTeamsAttachments>[0];
|
|
124
125
|
type DownloadGraphMediaParams = Parameters<typeof downloadMSTeamsGraphMedia>[0];
|
|
@@ -164,7 +165,13 @@ const IMAGE_ATTACHMENT = { contentType: CONTENT_TYPE_IMAGE_PNG, contentUrl: TEST
|
|
|
164
165
|
const PNG_BUFFER = Buffer.from("png");
|
|
165
166
|
const PNG_BASE64 = PNG_BUFFER.toString("base64");
|
|
166
167
|
const PDF_BUFFER = Buffer.from("pdf");
|
|
167
|
-
const createTokenProvider = (
|
|
168
|
+
const createTokenProvider = (
|
|
169
|
+
tokenOrResolver: string | ((scope: string) => string | Promise<string>) = "token",
|
|
170
|
+
) => ({
|
|
171
|
+
getAccessToken: vi.fn(async (scope: string) =>
|
|
172
|
+
typeof tokenOrResolver === "function" ? await tokenOrResolver(scope) : tokenOrResolver,
|
|
173
|
+
),
|
|
174
|
+
});
|
|
168
175
|
const asSingleItemArray = <T>(value: T) => [value];
|
|
169
176
|
const withLabel = <T extends object>(label: string, fields: T): T & LabeledCase => ({
|
|
170
177
|
label,
|
|
@@ -434,7 +441,9 @@ const ATTACHMENT_DOWNLOAD_SUCCESS_CASES: AttachmentDownloadSuccessCase[] = [
|
|
|
434
441
|
beforeDownload: () => {
|
|
435
442
|
detectMimeMock.mockResolvedValueOnce(CONTENT_TYPE_APPLICATION_PDF);
|
|
436
443
|
saveMediaBufferMock.mockResolvedValueOnce({
|
|
444
|
+
id: "saved.pdf",
|
|
437
445
|
path: SAVED_PDF_PATH,
|
|
446
|
+
size: Buffer.byteLength(PDF_BUFFER),
|
|
438
447
|
contentType: CONTENT_TYPE_APPLICATION_PDF,
|
|
439
448
|
});
|
|
440
449
|
},
|
|
@@ -694,6 +703,121 @@ describe("msteams attachments", () => {
|
|
|
694
703
|
runAttachmentAuthRetryCase,
|
|
695
704
|
);
|
|
696
705
|
|
|
706
|
+
it("preserves auth fallback when dispatcher-mode fetch returns a redirect", async () => {
|
|
707
|
+
const redirectedUrl = createTestUrl("redirected.png");
|
|
708
|
+
const tokenProvider = createTokenProvider();
|
|
709
|
+
const fetchMock = vi.fn(async (url: string, opts?: RequestInit) => {
|
|
710
|
+
const hasAuth = Boolean(new Headers(opts?.headers).get("Authorization"));
|
|
711
|
+
if (url === TEST_URL_IMAGE) {
|
|
712
|
+
return hasAuth
|
|
713
|
+
? createRedirectResponse(redirectedUrl)
|
|
714
|
+
: createTextResponse("unauthorized", 401);
|
|
715
|
+
}
|
|
716
|
+
if (url === redirectedUrl) {
|
|
717
|
+
return createBufferResponse(PNG_BUFFER, CONTENT_TYPE_IMAGE_PNG);
|
|
718
|
+
}
|
|
719
|
+
return createNotFoundResponse();
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
fetchRemoteMediaMock.mockImplementationOnce(async (params) => {
|
|
723
|
+
const fetchFn = params.fetchImpl ?? fetch;
|
|
724
|
+
let currentUrl = params.url;
|
|
725
|
+
for (let i = 0; i < MAX_REDIRECT_HOPS; i += 1) {
|
|
726
|
+
const res = await fetchFn(currentUrl, {
|
|
727
|
+
redirect: "manual",
|
|
728
|
+
dispatcher: {},
|
|
729
|
+
} as RequestInit);
|
|
730
|
+
if (REDIRECT_STATUS_CODES.includes(res.status)) {
|
|
731
|
+
const location = res.headers.get("location");
|
|
732
|
+
if (!location) {
|
|
733
|
+
throw new Error("redirect missing location");
|
|
734
|
+
}
|
|
735
|
+
currentUrl = new URL(location, currentUrl).toString();
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
return readRemoteMediaResponse(res, params);
|
|
739
|
+
}
|
|
740
|
+
throw new Error("too many redirects");
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
const media = await downloadAttachmentsWithFetch(
|
|
744
|
+
createImageAttachments(TEST_URL_IMAGE),
|
|
745
|
+
fetchMock,
|
|
746
|
+
{ tokenProvider, authAllowHosts: [TEST_HOST] },
|
|
747
|
+
);
|
|
748
|
+
|
|
749
|
+
expectAttachmentMediaLength(media, 1);
|
|
750
|
+
expect(tokenProvider.getAccessToken).toHaveBeenCalledOnce();
|
|
751
|
+
expect(fetchMock.mock.calls.map(([calledUrl]) => String(calledUrl))).toContain(redirectedUrl);
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
it("continues scope fallback after non-auth failure and succeeds on later scope", async () => {
|
|
755
|
+
let authAttempt = 0;
|
|
756
|
+
const tokenProvider = createTokenProvider((scope) => `token:${scope}`);
|
|
757
|
+
const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => {
|
|
758
|
+
const auth = new Headers(opts?.headers).get("Authorization");
|
|
759
|
+
if (!auth) {
|
|
760
|
+
return createTextResponse("unauthorized", 401);
|
|
761
|
+
}
|
|
762
|
+
authAttempt += 1;
|
|
763
|
+
if (authAttempt === 1) {
|
|
764
|
+
return createTextResponse("upstream transient", 500);
|
|
765
|
+
}
|
|
766
|
+
return createBufferResponse(PNG_BUFFER, CONTENT_TYPE_IMAGE_PNG);
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
const media = await downloadAttachmentsWithFetch(
|
|
770
|
+
createImageAttachments(TEST_URL_IMAGE),
|
|
771
|
+
fetchMock,
|
|
772
|
+
{ tokenProvider, authAllowHosts: [TEST_HOST] },
|
|
773
|
+
);
|
|
774
|
+
|
|
775
|
+
expectAttachmentMediaLength(media, 1);
|
|
776
|
+
expect(tokenProvider.getAccessToken).toHaveBeenCalledTimes(2);
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
it("does not forward Authorization to redirects outside auth allowlist", async () => {
|
|
780
|
+
const tokenProvider = createTokenProvider("top-secret-token");
|
|
781
|
+
const graphFileUrl = createUrlForHost(GRAPH_HOST, "file");
|
|
782
|
+
const seen: Array<{ url: string; auth: string }> = [];
|
|
783
|
+
const fetchMock = vi.fn(async (url: string, opts?: RequestInit) => {
|
|
784
|
+
const auth = new Headers(opts?.headers).get("Authorization") ?? "";
|
|
785
|
+
seen.push({ url, auth });
|
|
786
|
+
if (url === graphFileUrl && !auth) {
|
|
787
|
+
return new Response("unauthorized", { status: 401 });
|
|
788
|
+
}
|
|
789
|
+
if (url === graphFileUrl && auth) {
|
|
790
|
+
return new Response("", {
|
|
791
|
+
status: 302,
|
|
792
|
+
headers: { location: "https://attacker.azureedge.net/collect" },
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
if (url === "https://attacker.azureedge.net/collect") {
|
|
796
|
+
return new Response(Buffer.from("png"), {
|
|
797
|
+
status: 200,
|
|
798
|
+
headers: { "content-type": CONTENT_TYPE_IMAGE_PNG },
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
return createNotFoundResponse();
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
const media = await downloadMSTeamsAttachments(
|
|
805
|
+
buildDownloadParams([{ contentType: CONTENT_TYPE_IMAGE_PNG, contentUrl: graphFileUrl }], {
|
|
806
|
+
tokenProvider,
|
|
807
|
+
allowHosts: [GRAPH_HOST, AZUREEDGE_HOST],
|
|
808
|
+
authAllowHosts: [GRAPH_HOST],
|
|
809
|
+
fetchFn: asFetchFn(fetchMock),
|
|
810
|
+
}),
|
|
811
|
+
);
|
|
812
|
+
|
|
813
|
+
expectSingleMedia(media);
|
|
814
|
+
const redirected = seen.find(
|
|
815
|
+
(entry) => entry.url === "https://attacker.azureedge.net/collect",
|
|
816
|
+
);
|
|
817
|
+
expect(redirected).toBeDefined();
|
|
818
|
+
expect(redirected?.auth).toBe("");
|
|
819
|
+
});
|
|
820
|
+
|
|
697
821
|
it("skips urls outside the allowlist", async () => {
|
|
698
822
|
const fetchMock = vi.fn();
|
|
699
823
|
const media = await downloadAttachmentsWithFetch(
|
|
@@ -744,6 +868,49 @@ describe("msteams attachments", () => {
|
|
|
744
868
|
describe("downloadMSTeamsGraphMedia", () => {
|
|
745
869
|
it.each<GraphMediaSuccessCase>(GRAPH_MEDIA_SUCCESS_CASES)("$label", runGraphMediaSuccessCase);
|
|
746
870
|
|
|
871
|
+
it("does not forward Authorization for SharePoint redirects outside auth allowlist", async () => {
|
|
872
|
+
const tokenProvider = createTokenProvider("top-secret-token");
|
|
873
|
+
const escapedUrl = "https://example.com/collect";
|
|
874
|
+
const seen: Array<{ url: string; auth: string }> = [];
|
|
875
|
+
const referenceAttachment = createReferenceAttachment();
|
|
876
|
+
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
877
|
+
const url = String(input);
|
|
878
|
+
const auth = new Headers(init?.headers).get("Authorization") ?? "";
|
|
879
|
+
seen.push({ url, auth });
|
|
880
|
+
|
|
881
|
+
if (url === DEFAULT_MESSAGE_URL) {
|
|
882
|
+
return createJsonResponse({ attachments: [referenceAttachment] });
|
|
883
|
+
}
|
|
884
|
+
if (url === `${DEFAULT_MESSAGE_URL}/hostedContents`) {
|
|
885
|
+
return createGraphCollectionResponse([]);
|
|
886
|
+
}
|
|
887
|
+
if (url === `${DEFAULT_MESSAGE_URL}/attachments`) {
|
|
888
|
+
return createGraphCollectionResponse([referenceAttachment]);
|
|
889
|
+
}
|
|
890
|
+
if (url.startsWith(GRAPH_SHARES_URL_PREFIX)) {
|
|
891
|
+
return createRedirectResponse(escapedUrl);
|
|
892
|
+
}
|
|
893
|
+
if (url === escapedUrl) {
|
|
894
|
+
return createPdfResponse();
|
|
895
|
+
}
|
|
896
|
+
return createNotFoundResponse();
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
const media = await downloadMSTeamsGraphMedia({
|
|
900
|
+
messageUrl: DEFAULT_MESSAGE_URL,
|
|
901
|
+
tokenProvider,
|
|
902
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
903
|
+
allowHosts: [...DEFAULT_SHAREPOINT_ALLOW_HOSTS, "example.com"],
|
|
904
|
+
authAllowHosts: DEFAULT_SHAREPOINT_ALLOW_HOSTS,
|
|
905
|
+
fetchFn: asFetchFn(fetchMock),
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
expectAttachmentMediaLength(media.media, 1);
|
|
909
|
+
const redirected = seen.find((entry) => entry.url === escapedUrl);
|
|
910
|
+
expect(redirected).toBeDefined();
|
|
911
|
+
expect(redirected?.auth).toBe("");
|
|
912
|
+
});
|
|
913
|
+
|
|
747
914
|
it("blocks SharePoint redirects to hosts outside allowHosts", async () => {
|
|
748
915
|
const escapedUrl = "https://evil.example/internal.pdf";
|
|
749
916
|
const { fetchMock, media } = await downloadGraphMediaWithMockOptions(
|
package/src/errors.test.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
classifyMSTeamsSendError,
|
|
4
4
|
formatMSTeamsSendErrorHint,
|
|
5
5
|
formatUnknownError,
|
|
6
|
+
isRevokedProxyError,
|
|
6
7
|
} from "./errors.js";
|
|
7
8
|
|
|
8
9
|
describe("msteams errors", () => {
|
|
@@ -42,4 +43,28 @@ describe("msteams errors", () => {
|
|
|
42
43
|
expect(formatMSTeamsSendErrorHint({ kind: "auth" })).toContain("msteams");
|
|
43
44
|
expect(formatMSTeamsSendErrorHint({ kind: "throttled" })).toContain("throttled");
|
|
44
45
|
});
|
|
46
|
+
|
|
47
|
+
describe("isRevokedProxyError", () => {
|
|
48
|
+
it("returns true for revoked proxy TypeError", () => {
|
|
49
|
+
expect(
|
|
50
|
+
isRevokedProxyError(new TypeError("Cannot perform 'set' on a proxy that has been revoked")),
|
|
51
|
+
).toBe(true);
|
|
52
|
+
expect(
|
|
53
|
+
isRevokedProxyError(new TypeError("Cannot perform 'get' on a proxy that has been revoked")),
|
|
54
|
+
).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns false for non-TypeError errors", () => {
|
|
58
|
+
expect(isRevokedProxyError(new Error("proxy that has been revoked"))).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("returns false for unrelated TypeErrors", () => {
|
|
62
|
+
expect(isRevokedProxyError(new TypeError("undefined is not a function"))).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("returns false for non-error values", () => {
|
|
66
|
+
expect(isRevokedProxyError(null)).toBe(false);
|
|
67
|
+
expect(isRevokedProxyError("proxy that has been revoked")).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
45
70
|
});
|
package/src/errors.ts
CHANGED
|
@@ -174,6 +174,21 @@ export function classifyMSTeamsSendError(err: unknown): MSTeamsSendErrorClassifi
|
|
|
174
174
|
};
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Detect whether an error is caused by a revoked Proxy.
|
|
179
|
+
*
|
|
180
|
+
* The Bot Framework SDK wraps TurnContext in a Proxy that is revoked once the
|
|
181
|
+
* turn handler returns. Any later access (e.g. from a debounced callback)
|
|
182
|
+
* throws a TypeError whose message contains the distinctive "proxy that has
|
|
183
|
+
* been revoked" string.
|
|
184
|
+
*/
|
|
185
|
+
export function isRevokedProxyError(err: unknown): boolean {
|
|
186
|
+
if (!(err instanceof TypeError)) {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
return /proxy that has been revoked/i.test(err.message);
|
|
190
|
+
}
|
|
191
|
+
|
|
177
192
|
export function formatMSTeamsSendErrorHint(
|
|
178
193
|
classification: MSTeamsSendErrorClassification,
|
|
179
194
|
): string | undefined {
|
package/src/messenger.test.ts
CHANGED
|
@@ -3,6 +3,7 @@ import os from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { SILENT_REPLY_TOKEN, type PluginRuntime } from "openclaw/plugin-sdk";
|
|
5
5
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
|
|
6
7
|
import type { StoredConversationReference } from "./conversation-store.js";
|
|
7
8
|
const graphUploadMockState = vi.hoisted(() => ({
|
|
8
9
|
uploadAndShareOneDrive: vi.fn(),
|
|
@@ -38,7 +39,7 @@ const chunkMarkdownText = (text: string, limit: number) => {
|
|
|
38
39
|
return chunks;
|
|
39
40
|
};
|
|
40
41
|
|
|
41
|
-
const runtimeStub = {
|
|
42
|
+
const runtimeStub: PluginRuntime = createPluginRuntimeMock({
|
|
42
43
|
channel: {
|
|
43
44
|
text: {
|
|
44
45
|
chunkMarkdownText,
|
|
@@ -47,7 +48,7 @@ const runtimeStub = {
|
|
|
47
48
|
convertMarkdownTables: (text: string) => text,
|
|
48
49
|
},
|
|
49
50
|
},
|
|
50
|
-
}
|
|
51
|
+
});
|
|
51
52
|
|
|
52
53
|
const createNoopAdapter = (): MSTeamsAdapter => ({
|
|
53
54
|
continueConversation: async () => {},
|
|
@@ -291,6 +292,79 @@ describe("msteams messenger", () => {
|
|
|
291
292
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
292
293
|
});
|
|
293
294
|
|
|
295
|
+
it("falls back to proactive messaging when thread context is revoked", async () => {
|
|
296
|
+
const proactiveSent: string[] = [];
|
|
297
|
+
|
|
298
|
+
const ctx = {
|
|
299
|
+
sendActivity: async () => {
|
|
300
|
+
throw new TypeError("Cannot perform 'set' on a proxy that has been revoked");
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const adapter: MSTeamsAdapter = {
|
|
305
|
+
continueConversation: async (_appId, _reference, logic) => {
|
|
306
|
+
await logic({
|
|
307
|
+
sendActivity: createRecordedSendActivity(proactiveSent),
|
|
308
|
+
});
|
|
309
|
+
},
|
|
310
|
+
process: async () => {},
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const ids = await sendMSTeamsMessages({
|
|
314
|
+
replyStyle: "thread",
|
|
315
|
+
adapter,
|
|
316
|
+
appId: "app123",
|
|
317
|
+
conversationRef: baseRef,
|
|
318
|
+
context: ctx,
|
|
319
|
+
messages: [{ text: "hello" }],
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// Should have fallen back to proactive messaging
|
|
323
|
+
expect(proactiveSent).toEqual(["hello"]);
|
|
324
|
+
expect(ids).toEqual(["id:hello"]);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("falls back only for remaining thread messages after context revocation", async () => {
|
|
328
|
+
const threadSent: string[] = [];
|
|
329
|
+
const proactiveSent: string[] = [];
|
|
330
|
+
let attempt = 0;
|
|
331
|
+
|
|
332
|
+
const ctx = {
|
|
333
|
+
sendActivity: async (activity: unknown) => {
|
|
334
|
+
const { text } = activity as { text?: string };
|
|
335
|
+
const content = text ?? "";
|
|
336
|
+
attempt += 1;
|
|
337
|
+
if (attempt === 1) {
|
|
338
|
+
threadSent.push(content);
|
|
339
|
+
return { id: `id:${content}` };
|
|
340
|
+
}
|
|
341
|
+
throw new TypeError("Cannot perform 'set' on a proxy that has been revoked");
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const adapter: MSTeamsAdapter = {
|
|
346
|
+
continueConversation: async (_appId, _reference, logic) => {
|
|
347
|
+
await logic({
|
|
348
|
+
sendActivity: createRecordedSendActivity(proactiveSent),
|
|
349
|
+
});
|
|
350
|
+
},
|
|
351
|
+
process: async () => {},
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const ids = await sendMSTeamsMessages({
|
|
355
|
+
replyStyle: "thread",
|
|
356
|
+
adapter,
|
|
357
|
+
appId: "app123",
|
|
358
|
+
conversationRef: baseRef,
|
|
359
|
+
context: ctx,
|
|
360
|
+
messages: [{ text: "one" }, { text: "two" }, { text: "three" }],
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
expect(threadSent).toEqual(["one"]);
|
|
364
|
+
expect(proactiveSent).toEqual(["two", "three"]);
|
|
365
|
+
expect(ids).toEqual(["id:one", "id:two", "id:three"]);
|
|
366
|
+
});
|
|
367
|
+
|
|
294
368
|
it("retries top-level sends on transient (5xx)", async () => {
|
|
295
369
|
const attempts: string[] = [];
|
|
296
370
|
|