//! HTML 消毒器。 //! //! 基于 lol_html 清理不受信任的 HTML,限制允许的 tag/attribute/URL scheme, //! 分别提供文章正文(`clean_html`)与评论(`clean_comment_html`)两套白名单策略。 //! 仅在 `feature = "server"` 时执行。 #![allow(clippy::unused_unit, deprecated)] #[cfg(feature = "server")] use std::collections::HashSet; #[cfg(feature = "server")] use std::sync::LazyLock; #[cfg(feature = "server")] static DEFAULT_ALLOWED_TAGS: LazyLock> = LazyLock::new(|| { HashSet::from([ "a", "abbr", "acronym", "area", "article", "aside", "b", "bdi", "bdo", "blockquote", "br", "caption", "center", "cite", "code", "col", "colgroup", "data", "dd", "del", "details", "dfn", "div", "dl", "dt", "em", "figcaption", "figure", "footer", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hgroup", "hr", "i", "img", "ins", // input 仅用于 pulldown-cmark 任务列表渲染的 checkbox; // element_handler 会强制校验 type="checkbox",其余 type 一律整体移除。 "input", "kbd", "li", "map", "mark", "nav", "ol", "p", "pre", "q", "rp", "rt", "rtc", "ruby", "s", "samp", "section", "small", "span", "strike", "strong", "sub", "summary", "sup", // svg / path 仅用于 KaTeX 服务端数学公式渲染 (根号、矩阵竖线、大括号、矢量箭头等); // 属性仅放行 ViewBox / d 等绘图属性,script/style 标签由 CLEAN_CONTENT_TAGS 强行清除。 "svg", "path", "table", "tbody", "td", "th", "thead", "time", "tr", "tt", "u", "ul", "var", "wbr", ]) }); #[cfg(feature = "server")] static CLEAN_CONTENT_TAGS: LazyLock> = LazyLock::new(|| HashSet::from(["script", "style"])); #[cfg(feature = "server")] static DEFAULT_ALLOWED_SCHEMES: LazyLock> = LazyLock::new(|| { HashSet::from([ "bitcoin", "ftp", "ftps", "geo", "http", "https", "im", "irc", "ircs", "magnet", "mailto", "mms", "mx", "news", "nntp", "openpgp4fpr", "sip", "sms", "smsto", "ssh", "tel", "url", "webcal", "wtai", "xmpp", ]) }); #[cfg(feature = "server")] /// 评论允许的标签:在默认集合基础上移除 img / details / summary。 static COMMENT_ALLOWED_TAGS: LazyLock> = LazyLock::new(|| { let mut set = DEFAULT_ALLOWED_TAGS.clone(); set.remove("img"); set.remove("details"); set.remove("summary"); set }); #[cfg(feature = "server")] fn is_safe_data_uri(url: &str) -> bool { // data URI 只允许安全的图片类型;禁止 data:text/html、data:application/javascript 等。 let url = url.trim(); let Some(rest) = url.strip_prefix("data:") else { return false; }; let media_type = rest.split(',').next().unwrap_or(""); let media_type = media_type.split(';').next().unwrap_or("").trim(); matches!( media_type.to_lowercase().as_str(), "image/png" | "image/jpeg" | "image/jpg" | "image/gif" | "image/webp" | "image/avif" | "image/bmp" | "image/tiff" | "image/svg+xml" ) } #[cfg(feature = "server")] fn is_safe_url(url: &str, allowed_schemes: &HashSet<&str>, allow_data_uri: bool) -> bool { let trimmed = url.trim(); if trimmed.is_empty() { return true; } // 锚点(#开头)优先放行:fragment 内的冒号不是 scheme 分隔符 // (如脚注锚点 #fn:label),不应被下面的 scheme 解析误判。 // 仍显式拒绝 javascript:/vbscript:(虽以 # 开头不可能,但保持防御一致)。 if trimmed.starts_with('#') { return true; } // 解析 scheme 并与白名单对比;未知 scheme 默认拒绝。 if let Some(colon_pos) = trimmed.find(':') { let scheme = &trimmed[..colon_pos]; let scheme_lower = scheme.to_lowercase(); if scheme_lower == "javascript" || scheme_lower == "vbscript" { return false; } if scheme.contains(|c: char| c.is_ascii_whitespace()) { return false; } if allowed_schemes.contains(scheme_lower.as_str()) { return true; } if scheme_lower == "data" { return allow_data_uri && is_safe_data_uri(trimmed); } // 任何其它 scheme 均拒绝:file://、blob://、about:blank 等。 return false; } // 无 scheme 时只允许相对路径。 trimmed.starts_with('/') } #[cfg(feature = "server")] /// HTML 消毒配置:白名单 tag/attribute、允许 URL scheme 与链接 rel。 struct SanitizerConfig { allowed_tags: &'static HashSet<&'static str>, extra_generic_attrs: Vec<&'static str>, extra_tag_attrs: Vec<(&'static str, Vec<&'static str>)>, allowed_schemes: &'static HashSet<&'static str>, allow_data_uri: bool, link_rel: Option<&'static str>, remove_tags: &'static HashSet<&'static str>, } #[cfg(feature = "server")] fn sanitize(input: &str, config: &SanitizerConfig) -> String { let allowed_tags = config.allowed_tags; let remove_tags = config.remove_tags; let generic_attrs: HashSet<&str> = config .extra_generic_attrs .iter() .copied() .chain(["lang", "title"]) .collect(); let tag_attrs_map: std::collections::HashMap<&str, HashSet<&str>> = { let mut m = std::collections::HashMap::new(); let base = [ ("a", vec!["href", "hreflang"]), ("bdo", vec!["dir"]), ("blockquote", vec!["cite"]), ("col", vec!["align", "char", "charoff", "span"]), ("colgroup", vec!["align", "char", "charoff", "span"]), ("del", vec!["cite", "datetime"]), ("hr", vec!["align", "size", "width"]), ("img", vec!["align", "alt", "height", "src", "width"]), ("ins", vec!["cite", "datetime"]), ("ol", vec!["start"]), ("q", vec!["cite"]), ("table", vec!["align", "char", "charoff", "summary"]), ("tbody", vec!["align", "char", "charoff"]), ( "td", vec!["align", "char", "charoff", "colspan", "headers", "rowspan"], ), ("tfoot", vec!["align", "char", "charoff"]), ( "th", vec![ "align", "char", "charoff", "colspan", "headers", "rowspan", "scope", ], ), ("thead", vec!["align", "char", "charoff"]), ("tr", vec!["align", "char", "charoff"]), ]; for (tag, attrs) in &base { m.insert(*tag, attrs.iter().copied().collect()); } for (tag, attrs) in &config.extra_tag_attrs { m.entry(tag) .or_insert_with(HashSet::new) .extend(attrs.iter().copied()); } m }; let allowed_schemes = config.allowed_schemes; let allow_data_uri = config.allow_data_uri; let link_rel = config.link_rel; let element_handler = move |el: &mut lol_html::html_content::Element| { let tag = el.tag_name().to_lowercase(); if remove_tags.contains(tag.as_str()) { el.remove(); return Ok(()); } if !allowed_tags.contains(tag.as_str()) { el.remove_and_keep_content(); return Ok(()); } let allowed_for_tag: HashSet<&str> = { let mut s = generic_attrs.clone(); if let Some(tag_specific) = tag_attrs_map.get(tag.as_str()) { s.extend(tag_specific.iter().copied()); } s }; let attrs_to_remove: Vec = el .attributes() .iter() .filter_map(|attr| { let name = attr.name(); let name_lower = name.to_lowercase(); // 仅保留白名单属性;对 href/src/cite 额外校验 URL 安全性。 if allowed_for_tag.contains(name_lower.as_str()) { if name_lower == "href" || name_lower == "src" || name_lower == "cite" { let val = attr.value(); if !is_safe_url(&val, allowed_schemes, allow_data_uri) { return Some(name); } } // input 的 type 必须是 checkbox,其余取值(image/text/...)一概删除; // 缺失 type 的 input 由下面的兜底逻辑整标签移除。 if tag == "input" && name_lower == "type" && attr.value().trim().to_lowercase() != "checkbox" { return Some(name); } None } else { Some(name) } }) .collect(); for attr_name in attrs_to_remove { el.remove_attribute(&attr_name); } if link_rel.is_some() && tag == "a" { if let Some(rel) = link_rel { let existing = el.get_attribute("rel").unwrap_or_default(); if existing != rel { el.set_attribute("rel", rel).ok(); } } } // input 兜底:属性白名单 + type 值校验后,仍可能残留「无 type 属性」的 input。 // (例如 经属性过滤后 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(()) }; let settings = lol_html::RewriteStrSettings::new() .append_element_content_handler(lol_html::element!("*", element_handler)) .append_document_content_handler(lol_html::doc_comments!(|c| { c.remove(); Ok(()) })); lol_html::rewrite_str(input, settings).unwrap_or_default() } #[cfg(feature = "server")] /// 文章正文 HTML 清理:允许较完整的标签与 data URI,外链添加 `noopener noreferrer`。 pub fn clean_html(input: &str) -> String { let config = SanitizerConfig { allowed_tags: &DEFAULT_ALLOWED_TAGS, extra_generic_attrs: vec![ "class", "aria-hidden", "aria-label", "aria-labelledby", "id", "role", "accesskey", "title", ], 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"]), // pre 上的可运行代码块标记:data-runnable / data-lang / data-overrides / data-source。 // data-overrides / data-source 是 markdown 渲染时 HTML 转义后的内容(见 markdown.rs),不含未转义引号。 ( "pre", vec![ "data-runnable", "data-lang", "data-overrides", "data-source", ], ), ("span", vec!["class", "style"]), // KaTeX 数学公式 SSR 渲染生成的 SVG 矢量图 (根号 / 矩阵竖线 / 括号 / 箭头等) 必备属性 ( "svg", vec![ "xmlns", "width", "height", "viewbox", "preserveaspectratio", "style", ], ), ("path", vec!["d"]), ("h1", vec!["id", "class"]), ("h2", vec!["id", "class"]), ("h3", vec!["id", "class"]), ("h4", vec!["id", "class"]), ("h5", vec!["id", "class"]), ("h6", vec!["id", "class"]), ], allowed_schemes: &DEFAULT_ALLOWED_SCHEMES, allow_data_uri: false, link_rel: Some("noopener noreferrer"), remove_tags: &CLEAN_CONTENT_TAGS, }; sanitize(input, &config) } #[cfg(feature = "server")] /// 评论 HTML 清理:移除图片与折叠块,禁用 data URI,外链添加 `nofollow noopener`。 pub fn clean_comment_html(input: &str) -> String { let config = SanitizerConfig { allowed_tags: &COMMENT_ALLOWED_TAGS, extra_generic_attrs: vec![ "class", "title", "aria-hidden", "aria-label", "role", "accesskey", ], extra_tag_attrs: vec![ ("a", vec!["class", "aria-hidden", "aria-label"]), // span 的 style:KaTeX 服务端渲染产出的内联 style(元素垂直对齐/定位) // 需保留,否则公式排版错位。与文章正文路径(sanitizer.rs:382)对齐。 ("span", vec!["class", "style"]), // KaTeX 数学公式 SSR 渲染生成的 SVG 矢量图 (根号 / 矩阵竖线 / 括号 / 箭头等) 必备属性 ( "svg", vec![ "xmlns", "width", "height", "viewbox", "preserveaspectratio", "style", ], ), ("path", vec!["d"]), ], allowed_schemes: &DEFAULT_ALLOWED_SCHEMES, allow_data_uri: false, link_rel: Some("nofollow noopener"), remove_tags: &CLEAN_CONTENT_TAGS, }; sanitize(input, &config) } #[cfg(all(test, feature = "server"))] mod tests { use super::*; #[test] fn clean_html_allows_blur_img_attributes() { let input = r#"tt"#; let result = clean_html(input); assert!(result.contains("data-src"), "data-src should be allowed"); assert!( result.contains("blur-img-placeholder"), "class should be allowed" ); assert!(result.contains("--ar"), "style should be allowed"); } #[test] fn safe_tags_preserved() { assert_eq!(clean_html("

safe

"), "

safe

"); assert_eq!( clean_html("

bold

"), "

bold

" ); } #[test] fn script_and_style_removed() { assert_eq!( clean_html("

ok

"), "

ok

" ); } #[test] fn id_and_class_preserved() { assert_eq!( clean_html("

x

"), "

x

" ); assert_eq!( clean_html("

x

"), "

x

" ); } #[test] fn javascript_url_stripped() { assert_eq!( clean_html("x"), "x" ); } #[test] fn vbscript_url_stripped() { assert_eq!( clean_html("x"), "x" ); } #[test] fn unknown_tags_removed_content_kept() { assert_eq!(clean_html("keep me"), "keep me"); } #[test] fn comment_removes_img_details_summary() { assert_eq!( clean_comment_html("
sumbody
"), "sumbody" ); } #[test] fn katex_svg_and_path_preserved() { let katex_html = crate::api::katex::render_display("\\sqrt{\\pi}"); let cleaned = clean_html(&katex_html); assert!( cleaned.contains(" for KaTeX sqrt" ); assert!( cleaned.contains(" for KaTeX sqrt" ); assert!( cleaned.contains("d="), "clean_html should preserve d attribute on " ); assert!( cleaned.contains("viewBox=") || cleaned.contains("viewbox="), "clean_html should preserve viewBox attribute on " ); let comment_cleaned = clean_comment_html(&katex_html); assert!( comment_cleaned.contains("" ); assert!( comment_cleaned.contains("" ); } #[test] fn comment_removes_data_uris() { assert_eq!( clean_comment_html("x"), "x" ); } // ---- is_safe_url 直接分支测试 ---- // is_safe_url 是安全敏感的内部函数,以下测试锁定其各分支的行为契约。 #[test] fn is_safe_url_allows_https() { let schemes = DEFAULT_ALLOWED_SCHEMES.clone(); assert!(is_safe_url("https://example.com", &schemes, false)); assert!(is_safe_url("http://example.com", &schemes, false)); } #[test] fn is_safe_url_rejects_javascript() { let schemes = DEFAULT_ALLOWED_SCHEMES.clone(); assert!(!is_safe_url("javascript:alert(1)", &schemes, false)); } #[test] fn is_safe_url_rejects_vbscript() { let schemes = DEFAULT_ALLOWED_SCHEMES.clone(); assert!(!is_safe_url("vbscript:msgbox", &schemes, false)); } #[test] fn is_safe_url_data_uri_respects_flag_and_media_type() { let schemes = DEFAULT_ALLOWED_SCHEMES.clone(); // 仅在显式允许且 media type 为图片时通过 assert!(is_safe_url("data:image/png;base64,iVBOR", &schemes, true)); assert!(is_safe_url( "data:image/svg+xml;base64,PHN2Zz4=", &schemes, true )); // 禁用 data URI 时拒绝 assert!(!is_safe_url("data:image/png;base64,iVBOR", &schemes, false)); // 非图片 data URI 拒绝 assert!(!is_safe_url( "data:text/html,", &schemes, true )); assert!(!is_safe_url( "data:application/javascript,alert(1)", &schemes, true )); } #[test] fn is_safe_url_allows_relative_and_fragment() { let schemes = DEFAULT_ALLOWED_SCHEMES.clone(); // 绝对路径 assert!(is_safe_url("/path/to/page", &schemes, false)); // 锚点 assert!(is_safe_url("#section", &schemes, false)); } #[test] fn is_safe_url_empty_is_safe() { let schemes = DEFAULT_ALLOWED_SCHEMES.clone(); // 空 URL(如 img 无 src)视为安全。 assert!(is_safe_url("", &schemes, false)); assert!(is_safe_url(" ", &schemes, false)); } #[test] fn is_safe_url_allows_other_whitelisted_schemes() { let schemes = DEFAULT_ALLOWED_SCHEMES.clone(); // mailto / tel / ftp 等均在默认白名单中。 assert!(is_safe_url("mailto:user@example.com", &schemes, false)); assert!(is_safe_url("tel:+8613800138000", &schemes, false)); assert!(is_safe_url("ftp://example.com/file", &schemes, false)); } #[test] fn is_safe_url_rejects_scheme_with_whitespace() { let schemes = DEFAULT_ALLOWED_SCHEMES.clone(); // 含空格的 scheme 名是已知的混淆手法,应被拒绝。 assert!(!is_safe_url("java\tscript:alert(1)", &schemes, false)); } #[test] fn is_safe_url_rejects_unknown_schemes() { let schemes = DEFAULT_ALLOWED_SCHEMES.clone(); // 未知 scheme 默认拒绝。 assert!(!is_safe_url("file:///etc/passwd", &schemes, false)); assert!(!is_safe_url( "blob:https://example.com/abc", &schemes, false )); assert!(!is_safe_url("about:blank", &schemes, false)); assert!(!is_safe_url("custom-app://open", &schemes, false)); } #[test] fn is_safe_url_scheme_matching_is_case_insensitive() { let schemes = DEFAULT_ALLOWED_SCHEMES.clone(); // scheme 大小写不敏感:HTTPS 与 https 等价。 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#"
  • 未完成
  • 已完成
"#; let result = clean_html(input); // input 标签保留 assert!(result.contains("
  • 文本
  • "#; 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#""#); assert!( !result.contains("input"), "type=text 的 input 应被移除, got: {result}" ); } #[test] fn clean_html_input_without_type_removed() { // 缺省 type 属性的 input(如 经属性过滤后 type 缺省)必须整体移除 let result = clean_html(""); assert!( !result.contains("input"), "无 type 属性的 input 应被整体移除, got: {result}" ); } #[test] fn clean_comment_html_input_stripped() { // 评论白名单本就不含 input,任务列表 checkbox 在评论侧不放开 let result = clean_comment_html(r#""#); assert!( !result.contains("input"), "评论侧 input 应被剥离, got: {result}" ); } #[test] fn clean_html_preserves_runnable_pre_data_attrs() { // 可运行代码块标记应完整保留,供阅读器扫描挂载 CodeRunner。 let input = r#"
    print(1)
    "#; let result = clean_html(input); assert!( result.contains(r#"data-runnable="true""#), "data-runnable 应保留, got: {result}" ); assert!( result.contains(r#"data-lang="python""#), "data-lang 应保留, got: {result}" ); assert!( result.contains("data-overrides="), "data-overrides 应保留, got: {result}" ); } #[test] fn clean_html_strips_unknown_data_attrs_on_pre() { // 仅放行白名单的三个 data-* 属性,其它 data-*(如恶意 data-onclick)应被剥离。 let input = r#"
    x
    "#; let result = clean_html(input); assert!( result.contains("data-runnable"), "白名单 data-runnable 应保留" ); assert!( !result.contains("data-evil"), "未知 data-* 应被剥离, got: {result}" ); } // ---- XSS 攻击向量回归:属性白名单是这里的核心防线 ---- // 这些测试锁定"非白名单属性被剥离"这一不变量——若有人放宽属性过滤, // 经典 XSS 向量就会重新可用,测试会在那一步失败。 #[test] fn clean_html_strips_event_handler_attributes() { // onerror/onload/onclick 等事件处理器属性全不在白名单,必须被移除。 // 即便 本身合法,onerror 也不能留下。 let cases = [ r#""#, r#""#, r#""#, r#"
    x
    "#, r#"x"#, r#""#, ]; for input in cases { let result = clean_html(input); assert!( !result.contains("onerror") && !result.contains("onload") && !result.contains("onclick") && !result.contains("onmouseover"), "事件处理器属性应被剥离, input: {input}, got: {result}" ); } } #[test] fn clean_html_strips_event_handler_attribute_with_mixed_case() { // 大小写混淆绕过尝试:EvEr、大写、混合都应被拦(属性名匹配应大小写不敏感地拒绝)。 for attr in ["OnErRoR", "ONERROR", "On_Error".replace('_', "or").as_str()] { let input = format!(r#""#); let result = clean_html(&input); assert!( !result.to_lowercase().contains("onerror"), "大小写混淆的事件处理器应被剥离: {input} -> {result}" ); } } #[test] fn clean_html_removes_script_tag_and_content() { // script 在 CLEAN_CONTENT_TAGS:标签连同内容一起移除(而非转义后保留)。 let result = clean_html("

    hi

    bye

    "); assert!( !result.contains("script"), "script 标签应被完全移除: {result}" ); assert!(!result.contains("alert"), "script 内容应被清除: {result}"); assert!( result.contains("hi") && result.contains("bye"), "周围内容应保留: {result}" ); } #[test] fn clean_html_removes_style_tag_and_content() { // style 也走 CLEAN_CONTENT_TAGS:CSS 注入(expression()、@import)随内容一起移除。 let result = clean_html("

    x

    "); assert!(!result.contains("style"), "style 标签应被移除: {result}"); assert!( !result.contains("javascript"), "style 内危险内容应被清除: {result}" ); } #[test] fn clean_html_drops_dangerous_tags_entirely() { // 这些标签不在白名单:整体移除(含子树),无法用于 XSS / 数据外泄。 for tag in ["iframe", "object", "embed", "form", "math", "base", "meta"] { let input = format!("<{tag} src=\"javascript:alert(1)\">"); let result = clean_html(&input); assert!( !result.to_lowercase().contains(&format!("<{tag}")), "<{tag}> 不在白名单应被移除: {result}" ); } // svg 在白名单中(配合 KaTeX 渲染),但非白名单属性(如 src / onerror / onload)会被剥离 let svg_input = r#""#; let svg_result = clean_html(svg_input); assert!( !svg_result.contains("javascript"), "svg 上的非白名单属性/javascript 应被剥离: {svg_result}" ); assert!( !svg_result.contains("onload"), "svg 上的 onload 事件处理器应被剥离: {svg_result}" ); } #[test] fn clean_html_drops_javascript_scheme_in_href_and_src() { // 即便标签合法,javascript:/vbscript: scheme 必须被拒(is_safe_url 防线)。 let cases = [ r#"x"#, r#"x"#, r#""#, // 编码绕过尝试:HTML 实体编码的 javascript: r#"x"#, r#"x"#, ]; for input in cases { let result = clean_html(input); // href/src 值里不应残留可执行的 javascript scheme(属性可能被整段移除或值被清空)。 // 至少裸的 "javascript:" 字面量不应在 href/src 属性值中出现。 let lower = result.to_lowercase(); assert!( !lower.contains("javascript:") && !lower.contains("vbscript:"), "危险 scheme 应被移除, input: {input}, got: {result}" ); } } #[test] fn clean_html_data_uri_blocked_in_article_body() { // clean_html 配置 allow_data_uri: false——所有 data URI(含 image/png)都应被拒。 // 这是文章正文路径的契约;评论路径同样。 let result = clean_html(r#""#); assert!( !result.contains("data:image/png"), "文章正文应禁用 data URI: {result}" ); } #[test] fn clean_html_data_uri_text_html_blocked_even_if_flag_true() { // 即便 allow_data_uri=true,is_safe_data_uri 也只放行图片类型; // data:text/html(可执行脚本)永远拒绝。直接测内部函数锁定该不变量。 let schemes = DEFAULT_ALLOWED_SCHEMES.clone(); assert!(!is_safe_url( "data:text/html,", &schemes, true )); assert!(!is_safe_url( "data:application/javascript,alert(1)", &schemes, true )); // 安全图片类型在 flag=true 时放行 assert!(is_safe_url("data:image/png;base64,iVBOR=", &schemes, true)); } #[test] fn clean_html_svg_data_uri_carries_risk_even_when_allowed() { // is_safe_data_uri 允许 image/svg+xml——SVG 内可嵌 ", &schemes, false )); } #[test] fn clean_comment_html_strips_event_handlers() { // 评论路径(更严格)同样不能漏掉事件处理器。 let result = clean_comment_html(r#"

    x

    "#); assert!(!result.contains("onclick"), "评论 XSS 向量: {result}"); } }