diff --git a/src/gateway/managed-image-attachments.test.ts b/src/gateway/managed-image-attachments.test.ts index fcf6ef28991..b38e4ed4634 100644 --- a/src/gateway/managed-image-attachments.test.ts +++ b/src/gateway/managed-image-attachments.test.ts @@ -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 }); diff --git a/src/gateway/managed-image-attachments.ts b/src/gateway/managed-image-attachments.ts index 9f8b334e9ca..5a7b59c5586 100644 --- a/src/gateway/managed-image-attachments.ts +++ b/src/gateway/managed-image-attachments.ts @@ -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, }; }