Skip to content

Commit 28015e8

Browse files
authored
fix: escape HTML in markdown renderer to prevent XSS (#30)
* fix: escape HTML in markdown renderer to prevent XSS The renderMarkdown() function inserted user-controlled text directly into HTML via dangerouslySetInnerHTML without escaping. An attacker could inject arbitrary HTML/JS through task descriptions or code blocks (e.g. <img onerror="..."> inside a fenced code block). Fix: escape all HTML special characters (&, <, >, ", ') before applying markdown regex transformations. Code blocks are also reordered to run before inline patterns to avoid double-matching. * fix: tokenize code blocks before markdown formatting to prevent mangling Addresses review feedback: - Code blocks/inline code are now extracted as placeholders before markdown regexes (bold, italic, line breaks) run, preventing them from corrupting code content. - Removed duplicate escapeHtml; imports shared version from markdown-utils.ts (now exported with full entity escaping).
1 parent 0c53b6a commit 28015e8

2 files changed

Lines changed: 73 additions & 44 deletions

File tree

src/renderer/components/MarkdownEditor.tsx

Lines changed: 65 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,72 @@
11
import { useState } from 'react'
22
import { Eye, Pencil } from 'lucide-react'
3+
import { escapeHtml } from './rich-editor/markdown-utils'
34

45
function renderMarkdown(text: string): string {
5-
return (
6-
text
7-
// Headers
8-
.replace(/^### (.+)$/gm, '<h3 class="text-xs font-semibold text-gray-300 mt-3 mb-1">$1</h3>')
9-
.replace(/^## (.+)$/gm, '<h2 class="text-sm font-semibold text-gray-200 mt-3 mb-1">$1</h2>')
10-
// Bold
11-
.replace(/\*\*(.+?)\*\*/g, '<strong class="text-gray-200">$1</strong>')
12-
// Italic
13-
.replace(/\*(.+?)\*/g, '<em>$1</em>')
14-
// Inline code
15-
.replace(
16-
/`([^`]+)`/g,
17-
'<code class="px-1 py-0.5 bg-white/[0.06] rounded text-[11px] font-mono text-gray-300">$1</code>'
18-
)
19-
// Code blocks
20-
.replace(/```[\s\S]*?```/g, (match) => {
21-
const code = match.replace(/```\w*\n?/, '').replace(/\n?```$/, '')
22-
return `<pre class="px-2 py-1.5 bg-white/[0.04] rounded-md text-[11px] font-mono text-gray-300 overflow-x-auto my-1">${code}</pre>`
23-
})
24-
// Checkboxes
25-
.replace(
26-
/^- \[x\] (.+)$/gm,
27-
'<div class="flex items-center gap-1.5 py-0.5"><span class="text-green-400">&#10003;</span><span class="text-gray-300 line-through">$1</span></div>'
28-
)
29-
.replace(
30-
/^- \[ \] (.+)$/gm,
31-
'<div class="flex items-center gap-1.5 py-0.5"><span class="text-gray-600">&#9744;</span><span class="text-gray-300">$1</span></div>'
32-
)
33-
// Unordered lists
34-
.replace(
35-
/^- (.+)$/gm,
36-
'<div class="flex items-start gap-1.5 py-0.5"><span class="text-gray-600 mt-0.5">&#8226;</span><span class="text-gray-400">$1</span></div>'
37-
)
38-
// Ordered lists
39-
.replace(
40-
/^(\d+)\. (.+)$/gm,
41-
'<div class="flex items-start gap-1.5 py-0.5"><span class="text-gray-600">$1.</span><span class="text-gray-400">$2</span></div>'
42-
)
43-
// Line breaks
44-
.replace(/\n\n/g, '<div class="h-2"></div>')
45-
.replace(/\n/g, '<br />')
46-
)
6+
// Tokenize code blocks and inline code first, replacing them with placeholders.
7+
// This prevents markdown regexes (bold, italic, line breaks, etc.) from mangling
8+
// code content. All user text is HTML-escaped to prevent XSS.
9+
const tokens: string[] = []
10+
11+
const tokenize = (html: string): string => {
12+
const id = tokens.length
13+
tokens.push(html)
14+
return `\uFFFDTOKEN${id}\uFFFD`
15+
}
16+
17+
// 1. Extract fenced code blocks → placeholders
18+
let result = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_match, _lang, code) => {
19+
return tokenize(
20+
`<pre class="px-2 py-1.5 bg-white/[0.04] rounded-md text-[11px] font-mono text-gray-300 overflow-x-auto my-1">${escapeHtml(code.replace(/\n$/, ''))}</pre>`
21+
)
22+
})
23+
24+
// 2. Extract inline code → placeholders
25+
result = result.replace(/`([^`]+)`/g, (_match, code) => {
26+
return tokenize(
27+
`<code class="px-1 py-0.5 bg-white/[0.06] rounded text-[11px] font-mono text-gray-300">${escapeHtml(code)}</code>`
28+
)
29+
})
30+
31+
// 3. Escape remaining text (everything outside code blocks)
32+
result = escapeHtml(result)
33+
34+
// 4. Restore token placeholders (they were escaped, so fix them)
35+
result = result.replace(/\uFFFDTOKEN(\d+)\uFFFD/g, (_match, id) => tokens[parseInt(id)])
36+
37+
// 5. Apply markdown formatting on non-code text
38+
result = result
39+
// Headers
40+
.replace(/^### (.+)$/gm, '<h3 class="text-xs font-semibold text-gray-300 mt-3 mb-1">$1</h3>')
41+
.replace(/^## (.+)$/gm, '<h2 class="text-sm font-semibold text-gray-200 mt-3 mb-1">$1</h2>')
42+
// Bold
43+
.replace(/\*\*(.+?)\*\*/g, '<strong class="text-gray-200">$1</strong>')
44+
// Italic
45+
.replace(/\*(.+?)\*/g, '<em>$1</em>')
46+
// Checkboxes
47+
.replace(
48+
/^- \[x\] (.+)$/gm,
49+
'<div class="flex items-center gap-1.5 py-0.5"><span class="text-green-400">&#10003;</span><span class="text-gray-300 line-through">$1</span></div>'
50+
)
51+
.replace(
52+
/^- \[ \] (.+)$/gm,
53+
'<div class="flex items-center gap-1.5 py-0.5"><span class="text-gray-600">&#9744;</span><span class="text-gray-300">$1</span></div>'
54+
)
55+
// Unordered lists
56+
.replace(
57+
/^- (.+)$/gm,
58+
'<div class="flex items-start gap-1.5 py-0.5"><span class="text-gray-600 mt-0.5">&#8226;</span><span class="text-gray-400">$1</span></div>'
59+
)
60+
// Ordered lists
61+
.replace(
62+
/^(\d+)\. (.+)$/gm,
63+
'<div class="flex items-start gap-1.5 py-0.5"><span class="text-gray-600">$1.</span><span class="text-gray-400">$2</span></div>'
64+
)
65+
// Line breaks
66+
.replace(/\n\n/g, '<div class="h-2"></div>')
67+
.replace(/\n/g, '<br />')
68+
69+
return result
4770
}
4871

4972
export const TASK_TEMPLATE = `## Description

src/renderer/components/rich-editor/markdown-utils.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,14 @@ export function markdownToHtml(md: string): string {
117117
return htmlParts.join('')
118118
}
119119

120-
function escapeHtml(str: string): string {
121-
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
120+
/** Escape HTML special characters to prevent XSS injection. */
121+
export function escapeHtml(str: string): string {
122+
return str
123+
.replace(/&/g, '&amp;')
124+
.replace(/</g, '&lt;')
125+
.replace(/>/g, '&gt;')
126+
.replace(/"/g, '&quot;')
127+
.replace(/'/g, '&#39;')
122128
}
123129

124130
function inlineMarkdown(text: string): string {

0 commit comments

Comments
 (0)