From 406e626e3847a2bace29a3bdd3395c7db5850f86 Mon Sep 17 00:00:00 2001 From: Odonno Date: Tue, 28 Oct 2025 12:29:50 +0100 Subject: [PATCH 1/2] Extract rel values at build time --- .gitignore | 1 + Cargo.toml | 5 +++ build.rs | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+) create mode 100644 build.rs diff --git a/.gitignore b/.gitignore index a9d37c5..1fe4473 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ target Cargo.lock +src/whitelists diff --git a/Cargo.toml b/Cargo.toml index be85d39..34c4f3f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,5 +23,10 @@ cssparser = "0.35.0" version-sync = "0.9" env_logger = "0.11" +[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)'] } diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..466e7be --- /dev/null +++ b/build.rs @@ -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"); + + 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 { + let mut rels = Vec::new(); + extract_rels(&dom.document, &mut rels); + + rels.sort(); + rels.dedup(); + rels +} + +fn extract_rels(handle: &Handle, rels: &mut Vec) { + 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::>() + .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); + } + } + } +} From 142d1407da552412cd9a56e5f7619a77863afc11 Mon Sep 17 00:00:00 2001 From: Odonno Date: Tue, 28 Oct 2025 14:08:00 +0100 Subject: [PATCH 2/2] Handle intersection of possible and default rel values --- Cargo.toml | 4 +- src/lib.rs | 126 +++++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 96 insertions(+), 34 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 34c4f3f..be04427 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,15 +13,15 @@ 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" diff --git a/src/lib.rs b/src/lib.rs index f3b3c74..c7c4e1e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; @@ -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}; @@ -63,6 +63,17 @@ pub use url; static AMMONIA: LazyLock> = LazyLock::new(Builder::default); +static VALID_RELS: LazyLock> = LazyLock::new(|| { + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/whitelists/rel.txt" + )) + .lines() + .map(str::trim) + .filter(|s| !s.is_empty()) + .collect() +}); + /// Clean HTML with a conservative set of defaults. /// /// * [tags](struct.Builder.html#defaults) @@ -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" @@ -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() { @@ -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 = { @@ -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) { @@ -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::>(); + + 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::>() + .into_iter() + .collect::>(); + 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 { @@ -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(); } } } @@ -3163,6 +3224,18 @@ mod test { ); } #[test] + fn append_rel_with_valid_rels() { + let fragment = "Test"; + let result = Builder::new() + .url_relative(UrlRelative::PassThrough) + .clean(fragment) + .to_string(); + assert_eq!( + result, + "Test" + ); + } + #[test] fn consider_rel_still_banned() { let fragment = "Test"; let result = Builder::new() @@ -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")) @@ -3655,9 +3718,9 @@ mod test { #[test] fn ns_svg_2() { let fragment = "<!--"; - 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(), @@ -3700,9 +3763,9 @@ mod test { #[test] fn ns_mathml_2() { let fragment = "
<!--"; - 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(), @@ -3710,7 +3773,6 @@ mod test { ); } - #[test] fn xml_processing_instruction() { // https://blog.slonser.info/posts/dompurify-node-type-confusion/