958 lines
33 KiB
Rust
958 lines
33 KiB
Rust
//! 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 的 style:KaTeX 服务端渲染产出的内联 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="{"timeout_secs":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_TAGS:CSS 注入(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="javascript:alert(1)">x</a>"#,
|
||
r#"<a href="javascript: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=true,is_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=false,svg 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}");
|
||
}
|
||
}
|