yggdrasil/src/api/sanitizer.rs

958 lines
33 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! HTML 消毒器。
//!
//! 基于 lol_html 清理不受信任的 HTML限制允许的 tag/attribute/URL scheme
//! 分别提供文章正文(`clean_html`)与评论(`clean_comment_html`)两套白名单策略。
//! 仅在 `feature = "server"` 时执行。
#![allow(clippy::unused_unit, deprecated)]
#[cfg(feature = "server")]
use std::collections::HashSet;
#[cfg(feature = "server")]
use std::sync::LazyLock;
#[cfg(feature = "server")]
static DEFAULT_ALLOWED_TAGS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
HashSet::from([
"a",
"abbr",
"acronym",
"area",
"article",
"aside",
"b",
"bdi",
"bdo",
"blockquote",
"br",
"caption",
"center",
"cite",
"code",
"col",
"colgroup",
"data",
"dd",
"del",
"details",
"dfn",
"div",
"dl",
"dt",
"em",
"figcaption",
"figure",
"footer",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"header",
"hgroup",
"hr",
"i",
"img",
"ins",
// input 仅用于 pulldown-cmark 任务列表渲染的 checkbox;
// element_handler 会强制校验 type="checkbox",其余 type 一律整体移除。
"input",
"kbd",
"li",
"map",
"mark",
"nav",
"ol",
"p",
"pre",
"q",
"rp",
"rt",
"rtc",
"ruby",
"s",
"samp",
"section",
"small",
"span",
"strike",
"strong",
"sub",
"summary",
"sup",
// svg / path 仅用于 KaTeX 服务端数学公式渲染 (根号、矩阵竖线、大括号、矢量箭头等);
// 属性仅放行 ViewBox / d 等绘图属性script/style 标签由 CLEAN_CONTENT_TAGS 强行清除。
"svg",
"path",
"table",
"tbody",
"td",
"th",
"thead",
"time",
"tr",
"tt",
"u",
"ul",
"var",
"wbr",
])
});
#[cfg(feature = "server")]
static CLEAN_CONTENT_TAGS: LazyLock<HashSet<&'static str>> =
LazyLock::new(|| HashSet::from(["script", "style"]));
#[cfg(feature = "server")]
static DEFAULT_ALLOWED_SCHEMES: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
HashSet::from([
"bitcoin",
"ftp",
"ftps",
"geo",
"http",
"https",
"im",
"irc",
"ircs",
"magnet",
"mailto",
"mms",
"mx",
"news",
"nntp",
"openpgp4fpr",
"sip",
"sms",
"smsto",
"ssh",
"tel",
"url",
"webcal",
"wtai",
"xmpp",
])
});
#[cfg(feature = "server")]
/// 评论允许的标签:在默认集合基础上移除 img / details / summary。
static COMMENT_ALLOWED_TAGS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
let mut set = DEFAULT_ALLOWED_TAGS.clone();
set.remove("img");
set.remove("details");
set.remove("summary");
set
});
#[cfg(feature = "server")]
fn is_safe_data_uri(url: &str) -> bool {
// data URI 只允许安全的图片类型;禁止 data:text/html、data:application/javascript 等。
let url = url.trim();
let Some(rest) = url.strip_prefix("data:") else {
return false;
};
let media_type = rest.split(',').next().unwrap_or("");
let media_type = media_type.split(';').next().unwrap_or("").trim();
matches!(
media_type.to_lowercase().as_str(),
"image/png"
| "image/jpeg"
| "image/jpg"
| "image/gif"
| "image/webp"
| "image/avif"
| "image/bmp"
| "image/tiff"
| "image/svg+xml"
)
}
#[cfg(feature = "server")]
fn is_safe_url(url: &str, allowed_schemes: &HashSet<&str>, allow_data_uri: bool) -> bool {
let trimmed = url.trim();
if trimmed.is_empty() {
return true;
}
// 锚点(#开头优先放行fragment 内的冒号不是 scheme 分隔符
// (如脚注锚点 #fn:label不应被下面的 scheme 解析误判。
// 仍显式拒绝 javascript:/vbscript:(虽以 # 开头不可能,但保持防御一致)。
if trimmed.starts_with('#') {
return true;
}
// 解析 scheme 并与白名单对比;未知 scheme 默认拒绝。
if let Some(colon_pos) = trimmed.find(':') {
let scheme = &trimmed[..colon_pos];
let scheme_lower = scheme.to_lowercase();
if scheme_lower == "javascript" || scheme_lower == "vbscript" {
return false;
}
if scheme.contains(|c: char| c.is_ascii_whitespace()) {
return false;
}
if allowed_schemes.contains(scheme_lower.as_str()) {
return true;
}
if scheme_lower == "data" {
return allow_data_uri && is_safe_data_uri(trimmed);
}
// 任何其它 scheme 均拒绝file://、blob://、about:blank 等。
return false;
}
// 无 scheme 时只允许相对路径。
trimmed.starts_with('/')
}
#[cfg(feature = "server")]
/// HTML 消毒配置:白名单 tag/attribute、允许 URL scheme 与链接 rel。
struct SanitizerConfig {
allowed_tags: &'static HashSet<&'static str>,
extra_generic_attrs: Vec<&'static str>,
extra_tag_attrs: Vec<(&'static str, Vec<&'static str>)>,
allowed_schemes: &'static HashSet<&'static str>,
allow_data_uri: bool,
link_rel: Option<&'static str>,
remove_tags: &'static HashSet<&'static str>,
}
#[cfg(feature = "server")]
fn sanitize(input: &str, config: &SanitizerConfig) -> String {
let allowed_tags = config.allowed_tags;
let remove_tags = config.remove_tags;
let generic_attrs: HashSet<&str> = config
.extra_generic_attrs
.iter()
.copied()
.chain(["lang", "title"])
.collect();
let tag_attrs_map: std::collections::HashMap<&str, HashSet<&str>> = {
let mut m = std::collections::HashMap::new();
let base = [
("a", vec!["href", "hreflang"]),
("bdo", vec!["dir"]),
("blockquote", vec!["cite"]),
("col", vec!["align", "char", "charoff", "span"]),
("colgroup", vec!["align", "char", "charoff", "span"]),
("del", vec!["cite", "datetime"]),
("hr", vec!["align", "size", "width"]),
("img", vec!["align", "alt", "height", "src", "width"]),
("ins", vec!["cite", "datetime"]),
("ol", vec!["start"]),
("q", vec!["cite"]),
("table", vec!["align", "char", "charoff", "summary"]),
("tbody", vec!["align", "char", "charoff"]),
(
"td",
vec!["align", "char", "charoff", "colspan", "headers", "rowspan"],
),
("tfoot", vec!["align", "char", "charoff"]),
(
"th",
vec![
"align", "char", "charoff", "colspan", "headers", "rowspan", "scope",
],
),
("thead", vec!["align", "char", "charoff"]),
("tr", vec!["align", "char", "charoff"]),
];
for (tag, attrs) in &base {
m.insert(*tag, attrs.iter().copied().collect());
}
for (tag, attrs) in &config.extra_tag_attrs {
m.entry(tag)
.or_insert_with(HashSet::new)
.extend(attrs.iter().copied());
}
m
};
let allowed_schemes = config.allowed_schemes;
let allow_data_uri = config.allow_data_uri;
let link_rel = config.link_rel;
let element_handler = move |el: &mut lol_html::html_content::Element| {
let tag = el.tag_name().to_lowercase();
if remove_tags.contains(tag.as_str()) {
el.remove();
return Ok(());
}
if !allowed_tags.contains(tag.as_str()) {
el.remove_and_keep_content();
return Ok(());
}
let allowed_for_tag: HashSet<&str> = {
let mut s = generic_attrs.clone();
if let Some(tag_specific) = tag_attrs_map.get(tag.as_str()) {
s.extend(tag_specific.iter().copied());
}
s
};
let attrs_to_remove: Vec<String> = el
.attributes()
.iter()
.filter_map(|attr| {
let name = attr.name();
let name_lower = name.to_lowercase();
// 仅保留白名单属性;对 href/src/cite 额外校验 URL 安全性。
if allowed_for_tag.contains(name_lower.as_str()) {
if name_lower == "href" || name_lower == "src" || name_lower == "cite" {
let val = attr.value();
if !is_safe_url(&val, allowed_schemes, allow_data_uri) {
return Some(name);
}
}
// input 的 type 必须是 checkbox其余取值image/text/...)一概删除;
// 缺失 type 的 input 由下面的兜底逻辑整标签移除。
if tag == "input"
&& name_lower == "type"
&& attr.value().trim().to_lowercase() != "checkbox"
{
return Some(name);
}
None
} else {
Some(name)
}
})
.collect();
for attr_name in attrs_to_remove {
el.remove_attribute(&attr_name);
}
if link_rel.is_some() && tag == "a" {
if let Some(rel) = link_rel {
let existing = el.get_attribute("rel").unwrap_or_default();
if existing != rel {
el.set_attribute("rel", rel).ok();
}
}
}
// input 兜底:属性白名单 + type 值校验后,仍可能残留「无 type 属性」的 input。
// (例如 <input checked> 经属性过滤后 type 被删或缺省。)这种 input 整体移除——
// 它是 void 元素无文本内容remove() 不丢正文,且能彻底封堵缺省 type 的滥用。
if tag == "input" {
let type_ok = el
.get_attribute("type")
.map(|v| v.trim().to_lowercase() == "checkbox")
.unwrap_or(false);
if !type_ok {
el.remove();
return Ok(());
}
}
Ok(())
};
let settings = lol_html::RewriteStrSettings::new()
.append_element_content_handler(lol_html::element!("*", element_handler))
.append_document_content_handler(lol_html::doc_comments!(|c| {
c.remove();
Ok(())
}));
lol_html::rewrite_str(input, settings).unwrap_or_default()
}
#[cfg(feature = "server")]
/// 文章正文 HTML 清理:允许较完整的标签与 data URI外链添加 `noopener noreferrer`。
pub fn clean_html(input: &str) -> String {
let config = SanitizerConfig {
allowed_tags: &DEFAULT_ALLOWED_TAGS,
extra_generic_attrs: vec![
"class",
"aria-hidden",
"aria-label",
"aria-labelledby",
"id",
"role",
"accesskey",
"title",
],
extra_tag_attrs: vec![
("a", vec!["class", "aria-hidden", "aria-label"]),
("img", vec!["data-src", "class", "style"]),
// input 仅放行 checkbox 必备属性;type 的具体取值由 element_handler 强校验为 checkbox。
("input", vec!["type", "checked", "disabled"]),
// pre 上的可运行代码块标记data-runnable / data-lang / data-overrides / data-source。
// data-overrides / data-source 是 markdown 渲染时 HTML 转义后的内容(见 markdown.rs不含未转义引号。
(
"pre",
vec![
"data-runnable",
"data-lang",
"data-overrides",
"data-source",
],
),
("span", vec!["class", "style"]),
// KaTeX 数学公式 SSR 渲染生成的 SVG 矢量图 (根号 / 矩阵竖线 / 括号 / 箭头等) 必备属性
(
"svg",
vec![
"xmlns",
"width",
"height",
"viewbox",
"preserveaspectratio",
"style",
],
),
("path", vec!["d"]),
("h1", vec!["id", "class"]),
("h2", vec!["id", "class"]),
("h3", vec!["id", "class"]),
("h4", vec!["id", "class"]),
("h5", vec!["id", "class"]),
("h6", vec!["id", "class"]),
],
allowed_schemes: &DEFAULT_ALLOWED_SCHEMES,
allow_data_uri: false,
link_rel: Some("noopener noreferrer"),
remove_tags: &CLEAN_CONTENT_TAGS,
};
sanitize(input, &config)
}
#[cfg(feature = "server")]
/// 评论 HTML 清理:移除图片与折叠块,禁用 data URI外链添加 `nofollow noopener`。
pub fn clean_comment_html(input: &str) -> String {
let config = SanitizerConfig {
allowed_tags: &COMMENT_ALLOWED_TAGS,
extra_generic_attrs: vec![
"class",
"title",
"aria-hidden",
"aria-label",
"role",
"accesskey",
],
extra_tag_attrs: vec![
("a", vec!["class", "aria-hidden", "aria-label"]),
// span 的 styleKaTeX 服务端渲染产出的内联 style元素垂直对齐/定位)
// 需保留否则公式排版错位。与文章正文路径sanitizer.rs:382对齐。
("span", vec!["class", "style"]),
// KaTeX 数学公式 SSR 渲染生成的 SVG 矢量图 (根号 / 矩阵竖线 / 括号 / 箭头等) 必备属性
(
"svg",
vec![
"xmlns",
"width",
"height",
"viewbox",
"preserveaspectratio",
"style",
],
),
("path", vec!["d"]),
],
allowed_schemes: &DEFAULT_ALLOWED_SCHEMES,
allow_data_uri: false,
link_rel: Some("nofollow noopener"),
remove_tags: &CLEAN_CONTENT_TAGS,
};
sanitize(input, &config)
}
#[cfg(all(test, feature = "server"))]
mod tests {
use super::*;
#[test]
fn clean_html_allows_blur_img_attributes() {
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>"#;
let result = clean_html(input);
assert!(result.contains("data-src"), "data-src should be allowed");
assert!(
result.contains("blur-img-placeholder"),
"class should be allowed"
);
assert!(result.contains("--ar"), "style should be allowed");
}
#[test]
fn safe_tags_preserved() {
assert_eq!(clean_html("<p>safe</p>"), "<p>safe</p>");
assert_eq!(
clean_html("<p><strong>bold</strong></p>"),
"<p><strong>bold</strong></p>"
);
}
#[test]
fn script_and_style_removed() {
assert_eq!(
clean_html("<script>alert(1)</script><style>.x{}</style><p>ok</p>"),
"<p>ok</p>"
);
}
#[test]
fn id_and_class_preserved() {
assert_eq!(
clean_html("<h1 id=\"toc\" class=\"title\">x</h1>"),
"<h1 id=\"toc\" class=\"title\">x</h1>"
);
assert_eq!(
clean_html("<p id=\"note\" class=\"hint\">x</p>"),
"<p id=\"note\" class=\"hint\">x</p>"
);
}
#[test]
fn javascript_url_stripped() {
assert_eq!(
clean_html("<a href=\"javascript:alert(1)\">x</a>"),
"<a rel=\"noopener noreferrer\">x</a>"
);
}
#[test]
fn vbscript_url_stripped() {
assert_eq!(
clean_html("<a href=\"vbscript:msgbox\">x</a>"),
"<a rel=\"noopener noreferrer\">x</a>"
);
}
#[test]
fn unknown_tags_removed_content_kept() {
assert_eq!(clean_html("<custom>keep me</custom>"), "keep me");
}
#[test]
fn comment_removes_img_details_summary() {
assert_eq!(
clean_comment_html("<img src=\"x\"><details><summary>sum</summary>body</details>"),
"sumbody"
);
}
#[test]
fn katex_svg_and_path_preserved() {
let katex_html = crate::api::katex::render_display("\\sqrt{\\pi}");
let cleaned = clean_html(&katex_html);
assert!(
cleaned.contains("<svg"),
"clean_html should preserve <svg> for KaTeX sqrt"
);
assert!(
cleaned.contains("<path"),
"clean_html should preserve <path> for KaTeX sqrt"
);
assert!(
cleaned.contains("d="),
"clean_html should preserve d attribute on <path>"
);
assert!(
cleaned.contains("viewBox=") || cleaned.contains("viewbox="),
"clean_html should preserve viewBox attribute on <svg>"
);
let comment_cleaned = clean_comment_html(&katex_html);
assert!(
comment_cleaned.contains("<svg"),
"clean_comment_html should preserve <svg>"
);
assert!(
comment_cleaned.contains("<path"),
"clean_comment_html should preserve <path>"
);
}
#[test]
fn comment_removes_data_uris() {
assert_eq!(
clean_comment_html("<a href=\"data:text/html,hi\">x</a>"),
"<a rel=\"nofollow noopener\">x</a>"
);
}
// ---- is_safe_url 直接分支测试 ----
// is_safe_url 是安全敏感的内部函数,以下测试锁定其各分支的行为契约。
#[test]
fn is_safe_url_allows_https() {
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
assert!(is_safe_url("https://example.com", &schemes, false));
assert!(is_safe_url("http://example.com", &schemes, false));
}
#[test]
fn is_safe_url_rejects_javascript() {
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
assert!(!is_safe_url("javascript:alert(1)", &schemes, false));
}
#[test]
fn is_safe_url_rejects_vbscript() {
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
assert!(!is_safe_url("vbscript:msgbox", &schemes, false));
}
#[test]
fn is_safe_url_data_uri_respects_flag_and_media_type() {
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
// 仅在显式允许且 media type 为图片时通过
assert!(is_safe_url("data:image/png;base64,iVBOR", &schemes, true));
assert!(is_safe_url(
"data:image/svg+xml;base64,PHN2Zz4=",
&schemes,
true
));
// 禁用 data URI 时拒绝
assert!(!is_safe_url("data:image/png;base64,iVBOR", &schemes, false));
// 非图片 data URI 拒绝
assert!(!is_safe_url(
"data:text/html,<script>alert(1)</script>",
&schemes,
true
));
assert!(!is_safe_url(
"data:application/javascript,alert(1)",
&schemes,
true
));
}
#[test]
fn is_safe_url_allows_relative_and_fragment() {
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
// 绝对路径
assert!(is_safe_url("/path/to/page", &schemes, false));
// 锚点
assert!(is_safe_url("#section", &schemes, false));
}
#[test]
fn is_safe_url_empty_is_safe() {
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
// 空 URL如 img 无 src视为安全。
assert!(is_safe_url("", &schemes, false));
assert!(is_safe_url(" ", &schemes, false));
}
#[test]
fn is_safe_url_allows_other_whitelisted_schemes() {
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
// mailto / tel / ftp 等均在默认白名单中。
assert!(is_safe_url("mailto:user@example.com", &schemes, false));
assert!(is_safe_url("tel:+8613800138000", &schemes, false));
assert!(is_safe_url("ftp://example.com/file", &schemes, false));
}
#[test]
fn is_safe_url_rejects_scheme_with_whitespace() {
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
// 含空格的 scheme 名是已知的混淆手法,应被拒绝。
assert!(!is_safe_url("java\tscript:alert(1)", &schemes, false));
}
#[test]
fn is_safe_url_rejects_unknown_schemes() {
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
// 未知 scheme 默认拒绝。
assert!(!is_safe_url("file:///etc/passwd", &schemes, false));
assert!(!is_safe_url(
"blob:https://example.com/abc",
&schemes,
false
));
assert!(!is_safe_url("about:blank", &schemes, false));
assert!(!is_safe_url("custom-app://open", &schemes, false));
}
#[test]
fn is_safe_url_scheme_matching_is_case_insensitive() {
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
// scheme 大小写不敏感HTTPS 与 https 等价。
assert!(is_safe_url("HTTPS://example.com", &schemes, false));
assert!(!is_safe_url("JAVASCRIPT:alert(1)", &schemes, false));
}
// ---- input / 任务列表 checkbox 白名单与 XSS 边界 ----
#[test]
fn clean_html_allows_task_list_checkbox() {
// pulldown-cmark 对 - [ ] / - [x] 的实际输出结构
let input = r#"<ul>
<li><input disabled="" type="checkbox"/> 未完成</li>
<li><input disabled="" type="checkbox" checked=""/> 已完成</li>
</ul>"#;
let result = clean_html(input);
// input 标签保留
assert!(result.contains("<input"), "input 应保留, got: {result}");
// type=checkbox 保留
assert!(
result.contains(r#"type="checkbox""#),
"type=checkbox 应保留, got: {result}"
);
// checked 保留(体现勾选状态)
assert!(
result.contains("checked"),
"checked 属性应保留, got: {result}"
);
// disabled 保留
assert!(
result.contains("disabled"),
"disabled 属性应保留, got: {result}"
);
}
#[test]
fn clean_html_input_rejects_type_image() {
// type=image 是已知的 input 滥用面(可配合 src 触发请求),必须整体移除
let input =
r#"<ul><li><input type="image" src="https://evil.example/x.png">文本</li></ul>"#;
let result = clean_html(input);
assert!(
!result.contains("input"),
"type=image 的 input 必须被整体移除, got: {result}"
);
assert!(
!result.contains("evil.example"),
"残留的 src 也应随 input 一并移除, got: {result}"
);
// 文本内容保留
assert!(result.contains("文本"));
}
#[test]
fn clean_html_input_rejects_type_text() {
let result = clean_html(r#"<input type="text">"#);
assert!(
!result.contains("input"),
"type=text 的 input 应被移除, got: {result}"
);
}
#[test]
fn clean_html_input_without_type_removed() {
// 缺省 type 属性的 input如 <input checked> 经属性过滤后 type 缺省)必须整体移除
let result = clean_html("<input checked>");
assert!(
!result.contains("input"),
"无 type 属性的 input 应被整体移除, got: {result}"
);
}
#[test]
fn clean_comment_html_input_stripped() {
// 评论白名单本就不含 input任务列表 checkbox 在评论侧不放开
let result = clean_comment_html(r#"<input type="checkbox" checked>"#);
assert!(
!result.contains("input"),
"评论侧 input 应被剥离, got: {result}"
);
}
#[test]
fn clean_html_preserves_runnable_pre_data_attrs() {
// 可运行代码块标记应完整保留,供阅读器扫描挂载 CodeRunner。
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>"#;
let result = clean_html(input);
assert!(
result.contains(r#"data-runnable="true""#),
"data-runnable 应保留, got: {result}"
);
assert!(
result.contains(r#"data-lang="python""#),
"data-lang 应保留, got: {result}"
);
assert!(
result.contains("data-overrides="),
"data-overrides 应保留, got: {result}"
);
}
#[test]
fn clean_html_strips_unknown_data_attrs_on_pre() {
// 仅放行白名单的三个 data-* 属性,其它 data-*(如恶意 data-onclick应被剥离。
let input = r#"<pre data-runnable="true" data-evil="x"><code>x</code></pre>"#;
let result = clean_html(input);
assert!(
result.contains("data-runnable"),
"白名单 data-runnable 应保留"
);
assert!(
!result.contains("data-evil"),
"未知 data-* 应被剥离, got: {result}"
);
}
// ---- XSS 攻击向量回归:属性白名单是这里的核心防线 ----
// 这些测试锁定"非白名单属性被剥离"这一不变量——若有人放宽属性过滤,
// 经典 XSS 向量就会重新可用,测试会在那一步失败。
#[test]
fn clean_html_strips_event_handler_attributes() {
// onerror/onload/onclick 等事件处理器属性全不在白名单,必须被移除。
// 即便 <img> 本身合法onerror 也不能留下。
let cases = [
r#"<img src="x" onerror="alert(1)">"#,
r#"<img src=x onerror=alert(1)>"#,
r#"<body onload="alert(1)">"#,
r#"<div onclick="alert(1)">x</div>"#,
r#"<a href="/x" onmouseover="alert(1)">x</a>"#,
r#"<svg onload="alert(1)"></svg>"#,
];
for input in cases {
let result = clean_html(input);
assert!(
!result.contains("onerror")
&& !result.contains("onload")
&& !result.contains("onclick")
&& !result.contains("onmouseover"),
"事件处理器属性应被剥离, input: {input}, got: {result}"
);
}
}
#[test]
fn clean_html_strips_event_handler_attribute_with_mixed_case() {
// 大小写混淆绕过尝试EvEr、大写、混合都应被拦属性名匹配应大小写不敏感地拒绝
for attr in ["OnErRoR", "ONERROR", "On_Error".replace('_', "or").as_str()] {
let input = format!(r#"<img src="x" {attr}="alert(1)">"#);
let result = clean_html(&input);
assert!(
!result.to_lowercase().contains("onerror"),
"大小写混淆的事件处理器应被剥离: {input} -> {result}"
);
}
}
#[test]
fn clean_html_removes_script_tag_and_content() {
// script 在 CLEAN_CONTENT_TAGS标签连同内容一起移除而非转义后保留
let result = clean_html("<p>hi</p><script>alert(1)</script><p>bye</p>");
assert!(
!result.contains("script"),
"script 标签应被完全移除: {result}"
);
assert!(!result.contains("alert"), "script 内容应被清除: {result}");
assert!(
result.contains("hi") && result.contains("bye"),
"周围内容应保留: {result}"
);
}
#[test]
fn clean_html_removes_style_tag_and_content() {
// style 也走 CLEAN_CONTENT_TAGSCSS 注入expression()、@import随内容一起移除。
let result = clean_html("<style>body{background:url(javascript:alert(1))}</style><p>x</p>");
assert!(!result.contains("style"), "style 标签应被移除: {result}");
assert!(
!result.contains("javascript"),
"style 内危险内容应被清除: {result}"
);
}
#[test]
fn clean_html_drops_dangerous_tags_entirely() {
// 这些标签不在白名单:整体移除(含子树),无法用于 XSS / 数据外泄。
for tag in ["iframe", "object", "embed", "form", "math", "base", "meta"] {
let input = format!("<{tag} src=\"javascript:alert(1)\"></{tag}>");
let result = clean_html(&input);
assert!(
!result.to_lowercase().contains(&format!("<{tag}")),
"<{tag}> 不在白名单应被移除: {result}"
);
}
// svg 在白名单中(配合 KaTeX 渲染),但非白名单属性(如 src / onerror / onload会被剥离
let svg_input = r#"<svg src="javascript:alert(1)" onload="alert(2)"></svg>"#;
let svg_result = clean_html(svg_input);
assert!(
!svg_result.contains("javascript"),
"svg 上的非白名单属性/javascript 应被剥离: {svg_result}"
);
assert!(
!svg_result.contains("onload"),
"svg 上的 onload 事件处理器应被剥离: {svg_result}"
);
}
#[test]
fn clean_html_drops_javascript_scheme_in_href_and_src() {
// 即便标签合法javascript:/vbscript: scheme 必须被拒is_safe_url 防线)。
let cases = [
r#"<a href="javascript:alert(1)">x</a>"#,
r#"<a href="vbscript:msgbox(1)">x</a>"#,
r#"<img src="javascript:alert(1)">"#,
// 编码绕过尝试HTML 实体编码的 javascript:
r#"<a href="&#106;avascript:alert(1)">x</a>"#,
r#"<a href="java&#115;cript:alert(1)">x</a>"#,
];
for input in cases {
let result = clean_html(input);
// href/src 值里不应残留可执行的 javascript scheme属性可能被整段移除或值被清空
// 至少裸的 "javascript:" 字面量不应在 href/src 属性值中出现。
let lower = result.to_lowercase();
assert!(
!lower.contains("javascript:") && !lower.contains("vbscript:"),
"危险 scheme 应被移除, input: {input}, got: {result}"
);
}
}
#[test]
fn clean_html_data_uri_blocked_in_article_body() {
// clean_html 配置 allow_data_uri: false——所有 data URI含 image/png都应被拒。
// 这是文章正文路径的契约;评论路径同样。
let result = clean_html(r#"<img src="data:image/png;base64,iVBORw0KGgo=처리">"#);
assert!(
!result.contains("data:image/png"),
"文章正文应禁用 data URI: {result}"
);
}
#[test]
fn clean_html_data_uri_text_html_blocked_even_if_flag_true() {
// 即便 allow_data_uri=trueis_safe_data_uri 也只放行图片类型;
// data:text/html可执行脚本永远拒绝。直接测内部函数锁定该不变量。
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
assert!(!is_safe_url(
"data:text/html,<script>alert(1)</script>",
&schemes,
true
));
assert!(!is_safe_url(
"data:application/javascript,alert(1)",
&schemes,
true
));
// 安全图片类型在 flag=true 时放行
assert!(is_safe_url("data:image/png;base64,iVBOR=", &schemes, true));
}
#[test]
fn clean_html_svg_data_uri_carries_risk_even_when_allowed() {
// is_safe_data_uri 允许 image/svg+xml——SVG 内可嵌 <script>。
// 这里锁定当前行为flag=true 时 svg data URI 被放行(调用方需自行评估风险),
// 并用注释标记这是一个潜在风险点(文章正文 allow_data_uri=false 已堵住)。
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
assert!(
is_safe_url("data:image/svg+xml,<svg></svg>", &schemes, true),
"svg data URI 在 flag=true 时当前被放行(已知风险点)"
);
// 但文章正文路径 flag=falsesvg data URI 同样被拒
assert!(!is_safe_url(
"data:image/svg+xml,<svg><script>alert(1)</script></svg>",
&schemes,
false
));
}
#[test]
fn clean_comment_html_strips_event_handlers() {
// 评论路径(更严格)同样不能漏掉事件处理器。
let result = clean_comment_html(r#"<p onclick="alert(1)">x</p>"#);
assert!(!result.contains("onclick"), "评论 XSS 向量: {result}");
}
}