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(|| {
142 let mut set = DEFAULT_ALLOWED_TAGS.clone();
143 set.remove("img");
144 set.remove("details");
145 set.remove("summary");
146 set
147});
148
149#[cfg(feature = "server")]
150fn is_safe_data_uri(url: &str) -> bool {
157 let url = url.trim();
159 let Some(rest) = url.strip_prefix("data:") else {
160 return false;
161 };
162 let media_type = rest.split(',').next().unwrap_or("");
163 let media_type = media_type.split(';').next().unwrap_or("").trim();
164 matches!(
165 media_type.to_lowercase().as_str(),
166 "image/png"
167 | "image/jpeg"
168 | "image/jpg"
169 | "image/gif"
170 | "image/webp"
171 | "image/avif"
172 | "image/bmp"
173 | "image/tiff"
174 | "image/svg+xml"
175 )
176}
177
178#[cfg(feature = "server")]
179fn is_safe_url(url: &str, allowed_schemes: &HashSet<&str>, allow_data_uri: bool) -> bool {
180 let trimmed = url.trim();
181 if trimmed.is_empty() {
182 return true;
183 }
184 if trimmed.starts_with('#') {
188 return true;
189 }
190 if let Some(colon_pos) = trimmed.find(':') {
192 let scheme = &trimmed[..colon_pos];
193 let scheme_lower = scheme.to_lowercase();
194 if scheme_lower == "javascript" || scheme_lower == "vbscript" {
195 return false;
196 }
197 if scheme.contains(|c: char| c.is_ascii_whitespace()) {
198 return false;
199 }
200 if allowed_schemes.contains(scheme_lower.as_str()) {
201 return true;
202 }
203 if scheme_lower == "data" {
204 return allow_data_uri && is_safe_data_uri(trimmed);
205 }
206 return false;
208 }
209 trimmed.starts_with('/')
211}
212
213#[cfg(feature = "server")]
214struct SanitizerConfig {
216 allowed_tags: &'static HashSet<&'static str>,
217 extra_generic_attrs: Vec<&'static str>,
218 extra_tag_attrs: Vec<(&'static str, Vec<&'static str>)>,
219 allowed_schemes: &'static HashSet<&'static str>,
220 allow_data_uri: bool,
221 link_rel: Option<&'static str>,
222 remove_tags: &'static HashSet<&'static str>,
223}
224
225#[cfg(feature = "server")]
226fn sanitize(input: &str, config: &SanitizerConfig) -> String {
227 let allowed_tags = config.allowed_tags;
228 let remove_tags = config.remove_tags;
229 let generic_attrs: HashSet<&str> = config
230 .extra_generic_attrs
231 .iter()
232 .copied()
233 .chain(["lang", "title"])
234 .collect();
235 let tag_attrs_map: std::collections::HashMap<&str, HashSet<&str>> = {
236 let mut m = std::collections::HashMap::new();
237 let base = [
238 ("a", vec!["href", "hreflang"]),
239 ("bdo", vec!["dir"]),
240 ("blockquote", vec!["cite"]),
241 ("col", vec!["align", "char", "charoff", "span"]),
242 ("colgroup", vec!["align", "char", "charoff", "span"]),
243 ("del", vec!["cite", "datetime"]),
244 ("hr", vec!["align", "size", "width"]),
245 ("img", vec!["align", "alt", "height", "src", "width"]),
246 ("ins", vec!["cite", "datetime"]),
247 ("ol", vec!["start"]),
248 ("q", vec!["cite"]),
249 ("table", vec!["align", "char", "charoff", "summary"]),
250 ("tbody", vec!["align", "char", "charoff"]),
251 (
252 "td",
253 vec!["align", "char", "charoff", "colspan", "headers", "rowspan"],
254 ),
255 ("tfoot", vec!["align", "char", "charoff"]),
256 (
257 "th",
258 vec![
259 "align", "char", "charoff", "colspan", "headers", "rowspan", "scope",
260 ],
261 ),
262 ("thead", vec!["align", "char", "charoff"]),
263 ("tr", vec!["align", "char", "charoff"]),
264 ];
265 for (tag, attrs) in &base {
266 m.insert(*tag, attrs.iter().copied().collect());
267 }
268 for (tag, attrs) in &config.extra_tag_attrs {
269 m.entry(tag)
270 .or_insert_with(HashSet::new)
271 .extend(attrs.iter().copied());
272 }
273 m
274 };
275 let allowed_schemes = config.allowed_schemes;
276 let allow_data_uri = config.allow_data_uri;
277 let link_rel = config.link_rel;
278
279 let element_handler = move |el: &mut lol_html::html_content::Element| {
280 let tag = el.tag_name().to_lowercase();
281
282 if remove_tags.contains(tag.as_str()) {
283 el.remove();
284 return Ok(());
285 }
286
287 if !allowed_tags.contains(tag.as_str()) {
288 el.remove_and_keep_content();
289 return Ok(());
290 }
291
292 let allowed_for_tag: HashSet<&str> = {
293 let mut s = generic_attrs.clone();
294 if let Some(tag_specific) = tag_attrs_map.get(tag.as_str()) {
295 s.extend(tag_specific.iter().copied());
296 }
297 s
298 };
299
300 let attrs_to_remove: Vec<String> = el
301 .attributes()
302 .iter()
303 .filter_map(|attr| {
304 let name = attr.name();
305 let name_lower = name.to_lowercase();
306 if allowed_for_tag.contains(name_lower.as_str()) {
308 if name_lower == "href" || name_lower == "src" || name_lower == "cite" {
309 let val = attr.value();
310 if !is_safe_url(&val, allowed_schemes, allow_data_uri) {
311 return Some(name);
312 }
313 }
314 if tag == "input"
317 && name_lower == "type"
318 && attr.value().trim().to_lowercase() != "checkbox"
319 {
320 return Some(name);
321 }
322 None
323 } else {
324 Some(name)
325 }
326 })
327 .collect();
328
329 for attr_name in attrs_to_remove {
330 el.remove_attribute(&attr_name);
331 }
332
333 if link_rel.is_some() && tag == "a" {
334 if let Some(rel) = link_rel {
335 let existing = el.get_attribute("rel").unwrap_or_default();
336 if existing != rel {
337 el.set_attribute("rel", rel).ok();
338 }
339 }
340 }
341
342 if tag == "input" {
346 let type_ok = el
347 .get_attribute("type")
348 .map(|v| v.trim().to_lowercase() == "checkbox")
349 .unwrap_or(false);
350 if !type_ok {
351 el.remove();
352 return Ok(());
353 }
354 }
355
356 Ok(())
357 };
358
359 let settings = lol_html::RewriteStrSettings::new()
360 .append_element_content_handler(lol_html::element!("*", element_handler))
361 .append_document_content_handler(lol_html::doc_comments!(|c| {
362 c.remove();
363 Ok(())
364 }));
365
366 lol_html::rewrite_str(input, settings).unwrap_or_else(|e| {
367 tracing::error!(error = ?e, input_len = input.len(), "HTML 清理失败,回退空串会丢弃正文");
369 String::new()
370 })
371}
372
373#[cfg(feature = "server")]
374pub fn clean_html(input: &str) -> String {
376 let config = SanitizerConfig {
377 allowed_tags: &DEFAULT_ALLOWED_TAGS,
378 extra_generic_attrs: vec![
379 "class",
380 "aria-hidden",
381 "aria-label",
382 "aria-labelledby",
383 "id",
384 "role",
385 "accesskey",
386 "title",
387 ],
388 extra_tag_attrs: vec![
389 ("a", vec!["class", "aria-hidden", "aria-label"]),
390 ("img", vec!["data-src", "class", "style"]),
391 ("input", vec!["type", "checked", "disabled"]),
393 (
396 "pre",
397 vec![
398 "data-runnable",
399 "data-lang",
400 "data-overrides",
401 "data-source",
402 ],
403 ),
404 ("span", vec!["class", "style"]),
405 (
407 "svg",
408 vec![
409 "xmlns",
410 "width",
411 "height",
412 "viewbox",
413 "preserveaspectratio",
414 "style",
415 ],
416 ),
417 ("path", vec!["d"]),
418 ("h1", vec!["id", "class"]),
419 ("h2", vec!["id", "class"]),
420 ("h3", vec!["id", "class"]),
421 ("h4", vec!["id", "class"]),
422 ("h5", vec!["id", "class"]),
423 ("h6", vec!["id", "class"]),
424 ],
425 allowed_schemes: &DEFAULT_ALLOWED_SCHEMES,
426 allow_data_uri: false,
427 link_rel: Some("noopener noreferrer"),
428 remove_tags: &CLEAN_CONTENT_TAGS,
429 };
430 sanitize(input, &config)
431}
432
433#[cfg(feature = "server")]
434pub fn clean_comment_html(input: &str) -> String {
436 let config = SanitizerConfig {
437 allowed_tags: &COMMENT_ALLOWED_TAGS,
438 extra_generic_attrs: vec![
439 "class",
440 "title",
441 "aria-hidden",
442 "aria-label",
443 "role",
444 "accesskey",
445 ],
446 extra_tag_attrs: vec![
447 ("a", vec!["class", "aria-hidden", "aria-label"]),
448 ("span", vec!["class", "style"]),
451 (
453 "svg",
454 vec![
455 "xmlns",
456 "width",
457 "height",
458 "viewbox",
459 "preserveaspectratio",
460 "style",
461 ],
462 ),
463 ("path", vec!["d"]),
464 ],
465 allowed_schemes: &DEFAULT_ALLOWED_SCHEMES,
466 allow_data_uri: false,
467 link_rel: Some("nofollow noopener"),
468 remove_tags: &CLEAN_CONTENT_TAGS,
469 };
470 sanitize(input, &config)
471}
472
473#[cfg(all(test, feature = "server"))]
474mod tests {
475 use super::*;
476
477 #[test]
478 fn clean_html_allows_blur_img_attributes() {
479 let input = r#"<span class="blur-img" style="--ar:16/9"><img class="blur-img-placeholder" src="/uploads/x.webp?w=20" alt="t"><img class="blur-img-full" data-src="/uploads/x.webp?w=800" alt="t"></span>"#;
480 let result = clean_html(input);
481 assert!(result.contains("data-src"), "data-src should be allowed");
482 assert!(
483 result.contains("blur-img-placeholder"),
484 "class should be allowed"
485 );
486 assert!(result.contains("--ar"), "style should be allowed");
487 }
488
489 #[test]
490 fn safe_tags_preserved() {
491 assert_eq!(clean_html("<p>safe</p>"), "<p>safe</p>");
492 assert_eq!(
493 clean_html("<p><strong>bold</strong></p>"),
494 "<p><strong>bold</strong></p>"
495 );
496 }
497
498 #[test]
499 fn script_and_style_removed() {
500 assert_eq!(
501 clean_html("<script>alert(1)</script><style>.x{}</style><p>ok</p>"),
502 "<p>ok</p>"
503 );
504 }
505
506 #[test]
507 fn id_and_class_preserved() {
508 assert_eq!(
509 clean_html("<h1 id=\"toc\" class=\"title\">x</h1>"),
510 "<h1 id=\"toc\" class=\"title\">x</h1>"
511 );
512 assert_eq!(
513 clean_html("<p id=\"note\" class=\"hint\">x</p>"),
514 "<p id=\"note\" class=\"hint\">x</p>"
515 );
516 }
517
518 #[test]
519 fn javascript_url_stripped() {
520 assert_eq!(
521 clean_html("<a href=\"javascript:alert(1)\">x</a>"),
522 "<a rel=\"noopener noreferrer\">x</a>"
523 );
524 }
525
526 #[test]
527 fn vbscript_url_stripped() {
528 assert_eq!(
529 clean_html("<a href=\"vbscript:msgbox\">x</a>"),
530 "<a rel=\"noopener noreferrer\">x</a>"
531 );
532 }
533
534 #[test]
535 fn unknown_tags_removed_content_kept() {
536 assert_eq!(clean_html("<custom>keep me</custom>"), "keep me");
537 }
538
539 #[test]
540 fn comment_removes_img_details_summary() {
541 assert_eq!(
542 clean_comment_html("<img src=\"x\"><details><summary>sum</summary>body</details>"),
543 "sumbody"
544 );
545 }
546 #[test]
547 fn katex_svg_and_path_preserved() {
548 let katex_html = crate::api::katex::render_display("\\sqrt{\\pi}");
549 let cleaned = clean_html(&katex_html);
550 assert!(
551 cleaned.contains("<svg"),
552 "clean_html should preserve <svg> for KaTeX sqrt"
553 );
554 assert!(
555 cleaned.contains("<path"),
556 "clean_html should preserve <path> for KaTeX sqrt"
557 );
558 assert!(
559 cleaned.contains("d="),
560 "clean_html should preserve d attribute on <path>"
561 );
562 assert!(
563 cleaned.contains("viewBox=") || cleaned.contains("viewbox="),
564 "clean_html should preserve viewBox attribute on <svg>"
565 );
566
567 let comment_cleaned = clean_comment_html(&katex_html);
568 assert!(
569 comment_cleaned.contains("<svg"),
570 "clean_comment_html should preserve <svg>"
571 );
572 assert!(
573 comment_cleaned.contains("<path"),
574 "clean_comment_html should preserve <path>"
575 );
576 }
577
578 #[test]
579 fn comment_removes_data_uris() {
580 assert_eq!(
581 clean_comment_html("<a href=\"data:text/html,hi\">x</a>"),
582 "<a rel=\"nofollow noopener\">x</a>"
583 );
584 }
585
586 #[test]
590 fn is_safe_url_allows_https() {
591 let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
592 assert!(is_safe_url("https://example.com", &schemes, false));
593 assert!(is_safe_url("http://example.com", &schemes, false));
594 }
595
596 #[test]
597 fn is_safe_url_rejects_javascript() {
598 let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
599 assert!(!is_safe_url("javascript:alert(1)", &schemes, false));
600 }
601
602 #[test]
603 fn is_safe_url_rejects_vbscript() {
604 let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
605 assert!(!is_safe_url("vbscript:msgbox", &schemes, false));
606 }
607
608 #[test]
609 fn is_safe_url_data_uri_respects_flag_and_media_type() {
610 let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
611 assert!(is_safe_url("data:image/png;base64,iVBOR", &schemes, true));
613 assert!(is_safe_url(
614 "data:image/svg+xml;base64,PHN2Zz4=",
615 &schemes,
616 true
617 ));
618 assert!(!is_safe_url("data:image/png;base64,iVBOR", &schemes, false));
620 assert!(!is_safe_url(
622 "data:text/html,<script>alert(1)</script>",
623 &schemes,
624 true
625 ));
626 assert!(!is_safe_url(
627 "data:application/javascript,alert(1)",
628 &schemes,
629 true
630 ));
631 }
632
633 #[test]
634 fn is_safe_url_allows_relative_and_fragment() {
635 let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
636 assert!(is_safe_url("/path/to/page", &schemes, false));
638 assert!(is_safe_url("#section", &schemes, false));
640 }
641
642 #[test]
643 fn is_safe_url_empty_is_safe() {
644 let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
645 assert!(is_safe_url("", &schemes, false));
647 assert!(is_safe_url(" ", &schemes, false));
648 }
649
650 #[test]
651 fn is_safe_url_allows_other_whitelisted_schemes() {
652 let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
653 assert!(is_safe_url("mailto:user@example.com", &schemes, false));
655 assert!(is_safe_url("tel:+8613800138000", &schemes, false));
656 assert!(is_safe_url("ftp://example.com/file", &schemes, false));
657 }
658
659 #[test]
660 fn is_safe_url_rejects_scheme_with_whitespace() {
661 let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
662 assert!(!is_safe_url("java\tscript:alert(1)", &schemes, false));
664 }
665
666 #[test]
667 fn is_safe_url_rejects_unknown_schemes() {
668 let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
669 assert!(!is_safe_url("file:///etc/passwd", &schemes, false));
671 assert!(!is_safe_url(
672 "blob:https://example.com/abc",
673 &schemes,
674 false
675 ));
676 assert!(!is_safe_url("about:blank", &schemes, false));
677 assert!(!is_safe_url("custom-app://open", &schemes, false));
678 }
679
680 #[test]
681 fn is_safe_url_scheme_matching_is_case_insensitive() {
682 let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
683 assert!(is_safe_url("HTTPS://example.com", &schemes, false));
685 assert!(!is_safe_url("JAVASCRIPT:alert(1)", &schemes, false));
686 }
687
688 #[test]
691 fn clean_html_allows_task_list_checkbox() {
692 let input = r#"<ul>
694<li><input disabled="" type="checkbox"/> 未完成</li>
695<li><input disabled="" type="checkbox" checked=""/> 已完成</li>
696</ul>"#;
697 let result = clean_html(input);
698 assert!(result.contains("<input"), "input 应保留, got: {result}");
700 assert!(
702 result.contains(r#"type="checkbox""#),
703 "type=checkbox 应保留, got: {result}"
704 );
705 assert!(
707 result.contains("checked"),
708 "checked 属性应保留, got: {result}"
709 );
710 assert!(
712 result.contains("disabled"),
713 "disabled 属性应保留, got: {result}"
714 );
715 }
716
717 #[test]
718 fn clean_html_input_rejects_type_image() {
719 let input =
721 r#"<ul><li><input type="image" src="https://evil.example/x.png">文本</li></ul>"#;
722 let result = clean_html(input);
723 assert!(
724 !result.contains("input"),
725 "type=image 的 input 必须被整体移除, got: {result}"
726 );
727 assert!(
728 !result.contains("evil.example"),
729 "残留的 src 也应随 input 一并移除, got: {result}"
730 );
731 assert!(result.contains("文本"));
733 }
734
735 #[test]
736 fn clean_html_input_rejects_type_text() {
737 let result = clean_html(r#"<input type="text">"#);
738 assert!(
739 !result.contains("input"),
740 "type=text 的 input 应被移除, got: {result}"
741 );
742 }
743
744 #[test]
745 fn clean_html_input_without_type_removed() {
746 let result = clean_html("<input checked>");
748 assert!(
749 !result.contains("input"),
750 "无 type 属性的 input 应被整体移除, got: {result}"
751 );
752 }
753
754 #[test]
755 fn clean_comment_html_input_stripped() {
756 let result = clean_comment_html(r#"<input type="checkbox" checked>"#);
758 assert!(
759 !result.contains("input"),
760 "评论侧 input 应被剥离, got: {result}"
761 );
762 }
763
764 #[test]
765 fn clean_html_preserves_runnable_pre_data_attrs() {
766 let input = r#"<pre data-runnable="true" data-lang="python" data-overrides="{"timeout_secs":10}"><code class="language-python">print(1)</code></pre>"#;
768 let result = clean_html(input);
769 assert!(
770 result.contains(r#"data-runnable="true""#),
771 "data-runnable 应保留, got: {result}"
772 );
773 assert!(
774 result.contains(r#"data-lang="python""#),
775 "data-lang 应保留, got: {result}"
776 );
777 assert!(
778 result.contains("data-overrides="),
779 "data-overrides 应保留, got: {result}"
780 );
781 }
782
783 #[test]
784 fn clean_html_strips_unknown_data_attrs_on_pre() {
785 let input = r#"<pre data-runnable="true" data-evil="x"><code>x</code></pre>"#;
787 let result = clean_html(input);
788 assert!(
789 result.contains("data-runnable"),
790 "白名单 data-runnable 应保留"
791 );
792 assert!(
793 !result.contains("data-evil"),
794 "未知 data-* 应被剥离, got: {result}"
795 );
796 }
797
798 #[test]
803 fn clean_html_strips_event_handler_attributes() {
804 let cases = [
807 r#"<img src="x" onerror="alert(1)">"#,
808 r#"<img src=x onerror=alert(1)>"#,
809 r#"<body onload="alert(1)">"#,
810 r#"<div onclick="alert(1)">x</div>"#,
811 r#"<a href="/x" onmouseover="alert(1)">x</a>"#,
812 r#"<svg onload="alert(1)"></svg>"#,
813 ];
814 for input in cases {
815 let result = clean_html(input);
816 assert!(
817 !result.contains("onerror")
818 && !result.contains("onload")
819 && !result.contains("onclick")
820 && !result.contains("onmouseover"),
821 "事件处理器属性应被剥离, input: {input}, got: {result}"
822 );
823 }
824 }
825
826 #[test]
827 fn clean_html_strips_event_handler_attribute_with_mixed_case() {
828 for attr in ["OnErRoR", "ONERROR", "On_Error".replace('_', "or").as_str()] {
830 let input = format!(r#"<img src="x" {attr}="alert(1)">"#);
831 let result = clean_html(&input);
832 assert!(
833 !result.to_lowercase().contains("onerror"),
834 "大小写混淆的事件处理器应被剥离: {input} -> {result}"
835 );
836 }
837 }
838
839 #[test]
840 fn clean_html_removes_script_tag_and_content() {
841 let result = clean_html("<p>hi</p><script>alert(1)</script><p>bye</p>");
843 assert!(
844 !result.contains("script"),
845 "script 标签应被完全移除: {result}"
846 );
847 assert!(!result.contains("alert"), "script 内容应被清除: {result}");
848 assert!(
849 result.contains("hi") && result.contains("bye"),
850 "周围内容应保留: {result}"
851 );
852 }
853
854 #[test]
855 fn clean_html_removes_style_tag_and_content() {
856 let result = clean_html("<style>body{background:url(javascript:alert(1))}</style><p>x</p>");
858 assert!(!result.contains("style"), "style 标签应被移除: {result}");
859 assert!(
860 !result.contains("javascript"),
861 "style 内危险内容应被清除: {result}"
862 );
863 }
864
865 #[test]
866 fn clean_html_drops_dangerous_tags_entirely() {
867 for tag in ["iframe", "object", "embed", "form", "math", "base", "meta"] {
869 let input = format!("<{tag} src=\"javascript:alert(1)\"></{tag}>");
870 let result = clean_html(&input);
871 assert!(
872 !result.to_lowercase().contains(&format!("<{tag}")),
873 "<{tag}> 不在白名单应被移除: {result}"
874 );
875 }
876
877 let svg_input = r#"<svg src="javascript:alert(1)" onload="alert(2)"></svg>"#;
879 let svg_result = clean_html(svg_input);
880 assert!(
881 !svg_result.contains("javascript"),
882 "svg 上的非白名单属性/javascript 应被剥离: {svg_result}"
883 );
884 assert!(
885 !svg_result.contains("onload"),
886 "svg 上的 onload 事件处理器应被剥离: {svg_result}"
887 );
888 }
889
890 #[test]
891 fn clean_html_drops_javascript_scheme_in_href_and_src() {
892 let cases = [
894 r#"<a href="javascript:alert(1)">x</a>"#,
895 r#"<a href="vbscript:msgbox(1)">x</a>"#,
896 r#"<img src="javascript:alert(1)">"#,
897 r#"<a href="javascript:alert(1)">x</a>"#,
899 r#"<a href="javascript:alert(1)">x</a>"#,
900 ];
901 for input in cases {
902 let result = clean_html(input);
903 let lower = result.to_lowercase();
906 assert!(
907 !lower.contains("javascript:") && !lower.contains("vbscript:"),
908 "危险 scheme 应被移除, input: {input}, got: {result}"
909 );
910 }
911 }
912
913 #[test]
914 fn clean_html_data_uri_blocked_in_article_body() {
915 let result = clean_html(r#"<img src="data:image/png;base64,iVBORw0KGgo=처리">"#);
918 assert!(
919 !result.contains("data:image/png"),
920 "文章正文应禁用 data URI: {result}"
921 );
922 }
923
924 #[test]
925 fn clean_html_data_uri_text_html_blocked_even_if_flag_true() {
926 let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
929 assert!(!is_safe_url(
930 "data:text/html,<script>alert(1)</script>",
931 &schemes,
932 true
933 ));
934 assert!(!is_safe_url(
935 "data:application/javascript,alert(1)",
936 &schemes,
937 true
938 ));
939 assert!(is_safe_url("data:image/png;base64,iVBOR=", &schemes, true));
941 }
942
943 #[test]
944 fn clean_html_svg_data_uri_carries_risk_even_when_allowed() {
945 let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
949 assert!(
950 is_safe_url("data:image/svg+xml,<svg></svg>", &schemes, true),
951 "svg data URI 在 flag=true 时当前被放行(已知风险点)"
952 );
953 assert!(!is_safe_url(
955 "data:image/svg+xml,<svg><script>alert(1)</script></svg>",
956 &schemes,
957 false
958 ));
959 }
960
961 #[test]
962 fn clean_comment_html_strips_event_handlers() {
963 let result = clean_comment_html(r#"<p onclick="alert(1)">x</p>"#);
965 assert!(!result.contains("onclick"), "评论 XSS 向量: {result}");
966 }
967}