mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-03 13:22:14 +00:00
fix(security): prevent memory exhaustion in inline image decoding (#22325)
thanks @hackersifu
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user