From 3f311c33e1c770ec8bf436fb6c74afb2e887f53c Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Wed, 13 May 2026 22:35:46 +0000 Subject: [PATCH 1/3] feat(code): render PNGs and image data URLs in previews PNG files read through the Read tool, and file contents that are a single image data URL, were being dumped into CodeMirror as binary garbage. Detect both cases and route them through a new SafeImagePreview that validates the mime type allowlist (no SVG), caps the payload size, and falls back to a friendly message via an ErrorBoundary + onError so a malformed or malicious value can't crash the preview. Generated-By: PostHog Code Task-Id: 2f630f6e-b0b5-4193-85d3-d6ca39eccb28 --- .../components/ui/SafeImagePreview.tsx | 67 ++++++++++ .../components/CodeEditorPanel.tsx | 37 +++++- .../components/session-update/CodePreview.tsx | 52 ++++++++ .../session-update/ReadToolView.tsx | 24 +++- .../session-update/toolCallUtils.tsx | 20 +++ .../src/shared/utils/imageDataUrl.test.ts | 124 ++++++++++++++++++ apps/code/src/shared/utils/imageDataUrl.ts | 47 +++++++ 7 files changed, 366 insertions(+), 5 deletions(-) create mode 100644 apps/code/src/renderer/components/ui/SafeImagePreview.tsx create mode 100644 apps/code/src/shared/utils/imageDataUrl.test.ts create mode 100644 apps/code/src/shared/utils/imageDataUrl.ts diff --git a/apps/code/src/renderer/components/ui/SafeImagePreview.tsx b/apps/code/src/renderer/components/ui/SafeImagePreview.tsx new file mode 100644 index 000000000..d3ef9b250 --- /dev/null +++ b/apps/code/src/renderer/components/ui/SafeImagePreview.tsx @@ -0,0 +1,67 @@ +import { ErrorBoundary } from "@components/ErrorBoundary"; +import { Flex, Text } from "@radix-ui/themes"; +import { + buildImageDataUrl, + isAllowedImageMimeType, +} from "@shared/utils/imageDataUrl"; +import { useState } from "react"; + +interface SafeImagePreviewProps { + /** Base64-encoded image data (no data URL prefix). */ + base64: string; + mimeType: string; + alt?: string; + className?: string; + /** Rendered when the image fails to decode or has a disallowed mime type. */ + fallback?: React.ReactNode; +} + +function DefaultFallback() { + return ( + + Unable to render image preview + + ); +} + +function SafeImagePreviewInner({ + base64, + mimeType, + alt, + className, + fallback, +}: SafeImagePreviewProps) { + const [hasError, setHasError] = useState(false); + + if (!isAllowedImageMimeType(mimeType) || base64.length === 0) { + return <>{fallback ?? }; + } + + if (hasError) { + return <>{fallback ?? }; + } + + return ( + {alt setHasError(true)} + /> + ); +} + +export function SafeImagePreview(props: SafeImagePreviewProps) { + return ( + } + > + + + ); +} diff --git a/apps/code/src/renderer/features/code-editor/components/CodeEditorPanel.tsx b/apps/code/src/renderer/features/code-editor/components/CodeEditorPanel.tsx index 45bbb5090..7e11e46e6 100644 --- a/apps/code/src/renderer/features/code-editor/components/CodeEditorPanel.tsx +++ b/apps/code/src/renderer/features/code-editor/components/CodeEditorPanel.tsx @@ -1,4 +1,5 @@ import { PanelMessage } from "@components/ui/PanelMessage"; +import { SafeImagePreview } from "@components/ui/SafeImagePreview"; import { Tooltip } from "@components/ui/Tooltip"; import { CodeMirrorEditor } from "@features/code-editor/components/CodeMirrorEditor"; import { EnrichmentPopover } from "@features/code-editor/components/EnrichmentPopover"; @@ -16,6 +17,7 @@ import { Box, Flex, IconButton, Text } from "@radix-ui/themes"; import { trpcClient, useTRPC } from "@renderer/trpc/client"; import { getImageMimeType, isImageFile } from "@shared/constants/image"; import type { Task } from "@shared/types"; +import { parseImageDataUrl } from "@shared/utils/imageDataUrl"; import { useQuery } from "@tanstack/react-query"; import { useCallback, useMemo, useState } from "react"; @@ -153,10 +155,16 @@ export function CodeEditorPanel({ p="4" className="overflow-auto" > - {filePath} + Failed to render image + + } /> ); @@ -192,6 +200,31 @@ export function CodeEditorPanel({ return File is empty; } + const dataUrlImage = parseImageDataUrl(fileContent); + if (dataUrlImage) { + return ( + + + Failed to render image + + } + /> + + ); + } + if (isMarkdown) { const handleCopySource = () => { navigator.clipboard.writeText(fileContent); diff --git a/apps/code/src/renderer/features/sessions/components/session-update/CodePreview.tsx b/apps/code/src/renderer/features/sessions/components/session-update/CodePreview.tsx index 0efe74dca..1b8802594 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/CodePreview.tsx +++ b/apps/code/src/renderer/features/sessions/components/session-update/CodePreview.tsx @@ -1,6 +1,8 @@ import { EditorView } from "@codemirror/view"; +import { SafeImagePreview } from "@components/ui/SafeImagePreview"; import { MultiFileDiff } from "@pierre/diffs/react"; import { Code } from "@radix-ui/themes"; +import { parseImageDataUrl } from "@shared/utils/imageDataUrl"; import { useThemeStore } from "@stores/themeStore"; import { compactHomePath } from "@utils/path"; import { useEffect, useMemo, useRef } from "react"; @@ -45,6 +47,19 @@ export function CodePreview({ ); } + const imageDataUrl = parseImageDataUrl(content); + if (imageDataUrl) { + return ( + + ); + } + return ( + {showPath && filePath && ( +
+ + {compactHomePath(filePath)} + +
+ )} +
+ +
+ + ); +} + function DiffPreview({ content, filePath, diff --git a/apps/code/src/renderer/features/sessions/components/session-update/ReadToolView.tsx b/apps/code/src/renderer/features/sessions/components/session-update/ReadToolView.tsx index d10b9c07e..c9aefdfee 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/ReadToolView.tsx +++ b/apps/code/src/renderer/features/sessions/components/session-update/ReadToolView.tsx @@ -1,3 +1,4 @@ +import { SafeImagePreview } from "@components/ui/SafeImagePreview"; import { FileText } from "@phosphor-icons/react"; import { Box, Flex } from "@radix-ui/themes"; import { useState } from "react"; @@ -5,6 +6,7 @@ import { CodePreview } from "./CodePreview"; import { FileMentionChip } from "./FileMentionChip"; import { ExpandableIcon, + getContentImage, getReadToolContent, StatusIndicators, ToolTitle, @@ -27,9 +29,10 @@ export function ReadToolView({ const filePath = locations?.[0]?.path ?? ""; const startLine = locations?.[0]?.line ?? 0; - const fileContent = getReadToolContent(content); + const imageContent = getContentImage(content); + const fileContent = imageContent ? undefined : getReadToolContent(content); const lineCount = fileContent ? fileContent.split("\n").length : null; - const isExpandable = !!fileContent; + const isExpandable = !!fileContent || !!imageContent; const firstLineNumber = startLine + 1; const handleClick = () => { @@ -53,12 +56,27 @@ export function ReadToolView({ isExpanded={isExpanded} /> - Read{lineCount !== null ? ` ${lineCount} lines in` : ""} + {imageContent + ? "Read image" + : `Read${lineCount !== null ? ` ${lineCount} lines` : ""} in`} {filePath && } + {isExpanded && imageContent && ( + + + + + + )} + {isExpanded && fileContent && ( diff --git a/apps/code/src/renderer/features/sessions/components/session-update/toolCallUtils.tsx b/apps/code/src/renderer/features/sessions/components/session-update/toolCallUtils.tsx index bc49e457e..ab2949f73 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/toolCallUtils.tsx +++ b/apps/code/src/renderer/features/sessions/components/session-update/toolCallUtils.tsx @@ -68,6 +68,26 @@ export function getContentText( return undefined; } +export interface ImageContentData { + data: string; + mimeType: string; +} + +export function getContentImage( + content: ToolCall["content"], +): ImageContentData | undefined { + if (!content?.length) return undefined; + for (const item of content) { + if (item.type === "content" && item.content.type === "image") { + const { data, mimeType } = item.content; + if (typeof data === "string" && typeof mimeType === "string") { + return { data, mimeType }; + } + } + } + return undefined; +} + export function getReadToolContent( content: ToolCall["content"], ): string | undefined { diff --git a/apps/code/src/shared/utils/imageDataUrl.test.ts b/apps/code/src/shared/utils/imageDataUrl.test.ts new file mode 100644 index 000000000..d24a328eb --- /dev/null +++ b/apps/code/src/shared/utils/imageDataUrl.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { + buildImageDataUrl, + isAllowedImageMimeType, + parseImageDataUrl, +} from "./imageDataUrl"; + +const TINY_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; + +describe("parseImageDataUrl", () => { + it("parses a valid PNG data URL", () => { + const result = parseImageDataUrl( + `data:image/png;base64,${TINY_PNG_BASE64}`, + ); + expect(result).toEqual({ + mimeType: "image/png", + base64: TINY_PNG_BASE64, + }); + }); + + it("accepts other allowed mime types", () => { + expect( + parseImageDataUrl(`data:image/jpeg;base64,${TINY_PNG_BASE64}`), + ).not.toBeNull(); + expect( + parseImageDataUrl(`data:image/webp;base64,${TINY_PNG_BASE64}`), + ).not.toBeNull(); + expect( + parseImageDataUrl(`data:image/gif;base64,${TINY_PNG_BASE64}`), + ).not.toBeNull(); + }); + + it("rejects SVG data URLs to prevent script execution", () => { + expect( + parseImageDataUrl(`data:image/svg+xml;base64,${TINY_PNG_BASE64}`), + ).toBeNull(); + }); + + it("rejects non-image mime types", () => { + expect( + parseImageDataUrl(`data:text/html;base64,${TINY_PNG_BASE64}`), + ).toBeNull(); + expect( + parseImageDataUrl( + `data:application/javascript;base64,${TINY_PNG_BASE64}`, + ), + ).toBeNull(); + }); + + it("rejects non-base64 data URLs", () => { + expect(parseImageDataUrl("data:image/png,not-base64")).toBeNull(); + }); + + it("rejects empty or non-data-URL strings", () => { + expect(parseImageDataUrl("")).toBeNull(); + expect(parseImageDataUrl("hello world")).toBeNull(); + expect(parseImageDataUrl("https://example.com/image.png")).toBeNull(); + }); + + it("rejects malformed data URLs", () => { + expect(parseImageDataUrl("data:")).toBeNull(); + expect(parseImageDataUrl("data:image/png;base64")).toBeNull(); + expect(parseImageDataUrl("data:image/png;base64,")).toBeNull(); + }); + + it("rejects extremely large payloads", () => { + const huge = "A".repeat(30 * 1024 * 1024); + expect(parseImageDataUrl(`data:image/png;base64,${huge}`)).toBeNull(); + }); + + it("trims surrounding whitespace before parsing", () => { + const result = parseImageDataUrl( + `\n data:image/png;base64,${TINY_PNG_BASE64} \n`, + ); + expect(result?.mimeType).toBe("image/png"); + }); + + it("strips whitespace inside base64 payload", () => { + const withNewlines = TINY_PNG_BASE64.match(/.{1,40}/g)?.join("\n") ?? ""; + const result = parseImageDataUrl(`data:image/png;base64,${withNewlines}`); + expect(result?.base64).toBe(TINY_PNG_BASE64); + }); + + it("ignores additional parameters before the base64 marker", () => { + const result = parseImageDataUrl( + `data:image/png;charset=utf-8;base64,${TINY_PNG_BASE64}`, + ); + expect(result?.mimeType).toBe("image/png"); + }); + + it("normalises mime type casing", () => { + const result = parseImageDataUrl( + `data:IMAGE/PNG;base64,${TINY_PNG_BASE64}`, + ); + expect(result?.mimeType).toBe("image/png"); + }); + + it("handles non-string input safely", () => { + expect(parseImageDataUrl(null as unknown as string)).toBeNull(); + expect(parseImageDataUrl(undefined as unknown as string)).toBeNull(); + expect(parseImageDataUrl(123 as unknown as string)).toBeNull(); + }); +}); + +describe("isAllowedImageMimeType", () => { + it("accepts standard image mime types", () => { + expect(isAllowedImageMimeType("image/png")).toBe(true); + expect(isAllowedImageMimeType("IMAGE/JPEG")).toBe(true); + }); + + it("rejects SVG and non-image types", () => { + expect(isAllowedImageMimeType("image/svg+xml")).toBe(false); + expect(isAllowedImageMimeType("text/html")).toBe(false); + }); +}); + +describe("buildImageDataUrl", () => { + it("builds a data URL from parts", () => { + expect(buildImageDataUrl("image/png", "abc")).toBe( + "data:image/png;base64,abc", + ); + }); +}); diff --git a/apps/code/src/shared/utils/imageDataUrl.ts b/apps/code/src/shared/utils/imageDataUrl.ts new file mode 100644 index 000000000..55247a4b4 --- /dev/null +++ b/apps/code/src/shared/utils/imageDataUrl.ts @@ -0,0 +1,47 @@ +const ALLOWED_IMAGE_MIME_TYPES = new Set([ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/bmp", + "image/x-icon", + "image/vnd.microsoft.icon", + "image/tiff", + "image/avif", +]); + +const DATA_URL_PATTERN = + /^data:([a-zA-Z]+\/[a-zA-Z0-9.+-]+)(?:;[a-zA-Z0-9-]+=[^;,]+)*;base64,([A-Za-z0-9+/=\s]+)$/; + +const MAX_BASE64_LENGTH = 20 * 1024 * 1024; + +export interface ParsedImageDataUrl { + mimeType: string; + base64: string; +} + +export function parseImageDataUrl(value: string): ParsedImageDataUrl | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (trimmed.length === 0 || trimmed.length > MAX_BASE64_LENGTH) return null; + if (!trimmed.startsWith("data:")) return null; + + const match = DATA_URL_PATTERN.exec(trimmed); + if (!match) return null; + + const mimeType = match[1].toLowerCase(); + if (!ALLOWED_IMAGE_MIME_TYPES.has(mimeType)) return null; + + const base64 = match[2].replace(/\s+/g, ""); + if (base64.length === 0) return null; + + return { mimeType, base64 }; +} + +export function isAllowedImageMimeType(mimeType: string): boolean { + return ALLOWED_IMAGE_MIME_TYPES.has(mimeType.toLowerCase()); +} + +export function buildImageDataUrl(mimeType: string, base64: string): string { + return `data:${mimeType};base64,${base64}`; +} From 456b2992652dfca4d5095e8bf9e6bb5a2cf493c3 Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Wed, 13 May 2026 23:00:03 +0000 Subject: [PATCH 2/3] fix(code): address PR review feedback on image preview safety - Restore "Read" / "Read N lines in" title and add "Read image in" for image content blocks, fixing the "Read in" copy regression. - Drop svg from shared IMAGE_MIME_TYPES so .svg files fall through to the text path instead of regressing to "Failed to render image". - Reset hasError in SafeImagePreview when base64/mimeType change, using the render-time setState pattern; drop the ErrorBoundary wrapper since errors fire onError, not exceptions. - Validate base64 length against MAX_IMAGE_BASE64_LENGTH inside SafeImagePreview so tool-provided image data hits the same cap as user-pasted data URLs. - Extract FilePanelImagePreview helper to remove the duplicated wrapper between the PNG-file and data-URL branches of CodeEditorPanel. - Unify ImageContentData.data -> base64 so producers and consumers share one field name. - Rename MAX_BASE64_LENGTH -> MAX_DATA_URL_LENGTH (matches what is measured) and skip the trim() allocation when the value doesn't look like a data URL. - Memoise parseImageDataUrl calls in CodeEditorPanel and CodePreview. - Parameterise the multi-input parser tests with it.each. Generated-By: PostHog Code Task-Id: 2f630f6e-b0b5-4193-85d3-d6ca39eccb28 --- .../components/ui/SafeImagePreview.tsx | 28 +++--- .../components/CodeEditorPanel.tsx | 92 +++++++++++-------- .../components/session-update/CodePreview.tsx | 5 +- .../session-update/ReadToolView.tsx | 6 +- .../session-update/toolCallUtils.tsx | 4 +- apps/code/src/shared/constants/image.ts | 1 - .../src/shared/utils/imageDataUrl.test.ts | 91 ++++++++++-------- apps/code/src/shared/utils/imageDataUrl.ts | 14 ++- 8 files changed, 133 insertions(+), 108 deletions(-) diff --git a/apps/code/src/renderer/components/ui/SafeImagePreview.tsx b/apps/code/src/renderer/components/ui/SafeImagePreview.tsx index d3ef9b250..cbe6fbd63 100644 --- a/apps/code/src/renderer/components/ui/SafeImagePreview.tsx +++ b/apps/code/src/renderer/components/ui/SafeImagePreview.tsx @@ -1,8 +1,8 @@ -import { ErrorBoundary } from "@components/ErrorBoundary"; import { Flex, Text } from "@radix-ui/themes"; import { buildImageDataUrl, isAllowedImageMimeType, + MAX_IMAGE_BASE64_LENGTH, } from "@shared/utils/imageDataUrl"; import { useState } from "react"; @@ -28,7 +28,7 @@ function DefaultFallback() { ); } -function SafeImagePreviewInner({ +export function SafeImagePreview({ base64, mimeType, alt, @@ -36,12 +36,19 @@ function SafeImagePreviewInner({ fallback, }: SafeImagePreviewProps) { const [hasError, setHasError] = useState(false); + const [lastSource, setLastSource] = useState({ base64, mimeType }); - if (!isAllowedImageMimeType(mimeType) || base64.length === 0) { - return <>{fallback ?? }; + if (lastSource.base64 !== base64 || lastSource.mimeType !== mimeType) { + setLastSource({ base64, mimeType }); + setHasError(false); } - if (hasError) { + const isPayloadValid = + base64.length > 0 && + base64.length <= MAX_IMAGE_BASE64_LENGTH && + isAllowedImageMimeType(mimeType); + + if (!isPayloadValid || hasError) { return <>{fallback ?? }; } @@ -54,14 +61,3 @@ function SafeImagePreviewInner({ /> ); } - -export function SafeImagePreview(props: SafeImagePreviewProps) { - return ( - } - > - - - ); -} diff --git a/apps/code/src/renderer/features/code-editor/components/CodeEditorPanel.tsx b/apps/code/src/renderer/features/code-editor/components/CodeEditorPanel.tsx index 7e11e46e6..dccd768e9 100644 --- a/apps/code/src/renderer/features/code-editor/components/CodeEditorPanel.tsx +++ b/apps/code/src/renderer/features/code-editor/components/CodeEditorPanel.tsx @@ -31,6 +31,40 @@ interface CodeEditorPanelProps { absolutePath: string; } +function FilePanelImagePreview({ + base64, + mimeType, + filePath, + absolutePath, +}: { + base64: string; + mimeType: string; + filePath: string; + absolutePath: string; +}) { + return ( + + + Failed to render image + + } + /> + + ); +} + export function CodeEditorPanel({ taskId, task: _task, @@ -130,6 +164,12 @@ export function CodeEditorPanel({ content: isImage ? null : fileContent, }); + const dataUrlImage = useMemo( + () => + isImage || fileContent == null ? null : parseImageDataUrl(fileContent), + [isImage, fileContent], + ); + if (isImage) { if (isCloudRun) { return ( @@ -146,27 +186,13 @@ export function CodeEditorPanel({ Failed to load image ); } - const mimeType = getImageMimeType(absolutePath); return ( - - - Failed to render image - - } - /> - + ); } @@ -200,28 +226,14 @@ export function CodeEditorPanel({ return File is empty; } - const dataUrlImage = parseImageDataUrl(fileContent); if (dataUrlImage) { return ( - - - Failed to render image - - } - /> - + ); } diff --git a/apps/code/src/renderer/features/sessions/components/session-update/CodePreview.tsx b/apps/code/src/renderer/features/sessions/components/session-update/CodePreview.tsx index 1b8802594..7f7841d28 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/CodePreview.tsx +++ b/apps/code/src/renderer/features/sessions/components/session-update/CodePreview.tsx @@ -33,6 +33,10 @@ export function CodePreview({ cacheKey, }: CodePreviewProps) { const isDiff = oldContent !== undefined && oldContent !== null; + const imageDataUrl = useMemo( + () => (isDiff ? null : parseImageDataUrl(content)), + [isDiff, content], + ); if (isDiff) { return ( @@ -47,7 +51,6 @@ export function CodePreview({ ); } - const imageDataUrl = parseImageDataUrl(content); if (imageDataUrl) { return ( {imageContent - ? "Read image" - : `Read${lineCount !== null ? ` ${lineCount} lines` : ""} in`} + ? "Read image in" + : `Read${lineCount !== null ? ` ${lineCount} lines in` : ""}`} {filePath && } @@ -68,7 +68,7 @@ export function ReadToolView({ = { webp: "image/webp", bmp: "image/bmp", ico: "image/x-icon", - svg: "image/svg+xml", tiff: "image/tiff", tif: "image/tiff", }; diff --git a/apps/code/src/shared/utils/imageDataUrl.test.ts b/apps/code/src/shared/utils/imageDataUrl.test.ts index d24a328eb..5f24d19f1 100644 --- a/apps/code/src/shared/utils/imageDataUrl.test.ts +++ b/apps/code/src/shared/utils/imageDataUrl.test.ts @@ -19,16 +19,20 @@ describe("parseImageDataUrl", () => { }); }); - it("accepts other allowed mime types", () => { - expect( - parseImageDataUrl(`data:image/jpeg;base64,${TINY_PNG_BASE64}`), - ).not.toBeNull(); - expect( - parseImageDataUrl(`data:image/webp;base64,${TINY_PNG_BASE64}`), - ).not.toBeNull(); - expect( - parseImageDataUrl(`data:image/gif;base64,${TINY_PNG_BASE64}`), - ).not.toBeNull(); + it.each([ + ["image/jpeg"], + ["image/webp"], + ["image/gif"], + ["image/bmp"], + ["image/avif"], + ["image/tiff"], + ["image/x-icon"], + ])("accepts allowed mime type %s", (mimeType) => { + const result = parseImageDataUrl( + `data:${mimeType};base64,${TINY_PNG_BASE64}`, + ); + expect(result).not.toBeNull(); + expect(result?.mimeType).toBe(mimeType); }); it("rejects SVG data URLs to prevent script execution", () => { @@ -37,14 +41,14 @@ describe("parseImageDataUrl", () => { ).toBeNull(); }); - it("rejects non-image mime types", () => { - expect( - parseImageDataUrl(`data:text/html;base64,${TINY_PNG_BASE64}`), - ).toBeNull(); + it.each([ + ["text/html"], + ["application/javascript"], + ["application/octet-stream"], + ["text/plain"], + ])("rejects non-image mime type %s", (mimeType) => { expect( - parseImageDataUrl( - `data:application/javascript;base64,${TINY_PNG_BASE64}`, - ), + parseImageDataUrl(`data:${mimeType};base64,${TINY_PNG_BASE64}`), ).toBeNull(); }); @@ -52,16 +56,16 @@ describe("parseImageDataUrl", () => { expect(parseImageDataUrl("data:image/png,not-base64")).toBeNull(); }); - it("rejects empty or non-data-URL strings", () => { - expect(parseImageDataUrl("")).toBeNull(); - expect(parseImageDataUrl("hello world")).toBeNull(); - expect(parseImageDataUrl("https://example.com/image.png")).toBeNull(); - }); - - it("rejects malformed data URLs", () => { - expect(parseImageDataUrl("data:")).toBeNull(); - expect(parseImageDataUrl("data:image/png;base64")).toBeNull(); - expect(parseImageDataUrl("data:image/png;base64,")).toBeNull(); + it.each([ + ["empty string", ""], + ["plain text", "hello world"], + ["http URL", "https://example.com/image.png"], + ["truncated data prefix", "data"], + ["missing payload separator", "data:image/png;base64"], + ["empty payload", "data:image/png;base64,"], + ["bare prefix", "data:"], + ])("rejects non-data-URL or malformed input: %s", (_label, value) => { + expect(parseImageDataUrl(value)).toBeNull(); }); it("rejects extremely large payloads", () => { @@ -96,22 +100,29 @@ describe("parseImageDataUrl", () => { expect(result?.mimeType).toBe("image/png"); }); - it("handles non-string input safely", () => { - expect(parseImageDataUrl(null as unknown as string)).toBeNull(); - expect(parseImageDataUrl(undefined as unknown as string)).toBeNull(); - expect(parseImageDataUrl(123 as unknown as string)).toBeNull(); - }); + it.each([[null], [undefined], [123], [{}]])( + "handles non-string input safely: %p", + (value) => { + expect(parseImageDataUrl(value as unknown as string)).toBeNull(); + }, + ); }); describe("isAllowedImageMimeType", () => { - it("accepts standard image mime types", () => { - expect(isAllowedImageMimeType("image/png")).toBe(true); - expect(isAllowedImageMimeType("IMAGE/JPEG")).toBe(true); - }); - - it("rejects SVG and non-image types", () => { - expect(isAllowedImageMimeType("image/svg+xml")).toBe(false); - expect(isAllowedImageMimeType("text/html")).toBe(false); + it.each([["image/png"], ["IMAGE/JPEG"], ["image/webp"], ["image/gif"]])( + "accepts %s", + (mimeType) => { + expect(isAllowedImageMimeType(mimeType)).toBe(true); + }, + ); + + it.each([ + ["image/svg+xml"], + ["text/html"], + ["application/javascript"], + ["text/plain"], + ])("rejects %s", (mimeType) => { + expect(isAllowedImageMimeType(mimeType)).toBe(false); }); }); diff --git a/apps/code/src/shared/utils/imageDataUrl.ts b/apps/code/src/shared/utils/imageDataUrl.ts index 55247a4b4..7f3018576 100644 --- a/apps/code/src/shared/utils/imageDataUrl.ts +++ b/apps/code/src/shared/utils/imageDataUrl.ts @@ -13,7 +13,8 @@ const ALLOWED_IMAGE_MIME_TYPES = new Set([ const DATA_URL_PATTERN = /^data:([a-zA-Z]+\/[a-zA-Z0-9.+-]+)(?:;[a-zA-Z0-9-]+=[^;,]+)*;base64,([A-Za-z0-9+/=\s]+)$/; -const MAX_BASE64_LENGTH = 20 * 1024 * 1024; +const MAX_DATA_URL_LENGTH = 20 * 1024 * 1024; +export const MAX_IMAGE_BASE64_LENGTH = 15 * 1024 * 1024; export interface ParsedImageDataUrl { mimeType: string; @@ -21,10 +22,11 @@ export interface ParsedImageDataUrl { } export function parseImageDataUrl(value: string): ParsedImageDataUrl | null { - if (typeof value !== "string") return null; + if (typeof value !== "string" || value.length === 0) return null; + if (!/^\s*data:/.test(value.slice(0, 32))) return null; + const trimmed = value.trim(); - if (trimmed.length === 0 || trimmed.length > MAX_BASE64_LENGTH) return null; - if (!trimmed.startsWith("data:")) return null; + if (trimmed.length === 0 || trimmed.length > MAX_DATA_URL_LENGTH) return null; const match = DATA_URL_PATTERN.exec(trimmed); if (!match) return null; @@ -33,7 +35,9 @@ export function parseImageDataUrl(value: string): ParsedImageDataUrl | null { if (!ALLOWED_IMAGE_MIME_TYPES.has(mimeType)) return null; const base64 = match[2].replace(/\s+/g, ""); - if (base64.length === 0) return null; + if (base64.length === 0 || base64.length > MAX_IMAGE_BASE64_LENGTH) { + return null; + } return { mimeType, base64 }; } From 5a5bf670d02b3a1e8ea248fdf403bf1dc54eb6fb Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Wed, 13 May 2026 23:18:08 +0000 Subject: [PATCH 3/3] fix(code): widen fast-path leading-whitespace tolerance in data URL parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile flagged that the 32-char slice in the fast-path guard silently rejected data URLs with 28+ leading whitespace characters, even when the value would have parsed cleanly after trim(). Drop the slice, check the size limit up front (cheap, O(1)), and let the prefix regex tolerate up to 1024 leading whitespace characters — generous for realistic indented payloads while still bounding the scan so a pathological all-whitespace input can't burn CPU. Generated-By: PostHog Code Task-Id: 2f630f6e-b0b5-4193-85d3-d6ca39eccb28 --- apps/code/src/shared/utils/imageDataUrl.test.ts | 8 ++++++++ apps/code/src/shared/utils/imageDataUrl.ts | 5 +++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/code/src/shared/utils/imageDataUrl.test.ts b/apps/code/src/shared/utils/imageDataUrl.test.ts index 5f24d19f1..f197142da 100644 --- a/apps/code/src/shared/utils/imageDataUrl.test.ts +++ b/apps/code/src/shared/utils/imageDataUrl.test.ts @@ -80,6 +80,14 @@ describe("parseImageDataUrl", () => { expect(result?.mimeType).toBe("image/png"); }); + it("tolerates long leading-whitespace prefixes", () => { + const padding = " ".repeat(256); + const result = parseImageDataUrl( + `${padding}data:image/png;base64,${TINY_PNG_BASE64}`, + ); + expect(result?.mimeType).toBe("image/png"); + }); + it("strips whitespace inside base64 payload", () => { const withNewlines = TINY_PNG_BASE64.match(/.{1,40}/g)?.join("\n") ?? ""; const result = parseImageDataUrl(`data:image/png;base64,${withNewlines}`); diff --git a/apps/code/src/shared/utils/imageDataUrl.ts b/apps/code/src/shared/utils/imageDataUrl.ts index 7f3018576..7ce852265 100644 --- a/apps/code/src/shared/utils/imageDataUrl.ts +++ b/apps/code/src/shared/utils/imageDataUrl.ts @@ -23,10 +23,11 @@ export interface ParsedImageDataUrl { export function parseImageDataUrl(value: string): ParsedImageDataUrl | null { if (typeof value !== "string" || value.length === 0) return null; - if (!/^\s*data:/.test(value.slice(0, 32))) return null; + if (value.length > MAX_DATA_URL_LENGTH) return null; + if (!/^\s{0,1024}data:/.test(value)) return null; const trimmed = value.trim(); - if (trimmed.length === 0 || trimmed.length > MAX_DATA_URL_LENGTH) return null; + if (trimmed.length === 0) return null; const match = DATA_URL_PATTERN.exec(trimmed); if (!match) return null;