use std::sync::LazyLock;
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
#[rustfmt::skip]
pub const RICH_TEXT_ALLOWED_TAGS: &[&str] = &[
"p", "br", "hr", "blockquote",
"h1", "h2", "h3", "h4", "h5", "h6",
"em", "strong", "del", "sub", "sup",
"a",
"ul", "ol", "li",
"code", "pre",
"table", "thead", "tbody", "tr", "th", "td",
];
pub const RICH_TEXT_ALLOWED_URL_SCHEMES: &[&str] = &["http", "https", "mailto", "tel"];
const LINK_REL: &str = "noopener noreferrer nofollow";
const ALLOWED_TEXT_ALIGN: &[&str] = &["left", "right", "center"];
#[must_use]
pub fn render_user_content_html(source: &str) -> String {
let mut opts = Options::empty();
opts.insert(Options::ENABLE_TABLES);
opts.insert(Options::ENABLE_STRIKETHROUGH);
let events = Parser::new_ext(source, opts);
let safe = SafeEvents::new(events);
let mut html = String::with_capacity(source.len() + source.len() / 2);
pulldown_cmark::html::push_html(&mut html, safe);
sanitize_user_html(&html)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn render_user_content(source: &str) -> maud::Markup {
maud::PreEscaped(render_user_content_html(source))
}
#[must_use]
pub fn sanitize_user_html(html: &str) -> String {
SANITIZER.clean(html).to_string()
}
static SANITIZER: LazyLock<ammonia::Builder<'static>> = LazyLock::new(build_sanitizer);
fn build_sanitizer() -> ammonia::Builder<'static> {
use std::collections::{HashMap, HashSet};
let mut builder = ammonia::Builder::empty();
builder
.tags(RICH_TEXT_ALLOWED_TAGS.iter().copied().collect())
.url_schemes(RICH_TEXT_ALLOWED_URL_SCHEMES.iter().copied().collect())
.link_rel(Some(LINK_REL));
let attrs: HashMap<&str, HashSet<&str>> = HashMap::from([
("a", HashSet::from(["href", "title"])),
("code", HashSet::from(["class"])),
("pre", HashSet::from(["class"])),
("th", HashSet::from(["style"])),
("td", HashSet::from(["style"])),
("ol", HashSet::from(["start"])),
]);
builder.tag_attributes(attrs);
builder.generic_attributes(HashSet::new());
builder.attribute_filter(|element, attribute, value| match (element, attribute) {
("code" | "pre", "class") => {
let is_language_hint = value.strip_prefix("language-").is_some_and(|lang| {
!lang.is_empty()
&& lang
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '+' | '.'))
});
is_language_hint.then(|| value.to_owned().into())
}
("th" | "td", "style") => {
let normalized = value.trim().trim_end_matches(';');
let aligned = normalized.split_once(':').is_some_and(|(prop, val)| {
prop.trim().eq_ignore_ascii_case("text-align")
&& ALLOWED_TEXT_ALIGN.contains(&val.trim().to_ascii_lowercase().as_str())
});
aligned.then(|| value.to_owned().into())
}
_ => Some(value.into()),
});
builder
}
const MAX_BLOCK_NESTING_DEPTH: usize = 100;
struct SafeEvents<'a, I: Iterator<Item = Event<'a>>> {
inner: I,
image_depth: usize,
dropped_links: Vec<bool>,
dropped_blocks: Vec<bool>,
}
impl<'a, I: Iterator<Item = Event<'a>>> SafeEvents<'a, I> {
const fn new(inner: I) -> Self {
Self {
inner,
image_depth: 0,
dropped_links: Vec::new(),
dropped_blocks: Vec::new(),
}
}
}
const fn is_nesting_block(tag: &Tag<'_>) -> bool {
matches!(
tag,
Tag::BlockQuote(_)
| Tag::List(_)
| Tag::Item
| Tag::Table(_)
| Tag::TableHead
| Tag::TableRow
| Tag::TableCell
| Tag::FootnoteDefinition(_)
)
}
const fn is_nesting_block_end(tag: TagEnd) -> bool {
matches!(
tag,
TagEnd::BlockQuote(_)
| TagEnd::List(_)
| TagEnd::Item
| TagEnd::Table
| TagEnd::TableHead
| TagEnd::TableRow
| TagEnd::TableCell
| TagEnd::FootnoteDefinition
)
}
impl<'a, I: Iterator<Item = Event<'a>>> Iterator for SafeEvents<'a, I> {
type Item = Event<'a>;
fn next(&mut self) -> Option<Event<'a>> {
loop {
let event = self.inner.next()?;
match event {
Event::Html(s) | Event::InlineHtml(s) => return Some(Event::Text(s)),
Event::Start(Tag::Image { .. }) => {
self.image_depth += 1;
}
Event::End(TagEnd::Image) => {
self.image_depth = self.image_depth.saturating_sub(1);
}
Event::Start(Tag::Link {
link_type,
dest_url,
title,
id,
}) => {
if url_scheme_allowed(&dest_url) {
self.dropped_links.push(false);
return Some(Event::Start(Tag::Link {
link_type,
dest_url,
title,
id,
}));
}
self.dropped_links.push(true);
}
Event::End(TagEnd::Link) => {
if !self.dropped_links.pop().unwrap_or(false) {
return Some(Event::End(TagEnd::Link));
}
}
Event::Code(s) if self.image_depth > 0 => return Some(Event::Text(s)),
Event::Start(tag) if is_nesting_block(&tag) => {
let over_cap = self.dropped_blocks.len() >= MAX_BLOCK_NESTING_DEPTH;
self.dropped_blocks.push(over_cap);
if !over_cap {
return Some(Event::Start(tag));
}
}
Event::End(tag) if is_nesting_block_end(tag) => {
if !self.dropped_blocks.pop().unwrap_or(false) {
return Some(Event::End(tag));
}
}
other => return Some(other),
}
}
}
}
fn url_scheme_allowed(dest: &str) -> bool {
url_scheme(dest).is_none_or(|scheme| RICH_TEXT_ALLOWED_URL_SCHEMES.contains(&scheme.as_str()))
}
fn url_scheme(dest: &str) -> Option<String> {
let trimmed = dest.trim_matches(|c: char| (c as u32) <= 0x20);
let mut scheme = String::new();
for c in trimmed.chars() {
match c {
'\t' | '\n' | '\r' => {}
':' => {
return (!scheme.is_empty() && is_valid_scheme(&scheme)).then_some(scheme);
}
'/' | '?' | '#' => return None,
c => scheme.push(c.to_ascii_lowercase()),
}
}
None
}
fn is_valid_scheme(candidate: &str) -> bool {
let mut chars = candidate.chars();
chars.next().is_some_and(|c| c.is_ascii_alphabetic())
&& chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scheme_extraction_handles_obfuscation() {
assert_eq!(url_scheme("https://a"), Some("https".to_owned()));
assert_eq!(url_scheme("JaVaScRiPt:x"), Some("javascript".to_owned()));
assert_eq!(url_scheme("java\tscript:x"), Some("javascript".to_owned()));
assert_eq!(url_scheme(" javascript:x"), Some("javascript".to_owned()));
assert_eq!(
url_scheme("\u{0}javascript:x"),
Some("javascript".to_owned())
);
assert_eq!(url_scheme("/a/b"), None);
assert_eq!(url_scheme("#anchor"), None);
assert_eq!(url_scheme("//host/path"), None);
assert_eq!(url_scheme("a/b:c"), None);
assert_eq!(url_scheme("foo bar:baz"), None);
assert_eq!(url_scheme("1abc:x"), None);
}
#[test]
fn scheme_allowlist_accepts_only_curated_schemes() {
assert!(url_scheme_allowed("https://example.com"));
assert!(url_scheme_allowed("http://example.com"));
assert!(url_scheme_allowed("mailto:a@b.example"));
assert!(url_scheme_allowed("tel:+15551234"));
assert!(url_scheme_allowed("/relative"));
assert!(!url_scheme_allowed("javascript:alert(1)"));
assert!(!url_scheme_allowed("vbscript:x"));
assert!(!url_scheme_allowed("data:text/html,x"));
assert!(!url_scheme_allowed("file:///etc/passwd"));
}
#[test]
fn table_alignment_style_survives_but_other_css_does_not() {
let aligned =
sanitize_user_html("<table><tr><td style=\"text-align: right\">1</td></tr></table>");
assert!(aligned.contains("style=\"text-align: right\""), "{aligned}");
let injected =
sanitize_user_html("<table><tr><td style=\"position:fixed;top:0\">1</td></tr></table>");
assert!(!injected.contains("position"), "{injected}");
let smuggled = sanitize_user_html(
"<table><tr><td style=\"text-align:left;position:fixed\">1</td></tr></table>",
);
assert!(!smuggled.contains("position"), "{smuggled}");
}
#[test]
fn allowed_tag_and_scheme_lists_have_no_duplicates() {
let mut tags = RICH_TEXT_ALLOWED_TAGS.to_vec();
tags.sort_unstable();
let len = tags.len();
tags.dedup();
assert_eq!(tags.len(), len, "duplicate entry in RICH_TEXT_ALLOWED_TAGS");
}
}