Skip to main content

yggdrasil/api/
sanitizer.rs

1//! HTML 消毒器。
2//!
3//! 基于 lol_html 清理不受信任的 HTML,限制允许的 tag/attribute/URL scheme,
4//! 分别提供文章正文(`clean_html`)与评论(`clean_comment_html`)两套白名单策略。
5//! 仅在 `feature = "server"` 时执行。
6
7#![allow(clippy::unused_unit, deprecated)]
8
9#[cfg(feature = "server")]
10use std::collections::HashSet;
11
12#[cfg(feature = "server")]
13use std::sync::LazyLock;
14
15#[cfg(feature = "server")]
16static DEFAULT_ALLOWED_TAGS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
17    HashSet::from([
18        "a",
19        "abbr",
20        "acronym",
21        "area",
22        "article",
23        "aside",
24        "b",
25        "bdi",
26        "bdo",
27        "blockquote",
28        "br",
29        "caption",
30        "center",
31        "cite",
32        "code",
33        "col",
34        "colgroup",
35        "data",
36        "dd",
37        "del",
38        "details",
39        "dfn",
40        "div",
41        "dl",
42        "dt",
43        "em",
44        "figcaption",
45        "figure",
46        "footer",
47        "h1",
48        "h2",
49        "h3",
50        "h4",
51        "h5",
52        "h6",
53        "header",
54        "hgroup",
55        "hr",
56        "i",
57        "img",
58        "ins",
59        // input 仅用于 pulldown-cmark 任务列表渲染的 checkbox;
60        // element_handler 会强制校验 type="checkbox",其余 type 一律整体移除。
61        "input",
62        "kbd",
63        "li",
64        "map",
65        "mark",
66        "nav",
67        "ol",
68        "p",
69        "pre",
70        "q",
71        "rp",
72        "rt",
73        "rtc",
74        "ruby",
75        "s",
76        "samp",
77        "section",
78        "small",
79        "span",
80        "strike",
81        "strong",
82        "sub",
83        "summary",
84        "sup",
85        // svg / path 仅用于 KaTeX 服务端数学公式渲染 (根号、矩阵竖线、大括号、矢量箭头等);
86        // 属性仅放行 ViewBox / d 等绘图属性,script/style 标签由 CLEAN_CONTENT_TAGS 强行清除。
87        "svg",
88        "path",
89        "table",
90        "tbody",
91        "td",
92        "th",
93        "thead",
94        "time",
95        "tr",
96        "tt",
97        "u",
98        "ul",
99        "var",
100        "wbr",
101    ])
102});
103
104#[cfg(feature = "server")]
105static CLEAN_CONTENT_TAGS: LazyLock<HashSet<&'static str>> =
106    LazyLock::new(|| HashSet::from(["script", "style"]));
107
108#[cfg(feature = "server")]
109static DEFAULT_ALLOWED_SCHEMES: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
110    HashSet::from([
111        "bitcoin",
112        "ftp",
113        "ftps",
114        "geo",
115        "http",
116        "https",
117        "im",
118        "irc",
119        "ircs",
120        "magnet",
121        "mailto",
122        "mms",
123        "mx",
124        "news",
125        "nntp",
126        "openpgp4fpr",
127        "sip",
128        "sms",
129        "smsto",
130        "ssh",
131        "tel",
132        "url",
133        "webcal",
134        "wtai",
135        "xmpp",
136    ])
137});
138
139#[cfg(feature = "server")]
140/// 评论允许的标签:在默认集合基础上移除 img / details / summary。
141static COMMENT_ALLOWED_TAGS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
142    let mut set = DEFAULT_ALLOWED_TAGS.clone();
143    set.remove("img");
144    set.remove("details");
145    set.remove("summary");
146    set
147});
148
149#[cfg(feature = "server")]
150/// 判断 data URI 是否属于安全图片类型。
151///
152/// D7:生产死代码——两处 SanitizerConfig(clean_html / clean_comment_html)均硬编码
153/// `allow_data_uri: false`,本函数在请求路径上不可达(is_safe_url 提前短路)。
154/// 保留作为防御纵深:测试锁定\"即便 flag=true 也只放行图片类型、拒绝 data:text/html\"
155/// 的安全不变量。若未来需要内联 data URI,flag + 本函数已就绪。
156fn is_safe_data_uri(url: &str) -> bool {
157    // data URI 只允许安全的图片类型;禁止 data:text/html、data:application/javascript 等。
158    let url = url.trim();
159    let Some(rest) = url.strip_prefix("data:") else {
160        return false;
161    };
162    let media_type = rest.split(',').next().unwrap_or("");
163    let media_type = media_type.split(';').next().unwrap_or("").trim();
164    matches!(
165        media_type.to_lowercase().as_str(),
166        "image/png"
167            | "image/jpeg"
168            | "image/jpg"
169            | "image/gif"
170            | "image/webp"
171            | "image/avif"
172            | "image/bmp"
173            | "image/tiff"
174            | "image/svg+xml"
175    )
176}
177
178#[cfg(feature = "server")]
179fn is_safe_url(url: &str, allowed_schemes: &HashSet<&str>, allow_data_uri: bool) -> bool {
180    let trimmed = url.trim();
181    if trimmed.is_empty() {
182        return true;
183    }
184    // 锚点(#开头)优先放行:fragment 内的冒号不是 scheme 分隔符
185    // (如脚注锚点 #fn:label),不应被下面的 scheme 解析误判。
186    // 仍显式拒绝 javascript:/vbscript:(虽以 # 开头不可能,但保持防御一致)。
187    if trimmed.starts_with('#') {
188        return true;
189    }
190    // 解析 scheme 并与白名单对比;未知 scheme 默认拒绝。
191    if let Some(colon_pos) = trimmed.find(':') {
192        let scheme = &trimmed[..colon_pos];
193        let scheme_lower = scheme.to_lowercase();
194        if scheme_lower == "javascript" || scheme_lower == "vbscript" {
195            return false;
196        }
197        if scheme.contains(|c: char| c.is_ascii_whitespace()) {
198            return false;
199        }
200        if allowed_schemes.contains(scheme_lower.as_str()) {
201            return true;
202        }
203        if scheme_lower == "data" {
204            return allow_data_uri && is_safe_data_uri(trimmed);
205        }
206        // 任何其它 scheme 均拒绝:file://、blob://、about:blank 等。
207        return false;
208    }
209    // 无 scheme 时只允许相对路径。
210    trimmed.starts_with('/')
211}
212
213#[cfg(feature = "server")]
214/// HTML 消毒配置:白名单 tag/attribute、允许 URL scheme 与链接 rel。
215struct SanitizerConfig {
216    allowed_tags: &'static HashSet<&'static str>,
217    extra_generic_attrs: Vec<&'static str>,
218    extra_tag_attrs: Vec<(&'static str, Vec<&'static str>)>,
219    allowed_schemes: &'static HashSet<&'static str>,
220    allow_data_uri: bool,
221    link_rel: Option<&'static str>,
222    remove_tags: &'static HashSet<&'static str>,
223}
224
225#[cfg(feature = "server")]
226fn sanitize(input: &str, config: &SanitizerConfig) -> String {
227    let allowed_tags = config.allowed_tags;
228    let remove_tags = config.remove_tags;
229    let generic_attrs: HashSet<&str> = config
230        .extra_generic_attrs
231        .iter()
232        .copied()
233        .chain(["lang", "title"])
234        .collect();
235    let tag_attrs_map: std::collections::HashMap<&str, HashSet<&str>> = {
236        let mut m = std::collections::HashMap::new();
237        let base = [
238            ("a", vec!["href", "hreflang"]),
239            ("bdo", vec!["dir"]),
240            ("blockquote", vec!["cite"]),
241            ("col", vec!["align", "char", "charoff", "span"]),
242            ("colgroup", vec!["align", "char", "charoff", "span"]),
243            ("del", vec!["cite", "datetime"]),
244            ("hr", vec!["align", "size", "width"]),
245            ("img", vec!["align", "alt", "height", "src", "width"]),
246            ("ins", vec!["cite", "datetime"]),
247            ("ol", vec!["start"]),
248            ("q", vec!["cite"]),
249            ("table", vec!["align", "char", "charoff", "summary"]),
250            ("tbody", vec!["align", "char", "charoff"]),
251            (
252                "td",
253                vec!["align", "char", "charoff", "colspan", "headers", "rowspan"],
254            ),
255            ("tfoot", vec!["align", "char", "charoff"]),
256            (
257                "th",
258                vec![
259                    "align", "char", "charoff", "colspan", "headers", "rowspan", "scope",
260                ],
261            ),
262            ("thead", vec!["align", "char", "charoff"]),
263            ("tr", vec!["align", "char", "charoff"]),
264        ];
265        for (tag, attrs) in &base {
266            m.insert(*tag, attrs.iter().copied().collect());
267        }
268        for (tag, attrs) in &config.extra_tag_attrs {
269            m.entry(tag)
270                .or_insert_with(HashSet::new)
271                .extend(attrs.iter().copied());
272        }
273        m
274    };
275    let allowed_schemes = config.allowed_schemes;
276    let allow_data_uri = config.allow_data_uri;
277    let link_rel = config.link_rel;
278
279    let element_handler = move |el: &mut lol_html::html_content::Element| {
280        let tag = el.tag_name().to_lowercase();
281
282        if remove_tags.contains(tag.as_str()) {
283            el.remove();
284            return Ok(());
285        }
286
287        if !allowed_tags.contains(tag.as_str()) {
288            el.remove_and_keep_content();
289            return Ok(());
290        }
291
292        let allowed_for_tag: HashSet<&str> = {
293            let mut s = generic_attrs.clone();
294            if let Some(tag_specific) = tag_attrs_map.get(tag.as_str()) {
295                s.extend(tag_specific.iter().copied());
296            }
297            s
298        };
299
300        let attrs_to_remove: Vec<String> = el
301            .attributes()
302            .iter()
303            .filter_map(|attr| {
304                let name = attr.name();
305                let name_lower = name.to_lowercase();
306                // 仅保留白名单属性;对 href/src/cite 额外校验 URL 安全性。
307                if allowed_for_tag.contains(name_lower.as_str()) {
308                    if name_lower == "href" || name_lower == "src" || name_lower == "cite" {
309                        let val = attr.value();
310                        if !is_safe_url(&val, allowed_schemes, allow_data_uri) {
311                            return Some(name);
312                        }
313                    }
314                    // input 的 type 必须是 checkbox,其余取值(image/text/...)一概删除;
315                    // 缺失 type 的 input 由下面的兜底逻辑整标签移除。
316                    if tag == "input"
317                        && name_lower == "type"
318                        && attr.value().trim().to_lowercase() != "checkbox"
319                    {
320                        return Some(name);
321                    }
322                    None
323                } else {
324                    Some(name)
325                }
326            })
327            .collect();
328
329        for attr_name in attrs_to_remove {
330            el.remove_attribute(&attr_name);
331        }
332
333        if link_rel.is_some() && tag == "a" {
334            if let Some(rel) = link_rel {
335                let existing = el.get_attribute("rel").unwrap_or_default();
336                if existing != rel {
337                    el.set_attribute("rel", rel).ok();
338                }
339            }
340        }
341
342        // input 兜底:属性白名单 + type 值校验后,仍可能残留「无 type 属性」的 input。
343        // (例如 <input checked> 经属性过滤后 type 被删或缺省。)这种 input 整体移除——
344        // 它是 void 元素无文本内容,remove() 不丢正文,且能彻底封堵缺省 type 的滥用。
345        if tag == "input" {
346            let type_ok = el
347                .get_attribute("type")
348                .map(|v| v.trim().to_lowercase() == "checkbox")
349                .unwrap_or(false);
350            if !type_ok {
351                el.remove();
352                return Ok(());
353            }
354        }
355
356        Ok(())
357    };
358
359    let settings = lol_html::RewriteStrSettings::new()
360        .append_element_content_handler(lol_html::element!("*", element_handler))
361        .append_document_content_handler(lol_html::doc_comments!(|c| {
362            c.remove();
363            Ok(())
364        }));
365
366    lol_html::rewrite_str(input, settings).unwrap_or_else(|e| {
367        // 静默返回空串会整篇丢弃正文,记录错误以便排查。
368        tracing::error!(error = ?e, input_len = input.len(), "HTML 清理失败,回退空串会丢弃正文");
369        String::new()
370    })
371}
372
373#[cfg(feature = "server")]
374/// 文章正文 HTML 清理:允许较完整的标签与 data URI,外链添加 `noopener noreferrer`。
375pub fn clean_html(input: &str) -> String {
376    let config = SanitizerConfig {
377        allowed_tags: &DEFAULT_ALLOWED_TAGS,
378        extra_generic_attrs: vec![
379            "class",
380            "aria-hidden",
381            "aria-label",
382            "aria-labelledby",
383            "id",
384            "role",
385            "accesskey",
386            "title",
387        ],
388        extra_tag_attrs: vec![
389            ("a", vec!["class", "aria-hidden", "aria-label"]),
390            ("img", vec!["data-src", "class", "style"]),
391            // input 仅放行 checkbox 必备属性;type 的具体取值由 element_handler 强校验为 checkbox。
392            ("input", vec!["type", "checked", "disabled"]),
393            // pre 上的可运行代码块标记:data-runnable / data-lang / data-overrides / data-source。
394            // data-overrides / data-source 是 markdown 渲染时 HTML 转义后的内容(见 markdown.rs),不含未转义引号。
395            (
396                "pre",
397                vec![
398                    "data-runnable",
399                    "data-lang",
400                    "data-overrides",
401                    "data-source",
402                ],
403            ),
404            ("span", vec!["class", "style"]),
405            // KaTeX 数学公式 SSR 渲染生成的 SVG 矢量图 (根号 / 矩阵竖线 / 括号 / 箭头等) 必备属性
406            (
407                "svg",
408                vec![
409                    "xmlns",
410                    "width",
411                    "height",
412                    "viewbox",
413                    "preserveaspectratio",
414                    "style",
415                ],
416            ),
417            ("path", vec!["d"]),
418            ("h1", vec!["id", "class"]),
419            ("h2", vec!["id", "class"]),
420            ("h3", vec!["id", "class"]),
421            ("h4", vec!["id", "class"]),
422            ("h5", vec!["id", "class"]),
423            ("h6", vec!["id", "class"]),
424        ],
425        allowed_schemes: &DEFAULT_ALLOWED_SCHEMES,
426        allow_data_uri: false,
427        link_rel: Some("noopener noreferrer"),
428        remove_tags: &CLEAN_CONTENT_TAGS,
429    };
430    sanitize(input, &config)
431}
432
433#[cfg(feature = "server")]
434/// 评论 HTML 清理:移除图片与折叠块,禁用 data URI,外链添加 `nofollow noopener`。
435pub fn clean_comment_html(input: &str) -> String {
436    let config = SanitizerConfig {
437        allowed_tags: &COMMENT_ALLOWED_TAGS,
438        extra_generic_attrs: vec![
439            "class",
440            "title",
441            "aria-hidden",
442            "aria-label",
443            "role",
444            "accesskey",
445        ],
446        extra_tag_attrs: vec![
447            ("a", vec!["class", "aria-hidden", "aria-label"]),
448            // span 的 style:KaTeX 服务端渲染产出的内联 style(元素垂直对齐/定位)
449            // 需保留,否则公式排版错位。与文章正文路径(sanitizer.rs:382)对齐。
450            ("span", vec!["class", "style"]),
451            // KaTeX 数学公式 SSR 渲染生成的 SVG 矢量图 (根号 / 矩阵竖线 / 括号 / 箭头等) 必备属性
452            (
453                "svg",
454                vec![
455                    "xmlns",
456                    "width",
457                    "height",
458                    "viewbox",
459                    "preserveaspectratio",
460                    "style",
461                ],
462            ),
463            ("path", vec!["d"]),
464        ],
465        allowed_schemes: &DEFAULT_ALLOWED_SCHEMES,
466        allow_data_uri: false,
467        link_rel: Some("nofollow noopener"),
468        remove_tags: &CLEAN_CONTENT_TAGS,
469    };
470    sanitize(input, &config)
471}
472
473#[cfg(all(test, feature = "server"))]
474mod tests {
475    use super::*;
476
477    #[test]
478    fn clean_html_allows_blur_img_attributes() {
479        let input = r#"<span class="blur-img" style="--ar:16/9"><img class="blur-img-placeholder" src="/uploads/x.webp?w=20" alt="t"><img class="blur-img-full" data-src="/uploads/x.webp?w=800" alt="t"></span>"#;
480        let result = clean_html(input);
481        assert!(result.contains("data-src"), "data-src should be allowed");
482        assert!(
483            result.contains("blur-img-placeholder"),
484            "class should be allowed"
485        );
486        assert!(result.contains("--ar"), "style should be allowed");
487    }
488
489    #[test]
490    fn safe_tags_preserved() {
491        assert_eq!(clean_html("<p>safe</p>"), "<p>safe</p>");
492        assert_eq!(
493            clean_html("<p><strong>bold</strong></p>"),
494            "<p><strong>bold</strong></p>"
495        );
496    }
497
498    #[test]
499    fn script_and_style_removed() {
500        assert_eq!(
501            clean_html("<script>alert(1)</script><style>.x{}</style><p>ok</p>"),
502            "<p>ok</p>"
503        );
504    }
505
506    #[test]
507    fn id_and_class_preserved() {
508        assert_eq!(
509            clean_html("<h1 id=\"toc\" class=\"title\">x</h1>"),
510            "<h1 id=\"toc\" class=\"title\">x</h1>"
511        );
512        assert_eq!(
513            clean_html("<p id=\"note\" class=\"hint\">x</p>"),
514            "<p id=\"note\" class=\"hint\">x</p>"
515        );
516    }
517
518    #[test]
519    fn javascript_url_stripped() {
520        assert_eq!(
521            clean_html("<a href=\"javascript:alert(1)\">x</a>"),
522            "<a rel=\"noopener noreferrer\">x</a>"
523        );
524    }
525
526    #[test]
527    fn vbscript_url_stripped() {
528        assert_eq!(
529            clean_html("<a href=\"vbscript:msgbox\">x</a>"),
530            "<a rel=\"noopener noreferrer\">x</a>"
531        );
532    }
533
534    #[test]
535    fn unknown_tags_removed_content_kept() {
536        assert_eq!(clean_html("<custom>keep me</custom>"), "keep me");
537    }
538
539    #[test]
540    fn comment_removes_img_details_summary() {
541        assert_eq!(
542            clean_comment_html("<img src=\"x\"><details><summary>sum</summary>body</details>"),
543            "sumbody"
544        );
545    }
546    #[test]
547    fn katex_svg_and_path_preserved() {
548        let katex_html = crate::api::katex::render_display("\\sqrt{\\pi}");
549        let cleaned = clean_html(&katex_html);
550        assert!(
551            cleaned.contains("<svg"),
552            "clean_html should preserve <svg> for KaTeX sqrt"
553        );
554        assert!(
555            cleaned.contains("<path"),
556            "clean_html should preserve <path> for KaTeX sqrt"
557        );
558        assert!(
559            cleaned.contains("d="),
560            "clean_html should preserve d attribute on <path>"
561        );
562        assert!(
563            cleaned.contains("viewBox=") || cleaned.contains("viewbox="),
564            "clean_html should preserve viewBox attribute on <svg>"
565        );
566
567        let comment_cleaned = clean_comment_html(&katex_html);
568        assert!(
569            comment_cleaned.contains("<svg"),
570            "clean_comment_html should preserve <svg>"
571        );
572        assert!(
573            comment_cleaned.contains("<path"),
574            "clean_comment_html should preserve <path>"
575        );
576    }
577
578    #[test]
579    fn comment_removes_data_uris() {
580        assert_eq!(
581            clean_comment_html("<a href=\"data:text/html,hi\">x</a>"),
582            "<a rel=\"nofollow noopener\">x</a>"
583        );
584    }
585
586    // ---- is_safe_url 直接分支测试 ----
587    // is_safe_url 是安全敏感的内部函数,以下测试锁定其各分支的行为契约。
588
589    #[test]
590    fn is_safe_url_allows_https() {
591        let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
592        assert!(is_safe_url("https://example.com", &schemes, false));
593        assert!(is_safe_url("http://example.com", &schemes, false));
594    }
595
596    #[test]
597    fn is_safe_url_rejects_javascript() {
598        let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
599        assert!(!is_safe_url("javascript:alert(1)", &schemes, false));
600    }
601
602    #[test]
603    fn is_safe_url_rejects_vbscript() {
604        let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
605        assert!(!is_safe_url("vbscript:msgbox", &schemes, false));
606    }
607
608    #[test]
609    fn is_safe_url_data_uri_respects_flag_and_media_type() {
610        let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
611        // 仅在显式允许且 media type 为图片时通过
612        assert!(is_safe_url("data:image/png;base64,iVBOR", &schemes, true));
613        assert!(is_safe_url(
614            "data:image/svg+xml;base64,PHN2Zz4=",
615            &schemes,
616            true
617        ));
618        // 禁用 data URI 时拒绝
619        assert!(!is_safe_url("data:image/png;base64,iVBOR", &schemes, false));
620        // 非图片 data URI 拒绝
621        assert!(!is_safe_url(
622            "data:text/html,<script>alert(1)</script>",
623            &schemes,
624            true
625        ));
626        assert!(!is_safe_url(
627            "data:application/javascript,alert(1)",
628            &schemes,
629            true
630        ));
631    }
632
633    #[test]
634    fn is_safe_url_allows_relative_and_fragment() {
635        let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
636        // 绝对路径
637        assert!(is_safe_url("/path/to/page", &schemes, false));
638        // 锚点
639        assert!(is_safe_url("#section", &schemes, false));
640    }
641
642    #[test]
643    fn is_safe_url_empty_is_safe() {
644        let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
645        // 空 URL(如 img 无 src)视为安全。
646        assert!(is_safe_url("", &schemes, false));
647        assert!(is_safe_url("   ", &schemes, false));
648    }
649
650    #[test]
651    fn is_safe_url_allows_other_whitelisted_schemes() {
652        let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
653        // mailto / tel / ftp 等均在默认白名单中。
654        assert!(is_safe_url("mailto:user@example.com", &schemes, false));
655        assert!(is_safe_url("tel:+8613800138000", &schemes, false));
656        assert!(is_safe_url("ftp://example.com/file", &schemes, false));
657    }
658
659    #[test]
660    fn is_safe_url_rejects_scheme_with_whitespace() {
661        let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
662        // 含空格的 scheme 名是已知的混淆手法,应被拒绝。
663        assert!(!is_safe_url("java\tscript:alert(1)", &schemes, false));
664    }
665
666    #[test]
667    fn is_safe_url_rejects_unknown_schemes() {
668        let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
669        // 未知 scheme 默认拒绝。
670        assert!(!is_safe_url("file:///etc/passwd", &schemes, false));
671        assert!(!is_safe_url(
672            "blob:https://example.com/abc",
673            &schemes,
674            false
675        ));
676        assert!(!is_safe_url("about:blank", &schemes, false));
677        assert!(!is_safe_url("custom-app://open", &schemes, false));
678    }
679
680    #[test]
681    fn is_safe_url_scheme_matching_is_case_insensitive() {
682        let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
683        // scheme 大小写不敏感:HTTPS 与 https 等价。
684        assert!(is_safe_url("HTTPS://example.com", &schemes, false));
685        assert!(!is_safe_url("JAVASCRIPT:alert(1)", &schemes, false));
686    }
687
688    // ---- input / 任务列表 checkbox 白名单与 XSS 边界 ----
689
690    #[test]
691    fn clean_html_allows_task_list_checkbox() {
692        // pulldown-cmark 对 - [ ] / - [x] 的实际输出结构
693        let input = r#"<ul>
694<li><input disabled="" type="checkbox"/> 未完成</li>
695<li><input disabled="" type="checkbox" checked=""/> 已完成</li>
696</ul>"#;
697        let result = clean_html(input);
698        // input 标签保留
699        assert!(result.contains("<input"), "input 应保留, got: {result}");
700        // type=checkbox 保留
701        assert!(
702            result.contains(r#"type="checkbox""#),
703            "type=checkbox 应保留, got: {result}"
704        );
705        // checked 保留(体现勾选状态)
706        assert!(
707            result.contains("checked"),
708            "checked 属性应保留, got: {result}"
709        );
710        // disabled 保留
711        assert!(
712            result.contains("disabled"),
713            "disabled 属性应保留, got: {result}"
714        );
715    }
716
717    #[test]
718    fn clean_html_input_rejects_type_image() {
719        // type=image 是已知的 input 滥用面(可配合 src 触发请求),必须整体移除
720        let input =
721            r#"<ul><li><input type="image" src="https://evil.example/x.png">文本</li></ul>"#;
722        let result = clean_html(input);
723        assert!(
724            !result.contains("input"),
725            "type=image 的 input 必须被整体移除, got: {result}"
726        );
727        assert!(
728            !result.contains("evil.example"),
729            "残留的 src 也应随 input 一并移除, got: {result}"
730        );
731        // 文本内容保留
732        assert!(result.contains("文本"));
733    }
734
735    #[test]
736    fn clean_html_input_rejects_type_text() {
737        let result = clean_html(r#"<input type="text">"#);
738        assert!(
739            !result.contains("input"),
740            "type=text 的 input 应被移除, got: {result}"
741        );
742    }
743
744    #[test]
745    fn clean_html_input_without_type_removed() {
746        // 缺省 type 属性的 input(如 <input checked> 经属性过滤后 type 缺省)必须整体移除
747        let result = clean_html("<input checked>");
748        assert!(
749            !result.contains("input"),
750            "无 type 属性的 input 应被整体移除, got: {result}"
751        );
752    }
753
754    #[test]
755    fn clean_comment_html_input_stripped() {
756        // 评论白名单本就不含 input,任务列表 checkbox 在评论侧不放开
757        let result = clean_comment_html(r#"<input type="checkbox" checked>"#);
758        assert!(
759            !result.contains("input"),
760            "评论侧 input 应被剥离, got: {result}"
761        );
762    }
763
764    #[test]
765    fn clean_html_preserves_runnable_pre_data_attrs() {
766        // 可运行代码块标记应完整保留,供阅读器扫描挂载 CodeRunner。
767        let input = r#"<pre data-runnable="true" data-lang="python" data-overrides="{&quot;timeout_secs&quot;:10}"><code class="language-python">print(1)</code></pre>"#;
768        let result = clean_html(input);
769        assert!(
770            result.contains(r#"data-runnable="true""#),
771            "data-runnable 应保留, got: {result}"
772        );
773        assert!(
774            result.contains(r#"data-lang="python""#),
775            "data-lang 应保留, got: {result}"
776        );
777        assert!(
778            result.contains("data-overrides="),
779            "data-overrides 应保留, got: {result}"
780        );
781    }
782
783    #[test]
784    fn clean_html_strips_unknown_data_attrs_on_pre() {
785        // 仅放行白名单的三个 data-* 属性,其它 data-*(如恶意 data-onclick)应被剥离。
786        let input = r#"<pre data-runnable="true" data-evil="x"><code>x</code></pre>"#;
787        let result = clean_html(input);
788        assert!(
789            result.contains("data-runnable"),
790            "白名单 data-runnable 应保留"
791        );
792        assert!(
793            !result.contains("data-evil"),
794            "未知 data-* 应被剥离, got: {result}"
795        );
796    }
797
798    // ---- XSS 攻击向量回归:属性白名单是这里的核心防线 ----
799    // 这些测试锁定"非白名单属性被剥离"这一不变量——若有人放宽属性过滤,
800    // 经典 XSS 向量就会重新可用,测试会在那一步失败。
801
802    #[test]
803    fn clean_html_strips_event_handler_attributes() {
804        // onerror/onload/onclick 等事件处理器属性全不在白名单,必须被移除。
805        // 即便 <img> 本身合法,onerror 也不能留下。
806        let cases = [
807            r#"<img src="x" onerror="alert(1)">"#,
808            r#"<img src=x onerror=alert(1)>"#,
809            r#"<body onload="alert(1)">"#,
810            r#"<div onclick="alert(1)">x</div>"#,
811            r#"<a href="/x" onmouseover="alert(1)">x</a>"#,
812            r#"<svg onload="alert(1)"></svg>"#,
813        ];
814        for input in cases {
815            let result = clean_html(input);
816            assert!(
817                !result.contains("onerror")
818                    && !result.contains("onload")
819                    && !result.contains("onclick")
820                    && !result.contains("onmouseover"),
821                "事件处理器属性应被剥离, input: {input}, got: {result}"
822            );
823        }
824    }
825
826    #[test]
827    fn clean_html_strips_event_handler_attribute_with_mixed_case() {
828        // 大小写混淆绕过尝试:EvEr、大写、混合都应被拦(属性名匹配应大小写不敏感地拒绝)。
829        for attr in ["OnErRoR", "ONERROR", "On_Error".replace('_', "or").as_str()] {
830            let input = format!(r#"<img src="x" {attr}="alert(1)">"#);
831            let result = clean_html(&input);
832            assert!(
833                !result.to_lowercase().contains("onerror"),
834                "大小写混淆的事件处理器应被剥离: {input} -> {result}"
835            );
836        }
837    }
838
839    #[test]
840    fn clean_html_removes_script_tag_and_content() {
841        // script 在 CLEAN_CONTENT_TAGS:标签连同内容一起移除(而非转义后保留)。
842        let result = clean_html("<p>hi</p><script>alert(1)</script><p>bye</p>");
843        assert!(
844            !result.contains("script"),
845            "script 标签应被完全移除: {result}"
846        );
847        assert!(!result.contains("alert"), "script 内容应被清除: {result}");
848        assert!(
849            result.contains("hi") && result.contains("bye"),
850            "周围内容应保留: {result}"
851        );
852    }
853
854    #[test]
855    fn clean_html_removes_style_tag_and_content() {
856        // style 也走 CLEAN_CONTENT_TAGS:CSS 注入(expression()、@import)随内容一起移除。
857        let result = clean_html("<style>body{background:url(javascript:alert(1))}</style><p>x</p>");
858        assert!(!result.contains("style"), "style 标签应被移除: {result}");
859        assert!(
860            !result.contains("javascript"),
861            "style 内危险内容应被清除: {result}"
862        );
863    }
864
865    #[test]
866    fn clean_html_drops_dangerous_tags_entirely() {
867        // 这些标签不在白名单:整体移除(含子树),无法用于 XSS / 数据外泄。
868        for tag in ["iframe", "object", "embed", "form", "math", "base", "meta"] {
869            let input = format!("<{tag} src=\"javascript:alert(1)\"></{tag}>");
870            let result = clean_html(&input);
871            assert!(
872                !result.to_lowercase().contains(&format!("<{tag}")),
873                "<{tag}> 不在白名单应被移除: {result}"
874            );
875        }
876
877        // svg 在白名单中(配合 KaTeX 渲染),但非白名单属性(如 src / onerror / onload)会被剥离
878        let svg_input = r#"<svg src="javascript:alert(1)" onload="alert(2)"></svg>"#;
879        let svg_result = clean_html(svg_input);
880        assert!(
881            !svg_result.contains("javascript"),
882            "svg 上的非白名单属性/javascript 应被剥离: {svg_result}"
883        );
884        assert!(
885            !svg_result.contains("onload"),
886            "svg 上的 onload 事件处理器应被剥离: {svg_result}"
887        );
888    }
889
890    #[test]
891    fn clean_html_drops_javascript_scheme_in_href_and_src() {
892        // 即便标签合法,javascript:/vbscript: scheme 必须被拒(is_safe_url 防线)。
893        let cases = [
894            r#"<a href="javascript:alert(1)">x</a>"#,
895            r#"<a href="vbscript:msgbox(1)">x</a>"#,
896            r#"<img src="javascript:alert(1)">"#,
897            // 编码绕过尝试:HTML 实体编码的 javascript:
898            r#"<a href="&#106;avascript:alert(1)">x</a>"#,
899            r#"<a href="java&#115;cript:alert(1)">x</a>"#,
900        ];
901        for input in cases {
902            let result = clean_html(input);
903            // href/src 值里不应残留可执行的 javascript scheme(属性可能被整段移除或值被清空)。
904            // 至少裸的 "javascript:" 字面量不应在 href/src 属性值中出现。
905            let lower = result.to_lowercase();
906            assert!(
907                !lower.contains("javascript:") && !lower.contains("vbscript:"),
908                "危险 scheme 应被移除, input: {input}, got: {result}"
909            );
910        }
911    }
912
913    #[test]
914    fn clean_html_data_uri_blocked_in_article_body() {
915        // clean_html 配置 allow_data_uri: false——所有 data URI(含 image/png)都应被拒。
916        // 这是文章正文路径的契约;评论路径同样。
917        let result = clean_html(r#"<img src="data:image/png;base64,iVBORw0KGgo=처리">"#);
918        assert!(
919            !result.contains("data:image/png"),
920            "文章正文应禁用 data URI: {result}"
921        );
922    }
923
924    #[test]
925    fn clean_html_data_uri_text_html_blocked_even_if_flag_true() {
926        // 即便 allow_data_uri=true,is_safe_data_uri 也只放行图片类型;
927        // data:text/html(可执行脚本)永远拒绝。直接测内部函数锁定该不变量。
928        let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
929        assert!(!is_safe_url(
930            "data:text/html,<script>alert(1)</script>",
931            &schemes,
932            true
933        ));
934        assert!(!is_safe_url(
935            "data:application/javascript,alert(1)",
936            &schemes,
937            true
938        ));
939        // 安全图片类型在 flag=true 时放行
940        assert!(is_safe_url("data:image/png;base64,iVBOR=", &schemes, true));
941    }
942
943    #[test]
944    fn clean_html_svg_data_uri_carries_risk_even_when_allowed() {
945        // is_safe_data_uri 允许 image/svg+xml——SVG 内可嵌 <script>。
946        // 这里锁定当前行为:flag=true 时 svg data URI 被放行(调用方需自行评估风险),
947        // 并用注释标记这是一个潜在风险点(文章正文 allow_data_uri=false 已堵住)。
948        let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
949        assert!(
950            is_safe_url("data:image/svg+xml,<svg></svg>", &schemes, true),
951            "svg data URI 在 flag=true 时当前被放行(已知风险点)"
952        );
953        // 但文章正文路径 flag=false,svg data URI 同样被拒
954        assert!(!is_safe_url(
955            "data:image/svg+xml,<svg><script>alert(1)</script></svg>",
956            &schemes,
957            false
958        ));
959    }
960
961    #[test]
962    fn clean_comment_html_strips_event_handlers() {
963        // 评论路径(更严格)同样不能漏掉事件处理器。
964        let result = clean_comment_html(r#"<p onclick="alert(1)">x</p>"#);
965        assert!(!result.contains("onclick"), "评论 XSS 向量: {result}");
966    }
967}