1#![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",
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")]
215type TagAttrs = HashMap<&'static str, HashSet<&'static str>>;
216
217#[cfg(feature = "server")]
218static 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")]
254struct 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 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 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 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 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 tracing::error!(error = ?e, input_len = input.len(), "HTML 清理失败,回退空串会丢弃正文");
397 String::new()
398 })
399}
400
401#[cfg(feature = "server")]
402static 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", &["type", "checked", "disabled"]),
421 (
424 "pre",
425 &[
426 "data-runnable",
427 "data-lang",
428 "data-overrides",
429 "data-source",
430 ],
431 ),
432 ("span", &["class", "style"]),
433 (
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")]
458static 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", &["class", "style"]),
475 (
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")]
494pub fn clean_html(input: &str) -> String {
496 sanitize(input, &ARTICLE_CONFIG)
497}
498
499#[cfg(feature = "server")]
500pub 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 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 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 #[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 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 assert!(!is_safe_url("data:image/png;base64,iVBOR", &schemes, false));
688 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 assert!(is_safe_url("/path/to/page", &schemes, false));
706 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 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 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 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 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 assert!(is_safe_url("HTTPS://example.com", &schemes, false));
753 assert!(!is_safe_url("JAVASCRIPT:alert(1)", &schemes, false));
754 }
755
756 #[test]
759 fn clean_html_allows_task_list_checkbox() {
760 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 assert!(result.contains("<input"), "input 应保留, got: {result}");
768 assert!(
770 result.contains(r#"type="checkbox""#),
771 "type=checkbox 应保留, got: {result}"
772 );
773 assert!(
775 result.contains("checked"),
776 "checked 属性应保留, got: {result}"
777 );
778 assert!(
780 result.contains("disabled"),
781 "disabled 属性应保留, got: {result}"
782 );
783 }
784
785 #[test]
786 fn clean_html_input_rejects_type_image() {
787 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 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 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 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 let input = r#"<pre data-runnable="true" data-lang="python" data-overrides="{"timeout_secs":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 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 #[test]
871 fn clean_html_strips_event_handler_attributes() {
872 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 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 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 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 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 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 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 r#"<a href="javascript:alert(1)">x</a>"#,
967 r#"<a href="javascript:alert(1)">x</a>"#,
968 ];
969 for input in cases {
970 let result = clean_html(input);
971 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 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 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 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 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 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 let result = clean_comment_html(r#"<p onclick="alert(1)">x</p>"#);
1033 assert!(!result.contains("onclick"), "评论 XSS 向量: {result}");
1034 }
1035}