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:
parent
753525fb41
commit
3d187382cc
@ -10,9 +10,11 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
fn default_allowed_tags() -> HashSet<&'static str> {
|
use std::sync::LazyLock;
|
||||||
let mut set = HashSet::new();
|
|
||||||
for tag in [
|
#[cfg(feature = "server")]
|
||||||
|
static DEFAULT_ALLOWED_TAGS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
|
||||||
|
HashSet::from([
|
||||||
"a",
|
"a",
|
||||||
"abbr",
|
"abbr",
|
||||||
"acronym",
|
"acronym",
|
||||||
@ -88,24 +90,16 @@ fn default_allowed_tags() -> HashSet<&'static str> {
|
|||||||
"ul",
|
"ul",
|
||||||
"var",
|
"var",
|
||||||
"wbr",
|
"wbr",
|
||||||
] {
|
])
|
||||||
set.insert(tag);
|
});
|
||||||
}
|
|
||||||
set
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
fn clean_content_tags() -> HashSet<&'static str> {
|
static CLEAN_CONTENT_TAGS: LazyLock<HashSet<&'static str>> =
|
||||||
let mut set = HashSet::new();
|
LazyLock::new(|| HashSet::from(["script", "style"]));
|
||||||
set.insert("script");
|
|
||||||
set.insert("style");
|
|
||||||
set
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
fn default_allowed_schemes() -> HashSet<&'static str> {
|
static DEFAULT_ALLOWED_SCHEMES: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
|
||||||
let mut set = HashSet::new();
|
HashSet::from([
|
||||||
for scheme in [
|
|
||||||
"bitcoin",
|
"bitcoin",
|
||||||
"ftp",
|
"ftp",
|
||||||
"ftps",
|
"ftps",
|
||||||
@ -131,11 +125,18 @@ fn default_allowed_schemes() -> HashSet<&'static str> {
|
|||||||
"webcal",
|
"webcal",
|
||||||
"wtai",
|
"wtai",
|
||||||
"xmpp",
|
"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
|
set
|
||||||
}
|
});
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
fn is_safe_data_uri(url: &str) -> bool {
|
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")]
|
#[cfg(feature = "server")]
|
||||||
/// HTML 消毒配置:白名单 tag/attribute、允许 URL scheme 与链接 rel。
|
/// HTML 消毒配置:白名单 tag/attribute、允许 URL scheme 与链接 rel。
|
||||||
struct SanitizerConfig {
|
struct SanitizerConfig {
|
||||||
allowed_tags: HashSet<&'static str>,
|
allowed_tags: &'static HashSet<&'static str>,
|
||||||
extra_generic_attrs: Vec<&'static str>,
|
extra_generic_attrs: Vec<&'static str>,
|
||||||
extra_tag_attrs: Vec<(&'static str, 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,
|
allow_data_uri: bool,
|
||||||
link_rel: Option<&'static str>,
|
link_rel: Option<&'static str>,
|
||||||
remove_tags: HashSet<&'static str>,
|
remove_tags: &'static HashSet<&'static str>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
fn sanitize(input: &str, config: &SanitizerConfig) -> String {
|
fn sanitize(input: &str, config: &SanitizerConfig) -> String {
|
||||||
let allowed_tags = config.allowed_tags.clone();
|
let allowed_tags = config.allowed_tags;
|
||||||
let remove_tags = config.remove_tags.clone();
|
let remove_tags = config.remove_tags;
|
||||||
let generic_attrs: HashSet<&str> = config
|
let generic_attrs: HashSet<&str> = config
|
||||||
.extra_generic_attrs
|
.extra_generic_attrs
|
||||||
.iter()
|
.iter()
|
||||||
@ -251,7 +252,7 @@ fn sanitize(input: &str, config: &SanitizerConfig) -> String {
|
|||||||
}
|
}
|
||||||
m
|
m
|
||||||
};
|
};
|
||||||
let allowed_schemes = config.allowed_schemes.clone();
|
let allowed_schemes = config.allowed_schemes;
|
||||||
let allow_data_uri = config.allow_data_uri;
|
let allow_data_uri = config.allow_data_uri;
|
||||||
let link_rel = config.link_rel;
|
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 allowed_for_tag.contains(name_lower.as_str()) {
|
||||||
if name_lower == "href" || name_lower == "src" || name_lower == "cite" {
|
if name_lower == "href" || name_lower == "src" || name_lower == "cite" {
|
||||||
let val = attr.value();
|
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);
|
return Some(name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -331,7 +332,7 @@ fn sanitize(input: &str, config: &SanitizerConfig) -> String {
|
|||||||
/// 文章正文 HTML 清理:允许较完整的标签与 data URI,外链添加 `noopener noreferrer`。
|
/// 文章正文 HTML 清理:允许较完整的标签与 data URI,外链添加 `noopener noreferrer`。
|
||||||
pub fn clean_html(input: &str) -> String {
|
pub fn clean_html(input: &str) -> String {
|
||||||
let config = SanitizerConfig {
|
let config = SanitizerConfig {
|
||||||
allowed_tags: default_allowed_tags(),
|
allowed_tags: &DEFAULT_ALLOWED_TAGS,
|
||||||
extra_generic_attrs: vec![
|
extra_generic_attrs: vec![
|
||||||
"class",
|
"class",
|
||||||
"aria-hidden",
|
"aria-hidden",
|
||||||
@ -351,10 +352,10 @@ pub fn clean_html(input: &str) -> String {
|
|||||||
("h5", vec!["id", "class"]),
|
("h5", vec!["id", "class"]),
|
||||||
("h6", vec!["id", "class"]),
|
("h6", vec!["id", "class"]),
|
||||||
],
|
],
|
||||||
allowed_schemes: default_allowed_schemes(),
|
allowed_schemes: &DEFAULT_ALLOWED_SCHEMES,
|
||||||
allow_data_uri: false,
|
allow_data_uri: false,
|
||||||
link_rel: Some("noopener noreferrer"),
|
link_rel: Some("noopener noreferrer"),
|
||||||
remove_tags: clean_content_tags(),
|
remove_tags: &CLEAN_CONTENT_TAGS,
|
||||||
};
|
};
|
||||||
sanitize(input, &config)
|
sanitize(input, &config)
|
||||||
}
|
}
|
||||||
@ -362,13 +363,8 @@ pub fn clean_html(input: &str) -> String {
|
|||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
/// 评论 HTML 清理:移除图片与折叠块,禁用 data URI,外链添加 `nofollow noopener`。
|
/// 评论 HTML 清理:移除图片与折叠块,禁用 data URI,外链添加 `nofollow noopener`。
|
||||||
pub fn clean_comment_html(input: &str) -> String {
|
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 {
|
let config = SanitizerConfig {
|
||||||
allowed_tags: tags,
|
allowed_tags: &COMMENT_ALLOWED_TAGS,
|
||||||
extra_generic_attrs: vec![
|
extra_generic_attrs: vec![
|
||||||
"class",
|
"class",
|
||||||
"title",
|
"title",
|
||||||
@ -381,10 +377,10 @@ pub fn clean_comment_html(input: &str) -> String {
|
|||||||
("a", vec!["class", "aria-hidden", "aria-label"]),
|
("a", vec!["class", "aria-hidden", "aria-label"]),
|
||||||
("span", vec!["class"]),
|
("span", vec!["class"]),
|
||||||
],
|
],
|
||||||
allowed_schemes: default_allowed_schemes(),
|
allowed_schemes: &DEFAULT_ALLOWED_SCHEMES,
|
||||||
allow_data_uri: false,
|
allow_data_uri: false,
|
||||||
link_rel: Some("nofollow noopener"),
|
link_rel: Some("nofollow noopener"),
|
||||||
remove_tags: clean_content_tags(),
|
remove_tags: &CLEAN_CONTENT_TAGS,
|
||||||
};
|
};
|
||||||
sanitize(input, &config)
|
sanitize(input, &config)
|
||||||
}
|
}
|
||||||
@ -464,26 +460,26 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn is_safe_url_allows_https() {
|
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("https://example.com", &schemes, false));
|
||||||
assert!(is_safe_url("http://example.com", &schemes, false));
|
assert!(is_safe_url("http://example.com", &schemes, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn is_safe_url_rejects_javascript() {
|
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));
|
assert!(!is_safe_url("javascript:alert(1)", &schemes, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn is_safe_url_rejects_vbscript() {
|
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));
|
assert!(!is_safe_url("vbscript:msgbox", &schemes, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn is_safe_url_data_uri_respects_flag_and_media_type() {
|
fn is_safe_url_data_uri_respects_flag_and_media_type() {
|
||||||
let schemes = default_allowed_schemes();
|
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
|
||||||
// 仅在显式允许且 media type 为图片时通过
|
// 仅在显式允许且 media type 为图片时通过
|
||||||
assert!(is_safe_url("data:image/png;base64,iVBOR", &schemes, true));
|
assert!(is_safe_url("data:image/png;base64,iVBOR", &schemes, true));
|
||||||
assert!(is_safe_url("data:image/svg+xml;base64,PHN2Zz4=", &schemes, true));
|
assert!(is_safe_url("data:image/svg+xml;base64,PHN2Zz4=", &schemes, true));
|
||||||
@ -496,7 +492,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn is_safe_url_allows_relative_and_fragment() {
|
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));
|
assert!(is_safe_url("/path/to/page", &schemes, false));
|
||||||
// 锚点
|
// 锚点
|
||||||
@ -505,7 +501,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn is_safe_url_empty_is_safe() {
|
fn is_safe_url_empty_is_safe() {
|
||||||
let schemes = default_allowed_schemes();
|
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
|
||||||
// 空 URL(如 img 无 src)视为安全。
|
// 空 URL(如 img 无 src)视为安全。
|
||||||
assert!(is_safe_url("", &schemes, false));
|
assert!(is_safe_url("", &schemes, false));
|
||||||
assert!(is_safe_url(" ", &schemes, false));
|
assert!(is_safe_url(" ", &schemes, false));
|
||||||
@ -513,7 +509,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn is_safe_url_allows_other_whitelisted_schemes() {
|
fn is_safe_url_allows_other_whitelisted_schemes() {
|
||||||
let schemes = default_allowed_schemes();
|
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
|
||||||
// mailto / tel / ftp 等均在默认白名单中。
|
// mailto / tel / ftp 等均在默认白名单中。
|
||||||
assert!(is_safe_url("mailto:user@example.com", &schemes, false));
|
assert!(is_safe_url("mailto:user@example.com", &schemes, false));
|
||||||
assert!(is_safe_url("tel:+8613800138000", &schemes, false));
|
assert!(is_safe_url("tel:+8613800138000", &schemes, false));
|
||||||
@ -522,14 +518,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn is_safe_url_rejects_scheme_with_whitespace() {
|
fn is_safe_url_rejects_scheme_with_whitespace() {
|
||||||
let schemes = default_allowed_schemes();
|
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
|
||||||
// 含空格的 scheme 名是已知的混淆手法,应被拒绝。
|
// 含空格的 scheme 名是已知的混淆手法,应被拒绝。
|
||||||
assert!(!is_safe_url("java\tscript:alert(1)", &schemes, false));
|
assert!(!is_safe_url("java\tscript:alert(1)", &schemes, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn is_safe_url_rejects_unknown_schemes() {
|
fn is_safe_url_rejects_unknown_schemes() {
|
||||||
let schemes = default_allowed_schemes();
|
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
|
||||||
// 未知 scheme 默认拒绝。
|
// 未知 scheme 默认拒绝。
|
||||||
assert!(!is_safe_url("file:///etc/passwd", &schemes, false));
|
assert!(!is_safe_url("file:///etc/passwd", &schemes, false));
|
||||||
assert!(!is_safe_url("blob:https://example.com/abc", &schemes, false));
|
assert!(!is_safe_url("blob:https://example.com/abc", &schemes, false));
|
||||||
@ -539,7 +535,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn is_safe_url_scheme_matching_is_case_insensitive() {
|
fn is_safe_url_scheme_matching_is_case_insensitive() {
|
||||||
let schemes = default_allowed_schemes();
|
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
|
||||||
// scheme 大小写不敏感:HTTPS 与 https 等价。
|
// scheme 大小写不敏感:HTTPS 与 https 等价。
|
||||||
assert!(is_safe_url("HTTPS://example.com", &schemes, false));
|
assert!(is_safe_url("HTTPS://example.com", &schemes, false));
|
||||||
assert!(!is_safe_url("JAVASCRIPT:alert(1)", &schemes, false));
|
assert!(!is_safe_url("JAVASCRIPT:alert(1)", &schemes, false));
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user