1#![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",
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",
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")]
140static 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")]
151fn is_safe_data_uri(url: &str) -> bool {
158 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 if trimmed.starts_with('#') {
189 return true;
190 }
191 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 return false;
209 }
210 trimmed.starts_with('/')
212}
213
214#[cfg(feature = "server")]
215struct 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 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 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 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 tracing::error!(error = ?e, input_len = input.len(), "HTML 清理失败,回退空串会丢弃正文");
366 String::new()
367 })
368}
369
370#[cfg(feature = "server")]
371pub 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", vec!["type", "checked", "disabled"]),
390 (
393 "pre",
394 vec![
395 "data-runnable",
396 "data-lang",
397 "data-overrides",
398 "data-source",
399 ],
400 ),
401 ("span", vec!["class", "style"]),
402 (
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")]
431pub 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", vec!["class", "style"]),
448 (
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 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 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 #[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 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 assert!(!is_safe_url("data:image/png;base64,iVBOR", &schemes, false));
653 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 assert!(is_safe_url("/path/to/page", &schemes, false));
671 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 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 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 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 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 assert!(is_safe_url("HTTPS://example.com", &schemes, false));
718 assert!(!is_safe_url("JAVASCRIPT:alert(1)", &schemes, false));
719 }
720
721 #[test]
724 fn clean_html_allows_task_list_checkbox() {
725 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 assert!(result.contains("<input"), "input 应保留, got: {result}");
733 assert!(
735 result.contains(r#"type="checkbox""#),
736 "type=checkbox 应保留, got: {result}"
737 );
738 assert!(
740 result.contains("checked"),
741 "checked 属性应保留, got: {result}"
742 );
743 assert!(
745 result.contains("disabled"),
746 "disabled 属性应保留, got: {result}"
747 );
748 }
749
750 #[test]
751 fn clean_html_input_rejects_type_image() {
752 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 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 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 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 let input = r#"<pre data-runnable="true" data-lang="python" data-overrides="{"timeout_secs":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 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 #[test]
836 fn clean_html_strips_event_handler_attributes() {
837 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 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 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 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 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 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 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 r#"<a href="javascript:alert(1)">x</a>"#,
932 r#"<a href="javascript:alert(1)">x</a>"#,
933 ];
934 for input in cases {
935 let result = clean_html(input);
936 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 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 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 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 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 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 let result = clean_comment_html(r#"<p onclick="alert(1)">x</p>"#);
998 assert!(!result.contains("onclick"), "评论 XSS 向量: {result}");
999 }
1000}