webview: never render base64; render backend-saved image URIs; allow globalStorage URIs; fix 401

This commit is contained in:
daniel-lxs 2025-09-22 14:46:36 -05:00
parent f934363beb
commit e71ec43d0b
No known key found for this signature in database
GPG key ID: 21C74479048B3AA6
12 changed files with 301 additions and 66 deletions

View file

@ -256,7 +256,8 @@ export const openClineInNewTab = async ({ context, outputChannel }: Omit<Registe
const newPanel = vscode.window.createWebviewPanel(ClineProvider.tabPanelId, "Roo Code", targetCol, {
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: [context.extensionUri],
// Allow webview to load images saved under globalStorageUri
localResourceRoots: [context.extensionUri, context.globalStorageUri],
})
// Save as tab type panel.

View file

@ -728,7 +728,7 @@ export class ClineProvider
})
// Set up webview options with proper resource roots
const resourceRoots = [this.contextProxy.extensionUri]
const resourceRoots = [this.contextProxy.extensionUri, this.contextProxy.globalStorageUri]
// Add workspace folders to allow access to workspace files
if (vscode.workspace.workspaceFolders) {
@ -1008,7 +1008,7 @@ export class ClineProvider
"default-src 'none'",
`font-src ${webview.cspSource} data:`,
`style-src ${webview.cspSource} 'unsafe-inline' https://* http://${localServerUrl} http://0.0.0.0:${localPort}`,
`img-src ${webview.cspSource} https://storage.googleapis.com https://img.clerk.com data:`,
`img-src ${webview.cspSource} https://*.vscode-cdn.net https://storage.googleapis.com https://img.clerk.com data:`,
`media-src ${webview.cspSource}`,
`script-src 'unsafe-eval' ${webview.cspSource} https://* https://*.posthog.com http://${localServerUrl} http://0.0.0.0:${localPort} 'nonce-${nonce}'`,
`connect-src ${webview.cspSource} https://* https://*.posthog.com ws://${localServerUrl} ws://0.0.0.0:${localPort} http://${localServerUrl} http://0.0.0.0:${localPort}`,
@ -1093,7 +1093,7 @@ export class ClineProvider
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; font-src ${webview.cspSource} data:; style-src ${webview.cspSource} 'unsafe-inline'; img-src ${webview.cspSource} https://storage.googleapis.com https://img.clerk.com data:; media-src ${webview.cspSource}; script-src ${webview.cspSource} 'wasm-unsafe-eval' 'nonce-${nonce}' https://us-assets.i.posthog.com 'strict-dynamic'; connect-src ${webview.cspSource} https://openrouter.ai https://api.requesty.ai https://us.i.posthog.com https://us-assets.i.posthog.com;">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; font-src ${webview.cspSource} data:; style-src ${webview.cspSource} 'unsafe-inline'; img-src ${webview.cspSource} https://*.vscode-cdn.net https://storage.googleapis.com https://img.clerk.com data:; media-src ${webview.cspSource}; script-src ${webview.cspSource} 'wasm-unsafe-eval' 'nonce-${nonce}' https://us-assets.i.posthog.com 'strict-dynamic'; connect-src ${webview.cspSource} https://openrouter.ai https://api.requesty.ai https://us.i.posthog.com https://us-assets.i.posthog.com;">
<link rel="stylesheet" type="text/css" href="${stylesUri}">
<link href="${codiconsUri}" rel="stylesheet" />
<script nonce="${nonce}">
@ -2720,27 +2720,35 @@ export class ClineProvider
*/
public convertToWebviewUri(filePath: string): string {
try {
const fileUri = vscode.Uri.file(filePath)
// Check if we have a webview available
// If a webview is available, generate a URI relative to an allowed localResourceRoot when possible.
if (this.view?.webview) {
let fileUri: vscode.Uri
// Prefer mapping under globalStorageUri to guarantee allow-list match for localResourceRoots
const gsRoot = this.contextProxy?.globalStorageUri?.fsPath
if (gsRoot && filePath.startsWith(gsRoot)) {
// Build a URI under the globalStorage root using joinPath
const rel = path.relative(gsRoot, filePath)
const segments = rel.split(path.sep).filter(Boolean)
fileUri = vscode.Uri.joinPath(this.contextProxy.globalStorageUri, ...segments)
} else {
// Fallback to direct file URI
fileUri = vscode.Uri.file(filePath)
}
const webviewUri = this.view.webview.asWebviewUri(fileUri)
return webviewUri.toString()
}
// Specific error for no webview available
const error = new Error("No webview available for URI conversion")
console.error(error.message)
// Fallback to file URI if no webview available
return fileUri.toString()
// No webview available; fallback to file URI
console.error("No webview available for URI conversion")
return vscode.Uri.file(filePath).toString()
} catch (error) {
// More specific error handling
if (error instanceof TypeError) {
console.error("Invalid file path provided for URI conversion:", error)
} else {
console.error("Failed to convert to webview URI:", error)
}
// Return file URI as fallback
return vscode.Uri.file(filePath).toString()
}
}

View file

@ -456,7 +456,7 @@ describe("ClineProvider", () => {
expect(mockWebviewView.webview.options).toEqual({
enableScripts: true,
localResourceRoots: [mockContext.extensionUri],
localResourceRoots: [mockContext.extensionUri, mockContext.globalStorageUri],
})
expect(mockWebviewView.webview.html).toContain("<!DOCTYPE html>")
@ -475,7 +475,7 @@ describe("ClineProvider", () => {
expect(mockWebviewView.webview.options).toEqual({
enableScripts: true,
localResourceRoots: [mockContext.extensionUri],
localResourceRoots: [mockContext.extensionUri, mockContext.globalStorageUri],
})
expect(mockWebviewView.webview.html).toContain("<!DOCTYPE html>")

View file

@ -36,7 +36,12 @@ import { checkExistKey } from "../../shared/checkExistApiConfig"
import { experimentDefault } from "../../shared/experiments"
import { Terminal } from "../../integrations/terminal/Terminal"
import { openFile } from "../../integrations/misc/open-file"
import { openImage, saveImage } from "../../integrations/misc/image-handler"
import {
openImage,
saveImage,
savePastedImageToTemp,
importImageToGlobalStorage,
} from "../../integrations/misc/image-handler"
import { selectImages } from "../../integrations/misc/process-images"
import { getTheme } from "../../integrations/theme/getTheme"
import { discoverChromeHostUrl, tryChromeHostUrl } from "../../services/browser/browserDiscovery"
@ -614,13 +619,24 @@ export const webviewMessageHandler = async (
await provider.postStateToWebview()
break
case "selectImages":
const images = await selectImages()
await provider.postMessageToWebview({
type: "selectedImages",
images,
context: message.context,
messageTs: message.messageTs,
})
// Copy selected images into global storage and return webview-safe URIs
{
const pickedPaths = await selectImages()
const results = await Promise.all(
(pickedPaths || []).map((p) => importImageToGlobalStorage(p, provider)),
)
// Ensure URIs are derived via current webview context if available
const images = results
.filter((r): r is { imagePath: string; imageUri: string } => !!r)
.map((r) => provider?.convertToWebviewUri?.(r.imagePath) ?? r.imageUri)
await provider.postMessageToWebview({
type: "selectedImages",
images,
context: message.context,
messageTs: message.messageTs,
})
}
break
case "exportCurrentTask":
const currentTaskId = provider.getCurrentTask()?.taskId
@ -3035,5 +3051,26 @@ export const webviewMessageHandler = async (
})
break
}
case "savePastedImage": {
// Save pasted image to temporary file and return path and URI
if (message.dataUri) {
const result = await savePastedImageToTemp(message.dataUri, provider)
if (result) {
await provider.postMessageToWebview({
type: "pastedImageSaved",
imagePath: result.imagePath,
imageUri: result.imageUri,
requestId: message.requestId,
})
} else {
await provider.postMessageToWebview({
type: "pastedImageSaved",
error: "Failed to save pasted image",
requestId: message.requestId,
})
}
}
break
}
}
}

View file

@ -1,6 +1,7 @@
import * as path from "path"
import * as os from "os"
import * as vscode from "vscode"
import * as fs from "fs/promises"
import { getWorkspacePath } from "../../utils/path"
import { t } from "../../i18n"
@ -42,7 +43,6 @@ export async function openImage(dataUriOrPath: string, options?: { values?: { ac
return
}
// Handle data URI (existing logic)
const matches = dataUriOrPath.match(/^data:image\/([a-zA-Z]+);base64,(.+)$/)
if (!matches) {
vscode.window.showErrorMessage(t("common:errors.invalid_data_uri"))
@ -90,6 +90,108 @@ export async function openImage(dataUriOrPath: string, options?: { values?: { ac
}
}
/**
* Save a pasted/dropped image to global storage and return its path and webview URI
* This uses VSCode's global storage for persistence across sessions
*/
export async function importImageToGlobalStorage(
imagePath: string,
provider?: any,
): Promise<{ imagePath: string; imageUri: string } | null> {
try {
// Determine storage directory (global storage preferred, fallback to temp)
let imagesDir: string
if (provider?.contextProxy?.globalStorageUri) {
const globalStoragePath = provider.contextProxy.globalStorageUri.fsPath
const taskId = provider.getCurrentTask?.()?.taskId
imagesDir = taskId
? path.join(globalStoragePath, "user-images", `task-${taskId}`)
: path.join(globalStoragePath, "user-images", "general")
} else {
console.warn("Provider context not available, falling back to temp directory")
imagesDir = path.join(os.tmpdir(), "roo-user-images")
}
await fs.mkdir(imagesDir, { recursive: true })
// Preserve original extension if possible
const ext = path.extname(imagePath) || ".png"
const timestamp = Date.now()
const randomId = Math.random().toString(36).substring(2, 8)
const destFileName = `imported_image_${timestamp}_${randomId}${ext}`
const destPath = path.join(imagesDir, destFileName)
// Copy the original image into global storage
await fs.copyFile(imagePath, destPath)
// Convert to webview URI
let webviewUri = provider?.convertToWebviewUri?.(destPath) ?? vscode.Uri.file(destPath).toString()
return { imagePath: destPath, imageUri: webviewUri }
} catch (error) {
console.error("Failed to import image into global storage:", error)
return null
}
}
export async function savePastedImageToTemp(
dataUri: string,
provider?: any,
): Promise<{ imagePath: string; imageUri: string } | null> {
const matches = dataUri.match(/^data:image\/([a-zA-Z]+);base64,(.+)$/)
if (!matches) {
return null
}
const [, format, base64Data] = matches
const imageBuffer = Buffer.from(base64Data, "base64")
// Determine storage directory
let imagesDir: string
// Use global storage if provider context is available
if (provider?.contextProxy?.globalStorageUri) {
const globalStoragePath = provider.contextProxy.globalStorageUri.fsPath
// Organize by task ID if available
const taskId = provider.getCurrentTask?.()?.taskId
if (taskId) {
imagesDir = path.join(globalStoragePath, "pasted-images", `task-${taskId}`)
} else {
// Fallback to general pasted-images directory
imagesDir = path.join(globalStoragePath, "pasted-images", "general")
}
} else {
// Fallback to temp directory if provider context is not available
console.warn("Provider context not available, falling back to temp directory")
imagesDir = path.join(os.tmpdir(), "roo-pasted-images")
}
// Create directory if it doesn't exist
await fs.mkdir(imagesDir, { recursive: true })
// Generate a unique filename
const timestamp = Date.now()
const randomId = Math.random().toString(36).substring(2, 8)
const fileName = `pasted_image_${timestamp}_${randomId}.${format}`
const imagePath = path.join(imagesDir, fileName)
try {
// Write the image to the file
await fs.writeFile(imagePath, imageBuffer)
// Convert to webview URI if provider is available
let imageUri = provider?.convertToWebviewUri?.(imagePath) ?? vscode.Uri.file(imagePath).toString()
// Do not append custom query params to VS Code webview URIs (can break auth token and cause 401)
return { imagePath, imageUri }
} catch (error) {
console.error("Failed to save pasted image:", error)
return null
}
}
export async function saveImage(dataUri: string) {
const matches = dataUri.match(/^data:image\/([a-zA-Z]+);base64,(.+)$/)
if (!matches) {

View file

@ -1,7 +1,9 @@
import * as vscode from "vscode"
import fs from "fs/promises"
import * as path from "path"
/**
* Open a file picker to select images, returning absolute file system paths.
* Rendering-friendly webview URIs will be produced in the webviewMessageHandler.
*/
export async function selectImages(): Promise<string[]> {
const options: vscode.OpenDialogOptions = {
canSelectMany: true,
@ -12,34 +14,10 @@ export async function selectImages(): Promise<string[]> {
}
const fileUris = await vscode.window.showOpenDialog(options)
if (!fileUris || fileUris.length === 0) {
return []
}
return await Promise.all(
fileUris.map(async (uri) => {
const imagePath = uri.fsPath
const buffer = await fs.readFile(imagePath)
const base64 = buffer.toString("base64")
const mimeType = getMimeType(imagePath)
const dataUrl = `data:${mimeType};base64,${base64}`
return dataUrl
}),
)
}
function getMimeType(filePath: string): string {
const ext = path.extname(filePath).toLowerCase()
switch (ext) {
case ".png":
return "image/png"
case ".jpeg":
case ".jpg":
return "image/jpeg"
case ".webp":
return "image/webp"
default:
throw new Error(`Unsupported file type: ${ext}`)
}
// Return fs paths only; do not read/encode files here.
return fileUris.map((uri) => uri.fsPath)
}

View file

@ -124,6 +124,7 @@ export interface ExtensionMessage {
| "commands"
| "insertTextIntoTextarea"
| "dismissedUpsells"
| "pastedImageSaved"
text?: string
payload?: any // Add a generic payload for now, can refine later
action?:
@ -201,6 +202,8 @@ export interface ExtensionMessage {
commands?: Command[]
queuedMessages?: QueuedMessage[]
list?: string[] // For dismissedUpsells
imagePath?: string // For pastedImageSaved
imageUri?: string // For pastedImageSaved
}
export type ExtensionState = Pick<

View file

@ -225,6 +225,7 @@ export interface WebviewMessage {
| "editQueuedMessage"
| "dismissUpsell"
| "getDismissedUpsells"
| "savePastedImage"
text?: string
editedMessageContent?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"

View file

@ -121,7 +121,23 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const messageHandler = (event: MessageEvent) => {
const message = event.data
if (message.type === "enhancedPrompt") {
if (message.type === "pastedImageSaved") {
// Handle response from backend after saving pasted image
if (message.requestId && pendingImageUploadsRef.current.has(message.requestId)) {
// Remove from pending uploads
pendingImageUploadsRef.current.delete(message.requestId)
if (message.imageUri && !message.error) {
// Add the file URI to selected images (never base64)
setSelectedImages((prevImages) =>
[...prevImages, message.imageUri].slice(0, MAX_IMAGES_PER_MESSAGE),
)
} else {
console.error("Failed to save pasted image:", message.error)
// Do not fallback to base64 to ensure it is never rendered
}
}
} else if (message.type === "enhancedPrompt") {
if (message.text && textAreaRef.current) {
try {
// Use execCommand to replace text while preserving undo history
@ -194,7 +210,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
window.addEventListener("message", messageHandler)
return () => window.removeEventListener("message", messageHandler)
}, [setInputValue, searchRequestId, inputValue])
}, [setInputValue, searchRequestId, inputValue, setSelectedImages])
const [isDraggingOver, setIsDraggingOver] = useState(false)
const [textAreaBaseHeight, setTextAreaBaseHeight] = useState<number | undefined>(undefined)
@ -630,6 +646,10 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
setIsFocused(false)
}, [isMouseDownOnMenu])
// Track pending image upload request IDs only (never store base64)
// This ensures the frontend never retains image data URLs
const pendingImageUploadsRef = useRef<Set<string>>(new Set())
const handlePaste = useCallback(
async (e: React.ClipboardEvent) => {
const items = e.clipboardData.items
@ -699,13 +719,26 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null)
if (dataUrls.length > 0) {
setSelectedImages((prevImages) => [...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE))
// Process each image: send to backend to save as temp file
for (const dataUrl of dataUrls) {
const requestId = Math.random().toString(36).substring(2, 9)
// Track request ID only; never store base64
pendingImageUploadsRef.current.add(requestId)
// Send to backend to save as temporary file
vscode.postMessage({
type: "savePastedImage",
dataUri: dataUrl,
requestId: requestId,
})
}
} else {
console.warn(t("chat:noValidImages"))
}
}
},
[shouldDisableImages, setSelectedImages, cursorPosition, setInputValue, inputValue, t],
[shouldDisableImages, cursorPosition, setInputValue, inputValue, t],
)
const handleMenuMouseDown = useCallback(() => {
@ -851,12 +884,19 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null)
if (dataUrls.length > 0) {
setSelectedImages((prevImages) =>
[...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE),
)
// Process each dropped image: send to backend to save as temp file
for (const dataUrl of dataUrls) {
const requestId = Math.random().toString(36).substring(2, 9)
if (typeof vscode !== "undefined") {
vscode.postMessage({ type: "draggedImages", dataUrls: dataUrls })
// Track request ID only; never store base64
pendingImageUploadsRef.current.add(requestId)
// Send to backend to save as temporary file
vscode.postMessage({
type: "savePastedImage",
dataUri: dataUrl,
requestId: requestId,
})
}
} else {
console.warn(t("chat:noValidImages"))
@ -872,7 +912,6 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
setCursorPosition,
setIntendedCursorPosition,
shouldDisableImages,
setSelectedImages,
t,
],
)

View file

@ -663,7 +663,10 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
}
setInputValue(newValue)
setSelectedImages([...selectedImages, ...images])
setSelectedImages([
...selectedImages,
...(images || []).filter((i) => typeof i === "string" && !i.startsWith("data:")),
])
},
[inputValue, selectedImages],
)
@ -798,7 +801,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
// When context is "edit", ChatRow will handle the images
if (message.context !== "edit") {
setSelectedImages((prevImages: string[]) =>
appendImages(prevImages, message.images, MAX_IMAGES_PER_MESSAGE),
appendImages(
prevImages,
(message.images || []).filter((i) => typeof i === "string" && !i.startsWith("data:")),
MAX_IMAGES_PER_MESSAGE,
),
)
}
break

View file

@ -36,6 +36,9 @@ const Thumbnails = ({ images, style, setImages, onHeightChange }: ThumbnailsProp
vscode.postMessage({ type: "openImage", text: image })
}
// Never render base64 data URIs
const safeImages = images.filter((img) => typeof img === "string" && !img.startsWith("data:"))
return (
<div
ref={containerRef}
@ -47,7 +50,7 @@ const Thumbnails = ({ images, style, setImages, onHeightChange }: ThumbnailsProp
rowGap: 3,
...style,
}}>
{images.map((image, index) => (
{safeImages.map((image, index) => (
<div
key={index}
style={{ position: "relative" }}

View file

@ -0,0 +1,56 @@
import React from "react"
import { render, fireEvent, screen } from "@src/utils/test-utils"
// Mock vscode.postMessage
vi.mock("@src/utils/vscode", () => ({
vscode: {
postMessage: vi.fn(),
},
}))
import { vscode } from "@src/utils/vscode"
import Thumbnails from "../Thumbnails"
describe("Thumbnails", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("filters out base64 data URIs and renders only safe image URIs", () => {
const images = [
"data:image/png;base64,AAAA", // should be filtered
"file:///tmp/saved-image-1.png",
"https://example.com/image-2.webp",
]
render(<Thumbnails images={images} />)
const imgs = screen.getAllByRole("img") as HTMLImageElement[]
expect(imgs.length).toBe(2)
// Ensure no rendered src starts with data:
for (const img of imgs) {
const src = img.getAttribute("src") || ""
expect(src.startsWith("data:")).toBe(false)
}
})
it("posts openImage with clicked image URI (never base64)", () => {
const images = ["data:image/png;base64,BBBB", "file:///tmp/saved-image.png"]
render(<Thumbnails images={images} />)
const imgs = screen.getAllByRole("img") as HTMLImageElement[]
// Only the safe one should render
expect(imgs.length).toBe(1)
const safeImg = imgs[0]
const src = safeImg.getAttribute("src") || ""
expect(src.startsWith("data:")).toBe(false)
fireEvent.click(safeImg)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "openImage",
text: src,
})
})
})