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