perf(sanitizer): staticize allowlists with LazyLock to avoid per-call allocation

default_allowed_tags / clean_content_tags / default_allowed_schemes
原本每次调用都新建 HashSet 并逐个 insert;sanitize() 还 clone 一份。
改为 LazyLock 静态集合,SanitizerConfig 直接持有 &'static 引用,
评论白名单 COMMENT_ALLOWED_TAGS 在默认集合上派生。18 个 sanitizer
测试全部通过,XSS/URL 过滤行为不变。
This commit is contained in:
xfy 2026-06-18 11:23:30 +08:00
parent 753525fb41
commit 3d187382cc

View File

@ -10,9 +10,11 @@
use std::collections::HashSet;
#[cfg(feature = "server")]
fn default_allowed_tags() -> HashSet<&'static str> {
let mut set = HashSet::new();
for tag in [
use std::sync::LazyLock;
#[cfg(feature = "server")]
static DEFAULT_ALLOWED_TAGS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
HashSet::from([
"a",
"abbr",
"acronym",
@ -88,24 +90,16 @@ fn default_allowed_tags() -> HashSet<&'static str> {
"ul",
"var",
"wbr",
] {
set.insert(tag);
}
set
}
])
});
#[cfg(feature = "server")]
fn clean_content_tags() -> HashSet<&'static str> {
let mut set = HashSet::new();
set.insert("script");
set.insert("style");
set
}
static CLEAN_CONTENT_TAGS: LazyLock<HashSet<&'static str>> =
LazyLock::new(|| HashSet::from(["script", "style"]));
#[cfg(feature = "server")]
fn default_allowed_schemes() -> HashSet<&'static str> {
let mut set = HashSet::new();
for scheme in [
static DEFAULT_ALLOWED_SCHEMES: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
HashSet::from([
"bitcoin",
"ftp",
"ftps",
@ -131,11 +125,18 @@ fn default_allowed_schemes() -> HashSet<&'static str> {
"webcal",
"wtai",
"xmpp",
] {
set.insert(scheme);
}
])
});
#[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 {
@ -192,19 +193,19 @@ fn is_safe_url(url: &str, allowed_schemes: &HashSet<&str>, allow_data_uri: bool)
#[cfg(feature = "server")]
/// HTML 消毒配置:白名单 tag/attribute、允许 URL scheme 与链接 rel。
struct SanitizerConfig {
allowed_tags: HashSet<&'static str>,
allowed_tags: &'static HashSet<&'static str>,
extra_generic_attrs: Vec<&'static str>,
extra_tag_attrs: Vec<(&'static str, Vec<&'static str>)>,
allowed_schemes: HashSet<&'static str>,
allowed_schemes: &'static HashSet<&'static str>,
allow_data_uri: bool,
link_rel: Option<&'static str>,
remove_tags: HashSet<&'static str>,
remove_tags: &'static HashSet<&'static str>,
}
#[cfg(feature = "server")]
fn sanitize(input: &str, config: &SanitizerConfig) -> String {
let allowed_tags = config.allowed_tags.clone();
let remove_tags = config.remove_tags.clone();
let allowed_tags = config.allowed_tags;
let remove_tags = config.remove_tags;
let generic_attrs: HashSet<&str> = config
.extra_generic_attrs
.iter()
@ -251,7 +252,7 @@ fn sanitize(input: &str, config: &SanitizerConfig) -> String {
}
m
};
let allowed_schemes = config.allowed_schemes.clone();
let allowed_schemes = config.allowed_schemes;
let allow_data_uri = config.allow_data_uri;
let link_rel = config.link_rel;
@ -286,7 +287,7 @@ fn sanitize(input: &str, config: &SanitizerConfig) -> String {
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) {
if !is_safe_url(&val, allowed_schemes, allow_data_uri) {
return Some(name);
}
}
@ -331,7 +332,7 @@ fn sanitize(input: &str, config: &SanitizerConfig) -> String {
/// 文章正文 HTML 清理:允许较完整的标签与 data URI外链添加 `noopener noreferrer`。
pub fn clean_html(input: &str) -> String {
let config = SanitizerConfig {
allowed_tags: default_allowed_tags(),
allowed_tags: &DEFAULT_ALLOWED_TAGS,
extra_generic_attrs: vec![
"class",
"aria-hidden",
@ -351,10 +352,10 @@ pub fn clean_html(input: &str) -> String {
("h5", vec!["id", "class"]),
("h6", vec!["id", "class"]),
],
allowed_schemes: default_allowed_schemes(),
allowed_schemes: &DEFAULT_ALLOWED_SCHEMES,
allow_data_uri: false,
link_rel: Some("noopener noreferrer"),
remove_tags: clean_content_tags(),
remove_tags: &CLEAN_CONTENT_TAGS,
};
sanitize(input, &config)
}
@ -362,13 +363,8 @@ pub fn clean_html(input: &str) -> String {
#[cfg(feature = "server")]
/// 评论 HTML 清理:移除图片与折叠块,禁用 data URI外链添加 `nofollow noopener`。
pub fn clean_comment_html(input: &str) -> String {
let mut tags = default_allowed_tags();
tags.remove("img");
tags.remove("details");
tags.remove("summary");
let config = SanitizerConfig {
allowed_tags: tags,
allowed_tags: &COMMENT_ALLOWED_TAGS,
extra_generic_attrs: vec![
"class",
"title",
@ -381,10 +377,10 @@ pub fn clean_comment_html(input: &str) -> String {
("a", vec!["class", "aria-hidden", "aria-label"]),
("span", vec!["class"]),
],
allowed_schemes: default_allowed_schemes(),
allowed_schemes: &DEFAULT_ALLOWED_SCHEMES,
allow_data_uri: false,
link_rel: Some("nofollow noopener"),
remove_tags: clean_content_tags(),
remove_tags: &CLEAN_CONTENT_TAGS,
};
sanitize(input, &config)
}
@ -464,26 +460,26 @@ mod tests {
#[test]
fn is_safe_url_allows_https() {
let schemes = default_allowed_schemes();
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();
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();
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();
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));
@ -496,7 +492,7 @@ mod tests {
#[test]
fn is_safe_url_allows_relative_and_fragment() {
let schemes = default_allowed_schemes();
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
// 绝对路径
assert!(is_safe_url("/path/to/page", &schemes, false));
// 锚点
@ -505,7 +501,7 @@ mod tests {
#[test]
fn is_safe_url_empty_is_safe() {
let schemes = default_allowed_schemes();
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
// 空 URL如 img 无 src视为安全。
assert!(is_safe_url("", &schemes, false));
assert!(is_safe_url(" ", &schemes, false));
@ -513,7 +509,7 @@ mod tests {
#[test]
fn is_safe_url_allows_other_whitelisted_schemes() {
let schemes = default_allowed_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));
@ -522,14 +518,14 @@ mod tests {
#[test]
fn is_safe_url_rejects_scheme_with_whitespace() {
let schemes = default_allowed_schemes();
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();
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));
@ -539,7 +535,7 @@ mod tests {
#[test]
fn is_safe_url_scheme_matching_is_case_insensitive() {
let schemes = default_allowed_schemes();
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));