Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
target
Cargo.lock
src/whitelists
9 changes: 7 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,20 @@ edition = "2021"
rust-version = "1.80"

[dependencies]
cssparser = "0.35.0"
html5ever = "0.35"
maplit = "1.0"
tendril = "0.4"
url = "2"
cssparser = "0.35.0"

[dev-dependencies]
version-sync = "0.9"
env_logger = "0.11"
version-sync = "0.9"

[build-dependencies]
html5ever = "0.25"
markup5ever_rcdom = "0.1"
reqwest = { version = "0.11", features = ["blocking", "rustls-tls"] }

[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(ammonia_unstable)'] }
98 changes: 98 additions & 0 deletions build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
use html5ever::parse_document;
use html5ever::tendril::TendrilSink;
use markup5ever_rcdom::{Handle, NodeData, RcDom};
use std::{fs, io::Write, path::Path};

const LINK_RELATIONS_PAGE: &str =
"https://www.iana.org/assignments/link-relations/link-relations.xhtml";

fn main() {
println!("main");

let client = reqwest::blocking::Client::new();
let html = client
.get(LINK_RELATIONS_PAGE)
.header(
reqwest::header::USER_AGENT,
"Mozilla/5.0 (compatible; CopilotBot/1.0)",
)
.send()
.expect("Failed to fetch page")
.text()
.expect("Failed to read response text");
Comment on lines +13 to +22

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can't do that. This would break ammonia in build environments like docs.rs that don't have internet access.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No problem, I can change that.


let dom = parse_document(RcDom::default(), Default::default())
.from_utf8()
.read_from(&mut html.as_bytes())
.expect("Failed to parse HTML");

let rel_values = extract_rel_values(&dom);

let output_text = rel_values.join("\n");

let out_dir = Path::new("src/whitelists");
fs::create_dir_all(out_dir).expect("Failed to create output directory");

let mut file = fs::File::create(out_dir.join("rel.txt")).expect("Failed to create file");
file.write_all(output_text.as_bytes())
.expect("Failed to write file");
}

fn extract_rel_values(dom: &RcDom) -> Vec<String> {
let mut rels = Vec::new();
extract_rels(&dom.document, &mut rels);

rels.sort();
rels.dedup();
rels
}

fn extract_rels(handle: &Handle, rels: &mut Vec<String>) {
let node = handle;
match &node.data {
NodeData::Element { name, .. } => {
if name.local.as_ref() == "tr" {
if let Some(first_child) = node
.children
.take()
.iter()
.filter(|node| match &node.data {
NodeData::Element { name, .. } => name.local.as_ref() == "td",
_ => false,
})
.collect::<Vec<_>>()
.first()
{
let text = get_text(first_child);
if !text.is_empty() {
rels.push(text);
}
}
}
}
_ => {}
}

for child in node.children.borrow().iter() {
extract_rels(child, rels);
}
}

fn get_text(node: &Handle) -> String {
let mut result = String::new();
collect_text(node, &mut result);
result.trim().to_string()
}

fn collect_text(handle: &Handle, buffer: &mut String) {
match &handle.data {
NodeData::Text { contents } => {
buffer.push_str(&contents.borrow());
}
_ => {
for child in handle.children.borrow().iter() {
collect_text(child, buffer);
}
}
}
}
126 changes: 94 additions & 32 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ use html5ever::serialize::{serialize, SerializeOpts};
use html5ever::tree_builder::{NodeOrText, TreeSink};
use html5ever::{driver as html, local_name, ns, QualName};
use maplit::{hashmap, hashset};
use std::sync::LazyLock;
use rcdom::{Handle, NodeData, RcDom, SerializableHandle};
use std::borrow::{Borrow, Cow};
use std::cell::Cell;
Expand All @@ -52,6 +51,7 @@ use std::iter::IntoIterator as IntoIter;
use std::mem;
use std::rc::Rc;
use std::str::FromStr;
use std::sync::LazyLock;
use tendril::stream::TendrilSink;
use tendril::StrTendril;
use tendril::{format_tendril, ByteTendril};
Expand All @@ -63,6 +63,17 @@ pub use url;

static AMMONIA: LazyLock<Builder<'static>> = LazyLock::new(Builder::default);

static VALID_RELS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/src/whitelists/rel.txt"
))
.lines()
.map(str::trim)
.filter(|s| !s.is_empty())
.collect()
});
Comment on lines +66 to +75

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Many of the IANA-registered rel attributes are not safe (for example, canonical would allow hijacking your search results). You can't just allow all of them; you need to go through each one, make sure it's appropriate to your use case, and only allow the ones that are.

@Odonno Odonno Oct 29, 2025

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ones that seem legit are at least nofollow and the new ones ugc and sponsored. However, I don't know the exact list of valid rel values.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I asked ChatGPT and it gave me this list, what do you think?

Value Description
alternate Link to an alternate version of the document (e.g., print, translation)
author Link to the author of the document
bookmark Permanent bookmarkable link
external Link to an external site
help Link to a help document
license Link to copyright or licensing information
next Next document in a sequence
nofollow Tells search engines not to follow the link (often used for paid links)
noopener Prevents the new page from accessing window.opener (security measure)
noreferrer Prevents sending the HTTP referrer header
prefetch Suggests the browser should prefetch the linked resource
prev Previous document in a sequence
search Link to a search tool for the document
tag Specifies that the link is a tag (often used in blogs)
ugc User Generated Content — used for links in comments, forums, etc.
sponsored Paid or promotional link — used for affiliate or ad links

@GreenReaper GreenReaper Jun 14, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rel = "me" (similar to author, "a resource about the author of the link's context" i.e. "this is also me" on profile page links) would be useful for pypa/readme_renderer#305, but I can see why it might not be desired for all use-cases.


/// Clean HTML with a conservative set of defaults.
///
/// * [tags](struct.Builder.html#defaults)
Expand Down Expand Up @@ -390,7 +401,7 @@ impl<'a> Default for Builder<'a> {
let generic_attributes = hashset!["lang", "title"];
let tag_attributes = hashmap![
"a" => hashset![
"href", "hreflang"
"href", "hreflang", "rel"
],
"bdo" => hashset![
"dir"
Expand Down Expand Up @@ -1806,11 +1817,6 @@ impl<'a> Builder<'a> {
.map(|link_rel| format_tendril!("{}", link_rel));
if link_rel.is_some() {
assert!(self.generic_attributes.get("rel").is_none());
assert!(self
.tag_attributes
.get("a")
.and_then(|a| a.get("rel"))
.is_none());
}
assert!(self.allowed_classes.is_empty() || !self.generic_attributes.contains("class"));
for tag_name in self.allowed_classes.keys() {
Expand All @@ -1821,7 +1827,10 @@ impl<'a> Builder<'a> {
.is_none());
}
for tag_name in &self.clean_content_tags {
assert!(!self.tags.contains(tag_name), "`{tag_name}` appears in `clean_content_tags` and in `tags` at the same time");
assert!(
!self.tags.contains(tag_name),
"`{tag_name}` appears in `clean_content_tags` and in `tags` at the same time"
);
assert!(!self.tag_attributes.contains_key(tag_name), "`{tag_name}` appears in `clean_content_tags` and in `tag_attributes` at the same time");
}
let body = {
Expand Down Expand Up @@ -2052,12 +2061,20 @@ impl<'a> Builder<'a> {
matches!(
&*parent.local,
"mi" | "mo" | "mn" | "ms" | "mtext" | "annotation-xml"
) && if child.ns == ns!(html) { is_html_tag(&child.local) } else { true }
) && if child.ns == ns!(html) {
is_html_tag(&child.local)
} else {
true
}
// The only way to switch from svg to mathml/html is with an html integration point
} else if parent.ns == ns!(svg) && child.ns != ns!(svg) {
// https://html.spec.whatwg.org/#svg-0
matches!(&*parent.local, "foreignObject")
&& if child.ns == ns!(html) { is_html_tag(&child.local) } else { true }
&& if child.ns == ns!(html) {
is_html_tag(&child.local)
} else {
true
}
} else if child.ns == ns!(svg) {
is_svg_tag(&child.local)
} else if child.ns == ns!(mathml) {
Expand Down Expand Up @@ -2109,12 +2126,55 @@ impl<'a> Builder<'a> {
}
}
}
if let Some(ref link_rel) = *link_rel {
if &*name.local == "a" {
attrs.borrow_mut().push(Attribute {
name: QualName::new(None, ns!(), local_name!("rel")),
value: link_rel.clone(),
})
if &*name.local == "a" {
let rel_value_opt = {
attrs
.borrow()
.iter()
.find(|a| a.name.local.as_ref() == "rel")
.map(|a| a.value.clone())
};

if let Some(rel_value) = rel_value_opt {
let attr_valid_rels = rel_value
.split_whitespace()
.filter(|s| VALID_RELS.contains(*s))
.collect::<HashSet<_>>();

let link_rel = link_rel.clone().unwrap_or_default();

let mut updated_attrs = attr_valid_rels
.into_iter()
.chain(link_rel.split_whitespace())
.filter(|s| VALID_RELS.contains(*s))
.collect::<HashSet<_>>()
.into_iter()
.collect::<Vec<_>>();
updated_attrs.sort();

if updated_attrs.is_empty() {
// remove attr if no attribute
attrs
.borrow_mut()
.retain(|a| a.name.local.as_ref() != "rel");
} else {
if let Some(attr) = attrs
.borrow_mut()
.iter_mut()
.find(|a| a.name.local.as_ref() == "rel")
{
// update rel attribute with filtered values
attr.value = updated_attrs.join(" ").into();
}
}
} else {
if let Some(link_rel) = link_rel {
// add default rel attribute if none existed
attrs.borrow_mut().push(Attribute {
name: QualName::new(None, ns!(), local_name!("rel")),
value: link_rel.clone(),
});
}
}
}
if let Some(ref id_prefix) = id_prefix {
Expand Down Expand Up @@ -2173,7 +2233,8 @@ impl<'a> Builder<'a> {
if let Some(allowed_values) = &self.style_properties {
for attr in &mut *attrs.borrow_mut() {
if &attr.name.local == "style" {
attr.value = style::filter_style_attribute(&attr.value, allowed_values).into();
attr.value =
style::filter_style_attribute(&attr.value, allowed_values).into();
}
}
}
Expand Down Expand Up @@ -3163,6 +3224,18 @@ mod test {
);
}
#[test]
fn append_rel_with_valid_rels() {
let fragment = "<a href=test rel=\"ugc nofollow\">Test</a>";
let result = Builder::new()
.url_relative(UrlRelative::PassThrough)
.clean(fragment)
.to_string();
assert_eq!(
result,
"<a href=\"test\" rel=\"nofollow noopener noreferrer ugc\">Test</a>"
);
}
#[test]
fn consider_rel_still_banned() {
let fragment = "<a href=test rel=\"garbage\">Test</a>";
let result = Builder::new()
Expand Down Expand Up @@ -3208,16 +3281,6 @@ mod test {
.clean("something");
}
#[test]
#[should_panic]
fn panic_if_rel_is_allowed_and_replaced_a() {
Builder::new()
.link_rel(Some("noopener noreferrer"))
.tag_attributes(hashmap![
"a" => hashset!["rel"],
])
.clean("something");
}
#[test]
fn no_panic_if_rel_is_allowed_and_replaced_span() {
Builder::new()
.link_rel(Some("noopener noreferrer"))
Expand Down Expand Up @@ -3655,9 +3718,9 @@ mod test {
#[test]
fn ns_svg_2() {
let fragment = "<svg><foreignObject><table><path><xmp><!--</xmp><img title'--&gt;&lt;img src=1 onerror=alert(1)&gt;'>";
let result = Builder::default()
let result = Builder::default()
.strip_comments(false)
.add_tags(&["svg","foreignObject","table","path","xmp"])
.add_tags(&["svg", "foreignObject", "table", "path", "xmp"])
.clean(fragment);
assert_eq!(
result.to_string(),
Expand Down Expand Up @@ -3700,17 +3763,16 @@ mod test {
#[test]
fn ns_mathml_2() {
let fragment = "<math><mtext><table><mglyph><xmp><!--</xmp><img title='--&gt;&lt;img src=1 onerror=alert(1)&gt;'>";
let result = Builder::default()
let result = Builder::default()
.strip_comments(false)
.add_tags(&["math","mtext","table","mglyph","xmp"])
.add_tags(&["math", "mtext", "table", "mglyph", "xmp"])
.clean(fragment);
assert_eq!(
result.to_string(),
"<math><mtext><table></table></mtext></math>"
);
}


#[test]
fn xml_processing_instruction() {
// https://blog.slonser.info/posts/dompurify-node-type-confusion/
Expand Down