fix(markdown): 修复任务列表 checkbox 在前台被 sanitizer 剥离

编辑器端已挂 TaskList/TaskItem,但前台渲染走 pulldown-cmark,其输出的
<input type="checkbox"> 不在 sanitizer 白名单内,被 remove_and_keep_content
剥离,导致任务列表在前台只剩裸 <li> 文本。

- sanitizer: 白名单放开 input,但强制 type="checkbox" 属性值校验
  - attrs_to_remove 增加 input.type 值校验,非 checkbox 一律删除属性
  - element_handler 末尾兜底:缺 type 或非 checkbox 的 input 整标签移除
    (void 元素无内容,remove() 不丢正文,封堵 type=image/text 等 XSS 滥用)
- 仅 clean_html 放开,clean_comment_html 保持不放开(评论无需任务列表)
- input.css 补 .md-content 任务列表样式(pulldown-cmark 裸 li>input 结构,
  用 :has() 识别,不支持 :has() 的浏览器降级为普通列表仍可读)
- 新增 sanitizer 5 个测试(白名单/XSS 边界)+ markdown 1 个端到端测试
This commit is contained in:
xfy 2026-07-02 13:22:37 +08:00
parent a10003c90d
commit 865871f45c
3 changed files with 143 additions and 0 deletions

View File

@ -300,6 +300,26 @@
margin-bottom: 0;
}
/* 任务列表pulldown-cmark - [ ] / - [x] 渲染成裸 <li><input type="checkbox">
* <label> 包裹也无 data-type 标记 :has() 识别含 checkbox li
* 布局成 flex 让复选框与文本对齐不支持 :has() 的浏览器降级为普通列表仍可读 */
.md-content li:has(> input[type="checkbox"]) {
list-style: none;
margin-left: -1.25rem;
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.md-content li > input[type="checkbox"] {
flex-shrink: 0;
width: 1rem;
height: 1rem;
margin: 0.35rem 0 0;
accent-color: var(--color-paper-accent);
cursor: default;
}
.md-content hr {
margin: 30px 0;
height: 2px;

View File

@ -600,4 +600,26 @@ mod tests {
);
assert!(result.html.contains("alt=\"alt\""));
}
#[test]
fn render_markdown_task_list() {
// 端到端验证pulldown-cmark 解析任务列表 → sanitizer 清理后 checkbox 不丢失。
// 覆盖「编辑器写入 → 入库 → 服务端重渲染」链路的最终 HTML 形态。
let result = render_markdown_enhanced("- [ ] 未完成\n- [x] 已完成\n");
// 两个 checkbox 都应保留
assert!(
result.html.contains(r#"type="checkbox""#),
"checkbox type 应保留, got: {}",
result.html
);
// 已勾选项的 checked 属性应保留
assert!(
result.html.contains("checked"),
"checked 属性应保留, got: {}",
result.html
);
// 文本内容保留
assert!(result.html.contains("未完成"));
assert!(result.html.contains("已完成"));
}
}

View File

@ -56,6 +56,9 @@ static DEFAULT_ALLOWED_TAGS: LazyLock<HashSet<&'static str>> = LazyLock::new(||
"i",
"img",
"ins",
// input 仅用于 pulldown-cmark 任务列表渲染的 checkbox;
// element_handler 会强制校验 type="checkbox",其余 type 一律整体移除。
"input",
"kbd",
"li",
"map",
@ -291,6 +294,13 @@ fn sanitize(input: &str, config: &SanitizerConfig) -> String {
return Some(name);
}
}
// input 的 type 必须是 checkbox其余取值image/text/...)一概删除;
// 缺失 type 的 input 由下面的兜底逻辑整标签移除。
if tag == "input" && name_lower == "type" {
if attr.value().trim().to_lowercase() != "checkbox" {
return Some(name);
}
}
None
} else {
Some(name)
@ -311,6 +321,20 @@ fn sanitize(input: &str, config: &SanitizerConfig) -> String {
}
}
// input 兜底:属性白名单 + type 值校验后,仍可能残留「无 type 属性」的 input。
// (例如 <input checked> 经属性过滤后 type 被删或缺省。)这种 input 整体移除——
// 它是 void 元素无文本内容remove() 不丢正文,且能彻底封堵缺省 type 的滥用。
if tag == "input" {
let type_ok = el
.get_attribute("type")
.map(|v| v.trim().to_lowercase() == "checkbox")
.unwrap_or(false);
if !type_ok {
el.remove();
return Ok(());
}
}
Ok(())
};
@ -345,6 +369,8 @@ pub fn clean_html(input: &str) -> String {
extra_tag_attrs: vec![
("a", vec!["class", "aria-hidden", "aria-label"]),
("img", vec!["data-src", "class", "style"]),
// input 仅放行 checkbox 必备属性;type 的具体取值由 element_handler 强校验为 checkbox。
("input", vec!["type", "checked", "disabled"]),
("span", vec!["class", "style"]),
("h1", vec!["id", "class"]),
("h2", vec!["id", "class"]),
@ -569,4 +595,79 @@ mod tests {
assert!(is_safe_url("HTTPS://example.com", &schemes, false));
assert!(!is_safe_url("JAVASCRIPT:alert(1)", &schemes, false));
}
// ---- input / 任务列表 checkbox 白名单与 XSS 边界 ----
#[test]
fn clean_html_allows_task_list_checkbox() {
// pulldown-cmark 对 - [ ] / - [x] 的实际输出结构
let input = r#"<ul>
<li><input disabled="" type="checkbox"/> </li>
<li><input disabled="" type="checkbox" checked=""/> </li>
</ul>"#;
let result = clean_html(input);
// input 标签保留
assert!(result.contains("<input"), "input 应保留, got: {result}");
// type=checkbox 保留
assert!(
result.contains(r#"type="checkbox""#),
"type=checkbox 应保留, got: {result}"
);
// checked 保留(体现勾选状态)
assert!(
result.contains("checked"),
"checked 属性应保留, got: {result}"
);
// disabled 保留
assert!(
result.contains("disabled"),
"disabled 属性应保留, got: {result}"
);
}
#[test]
fn clean_html_input_rejects_type_image() {
// type=image 是已知的 input 滥用面(可配合 src 触发请求),必须整体移除
let input = r#"<ul><li><input type="image" src="https://evil.example/x.png">文本</li></ul>"#;
let result = clean_html(input);
assert!(
!result.contains("input"),
"type=image 的 input 必须被整体移除, got: {result}"
);
assert!(
!result.contains("evil.example"),
"残留的 src 也应随 input 一并移除, got: {result}"
);
// 文本内容保留
assert!(result.contains("文本"));
}
#[test]
fn clean_html_input_rejects_type_text() {
let result = clean_html(r#"<input type="text">"#);
assert!(
!result.contains("input"),
"type=text 的 input 应被移除, got: {result}"
);
}
#[test]
fn clean_html_input_without_type_removed() {
// 缺省 type 属性的 input如 <input checked> 经属性过滤后 type 缺省)必须整体移除
let result = clean_html("<input checked>");
assert!(
!result.contains("input"),
"无 type 属性的 input 应被整体移除, got: {result}"
);
}
#[test]
fn clean_comment_html_input_stripped() {
// 评论白名单本就不含 input任务列表 checkbox 在评论侧不放开
let result = clean_comment_html(r#"<input type="checkbox" checked>"#);
assert!(
!result.contains("input"),
"评论侧 input 应被剥离, got: {result}"
);
}
}