diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index ccd24b7d71c..1e165b2ed54 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -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 { 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() diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 6231f081670..d53c7a4f023 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -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), diff --git a/src/utils/__tests__/htmlEscape.spec.ts b/src/utils/__tests__/htmlEscape.spec.ts new file mode 100644 index 00000000000..e371cdd2a85 --- /dev/null +++ b/src/utils/__tests__/htmlEscape.spec.ts @@ -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('')).toBe( + "<script>alert("XSS")</script>", + ) + }) + + it("should escape angle brackets used in diff markers", () => { + expect(escapeHtml("<<<<<<< SEARCH")).toBe("<<<<<<< SEARCH") + expect(escapeHtml("=======")).toBe("=======") + expect(escapeHtml(">>>>>>> REPLACE")).toBe(">>>>>>> REPLACE") + }) + + it("should escape all special characters", () => { + expect(escapeHtml("& < > \" ' /")).toBe("& < > " ' /") + }) + + 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("<<<<<<< SEARCH") + expect(escaped).toContain(">>>>>>> REPLACE") + expect(escaped).not.toContain("<<<<<<< SEARCH") + expect(escaped).not.toContain(">>>>>>> REPLACE") + }) + }) + + describe("escapeHtmlInObject", () => { + it("should escape strings in objects", () => { + const obj = { + text: "
Hello
", + nested: { + value: '', + }, + } + + const escaped = escapeHtmlInObject(obj) + expect(escaped.text).toBe("<div>Hello</div>") + expect(escaped.nested.value).toBe("<script>alert("XSS")</script>") + }) + + it("should handle arrays", () => { + const arr = ["
1
", "
2
"] + const escaped = escapeHtmlInObject(arr) + expect(escaped[0]).toBe("<div>1</div>") + expect(escaped[1]).toBe("<div>2</div>") + }) + + 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: "
Hello
", + number: 123, + boolean: true, + date: new Date("2024-01-01"), + } + + const escaped = escapeHtmlInObject(obj) + expect(escaped.text).toBe("<div>Hello</div>") + 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("<<<<<<< SEARCH") + expect(escaped.text).toContain(">>>>>>> REPLACE") + expect(escaped.ts).toBe(1234567890) + expect(escaped.type).toBe("say") + }) + + it("should escape reasoning field if present", () => { + const message = { + type: "say", + reasoning: "This is my reasoning", + } + + const escaped = escapeClineMessage(message) + expect(escaped.reasoning).toBe("<thinking>This is my reasoning</thinking>") + }) + + it("should handle images array", () => { + const message = { + type: "say", + images: ["data:image/png;base64,iVBORw0KGgo...", '', "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("<img src="xss">") + expect(escaped.images[2]).toBe("https://example.com/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: "
Hello
", + } + + const escaped = escapeClineMessage(message) + expect(message.text).toBe("
Hello
") + expect(escaped.text).toBe("<div>Hello</div>") + }) + }) +}) diff --git a/src/utils/htmlEscape.ts b/src/utils/htmlEscape.ts new file mode 100644 index 00000000000..84403c4b11d --- /dev/null +++ b/src/utils/htmlEscape.ts @@ -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 = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + "/": "/", + } + + 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(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 +}