diff --git a/extensions/msteams/src/attachments/download.ts b/extensions/msteams/src/attachments/download.ts index 5a982df1b9f..da973329d8f 100644 --- a/extensions/msteams/src/attachments/download.ts +++ b/extensions/msteams/src/attachments/download.ts @@ -182,7 +182,10 @@ export async function downloadMSTeamsAttachments(params: { .map(resolveDownloadCandidate) .filter(Boolean) as DownloadCandidate[]; - const inlineCandidates = extractInlineImageCandidates(list); + const inlineCandidates = extractInlineImageCandidates(list, { + maxInlineBytes: params.maxBytes, + maxInlineTotalBytes: params.maxBytes, + }); const seenUrls = new Set(); for (const inline of inlineCandidates) { diff --git a/extensions/msteams/src/attachments/shared.test.ts b/extensions/msteams/src/attachments/shared.test.ts index 3e29e65aac4..143c5ba7df0 100644 --- a/extensions/msteams/src/attachments/shared.test.ts +++ b/extensions/msteams/src/attachments/shared.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { applyAuthorizationHeaderForUrl, + extractInlineImageCandidates, isPrivateOrReservedIP, isUrlAllowed, resolveAndValidateIP, @@ -391,3 +392,53 @@ describe("attachment fetch auth helpers", () => { expect(fetchMock).toHaveBeenCalledOnce(); }); }); + +describe("msteams inline image limits", () => { + const smallPngDataUrl = "data:image/png;base64,aGVsbG8="; // "hello" (5 bytes) + + it("rejects inline data images above per-image limit", () => { + const attachments = [ + { + contentType: "text/html", + content: ``, + }, + ]; + const out = extractInlineImageCandidates(attachments, { maxInlineBytes: 4 }); + expect(out).toEqual([]); + }); + + it("accepts inline data images within limit", () => { + const attachments = [ + { + contentType: "text/html", + content: ``, + }, + ]; + const out = extractInlineImageCandidates(attachments, { maxInlineBytes: 10 }); + expect(out.length).toBe(1); + expect(out[0]?.kind).toBe("data"); + if (out[0]?.kind === "data") { + expect(out[0].data.byteLength).toBeGreaterThan(0); + expect(out[0].contentType).toBe("image/png"); + } + }); + + it("enforces cumulative inline size limit across attachments", () => { + const attachments = [ + { + contentType: "text/html", + content: ``, + }, + { + contentType: "text/html", + content: ``, + }, + ]; + const out = extractInlineImageCandidates(attachments, { + maxInlineBytes: 10, + maxInlineTotalBytes: 6, + }); + expect(out.length).toBe(1); + expect(out[0]?.kind).toBe("data"); + }); +}); diff --git a/extensions/msteams/src/attachments/shared.ts b/extensions/msteams/src/attachments/shared.ts index 56c04e725ec..97e5eb5ac8b 100644 --- a/extensions/msteams/src/attachments/shared.ts +++ b/extensions/msteams/src/attachments/shared.ts @@ -1,3 +1,4 @@ +import { Buffer } from "node:buffer"; import { lookup } from "node:dns/promises"; import { buildHostnameAllowlistPolicyFromSuffixAllowlist, @@ -23,6 +24,11 @@ type InlineImageCandidate = placeholder: string; }; +type InlineImageLimitOptions = { + maxInlineBytes?: number; + maxInlineTotalBytes?: number; +}; + export const IMAGE_EXT_RE = /\.(avif|bmp|gif|heic|heif|jpe?g|png|tiff?|webp)$/i; export const IMG_SRC_RE = /]+src=["']([^"']+)["'][^>]*>/gi; @@ -187,25 +193,58 @@ export function extractHtmlFromAttachment(att: MSTeamsAttachmentLike): string | return text; } -function decodeDataImage(src: string): InlineImageCandidate | null { +function estimateBase64DecodedBytes(value: string): number { + const normalized = value.replace(/\s+/g, ""); + if (!normalized) { + return 0; + } + let padding = 0; + if (normalized.endsWith("==")) { + padding = 2; + } else if (normalized.endsWith("=")) { + padding = 1; + } + return Math.max(0, Math.floor((normalized.length * 3) / 4) - padding); +} + +function isLikelyBase64Payload(value: string): boolean { + return /^[A-Za-z0-9+/=\r\n]+$/.test(value); +} + +function decodeDataImageWithLimits( + src: string, + opts: { maxInlineBytes?: number }, +): { candidate: InlineImageCandidate | null; estimatedBytes: number } { const match = /^data:(image\/[a-z0-9.+-]+)?(;base64)?,(.*)$/i.exec(src); if (!match) { - return null; + return { candidate: null, estimatedBytes: 0 }; } const contentType = match[1]?.toLowerCase(); const isBase64 = Boolean(match[2]); if (!isBase64) { - return null; + return { candidate: null, estimatedBytes: 0 }; } const payload = match[3] ?? ""; - if (!payload) { - return null; + if (!payload || !isLikelyBase64Payload(payload)) { + return { candidate: null, estimatedBytes: 0 }; } + + const estimatedBytes = estimateBase64DecodedBytes(payload); + if (estimatedBytes <= 0) { + return { candidate: null, estimatedBytes: 0 }; + } + if (typeof opts.maxInlineBytes === "number" && estimatedBytes > opts.maxInlineBytes) { + return { candidate: null, estimatedBytes }; + } + try { const data = Buffer.from(payload, "base64"); - return { kind: "data", data, contentType, placeholder: "" }; + return { + candidate: { kind: "data", data, contentType, placeholder: "" }, + estimatedBytes, + }; } catch { - return null; + return { candidate: null, estimatedBytes: 0 }; } } @@ -221,9 +260,11 @@ function fileHintFromUrl(src: string): string | undefined { export function extractInlineImageCandidates( attachments: MSTeamsAttachmentLike[], + limits?: InlineImageLimitOptions, ): InlineImageCandidate[] { const out: InlineImageCandidate[] = []; - for (const att of attachments) { + let totalEstimatedInlineBytes = 0; + outerLoop: for (const att of attachments) { const html = extractHtmlFromAttachment(att); if (!html) { continue; @@ -234,8 +275,18 @@ export function extractInlineImageCandidates( const src = match[1]?.trim(); if (src && !src.startsWith("cid:")) { if (src.startsWith("data:")) { - const decoded = decodeDataImage(src); + const { candidate: decoded, estimatedBytes } = decodeDataImageWithLimits(src, { + maxInlineBytes: limits?.maxInlineBytes, + }); if (decoded) { + const nextTotal = totalEstimatedInlineBytes + estimatedBytes; + if ( + typeof limits?.maxInlineTotalBytes === "number" && + nextTotal > limits.maxInlineTotalBytes + ) { + break outerLoop; + } + totalEstimatedInlineBytes = nextTotal; out.push(decoded); } } else {