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:
parent
a10003c90d
commit
865871f45c
20
input.css
20
input.css
@ -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;
|
||||
|
||||
@ -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("已完成"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user