fix(gateway): parse image data URLs linearly (#102825)

Co-authored-by: WangYan <wang.yan29@xydigit.com>
This commit is contained in:
Peter Steinberger 2026-07-09 15:42:07 +01:00 committed by GitHub
parent 8985598302
commit 79d3daa09b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 76 additions and 5 deletions

View file

@ -586,6 +586,59 @@ describe("createManagedOutgoingImageBlocks", () => {
await expectPathMissing(path.join(stateDir, "media", "outgoing", "records"));
});
it("rejects semicolon-heavy malformed data urls without backtracking", async () => {
const semicolons = ";".repeat(64);
const malformed = `data:image/png${semicolons}`;
await expect(
createManagedOutgoingImageBlocks({
sessionKey: "agent:main:main",
mediaUrls: [malformed],
stateDir,
}),
).rejects.toThrow("Invalid image data URL");
});
it("rejects malformed data urls with a later ;base64, marker after a payload comma", async () => {
await expect(
createManagedOutgoingImageBlocks({
sessionKey: "agent:main:main",
mediaUrls: ["data:image/png;base64,garbage;base64,iVBORw0KGgo="],
stateDir,
}),
).rejects.toThrow("Invalid image data URL");
});
it("requires the base64 marker to be adjacent to the payload comma", async () => {
await expect(
createManagedOutgoingImageBlocks({
sessionKey: "agent:main:main",
mediaUrls: [`data:image/png;base64\n,${TINY_PNG_BASE64}`],
stateDir,
}),
).rejects.toThrow("Invalid image data URL");
});
it("preserves parameterized image data urls", async () => {
const blocks = await createManagedOutgoingImageBlocks({
sessionKey: "agent:main:main",
mediaUrls: [`data:image/png;charset=utf-8;base64,${TINY_PNG_BASE64}`],
stateDir,
});
expect(requireBlock(blocks).mimeType).toBe("image/png");
});
it("rejects data urls without a media type", async () => {
await expect(
createManagedOutgoingImageBlocks({
sessionKey: "agent:main:main",
mediaUrls: [`data:;base64,${TINY_PNG_BASE64}`],
stateDir,
}),
).rejects.toThrow("Invalid image data URL");
});
it("rewrites local image sources into managed display blocks without leaking the source path", async () => {
const sourcePath = path.join(stateDir, "workspace", "fixtures", "dot.png");
await fs.mkdir(path.dirname(sourcePath), { recursive: true });

View file

@ -279,22 +279,40 @@ function parseImageDataUrl(
if (!trimmed.startsWith("data:")) {
return { kind: "not-data-url" };
}
const match = /^data:([^;,]+)(?:;[^,]*)*;base64,([A-Za-z0-9+/=\s]+)$/i.exec(trimmed);
if (!match) {
const afterPrefix = trimmed.slice("data:".length);
const commaIdx = afterPrefix.indexOf(",");
const mimeAndParams = commaIdx < 0 ? "" : afterPrefix.slice(0, commaIdx);
const base64Marker = ";base64";
if (mimeAndParams.slice(-base64Marker.length).toLowerCase() !== base64Marker) {
throw new Error("Invalid image data URL");
}
const contentType = match[1]?.trim().toLowerCase() ?? "";
const semicolonIdx = mimeAndParams.indexOf(";");
const contentType = (semicolonIdx < 0 ? mimeAndParams : mimeAndParams.slice(0, semicolonIdx))
.trim()
.toLowerCase();
if (!contentType) {
throw new Error("Invalid image data URL");
}
const base64Part = afterPrefix.slice(commaIdx + 1);
if (!/^[A-Za-z0-9+/=\s]+$/.test(base64Part)) {
throw new Error("Invalid image data URL");
}
if (!contentType.startsWith("image/")) {
return { kind: "non-image-data-url" };
}
if (estimateBase64DecodedByteLength(match[2]) > limits.maxBytes) {
if (estimateBase64DecodedByteLength(base64Part) > limits.maxBytes) {
throw createManagedImageAttachmentError(
`Managed image attachment ${JSON.stringify(alt)} exceeds the ${formatLimitMiB(limits.maxBytes)} byte limit`,
);
}
return {
kind: "image-data-url",
buffer: Buffer.from(match[2].replace(/\s+/g, ""), "base64"),
buffer: Buffer.from(base64Part.replace(/\s+/g, ""), "base64"),
contentType,
};
}