From 1c7700eb699b3cad3a68f0d3002a623c82ec61de Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Tue, 28 Oct 2025 11:33:08 -0500 Subject: [PATCH] feat(images): enforce 10MB limit UI+backend; reuse original base64 via cache to avoid re-encoding UI: early-reject >10MB in ChatTextArea paste/drop handlers. Backend: validate >10MB in savePastedImageToTemp(). Cache base64 mapped to file path and consume in normalizeImageRefsToDataUrls() to avoid re-encoding UI-pasted images. --- src/integrations/misc/image-cache.ts | 94 +++++++++++++++++++ src/integrations/misc/image-handler.ts | 14 +++ src/integrations/misc/imageDataUrl.ts | 56 ++--------- .../src/components/chat/ChatTextArea.tsx | 30 +++++- 4 files changed, 146 insertions(+), 48 deletions(-) create mode 100644 src/integrations/misc/image-cache.ts diff --git a/src/integrations/misc/image-cache.ts b/src/integrations/misc/image-cache.ts new file mode 100644 index 0000000000..2df5f85758 --- /dev/null +++ b/src/integrations/misc/image-cache.ts @@ -0,0 +1,94 @@ +/** + * Transient in-memory cache mapping image file paths to their base64 data URLs. + * - Enforces per-entry max size (10MB) + * - TTL eviction to avoid leaks + * - Used to avoid re-reading files for images that originated as base64 from UI + */ + +type ImageCacheEntry = { + dataUrl: string + size: number // raw bytes (approx from base64) + ts: number // insertion timestamp +} + +const CACHE = new Map() + +// 10 MB limit per image +const MAX_ENTRY_BYTES = 10 * 1024 * 1024 +// 10 minutes TTL +const TTL_MS = 10 * 60 * 1000 +// Soft cap to prevent unbounded growth +const MAX_ENTRIES = 500 + +/** Approximate raw bytes for a base64 string (excluding data: header) */ +function estimateBytesFromBase64(base64: string): number { + // Remove padding characters for estimation (not strictly required) + const cleaned = base64.replace(/=+$/, "") + // 4 base64 chars represent 3 bytes => bytes ≈ floor(len * 3 / 4) + return Math.floor((cleaned.length * 3) / 4) +} + +function purgeExpired(now = Date.now()) { + for (const [k, v] of CACHE) { + if (now - v.ts > TTL_MS) { + CACHE.delete(k) + } + } + // Simple size cap: if still too large, drop oldest + if (CACHE.size > MAX_ENTRIES) { + const entries = Array.from(CACHE.entries()).sort((a, b) => a[1].ts - b[1].ts) + const toDrop = CACHE.size - MAX_ENTRIES + for (let i = 0; i < toDrop; i++) { + CACHE.delete(entries[i][0]) + } + } +} + +/** + * Store a data URL for a file path (returns false if rejected by size limits) + */ +export function setImageBase64ForPath(filePath: string, dataUrl: string): boolean { + try { + const commaIdx = dataUrl.indexOf(",") + if (commaIdx === -1) return false + const base64 = dataUrl.slice(commaIdx + 1) + const size = estimateBytesFromBase64(base64) + + if (size > MAX_ENTRY_BYTES) { + // Too large; do not cache + return false + } + + purgeExpired() + CACHE.set(filePath, { dataUrl, size, ts: Date.now() }) + return true + } catch { + return false + } +} + +/** + * Retrieve a cached data URL if present and not expired + */ +export function getImageBase64ForPath(filePath: string): string | undefined { + purgeExpired() + const entry = CACHE.get(filePath) + if (!entry) return undefined + return entry.dataUrl +} + +/** Remove a single path from cache */ +export function clearImageForPath(filePath: string): void { + CACHE.delete(filePath) +} + +/** Clear entire cache */ +export function clearImageCache(): void { + CACHE.clear() +} + +/** Expose limits for callers that want to validate before calling set() */ +export const IMAGE_CACHE_LIMITS = { + MAX_ENTRY_BYTES, + TTL_MS, +} diff --git a/src/integrations/misc/image-handler.ts b/src/integrations/misc/image-handler.ts index f66c5a3123..49ab72b238 100644 --- a/src/integrations/misc/image-handler.ts +++ b/src/integrations/misc/image-handler.ts @@ -4,6 +4,8 @@ import * as vscode from "vscode" import * as fs from "fs/promises" import { getWorkspacePath } from "../../utils/path" import { t } from "../../i18n" +const MAX_IMAGE_BYTES = 10 * 1024 * 1024 +import { setImageBase64ForPath } from "./image-cache" export async function openImage(dataUriOrPath: string, options?: { values?: { action?: string } }) { // Minimal handling for VS Code webview CDN URLs: @@ -187,6 +189,14 @@ export async function savePastedImageToTemp( } const [, format, base64Data] = matches + // Enforce a 10MB/image limit (approximate from base64 length) + { + const approxBytes = Math.floor((base64Data.replace(/=+$/, "").length * 3) / 4) + if (approxBytes > MAX_IMAGE_BYTES) { + console.error("Pasted image exceeds 10MB limit") + return null + } + } const imageBuffer = Buffer.from(base64Data, "base64") // Determine storage directory @@ -223,6 +233,10 @@ export async function savePastedImageToTemp( // Write the image to the file await fs.writeFile(imagePath, imageBuffer) + // Since this image originated as base64 from the UI, cache the dataUrl to avoid future re-encoding + // Reconstruct the full data URL using the original input + setImageBase64ForPath(imagePath, dataUri) + // Convert to webview URI if provider is available let imageUri = provider?.convertToWebviewUri?.(imagePath) ?? vscode.Uri.file(imagePath).toString() diff --git a/src/integrations/misc/imageDataUrl.ts b/src/integrations/misc/imageDataUrl.ts index 2179802881..42d93606f7 100644 --- a/src/integrations/misc/imageDataUrl.ts +++ b/src/integrations/misc/imageDataUrl.ts @@ -1,5 +1,6 @@ import * as fs from "fs/promises" import * as path from "path" +import { getImageBase64ForPath } from "./image-cache" /** * Converts webview URIs to base64 data URLs for API calls. @@ -20,6 +21,14 @@ export async function normalizeImageRefsToDataUrls(imageRefs: string[]): Promise // Convert webview URI to file path and then to base64 try { const filePath = webviewUriToFilePath(imageRef) + + // If the image originated from the UI as base64 and was cached, use it to avoid re-encoding + const cached = getImageBase64ForPath(filePath) + if (cached) { + results.push(cached) + continue + } + const buffer = await fs.readFile(filePath) const base64 = buffer.toString("base64") const mimeType = getMimeTypeFromPath(filePath) @@ -52,54 +61,9 @@ function webviewUriToFilePath(webviewUri: string): string { return path.normalize(decodeURIComponent(p)) } } catch { - // fall through if not a valid URL or not the expected host + throw new Error("Invalid URL") } } - - // Handle vscode-resource URIs like: - // vscode-resource://vscode-webview/path/to/file - if (webviewUri.includes("vscode-resource://")) { - // Extract the path portion after vscode-resource://vscode-webview/ - const match = webviewUri.match(/vscode-resource:\/\/[^\/]+(.+)/) - if (match) { - return decodeURIComponent(match[1]) - } - } - - // Handle file:// URIs - if (webviewUri.startsWith("file://")) { - return decodeURIComponent(webviewUri.substring(7)) - } - - // Handle VS Code webview URIs that contain encoded paths - // Use strict prefix matching to prevent arbitrary host injection - if (webviewUri.startsWith("vscode-resource://vscode-webview/") && webviewUri.includes("vscode-userdata")) { - try { - // Decode safely with length limits - if (webviewUri.length > 2048) { - throw new Error("URI too long") - } - - const decoded = decodeURIComponent(webviewUri) - - // Use specific, bounded patterns to prevent ReDoS - // Match exact patterns without backtracking - const unixMatch = decoded.match( - /^[^?#]*\/(?:Users|home|root|var|tmp|opt)\/[^?#]{1,300}\.(png|jpg|jpeg|gif|webp)$/i, - ) - if (unixMatch) { - return unixMatch[0] - } - - const windowsMatch = decoded.match(/^[^?#]*[A-Za-z]:\\[^?#]{1,300}\.(png|jpg|jpeg|gif|webp)$/i) - if (windowsMatch) { - return windowsMatch[0] - } - } catch (error) { - console.error("Failed to decode webview URI:", error) - } - } - // As a last resort, try treating it as a file path return webviewUri } diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 05069bf6eb..63e1fd4a0a 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -730,8 +730,21 @@ export const ChatTextArea = forwardRef( const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null) if (dataUrls.length > 0) { - // Process each image: send to backend to save as temp file + // Process each image: enforce 10MB limit and send to backend to save as temp file for (const dataUrl of dataUrls) { + // Approximate bytes from base64 (ignore header and padding) + const commaIdx = dataUrl.indexOf(",") + let tooLarge = false + if (commaIdx !== -1) { + const base64 = dataUrl.slice(commaIdx + 1).replace(/=+$/, "") + const approxBytes = Math.floor((base64.length * 3) / 4) + if (approxBytes > 10 * 1024 * 1024) { + console.warn("Pasted image exceeds 10MB; skipping") + tooLarge = true + } + } + if (tooLarge) continue + const requestId = Math.random().toString(36).substring(2, 9) // Track request ID only; never store base64 @@ -895,8 +908,21 @@ export const ChatTextArea = forwardRef( const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null) if (dataUrls.length > 0) { - // Process each dropped image: send to backend to save as temp file + // Process each dropped image: enforce 10MB limit and send to backend to save as temp file for (const dataUrl of dataUrls) { + // Approximate bytes from base64 (ignore header and padding) + const commaIdx = dataUrl.indexOf(",") + let tooLarge = false + if (commaIdx !== -1) { + const base64 = dataUrl.slice(commaIdx + 1).replace(/=+$/, "") + const approxBytes = Math.floor((base64.length * 3) / 4) + if (approxBytes > 10 * 1024 * 1024) { + console.warn("Dropped image exceeds 10MB; skipping") + tooLarge = true + } + } + if (tooLarge) continue + const requestId = Math.random().toString(36).substring(2, 9) // Track request ID only; never store base64