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