fix(security): prevent memory exhaustion in inline image decoding (#22325)

thanks @hackersifu
This commit is contained in:
Joshua McKiddy
2026-04-01 12:44:05 -05:00
committed by GitHub
parent 5e3352f367
commit dd7df0753f
3 changed files with 115 additions and 10 deletions

View File

@@ -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<string>();
for (const inline of inlineCandidates) {

View File

@@ -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: `<img src=\"${smallPngDataUrl}\" />`,
},
];
const out = extractInlineImageCandidates(attachments, { maxInlineBytes: 4 });
expect(out).toEqual([]);
});
it("accepts inline data images within limit", () => {
const attachments = [
{
contentType: "text/html",
content: `<img src=\"${smallPngDataUrl}\" />`,
},
];
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: `<img src=\"${smallPngDataUrl}\" />`,
},
{
contentType: "text/html",
content: `<img src=\"${smallPngDataUrl}\" />`,
},
];
const out = extractInlineImageCandidates(attachments, {
maxInlineBytes: 10,
maxInlineTotalBytes: 6,
});
expect(out.length).toBe(1);
expect(out[0]?.kind).toBe("data");
});
});

View File

@@ -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 = /<img[^>]+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: "<media:image>" };
return {
candidate: { kind: "data", data, contentType, placeholder: "<media:image>" },
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 {