Skip to content

Commit 1c7700e

Browse files
committed
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.
1 parent 5d3f45b commit 1c7700e

File tree

4 files changed

+146
-48
lines changed

4 files changed

+146
-48
lines changed
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* Transient in-memory cache mapping image file paths to their base64 data URLs.
3+
* - Enforces per-entry max size (10MB)
4+
* - TTL eviction to avoid leaks
5+
* - Used to avoid re-reading files for images that originated as base64 from UI
6+
*/
7+
8+
type ImageCacheEntry = {
9+
dataUrl: string
10+
size: number // raw bytes (approx from base64)
11+
ts: number // insertion timestamp
12+
}
13+
14+
const CACHE = new Map<string, ImageCacheEntry>()
15+
16+
// 10 MB limit per image
17+
const MAX_ENTRY_BYTES = 10 * 1024 * 1024
18+
// 10 minutes TTL
19+
const TTL_MS = 10 * 60 * 1000
20+
// Soft cap to prevent unbounded growth
21+
const MAX_ENTRIES = 500
22+
23+
/** Approximate raw bytes for a base64 string (excluding data: header) */
24+
function estimateBytesFromBase64(base64: string): number {
25+
// Remove padding characters for estimation (not strictly required)
26+
const cleaned = base64.replace(/=+$/, "")
27+
// 4 base64 chars represent 3 bytes => bytes ≈ floor(len * 3 / 4)
28+
return Math.floor((cleaned.length * 3) / 4)
29+
}
30+
31+
function purgeExpired(now = Date.now()) {
32+
for (const [k, v] of CACHE) {
33+
if (now - v.ts > TTL_MS) {
34+
CACHE.delete(k)
35+
}
36+
}
37+
// Simple size cap: if still too large, drop oldest
38+
if (CACHE.size > MAX_ENTRIES) {
39+
const entries = Array.from(CACHE.entries()).sort((a, b) => a[1].ts - b[1].ts)
40+
const toDrop = CACHE.size - MAX_ENTRIES
41+
for (let i = 0; i < toDrop; i++) {
42+
CACHE.delete(entries[i][0])
43+
}
44+
}
45+
}
46+
47+
/**
48+
* Store a data URL for a file path (returns false if rejected by size limits)
49+
*/
50+
export function setImageBase64ForPath(filePath: string, dataUrl: string): boolean {
51+
try {
52+
const commaIdx = dataUrl.indexOf(",")
53+
if (commaIdx === -1) return false
54+
const base64 = dataUrl.slice(commaIdx + 1)
55+
const size = estimateBytesFromBase64(base64)
56+
57+
if (size > MAX_ENTRY_BYTES) {
58+
// Too large; do not cache
59+
return false
60+
}
61+
62+
purgeExpired()
63+
CACHE.set(filePath, { dataUrl, size, ts: Date.now() })
64+
return true
65+
} catch {
66+
return false
67+
}
68+
}
69+
70+
/**
71+
* Retrieve a cached data URL if present and not expired
72+
*/
73+
export function getImageBase64ForPath(filePath: string): string | undefined {
74+
purgeExpired()
75+
const entry = CACHE.get(filePath)
76+
if (!entry) return undefined
77+
return entry.dataUrl
78+
}
79+
80+
/** Remove a single path from cache */
81+
export function clearImageForPath(filePath: string): void {
82+
CACHE.delete(filePath)
83+
}
84+
85+
/** Clear entire cache */
86+
export function clearImageCache(): void {
87+
CACHE.clear()
88+
}
89+
90+
/** Expose limits for callers that want to validate before calling set() */
91+
export const IMAGE_CACHE_LIMITS = {
92+
MAX_ENTRY_BYTES,
93+
TTL_MS,
94+
}

src/integrations/misc/image-handler.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import * as vscode from "vscode"
44
import * as fs from "fs/promises"
55
import { getWorkspacePath } from "../../utils/path"
66
import { t } from "../../i18n"
7+
const MAX_IMAGE_BYTES = 10 * 1024 * 1024
8+
import { setImageBase64ForPath } from "./image-cache"
79

810
export async function openImage(dataUriOrPath: string, options?: { values?: { action?: string } }) {
911
// Minimal handling for VS Code webview CDN URLs:
@@ -187,6 +189,14 @@ export async function savePastedImageToTemp(
187189
}
188190

189191
const [, format, base64Data] = matches
192+
// Enforce a 10MB/image limit (approximate from base64 length)
193+
{
194+
const approxBytes = Math.floor((base64Data.replace(/=+$/, "").length * 3) / 4)
195+
if (approxBytes > MAX_IMAGE_BYTES) {
196+
console.error("Pasted image exceeds 10MB limit")
197+
return null
198+
}
199+
}
190200
const imageBuffer = Buffer.from(base64Data, "base64")
191201

192202
// Determine storage directory
@@ -223,6 +233,10 @@ export async function savePastedImageToTemp(
223233
// Write the image to the file
224234
await fs.writeFile(imagePath, imageBuffer)
225235

236+
// Since this image originated as base64 from the UI, cache the dataUrl to avoid future re-encoding
237+
// Reconstruct the full data URL using the original input
238+
setImageBase64ForPath(imagePath, dataUri)
239+
226240
// Convert to webview URI if provider is available
227241
let imageUri = provider?.convertToWebviewUri?.(imagePath) ?? vscode.Uri.file(imagePath).toString()
228242

src/integrations/misc/imageDataUrl.ts

Lines changed: 10 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as fs from "fs/promises"
22
import * as path from "path"
3+
import { getImageBase64ForPath } from "./image-cache"
34

45
/**
56
* Converts webview URIs to base64 data URLs for API calls.
@@ -20,6 +21,14 @@ export async function normalizeImageRefsToDataUrls(imageRefs: string[]): Promise
2021
// Convert webview URI to file path and then to base64
2122
try {
2223
const filePath = webviewUriToFilePath(imageRef)
24+
25+
// If the image originated from the UI as base64 and was cached, use it to avoid re-encoding
26+
const cached = getImageBase64ForPath(filePath)
27+
if (cached) {
28+
results.push(cached)
29+
continue
30+
}
31+
2332
const buffer = await fs.readFile(filePath)
2433
const base64 = buffer.toString("base64")
2534
const mimeType = getMimeTypeFromPath(filePath)
@@ -52,54 +61,9 @@ function webviewUriToFilePath(webviewUri: string): string {
5261
return path.normalize(decodeURIComponent(p))
5362
}
5463
} catch {
55-
// fall through if not a valid URL or not the expected host
56-
}
57-
}
58-
59-
// Handle vscode-resource URIs like:
60-
// vscode-resource://vscode-webview/path/to/file
61-
if (webviewUri.includes("vscode-resource://")) {
62-
// Extract the path portion after vscode-resource://vscode-webview/
63-
const match = webviewUri.match(/vscode-resource:\/\/[^\/]+(.+)/)
64-
if (match) {
65-
return decodeURIComponent(match[1])
66-
}
67-
}
68-
69-
// Handle file:// URIs
70-
if (webviewUri.startsWith("file://")) {
71-
return decodeURIComponent(webviewUri.substring(7))
72-
}
73-
74-
// Handle VS Code webview URIs that contain encoded paths
75-
// Use strict prefix matching to prevent arbitrary host injection
76-
if (webviewUri.startsWith("vscode-resource://vscode-webview/") && webviewUri.includes("vscode-userdata")) {
77-
try {
78-
// Decode safely with length limits
79-
if (webviewUri.length > 2048) {
80-
throw new Error("URI too long")
81-
}
82-
83-
const decoded = decodeURIComponent(webviewUri)
84-
85-
// Use specific, bounded patterns to prevent ReDoS
86-
// Match exact patterns without backtracking
87-
const unixMatch = decoded.match(
88-
/^[^?#]*\/(?:Users|home|root|var|tmp|opt)\/[^?#]{1,300}\.(png|jpg|jpeg|gif|webp)$/i,
89-
)
90-
if (unixMatch) {
91-
return unixMatch[0]
92-
}
93-
94-
const windowsMatch = decoded.match(/^[^?#]*[A-Za-z]:\\[^?#]{1,300}\.(png|jpg|jpeg|gif|webp)$/i)
95-
if (windowsMatch) {
96-
return windowsMatch[0]
97-
}
98-
} catch (error) {
99-
console.error("Failed to decode webview URI:", error)
64+
throw new Error("Invalid URL")
10065
}
10166
}
102-
10367
// As a last resort, try treating it as a file path
10468
return webviewUri
10569
}

webview-ui/src/components/chat/ChatTextArea.tsx

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -730,8 +730,21 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
730730
const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null)
731731

732732
if (dataUrls.length > 0) {
733-
// Process each image: send to backend to save as temp file
733+
// Process each image: enforce 10MB limit and send to backend to save as temp file
734734
for (const dataUrl of dataUrls) {
735+
// Approximate bytes from base64 (ignore header and padding)
736+
const commaIdx = dataUrl.indexOf(",")
737+
let tooLarge = false
738+
if (commaIdx !== -1) {
739+
const base64 = dataUrl.slice(commaIdx + 1).replace(/=+$/, "")
740+
const approxBytes = Math.floor((base64.length * 3) / 4)
741+
if (approxBytes > 10 * 1024 * 1024) {
742+
console.warn("Pasted image exceeds 10MB; skipping")
743+
tooLarge = true
744+
}
745+
}
746+
if (tooLarge) continue
747+
735748
const requestId = Math.random().toString(36).substring(2, 9)
736749

737750
// Track request ID only; never store base64
@@ -895,8 +908,21 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
895908
const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null)
896909

897910
if (dataUrls.length > 0) {
898-
// Process each dropped image: send to backend to save as temp file
911+
// Process each dropped image: enforce 10MB limit and send to backend to save as temp file
899912
for (const dataUrl of dataUrls) {
913+
// Approximate bytes from base64 (ignore header and padding)
914+
const commaIdx = dataUrl.indexOf(",")
915+
let tooLarge = false
916+
if (commaIdx !== -1) {
917+
const base64 = dataUrl.slice(commaIdx + 1).replace(/=+$/, "")
918+
const approxBytes = Math.floor((base64.length * 3) / 4)
919+
if (approxBytes > 10 * 1024 * 1024) {
920+
console.warn("Dropped image exceeds 10MB; skipping")
921+
tooLarge = true
922+
}
923+
}
924+
if (tooLarge) continue
925+
900926
const requestId = Math.random().toString(36).substring(2, 9)
901927

902928
// Track request ID only; never store base64

0 commit comments

Comments
 (0)