use crate::utils::constante::parse::{ALLOWED_ATTRS, ALLOWED_TAGS, RICH_CONTENT_FIELDS};
use ammonia::Builder;
use std::{collections::HashSet, sync::LazyLock};
static RICH_BUILDER: LazyLock<Builder<'static>> = LazyLock::new(|| {
let mut builder = Builder::new();
builder.tags(ALLOWED_TAGS.clone());
builder.tag_attributes(ALLOWED_ATTRS.clone());
builder.url_schemes(HashSet::from(["http", "https"]));
builder.url_relative(ammonia::UrlRelative::Deny);
builder.link_rel(Some("noopener noreferrer"));
builder.add_tag_attributes("a", &["target"]);
builder.add_tag_attribute_values("a", "target", &["_blank"]);
builder.strip_comments(true);
builder
});
static MARKDOWN_BUILDER: LazyLock<Builder<'static>> = LazyLock::new(|| {
let mut builder = Builder::new();
builder.tags(ALLOWED_TAGS.clone());
builder.tag_attributes(ALLOWED_ATTRS.clone());
builder.url_schemes(HashSet::from(["http", "https", "mailto"]));
builder.url_relative(ammonia::UrlRelative::PassThrough);
builder.link_rel(Some("noopener noreferrer"));
builder.strip_comments(true);
builder
});
#[doc = include_str!("../../../doc-tests/sanitizer/sanitizer_strict.md")]
#[must_use]
pub fn sanitize_strict(input: &str) -> String {
if input.is_empty() {
return String::new();
}
let stripped = Builder::new().tags(HashSet::new()).clean(input).to_string();
let decoded = html_escape::decode_html_entities(&stripped);
strip_protocols(&decoded).trim().to_string()
}
fn strip_protocols(input: &str) -> String {
const PROTOCOLS: &[&str] = &["javascript:", "vbscript:", "data:", "file:"];
let mut current = input.to_string();
loop {
let mut out = String::with_capacity(current.len());
let mut rest = current.as_str();
let mut removed = false;
while !rest.is_empty() {
if let Some(proto) = PROTOCOLS.iter().find(|p| {
rest.as_bytes()
.get(..p.len())
.is_some_and(|head| head.eq_ignore_ascii_case(p.as_bytes()))
}) {
rest = &rest[proto.len()..];
removed = true;
continue;
}
let mut chars = rest.chars();
if let Some(ch) = chars.next() {
out.push(ch);
}
rest = chars.as_str();
}
if !removed {
return out;
}
current = out;
}
}
#[doc = include_str!("../../../doc-tests/sanitizer/sanitizer_rich.md")]
#[must_use]
pub fn sanitize_rich(input: &str) -> String {
if input.is_empty() {
return String::new();
}
RICH_BUILDER.clean(input).to_string().trim().to_string()
}
#[must_use]
pub fn sanitize_markdown(html: &str) -> String {
if html.is_empty() {
return String::new();
}
MARKDOWN_BUILDER.clean(html).to_string()
}
#[doc = include_str!("../../../doc-tests/sanitizer/sanitizer.md")]
#[must_use]
pub fn sanitize(field: &str, input: &str) -> String {
if RICH_CONTENT_FIELDS.contains(field) {
sanitize_rich(input)
} else {
sanitize_strict(input)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strict_blocks_all_xss() {
let payloads = [
"<script>alert(1)</script>",
"<img src=x onerror=alert(1)>",
"javascript:alert(1)",
"data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==",
"<svg/onload=alert(1)>",
"<a href=\"javascript:alert(1)\">x</a>",
"<iframe src=javascript:alert(1)>",
"<object data=javascript:alert(1)>",
"<embed src=javascript:alert(1)>",
"<form action=javascript:alert(1)><button>click</button></form>",
"<input onfocus=alert(1) autofocus>",
"<marquee onstart=alert(1)>",
"<details ontoggle=alert(1)>",
"<math href=\"javascript:alert(1)\">CLICKME</math>",
"<mml:math href=\"javascript:alert(1)\">CLICKME</mml:math>",
];
for p in payloads {
let out = sanitize_strict(p);
assert!(!out.contains("<"), "Failed on: {}", p); }
}
#[test]
fn rich_allows_basic_html() {
let html = "<p><strong>Hello</strong> <a href=\"https://example.com\">link</a></p>";
let out = sanitize_rich(html);
assert!(out.contains("<p>"));
assert!(out.contains("<strong>"));
assert!(out.contains("href=\"https://example.com\""));
assert!(out.contains("rel=\"noopener noreferrer\"") || !out.contains("target="));
}
#[test]
fn rich_blocks_script_and_events() {
let html = "<p onclick=alert(1)>Test</p><script>alert(1)</script>";
let out = sanitize_rich(html);
assert!(!out.contains("script"));
assert!(!out.contains("onclick"));
assert!(out.contains("Test")); }
#[test]
fn rich_blocks_svg_mathml() {
let html = r#"<svg onload=alert(1)><circle r=10></circle></svg><math href="javascript:alert(1)">x</math>"#;
let out = sanitize_rich(html);
assert!(!out.contains("<svg"));
assert!(!out.contains("<math"));
assert!(!out.contains("onload"));
assert!(!out.contains("javascript"));
}
#[test]
fn rich_adds_rel_to_links() {
let html = r#"<a href="https://example.com" target="_blank">link</a>"#;
let out = sanitize_rich(html);
assert!(out.contains("rel=\"noopener noreferrer\""));
}
#[test]
fn rich_blocks_mailto_if_not_allowed() {
let html = r#"<a href="mailto:test@example.com">email</a>"#;
let out = sanitize_rich(html);
assert!(!out.contains("mailto:"));
}
#[test]
fn strict_preserves_legitimate_punctuation() {
assert_eq!(
sanitize_strict("one bug => fix soon"),
"one bug => fix soon"
);
assert_eq!(sanitize_strict("R&D budget"), "R&D budget");
assert_eq!(sanitize_strict("a => b => c"), "a => b => c");
}
#[test]
fn strict_still_removes_script_around_punctuation() {
let out = sanitize_strict("hi => <script>alert(1)</script> bye");
assert!(!out.contains('<'), "script must be gone: {out}");
assert!(out.contains("=>"), "legitimate `=>` must survive: {out}");
}
#[test]
fn strict_handles_unicode_obfuscation() {
let html = "<scr\u{0000}ipt>alert(1)</script>"; let out = sanitize_strict(html);
assert!(!out.contains("<")); }
#[test]
fn sanitize_routing_works() {
assert_eq!(sanitize("content", "<p>Test</p>"), "<p>Test</p>");
assert_eq!(sanitize("username", "<p>Test</p>"), "Test");
}
}