mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-06 08:18:39 +00:00
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.
This commit is contained in:
parent
5d3f45bec0
commit
1c7700eb69
4 changed files with 146 additions and 48 deletions
94
src/integrations/misc/image-cache.ts
Normal file
94
src/integrations/misc/image-cache.ts
Normal file
|
|
@ -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<string, ImageCacheEntry>()
|
||||
|
||||
// 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,
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -730,8 +730,21 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
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<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue