Skip to content

Commit 3c02b80

Browse files
committed
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
1 parent 7e34fbc commit 3c02b80

File tree

4 files changed

+237
-2
lines changed

4 files changed

+237
-2
lines changed

src/core/task/Task.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ import { ApiMessage } from "../task-persistence/apiMessages"
9191
import { getMessagesSinceLastSummary, summarizeConversation } from "../condense"
9292
import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning"
9393
import { restoreTodoListForTask } from "../tools/updateTodoListTool"
94+
import { escapeClineMessage } from "../../utils/htmlEscape"
9495

9596
// Constants
9697
const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes
@@ -383,7 +384,7 @@ export class Task extends EventEmitter<ClineEvents> {
383384

384385
private async updateClineMessage(message: ClineMessage) {
385386
const provider = this.providerRef.deref()
386-
await provider?.postMessageToWebview({ type: "messageUpdated", clineMessage: message })
387+
await provider?.postMessageToWebview({ type: "messageUpdated", clineMessage: escapeClineMessage(message) })
387388
this.emit("message", { action: "updated", message })
388389

389390
const shouldCaptureMessage = message.partial !== true && CloudService.isEnabled()

src/core/webview/ClineProvider.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ import { WebviewMessage } from "../../shared/WebviewMessage"
7171
import { EMBEDDING_MODEL_PROFILES } from "../../shared/embeddingModels"
7272
import { ProfileValidator } from "../../shared/ProfileValidator"
7373
import { getWorkspaceGitInfo } from "../../utils/git"
74+
import { escapeClineMessage } from "../../utils/htmlEscape"
7475

7576
/**
7677
* 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
14741475
currentTaskItem: this.getCurrentCline()?.taskId
14751476
? (taskHistory || []).find((item: HistoryItem) => item.id === this.getCurrentCline()?.taskId)
14761477
: undefined,
1477-
clineMessages: this.getCurrentCline()?.clineMessages || [],
1478+
clineMessages: (this.getCurrentCline()?.clineMessages || []).map((msg) => escapeClineMessage(msg)),
14781479
taskHistory: (taskHistory || [])
14791480
.filter((item: HistoryItem) => item.ts && item.task)
14801481
.sort((a: HistoryItem, b: HistoryItem) => b.ts - a.ts),
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { describe, it, expect } from "vitest"
2+
import { escapeHtml, escapeHtmlInObject, escapeClineMessage } from "../htmlEscape"
3+
4+
describe("htmlEscape", () => {
5+
describe("escapeHtml", () => {
6+
it("should escape HTML special characters", () => {
7+
expect(escapeHtml('<script>alert("XSS")</script>')).toBe(
8+
"&lt;script&gt;alert(&quot;XSS&quot;)&lt;&#x2F;script&gt;",
9+
)
10+
})
11+
12+
it("should escape angle brackets used in diff markers", () => {
13+
expect(escapeHtml("<<<<<<< SEARCH")).toBe("&lt;&lt;&lt;&lt;&lt;&lt;&lt; SEARCH")
14+
expect(escapeHtml("=======")).toBe("=======")
15+
expect(escapeHtml(">>>>>>> REPLACE")).toBe("&gt;&gt;&gt;&gt;&gt;&gt;&gt; REPLACE")
16+
})
17+
18+
it("should escape all special characters", () => {
19+
expect(escapeHtml("& < > \" ' /")).toBe("&amp; &lt; &gt; &quot; &#39; &#x2F;")
20+
})
21+
22+
it("should handle empty strings", () => {
23+
expect(escapeHtml("")).toBe("")
24+
})
25+
26+
it("should handle strings without special characters", () => {
27+
expect(escapeHtml("Hello World")).toBe("Hello World")
28+
})
29+
30+
it("should handle the specific Windows crash case", () => {
31+
const diffContent = `<<<<<<< SEARCH
32+
:start_line:1
33+
-------
34+
def calculate_total(items):
35+
total = 0
36+
for item in items:
37+
total += item
38+
return total
39+
=======
40+
def calculate_total(items):
41+
"""Calculate total with 10% markup"""
42+
return sum(item * 1.1 for item in items)
43+
>>>>>>> REPLACE`
44+
45+
const escaped = escapeHtml(diffContent)
46+
expect(escaped).toContain("&lt;&lt;&lt;&lt;&lt;&lt;&lt; SEARCH")
47+
expect(escaped).toContain("&gt;&gt;&gt;&gt;&gt;&gt;&gt; REPLACE")
48+
expect(escaped).not.toContain("<<<<<<< SEARCH")
49+
expect(escaped).not.toContain(">>>>>>> REPLACE")
50+
})
51+
})
52+
53+
describe("escapeHtmlInObject", () => {
54+
it("should escape strings in objects", () => {
55+
const obj = {
56+
text: "<div>Hello</div>",
57+
nested: {
58+
value: '<script>alert("XSS")</script>',
59+
},
60+
}
61+
62+
const escaped = escapeHtmlInObject(obj)
63+
expect(escaped.text).toBe("&lt;div&gt;Hello&lt;&#x2F;div&gt;")
64+
expect(escaped.nested.value).toBe("&lt;script&gt;alert(&quot;XSS&quot;)&lt;&#x2F;script&gt;")
65+
})
66+
67+
it("should handle arrays", () => {
68+
const arr = ["<div>1</div>", "<div>2</div>"]
69+
const escaped = escapeHtmlInObject(arr)
70+
expect(escaped[0]).toBe("&lt;div&gt;1&lt;&#x2F;div&gt;")
71+
expect(escaped[1]).toBe("&lt;div&gt;2&lt;&#x2F;div&gt;")
72+
})
73+
74+
it("should handle null and undefined", () => {
75+
expect(escapeHtmlInObject(null)).toBe(null)
76+
expect(escapeHtmlInObject(undefined)).toBe(undefined)
77+
})
78+
79+
it("should preserve non-string values", () => {
80+
const obj = {
81+
text: "<div>Hello</div>",
82+
number: 123,
83+
boolean: true,
84+
date: new Date("2024-01-01"),
85+
}
86+
87+
const escaped = escapeHtmlInObject(obj)
88+
expect(escaped.text).toBe("&lt;div&gt;Hello&lt;&#x2F;div&gt;")
89+
expect(escaped.number).toBe(123)
90+
expect(escaped.boolean).toBe(true)
91+
expect(escaped.date).toEqual(new Date("2024-01-01"))
92+
})
93+
})
94+
95+
describe("escapeClineMessage", () => {
96+
it("should escape text field in ClineMessage", () => {
97+
const message = {
98+
ts: 1234567890,
99+
type: "say",
100+
say: "tool",
101+
text: "<<<<<<< SEARCH\n:start_line:1\n-------\ncode here\n=======\nnew code\n>>>>>>> REPLACE",
102+
}
103+
104+
const escaped = escapeClineMessage(message)
105+
expect(escaped.text).toContain("&lt;&lt;&lt;&lt;&lt;&lt;&lt; SEARCH")
106+
expect(escaped.text).toContain("&gt;&gt;&gt;&gt;&gt;&gt;&gt; REPLACE")
107+
expect(escaped.ts).toBe(1234567890)
108+
expect(escaped.type).toBe("say")
109+
})
110+
111+
it("should escape reasoning field if present", () => {
112+
const message = {
113+
type: "say",
114+
reasoning: "<thinking>This is my reasoning</thinking>",
115+
}
116+
117+
const escaped = escapeClineMessage(message)
118+
expect(escaped.reasoning).toBe("&lt;thinking&gt;This is my reasoning&lt;&#x2F;thinking&gt;")
119+
})
120+
121+
it("should handle images array", () => {
122+
const message = {
123+
type: "say",
124+
images: ["data:image/png;base64,iVBORw0KGgo...", '<img src="xss">', "https://example.com/image.png"],
125+
}
126+
127+
const escaped = escapeClineMessage(message)
128+
expect(escaped.images[0]).toBe("data:image/png;base64,iVBORw0KGgo...") // Data URIs not escaped
129+
expect(escaped.images[1]).toBe("&lt;img src=&quot;xss&quot;&gt;")
130+
expect(escaped.images[2]).toBe("https:&#x2F;&#x2F;example.com&#x2F;image.png")
131+
})
132+
133+
it("should handle null or undefined messages", () => {
134+
expect(escapeClineMessage(null)).toBe(null)
135+
expect(escapeClineMessage(undefined)).toBe(undefined)
136+
})
137+
138+
it("should not modify original message", () => {
139+
const message = {
140+
text: "<div>Hello</div>",
141+
}
142+
143+
const escaped = escapeClineMessage(message)
144+
expect(message.text).toBe("<div>Hello</div>")
145+
expect(escaped.text).toBe("&lt;div&gt;Hello&lt;&#x2F;div&gt;")
146+
})
147+
})
148+
})

src/utils/htmlEscape.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* Escapes HTML special characters to prevent XSS and parsing issues in webviews
3+
* This is critical for preventing crashes when displaying content with special characters
4+
* like "<<<<<<< SEARCH" which can break the webview on Windows
5+
*/
6+
export function escapeHtml(text: string): string {
7+
const htmlEscapeMap: Record<string, string> = {
8+
"&": "&amp;",
9+
"<": "&lt;",
10+
">": "&gt;",
11+
'"': "&quot;",
12+
"'": "&#39;",
13+
"/": "&#x2F;",
14+
}
15+
16+
return text.replace(/[&<>"'/]/g, (char) => htmlEscapeMap[char] || char)
17+
}
18+
19+
/**
20+
* Recursively escapes HTML in an object's string properties
21+
* Useful for escaping entire message objects before sending to webview
22+
*/
23+
export function escapeHtmlInObject<T>(obj: T): T {
24+
if (obj === null || obj === undefined) {
25+
return obj
26+
}
27+
28+
if (typeof obj === "string") {
29+
return escapeHtml(obj) as T
30+
}
31+
32+
if (Array.isArray(obj)) {
33+
return obj.map((item) => escapeHtmlInObject(item)) as T
34+
}
35+
36+
if (typeof obj === "object") {
37+
// Handle Date objects
38+
if (obj instanceof Date) {
39+
return obj
40+
}
41+
42+
const result: any = {}
43+
for (const key in obj) {
44+
if (Object.prototype.hasOwnProperty.call(obj, key)) {
45+
result[key] = escapeHtmlInObject(obj[key])
46+
}
47+
}
48+
return result as T
49+
}
50+
51+
return obj
52+
}
53+
54+
/**
55+
* Escapes HTML in ClineMessage objects, specifically targeting text fields
56+
* that may contain user-generated content or tool outputs
57+
*/
58+
export function escapeClineMessage(message: any): any {
59+
if (!message) return message
60+
61+
const escaped = { ...message }
62+
63+
// Escape text field which contains tool outputs and user messages
64+
if (escaped.text && typeof escaped.text === "string") {
65+
escaped.text = escapeHtml(escaped.text)
66+
}
67+
68+
// Escape reasoning field if present
69+
if (escaped.reasoning && typeof escaped.reasoning === "string") {
70+
escaped.reasoning = escapeHtml(escaped.reasoning)
71+
}
72+
73+
// Escape images array if it contains base64 data URIs
74+
if (escaped.images && Array.isArray(escaped.images)) {
75+
escaped.images = escaped.images.map((img: string) => {
76+
// Only escape if it's not a data URI
77+
if (typeof img === "string" && !img.startsWith("data:")) {
78+
return escapeHtml(img)
79+
}
80+
return img
81+
})
82+
}
83+
84+
return escaped
85+
}

0 commit comments

Comments
 (0)