fix: marked兼容、保存竞态、关闭死锁、XSS防护、行号虚拟化、CSP加固

This commit is contained in:
thzxx
2026-05-18 17:23:26 +08:00
parent 320980030e
commit 1a5ec08728
3 changed files with 188 additions and 22 deletions
+4 -1
View File
@@ -3,7 +3,10 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:;">
<!-- CSP: script-src 只允许 self,阻断内联脚本执行 -->
<!-- style-src 保留 unsafe-inlinehighlight.js 代码高亮依赖 CSS class
但已通过 sanitizeHTML 移除恶意 style 属性和 expression() -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self';">
<title>MarkLite</title>
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="../lib/highlight-github.css">
+157 -15
View File
@@ -100,13 +100,20 @@
function initMarked() {
if (typeof marked !== 'undefined') {
const renderer = new marked.Renderer();
renderer.code = function (code, lang) {
let highlighted = code;
// 兼容 marked v4positional args)和 v9+token object
renderer.code = function (codeOrToken, legacyLang) {
const text = (typeof codeOrToken === 'object' && codeOrToken !== null)
? codeOrToken.text
: codeOrToken;
const lang = (typeof codeOrToken === 'object' && codeOrToken !== null)
? codeOrToken.lang
: legacyLang;
let highlighted = text;
if (typeof hljs !== 'undefined') {
if (lang && hljs.getLanguage(lang)) {
try { highlighted = hljs.highlight(code, { language: lang }).value; } catch (e) { /* fallback */ }
try { highlighted = hljs.highlight(text, { language: lang }).value; } catch (e) { /* fallback */ }
} else {
try { highlighted = hljs.highlightAuto(code).value; } catch (e) { /* fallback */ }
try { highlighted = hljs.highlightAuto(text).value; } catch (e) { /* fallback */ }
}
}
const langClass = lang ? ` language-${lang}` : '';
@@ -117,6 +124,77 @@
}
}
// ===== HTML Sanitizer =====
// 移除事件处理器、危险元素和 javascript: 协议,防止 XSS
const DANGEROUS_TAGS = new Set([
'script', 'iframe', 'object', 'embed', 'applet', 'form',
'base', 'meta', 'link', 'style'
]);
const DANGEROUS_ATTRS = new Set([
'onabort', 'onanimationend', 'onanimationiteration', 'onanimationstart',
'onauxclick', 'onbeforecopy', 'onbeforecut', 'onbeforepaste', 'onblur',
'oncancel', 'oncanplay', 'oncanplaythrough', 'onchange', 'onclick',
'onclose', 'oncontextmenu', 'oncopy', 'oncuechange', 'oncut',
'ondblclick', 'ondrag', 'ondragend', 'ondragenter', 'ondragleave',
'ondragover', 'ondragstart', 'ondrop', 'ondurationchange', 'onemptied',
'onended', 'onerror', 'onfocus', 'onfocusin', 'onfocusout',
'onfullscreenchange', 'onfullscreenerror', 'ongotpointercapture',
'oninput', 'oninvalid', 'onkeydown', 'onkeypress', 'onkeyup',
'onload', 'onloadeddata', 'onloadedmetadata', 'onloadstart',
'onlostpointercapture', 'onmousedown', 'onmouseenter', 'onmouseleave',
'onmousemove', 'onmouseout', 'onmouseover', 'onmouseup', 'onmousewheel',
'onpaste', 'onpause', 'onplay', 'onplaying', 'onpointercancel',
'onpointerdown', 'onpointerenter', 'onpointerleave', 'onpointermove',
'onpointerout', 'onpointerover', 'onpointerup', 'onprogress',
'onratechange', 'onreset', 'onresize', 'onscroll', 'onsearch',
'onseeked', 'onseeking', 'onselect', 'onselectionchange',
'onselectstart', 'onshow', 'onstalled', 'onsubmit', 'onsuspend',
'ontimeupdate', 'ontoggle', 'ontouchcancel', 'ontouchend',
'ontouchmove', 'ontouchstart', 'ontransitionend', 'onvolumechange',
'onwaiting', 'onwheel', 'onanimationend', 'oncontentvisibilityautostatechange',
'onscrollend', 'onscrollsnapchange', 'onscrollsnapchanging'
]);
function sanitizeHTML(html) {
// 用 DOMParser 解析(不会执行脚本)
const doc = new DOMParser().parseFromString(html, 'text/html');
const walker = document.createTreeWalker(doc.body, NodeFilter.SHOW_ELEMENT);
const toRemove = [];
while (walker.nextNode()) {
const el = walker.currentNode;
const tag = el.tagName.toLowerCase();
// 移除危险元素
if (DANGEROUS_TAGS.has(tag)) {
toRemove.push(el);
continue;
}
// 移除危险属性(on* 事件处理器 + javascript: 协议)
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase();
if (DANGEROUS_ATTRS.has(name) || name.startsWith('on')) {
el.removeAttribute(attr.name);
} else if (
(name === 'href' || name === 'action' || name === 'formaction' || name === 'xlink:href') &&
/^\s*javascript:/i.test(attr.value)
) {
el.removeAttribute(attr.name);
} else if (name === 'style' && /expression\s*\(|url\s*\(\s*['"]?\s*javascript:/i.test(attr.value)) {
el.removeAttribute(attr.name);
}
}
}
// 批量移除危险元素
for (const el of toRemove) {
el.parentNode.removeChild(el);
}
return doc.body.innerHTML;
}
// ===== Markdown Rendering =====
function renderMarkdown(text) {
if (typeof marked === 'undefined') {
@@ -125,7 +203,8 @@
return;
}
try {
preview.innerHTML = marked.parse(text);
const raw = marked.parse(text);
preview.innerHTML = sanitizeHTML(raw);
preview.style.color = '';
} catch (e) {
preview.textContent = '渲染错误: ' + e.message;
@@ -165,15 +244,57 @@
}
// ===== Line Numbers =====
// 快速计算换行符数量,避免 split 产生大数组
function countNewlines(text) {
let count = 1;
for (let i = 0, len = text.length; i < len; i++) {
if (text.charCodeAt(i) === 10) count++; // '\n' = 0x0A
}
return count;
}
function updateLineNumbers() {
const count = editor.value.split('\n').length;
const count = countNewlines(editor.value);
if (count === lineCountCache) return;
lineCountCache = count;
const fragment = [];
for (let i = 1; i <= count; i++) {
fragment.push('<div class="line-num">', i, '</div>');
// 大文件(>2000 行)时用虚拟化:只渲染可视区域的行号
const LARGE_FILE_THRESHOLD = 2000;
if (count > LARGE_FILE_THRESHOLD) {
renderVirtualLineNumbers(count);
} else {
// 小文件:一次性生成全部行号(使用数组 join 比字符串拼接快)
const arr = new Array(count);
for (let i = 0; i < count; i++) {
arr[i] = '<div class="line-num">' + (i + 1) + '</div>';
}
lineNumbers.innerHTML = arr.join('');
}
lineNumbers.innerHTML = fragment.join('');
}
// 虚拟化行号:只渲染当前可视区域附近的行号
function renderVirtualLineNumbers(totalLines) {
const lineHeight = 20.8; // 与 CSS line-height 一致
const scrollTop = editor.scrollTop;
const viewportHeight = editor.clientHeight;
const buffer = 20; // 上下缓冲行数
const startLine = Math.max(0, Math.floor(scrollTop / lineHeight) - buffer);
const endLine = Math.min(totalLines, Math.ceil((scrollTop + viewportHeight) / lineHeight) + buffer);
const visibleCount = endLine - startLine;
// 顶部占位
const topPadding = startLine * lineHeight;
// 底部占位
const bottomPadding = (totalLines - endLine) * lineHeight;
const arr = new Array(visibleCount + 2);
arr[0] = '<div style="height:' + topPadding + 'px"></div>';
for (let i = 0; i < visibleCount; i++) {
arr[i + 1] = '<div class="line-num">' + (startLine + i + 1) + '</div>';
}
arr[visibleCount + 1] = '<div style="height:' + bottomPadding + 'px"></div>';
lineNumbers.innerHTML = arr.join('');
}
// ===== Cursor Position =====
@@ -304,7 +425,15 @@
// Load a tab's state into the editor
function loadTabState(tab) {
editor.value = tab.content;
// 使用 execCommand('insertText') 替换内容,保留 undo/redo 历史
// setRangeText 会清空 undo 栈,所以此处必须用 execCommand
editor.focus();
editor.select();
const inserted = document.execCommand('insertText', false, tab.content);
if (!inserted) {
// execCommand 被禁用时的 fallback(会丢失 undo 历史)
editor.value = tab.content;
}
lineCountCache = 0;
updateLineNumbers();
renderMarkdown(tab.content);
@@ -640,9 +769,16 @@
editor.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
e.preventDefault();
if (!document.execCommand('insertText', false, ' ')) {
const start = editor.selectionStart;
const end = editor.selectionEnd;
// setRangeText 是标准 APIexecCommand 已废弃但能保留 undo 历史
const start = editor.selectionStart;
const end = editor.selectionEnd;
if (start !== end) {
// 多行选区:批量缩进
const lines = editor.value.substring(start, end).split('\n');
const indented = lines.map(line => ' ' + line).join('\n');
editor.setRangeText(indented, start, end, 'end');
} else {
// 单光标:插入 4 空格
editor.setRangeText(' ', start, end, 'end');
}
scheduleUpdate();
@@ -724,7 +860,13 @@
scheduleUpdate();
});
editor.addEventListener('scroll', syncScroll);
editor.addEventListener('scroll', () => {
syncScroll();
// 大文件滚动时重新渲染虚拟行号
if (lineCountCache > 2000) {
renderVirtualLineNumbers(lineCountCache);
}
});
editor.addEventListener('click', updateCursorPosition);
editor.addEventListener('keyup', updateCursorPosition);
editor.addEventListener('select', updateCursorPosition);