fix: escape HTML special characters to prevent VSCode crash on Windows

- Created htmlEscape utility to convert special characters to HTML entities
- Applied escaping to all ClineMessage content before sending to webview
- Added comprehensive tests for the escaping functionality
- Fixes issue where apply_diff markers containing < and > caused crashes

Fixes #6041
This commit is contained in:
Roo Code 2025-07-22 03:05:22 +00:00
parent 7e34fbcb0c
commit 3c02b80307
4 changed files with 237 additions and 2 deletions

View file

@ -91,6 +91,7 @@ import { ApiMessage } from "../task-persistence/apiMessages"
import { getMessagesSinceLastSummary, summarizeConversation } from "../condense"
import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning"
import { restoreTodoListForTask } from "../tools/updateTodoListTool"
import { escapeClineMessage } from "../../utils/htmlEscape"
// Constants
const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes
@ -383,7 +384,7 @@ export class Task extends EventEmitter<ClineEvents> {
private async updateClineMessage(message: ClineMessage) {
const provider = this.providerRef.deref()
await provider?.postMessageToWebview({ type: "messageUpdated", clineMessage: message })
await provider?.postMessageToWebview({ type: "messageUpdated", clineMessage: escapeClineMessage(message) })
this.emit("message", { action: "updated", message })
const shouldCaptureMessage = message.partial !== true && CloudService.isEnabled()

View file

@ -71,6 +71,7 @@ import { WebviewMessage } from "../../shared/WebviewMessage"
import { EMBEDDING_MODEL_PROFILES } from "../../shared/embeddingModels"
import { ProfileValidator } from "../../shared/ProfileValidator"
import { getWorkspaceGitInfo } from "../../utils/git"
import { escapeClineMessage } from "../../utils/htmlEscape"
/**
* https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@ -1474,7 +1475,7 @@ export class ClineProvider
currentTaskItem: this.getCurrentCline()?.taskId
? (taskHistory || []).find((item: HistoryItem) => item.id === this.getCurrentCline()?.taskId)
: undefined,
clineMessages: this.getCurrentCline()?.clineMessages || [],
clineMessages: (this.getCurrentCline()?.clineMessages || []).map((msg) => escapeClineMessage(msg)),
taskHistory: (taskHistory || [])
.filter((item: HistoryItem) => item.ts && item.task)
.sort((a: HistoryItem, b: HistoryItem) => b.ts - a.ts),

View file

@ -0,0 +1,148 @@
import { describe, it, expect } from "vitest"
import { escapeHtml, escapeHtmlInObject, escapeClineMessage } from "../htmlEscape"
describe("htmlEscape", () => {
describe("escapeHtml", () => {
it("should escape HTML special characters", () => {
expect(escapeHtml('<script>alert("XSS")</script>')).toBe(
"&lt;script&gt;alert(&quot;XSS&quot;)&lt;&#x2F;script&gt;",
)
})
it("should escape angle brackets used in diff markers", () => {
expect(escapeHtml("<<<<<<< SEARCH")).toBe("&lt;&lt;&lt;&lt;&lt;&lt;&lt; SEARCH")
expect(escapeHtml("=======")).toBe("=======")
expect(escapeHtml(">>>>>>> REPLACE")).toBe("&gt;&gt;&gt;&gt;&gt;&gt;&gt; REPLACE")
})
it("should escape all special characters", () => {
expect(escapeHtml("& < > \" ' /")).toBe("&amp; &lt; &gt; &quot; &#39; &#x2F;")
})
it("should handle empty strings", () => {
expect(escapeHtml("")).toBe("")
})
it("should handle strings without special characters", () => {
expect(escapeHtml("Hello World")).toBe("Hello World")
})
it("should handle the specific Windows crash case", () => {
const diffContent = `<<<<<<< SEARCH
:start_line:1
-------
def calculate_total(items):
total = 0
for item in items:
total += item
return total
=======
def calculate_total(items):
"""Calculate total with 10% markup"""
return sum(item * 1.1 for item in items)
>>>>>>> REPLACE`
const escaped = escapeHtml(diffContent)
expect(escaped).toContain("&lt;&lt;&lt;&lt;&lt;&lt;&lt; SEARCH")
expect(escaped).toContain("&gt;&gt;&gt;&gt;&gt;&gt;&gt; REPLACE")
expect(escaped).not.toContain("<<<<<<< SEARCH")
expect(escaped).not.toContain(">>>>>>> REPLACE")
})
})
describe("escapeHtmlInObject", () => {
it("should escape strings in objects", () => {
const obj = {
text: "<div>Hello</div>",
nested: {
value: '<script>alert("XSS")</script>',
},
}
const escaped = escapeHtmlInObject(obj)
expect(escaped.text).toBe("&lt;div&gt;Hello&lt;&#x2F;div&gt;")
expect(escaped.nested.value).toBe("&lt;script&gt;alert(&quot;XSS&quot;)&lt;&#x2F;script&gt;")
})
it("should handle arrays", () => {
const arr = ["<div>1</div>", "<div>2</div>"]
const escaped = escapeHtmlInObject(arr)
expect(escaped[0]).toBe("&lt;div&gt;1&lt;&#x2F;div&gt;")
expect(escaped[1]).toBe("&lt;div&gt;2&lt;&#x2F;div&gt;")
})
it("should handle null and undefined", () => {
expect(escapeHtmlInObject(null)).toBe(null)
expect(escapeHtmlInObject(undefined)).toBe(undefined)
})
it("should preserve non-string values", () => {
const obj = {
text: "<div>Hello</div>",
number: 123,
boolean: true,
date: new Date("2024-01-01"),
}
const escaped = escapeHtmlInObject(obj)
expect(escaped.text).toBe("&lt;div&gt;Hello&lt;&#x2F;div&gt;")
expect(escaped.number).toBe(123)
expect(escaped.boolean).toBe(true)
expect(escaped.date).toEqual(new Date("2024-01-01"))
})
})
describe("escapeClineMessage", () => {
it("should escape text field in ClineMessage", () => {
const message = {
ts: 1234567890,
type: "say",
say: "tool",
text: "<<<<<<< SEARCH\n:start_line:1\n-------\ncode here\n=======\nnew code\n>>>>>>> REPLACE",
}
const escaped = escapeClineMessage(message)
expect(escaped.text).toContain("&lt;&lt;&lt;&lt;&lt;&lt;&lt; SEARCH")
expect(escaped.text).toContain("&gt;&gt;&gt;&gt;&gt;&gt;&gt; REPLACE")
expect(escaped.ts).toBe(1234567890)
expect(escaped.type).toBe("say")
})
it("should escape reasoning field if present", () => {
const message = {
type: "say",
reasoning: "<thinking>This is my reasoning</thinking>",
}
const escaped = escapeClineMessage(message)
expect(escaped.reasoning).toBe("&lt;thinking&gt;This is my reasoning&lt;&#x2F;thinking&gt;")
})
it("should handle images array", () => {
const message = {
type: "say",
images: ["data:image/png;base64,iVBORw0KGgo...", '<img src="xss">', "https://example.com/image.png"],
}
const escaped = escapeClineMessage(message)
expect(escaped.images[0]).toBe("data:image/png;base64,iVBORw0KGgo...") // Data URIs not escaped
expect(escaped.images[1]).toBe("&lt;img src=&quot;xss&quot;&gt;")
expect(escaped.images[2]).toBe("https:&#x2F;&#x2F;example.com&#x2F;image.png")
})
it("should handle null or undefined messages", () => {
expect(escapeClineMessage(null)).toBe(null)
expect(escapeClineMessage(undefined)).toBe(undefined)
})
it("should not modify original message", () => {
const message = {
text: "<div>Hello</div>",
}
const escaped = escapeClineMessage(message)
expect(message.text).toBe("<div>Hello</div>")
expect(escaped.text).toBe("&lt;div&gt;Hello&lt;&#x2F;div&gt;")
})
})
})

85
src/utils/htmlEscape.ts Normal file
View file

@ -0,0 +1,85 @@
/**
* Escapes HTML special characters to prevent XSS and parsing issues in webviews
* This is critical for preventing crashes when displaying content with special characters
* like "<<<<<<< SEARCH" which can break the webview on Windows
*/
export function escapeHtml(text: string): string {
const htmlEscapeMap: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
"/": "&#x2F;",
}
return text.replace(/[&<>"'/]/g, (char) => htmlEscapeMap[char] || char)
}
/**
* Recursively escapes HTML in an object's string properties
* Useful for escaping entire message objects before sending to webview
*/
export function escapeHtmlInObject<T>(obj: T): T {
if (obj === null || obj === undefined) {
return obj
}
if (typeof obj === "string") {
return escapeHtml(obj) as T
}
if (Array.isArray(obj)) {
return obj.map((item) => escapeHtmlInObject(item)) as T
}
if (typeof obj === "object") {
// Handle Date objects
if (obj instanceof Date) {
return obj
}
const result: any = {}
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
result[key] = escapeHtmlInObject(obj[key])
}
}
return result as T
}
return obj
}
/**
* Escapes HTML in ClineMessage objects, specifically targeting text fields
* that may contain user-generated content or tool outputs
*/
export function escapeClineMessage(message: any): any {
if (!message) return message
const escaped = { ...message }
// Escape text field which contains tool outputs and user messages
if (escaped.text && typeof escaped.text === "string") {
escaped.text = escapeHtml(escaped.text)
}
// Escape reasoning field if present
if (escaped.reasoning && typeof escaped.reasoning === "string") {
escaped.reasoning = escapeHtml(escaped.reasoning)
}
// Escape images array if it contains base64 data URIs
if (escaped.images && Array.isArray(escaped.images)) {
escaped.images = escaped.images.map((img: string) => {
// Only escape if it's not a data URI
if (typeof img === "string" && !img.startsWith("data:")) {
return escapeHtml(img)
}
return img
})
}
return escaped
}