Skip to content

Commit 41cc5bc

Browse files
committed
fix: gate Teams media auth retries
1 parent f6d98a9 commit 41cc5bc

File tree

9 files changed

+115
-0
lines changed

9 files changed

+115
-0
lines changed

docs/channels/msteams.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,7 @@ Key settings (see `/gateway/configuration` for shared channel patterns):
456456
- `channels.msteams.textChunkLimit`: outbound text chunk size.
457457
- `channels.msteams.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
458458
- `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains).
459+
- `channels.msteams.mediaAuthAllowHosts`: allowlist for attaching Authorization headers on media retries (defaults to Graph + Bot Framework hosts).
459460
- `channels.msteams.requireMention`: require @mention in channels/groups (default true).
460461
- `channels.msteams.replyStyle`: `thread | top-level` (see [Reply Style](#reply-style-threads-vs-posts)).
461462
- `channels.msteams.teams.<teamId>.replyStyle`: per-team override.
@@ -518,6 +519,7 @@ Teams recently introduced two channel UI styles over the same underlying data mo
518519

519520
Without Graph permissions, channel messages with images will be received as text-only (the image content is not accessible to the bot).
520521
By default, OpenClaw only downloads media from Microsoft/Teams hostnames. Override with `channels.msteams.mediaAllowHosts` (use `["*"]` to allow any host).
522+
Authorization headers are only attached for hosts in `channels.msteams.mediaAuthAllowHosts` (defaults to Graph + Bot Framework hosts). Keep this list strict (avoid multi-tenant suffixes).
521523

522524
## Sending files in group chats
523525

extensions/msteams/src/attachments.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ describe("msteams attachments", () => {
241241
maxBytes: 1024 * 1024,
242242
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
243243
allowHosts: ["x"],
244+
authAllowHosts: ["x"],
244245
fetchFn: fetchMock as unknown as typeof fetch,
245246
});
246247

@@ -249,6 +250,41 @@ describe("msteams attachments", () => {
249250
expect(fetchMock).toHaveBeenCalledTimes(2);
250251
});
251252

253+
it("skips auth retries when the host is not in auth allowlist", async () => {
254+
const { downloadMSTeamsAttachments } = await load();
255+
const tokenProvider = { getAccessToken: vi.fn(async () => "token") };
256+
const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => {
257+
const hasAuth = Boolean(
258+
opts &&
259+
typeof opts === "object" &&
260+
"headers" in opts &&
261+
(opts.headers as Record<string, string>)?.Authorization,
262+
);
263+
if (!hasAuth) {
264+
return new Response("forbidden", { status: 403 });
265+
}
266+
return new Response(Buffer.from("png"), {
267+
status: 200,
268+
headers: { "content-type": "image/png" },
269+
});
270+
});
271+
272+
const media = await downloadMSTeamsAttachments({
273+
attachments: [
274+
{ contentType: "image/png", contentUrl: "https://attacker.azureedge.net/img" },
275+
],
276+
maxBytes: 1024 * 1024,
277+
tokenProvider,
278+
allowHosts: ["azureedge.net"],
279+
authAllowHosts: ["graph.microsoft.com"],
280+
fetchFn: fetchMock as unknown as typeof fetch,
281+
});
282+
283+
expect(media).toHaveLength(0);
284+
expect(fetchMock).toHaveBeenCalledTimes(1);
285+
expect(tokenProvider.getAccessToken).not.toHaveBeenCalled();
286+
});
287+
252288
it("skips urls outside the allowlist", async () => {
253289
const { downloadMSTeamsAttachments } = await load();
254290
const fetchMock = vi.fn();

extensions/msteams/src/attachments/download.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
isRecord,
1212
isUrlAllowed,
1313
normalizeContentType,
14+
resolveAuthAllowedHosts,
1415
resolveAllowedHosts,
1516
} from "./shared.js";
1617

@@ -85,6 +86,8 @@ async function fetchWithAuthFallback(params: {
8586
url: string;
8687
tokenProvider?: MSTeamsAccessTokenProvider;
8788
fetchFn?: typeof fetch;
89+
allowHosts: string[];
90+
authAllowHosts: string[];
8891
}): Promise<Response> {
8992
const fetchFn = params.fetchFn ?? fetch;
9093
const firstAttempt = await fetchFn(params.url);
@@ -97,17 +100,40 @@ async function fetchWithAuthFallback(params: {
97100
if (firstAttempt.status !== 401 && firstAttempt.status !== 403) {
98101
return firstAttempt;
99102
}
103+
if (!isUrlAllowed(params.url, params.authAllowHosts)) {
104+
return firstAttempt;
105+
}
100106

101107
const scopes = scopeCandidatesForUrl(params.url);
102108
for (const scope of scopes) {
103109
try {
104110
const token = await params.tokenProvider.getAccessToken(scope);
105111
const res = await fetchFn(params.url, {
106112
headers: { Authorization: `Bearer ${token}` },
113+
redirect: "manual",
107114
});
108115
if (res.ok) {
109116
return res;
110117
}
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+
}
136+
}
111137
} catch {
112138
// Try the next scope.
113139
}
@@ -116,6 +142,21 @@ async function fetchWithAuthFallback(params: {
116142
return firstAttempt;
117143
}
118144

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+
119160
/**
120161
* Download all file attachments from a Teams message (images, documents, etc.).
121162
* Renamed from downloadMSTeamsImageAttachments to support all file types.
@@ -125,6 +166,7 @@ export async function downloadMSTeamsAttachments(params: {
125166
maxBytes: number;
126167
tokenProvider?: MSTeamsAccessTokenProvider;
127168
allowHosts?: string[];
169+
authAllowHosts?: string[];
128170
fetchFn?: typeof fetch;
129171
/** When true, embeds original filename in stored path for later extraction. */
130172
preserveFilenames?: boolean;
@@ -134,6 +176,7 @@ export async function downloadMSTeamsAttachments(params: {
134176
return [];
135177
}
136178
const allowHosts = resolveAllowedHosts(params.allowHosts);
179+
const authAllowHosts = resolveAuthAllowedHosts(params.authAllowHosts);
137180

138181
// Download ANY downloadable attachment (not just images)
139182
const downloadable = list.filter(isDownloadableAttachment);
@@ -199,6 +242,8 @@ export async function downloadMSTeamsAttachments(params: {
199242
url: candidate.url,
200243
tokenProvider: params.tokenProvider,
201244
fetchFn: params.fetchFn,
245+
allowHosts,
246+
authAllowHosts,
202247
});
203248
if (!res.ok) {
204249
continue;

extensions/msteams/src/attachments/graph.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ export async function downloadMSTeamsGraphMedia(params: {
215215
tokenProvider?: MSTeamsAccessTokenProvider;
216216
maxBytes: number;
217217
allowHosts?: string[];
218+
authAllowHosts?: string[];
218219
fetchFn?: typeof fetch;
219220
/** When true, embeds original filename in stored path for later extraction. */
220221
preserveFilenames?: boolean;
@@ -336,6 +337,7 @@ export async function downloadMSTeamsGraphMedia(params: {
336337
maxBytes: params.maxBytes,
337338
tokenProvider: params.tokenProvider,
338339
allowHosts,
340+
authAllowHosts: params.authAllowHosts,
339341
fetchFn: params.fetchFn,
340342
preserveFilenames: params.preserveFilenames,
341343
});

extensions/msteams/src/attachments/shared.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,15 @@ export const DEFAULT_MEDIA_HOST_ALLOWLIST = [
4848
"microsoft.com",
4949
] as const;
5050

51+
export const DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST = [
52+
"api.botframework.com",
53+
"botframework.com",
54+
"graph.microsoft.com",
55+
"graph.microsoft.us",
56+
"graph.microsoft.de",
57+
"graph.microsoft.cn",
58+
] as const;
59+
5160
export const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
5261

5362
export function isRecord(value: unknown): value is Record<string, unknown> {
@@ -250,6 +259,17 @@ export function resolveAllowedHosts(input?: string[]): string[] {
250259
return normalized;
251260
}
252261

262+
export function resolveAuthAllowedHosts(input?: string[]): string[] {
263+
if (!Array.isArray(input) || input.length === 0) {
264+
return DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST.slice();
265+
}
266+
const normalized = input.map(normalizeAllowHost).filter(Boolean);
267+
if (normalized.includes("*")) {
268+
return ["*"];
269+
}
270+
return normalized;
271+
}
272+
253273
function isHostAllowed(host: string, allowlist: string[]): boolean {
254274
if (allowlist.includes("*")) {
255275
return true;

extensions/msteams/src/monitor-handler/inbound-media.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export async function resolveMSTeamsInboundMedia(params: {
1818
htmlSummary?: MSTeamsHtmlAttachmentSummary;
1919
maxBytes: number;
2020
allowHosts?: string[];
21+
authAllowHosts?: string[];
2122
tokenProvider: MSTeamsAccessTokenProvider;
2223
conversationType: string;
2324
conversationId: string;
@@ -46,6 +47,7 @@ export async function resolveMSTeamsInboundMedia(params: {
4647
maxBytes,
4748
tokenProvider,
4849
allowHosts,
50+
authAllowHosts: params.authAllowHosts,
4951
preserveFilenames,
5052
});
5153

@@ -85,6 +87,7 @@ export async function resolveMSTeamsInboundMedia(params: {
8587
tokenProvider,
8688
maxBytes,
8789
allowHosts,
90+
authAllowHosts: params.authAllowHosts,
8891
preserveFilenames,
8992
});
9093
attempts.push({

extensions/msteams/src/monitor-handler/message-handler.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
403403
maxBytes: mediaMaxBytes,
404404
tokenProvider,
405405
allowHosts: msteamsCfg?.mediaAllowHosts,
406+
authAllowHosts: msteamsCfg?.mediaAuthAllowHosts,
406407
conversationType,
407408
conversationId,
408409
conversationMessageId: conversationMessageId ?? undefined,

src/config/types.msteams.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ export type MSTeamsConfig = {
8383
* Use ["*"] to allow any host (not recommended).
8484
*/
8585
mediaAllowHosts?: Array<string>;
86+
/**
87+
* Allowed host suffixes for attaching Authorization headers to inbound media retries.
88+
* Use specific hosts only; avoid multi-tenant suffixes.
89+
*/
90+
mediaAuthAllowHosts?: Array<string>;
8691
/** Default: require @mention to respond in channels/groups. */
8792
requireMention?: boolean;
8893
/** Max group/channel messages to keep as history context (0 disables). */

src/config/zod-schema.providers-core.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -800,6 +800,7 @@ export const MSTeamsConfigSchema = z
800800
chunkMode: z.enum(["length", "newline"]).optional(),
801801
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
802802
mediaAllowHosts: z.array(z.string()).optional(),
803+
mediaAuthAllowHosts: z.array(z.string()).optional(),
803804
requireMention: z.boolean().optional(),
804805
historyLimit: z.number().int().min(0).optional(),
805806
dmHistoryLimit: z.number().int().min(0).optional(),

0 commit comments

Comments
 (0)