fix(markdown): 转义 TOC 标题文本,防止属性上下文注入
generate_toc_html 原先用 clean_html 处理标题后拼进 aria-label="..." 与 <a> 正文,但 clean_html 只做正文 HTML 消毒、不转义双引号。标题形如 " onmouseover="alert(1) 会越出属性边界。新增 escape_html_attr(),对 标题文本统一做属性上下文转义(& " <),正文与属性两处一致使用。 内容由 admin 写入、严重度中低,但属真实消毒逻辑缺口,TOC 会展示给所有读者。
This commit is contained in:
parent
cfa4975813
commit
6d32664020
@ -12,6 +12,18 @@ pub fn clean_html(input: &str) -> String {
|
|||||||
crate::api::sanitizer::clean_html(input)
|
crate::api::sanitizer::clean_html(input)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
/// 将文本转义为可安全放入 HTML **属性值**(双引号包裹)的形式。
|
||||||
|
///
|
||||||
|
/// `clean_html` 用于消毒正文 HTML,但把文本拼进 `attr="..."` 时不会转义双引号,
|
||||||
|
/// 形如标题 `" onmouseover="alert(1)` 会越出属性边界导致属性注入。此处补齐
|
||||||
|
/// `&` / `"` / `<` 的属性上下文转义。
|
||||||
|
fn escape_html_attr(s: &str) -> String {
|
||||||
|
s.replace('&', "&")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('<', "<")
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
/// Markdown 渲染结果。
|
/// Markdown 渲染结果。
|
||||||
@ -222,10 +234,13 @@ fn generate_toc_html(headings: &[(u8, String, String)]) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let clean_text = clean_html(text);
|
// 标题 text 是 pulldown-cmark 收集的纯文本(Text/Code 字面字符),不是 HTML 片段,
|
||||||
|
// 因此正文与属性两处都用属性转义(& " <)。原先用 clean_html 处理正文会漏掉 `"`,
|
||||||
|
// 虽然文本节点中的 `"` 不会闭合属性,但统一转义更稳健、可读性更好。
|
||||||
|
let escaped_text = escape_html_attr(text);
|
||||||
html.push_str(&format!(
|
html.push_str(&format!(
|
||||||
"<li><a href=\"#{}\" aria-label=\"{}\">{}</a>",
|
"<li><a href=\"#{}\" aria-label=\"{}\">{}</a>",
|
||||||
id, clean_text, clean_text
|
id, escaped_text, escaped_text
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -357,6 +372,42 @@ mod tests {
|
|||||||
assert_eq!(ul_count, 2);
|
assert_eq!(ul_count, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_toc_html_escapes_quote_in_attr() {
|
||||||
|
// 标题中的双引号不得越出 aria-label 属性边界。
|
||||||
|
let headings = vec![(
|
||||||
|
2u8,
|
||||||
|
"\" onmouseover=\"alert(1)".to_string(),
|
||||||
|
"heading".to_string(),
|
||||||
|
)];
|
||||||
|
let html = generate_toc_html(&headings);
|
||||||
|
// aria-label 中的双引号被转义为 ",无法越出属性边界注入新属性。
|
||||||
|
assert!(
|
||||||
|
html.contains("aria-label=\"" onmouseover="alert(1)\""),
|
||||||
|
"aria-label 应转义内部双引号,got: {html}"
|
||||||
|
);
|
||||||
|
// 关键:不得出现「未被引号包裹、可被解析为真实属性」的 onmouseover= 片段。
|
||||||
|
// 正文中作为纯文本出现 "onmouseover" 字符串是安全的(无 < 或属性结构)。
|
||||||
|
let attr_injection = "\" onmouseover=\"";
|
||||||
|
let injected = html.matches(attr_injection).count();
|
||||||
|
// 原始输入里有 1 个裸双引号起头;转义后该模式不应再作为属性边界出现。
|
||||||
|
// 注意 aria-label 内部的双引号已变成 ",因此裸的 `" onmouseover="` 不应存在。
|
||||||
|
assert_eq!(
|
||||||
|
injected, 0,
|
||||||
|
"不应存在未转义的属性边界 `\" onmouseover=\"`,got: {html}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_toc_html_escapes_ampersand_in_attr() {
|
||||||
|
let headings = vec![(2u8, "A & B".to_string(), "heading".to_string())];
|
||||||
|
let html = generate_toc_html(&headings);
|
||||||
|
assert!(
|
||||||
|
html.contains("aria-label=\"A & B\""),
|
||||||
|
"& 应在属性中转义,got: {html}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn render_markdown_simple_paragraph() {
|
fn render_markdown_simple_paragraph() {
|
||||||
let result = render_markdown_enhanced("Hello **world**");
|
let result = render_markdown_enhanced("Hello **world**");
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user