mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
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:
parent
7e34fbcb0c
commit
3c02b80307
4 changed files with 237 additions and 2 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
148
src/utils/__tests__/htmlEscape.spec.ts
Normal file
148
src/utils/__tests__/htmlEscape.spec.ts
Normal 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(
|
||||
"<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: "<div>Hello</div>",
|
||||
nested: {
|
||||
value: '<script>alert("XSS")</script>',
|
||||
},
|
||||
}
|
||||
|
||||
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 = ["<div>1</div>", "<div>2</div>"]
|
||||
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: "<div>Hello</div>",
|
||||
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: "<thinking>This is my reasoning</thinking>",
|
||||
}
|
||||
|
||||
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...", '<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("<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: "<div>Hello</div>",
|
||||
}
|
||||
|
||||
const escaped = escapeClineMessage(message)
|
||||
expect(message.text).toBe("<div>Hello</div>")
|
||||
expect(escaped.text).toBe("<div>Hello</div>")
|
||||
})
|
||||
})
|
||||
})
|
||||
85
src/utils/htmlEscape.ts
Normal file
85
src/utils/htmlEscape.ts
Normal 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> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
"/": "/",
|
||||
}
|
||||
|
||||
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…
Add table
Reference in a new issue