Skip to content

Fix: Escape HTML special characters to prevent VSCode crash on Windows #6042

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
3 changes: 2 additions & 1 deletion src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
148 changes: 148 additions & 0 deletions src/utils/__tests__/htmlEscape.spec.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 85 additions & 0 deletions src/utils/htmlEscape.ts
Original file line number Diff line number Diff line change
@@ -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
}
Loading