From ebe5ed3df8d30a9b1df1f28e623f7b476f7e1070 Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sun, 8 Feb 2026 17:31:45 -0300 Subject: [PATCH 1/3] fix: escape single quotes in DOM attribute rendering Add single-quote escaping (') to Element::render_to to prevent XSS when attribute values contain single quotes and are later placed in single-quoted contexts. Closes #59 --- crates/ironhtml-parser/src/dom.rs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/ironhtml-parser/src/dom.rs b/crates/ironhtml-parser/src/dom.rs index 0851815..f6ce4e0 100644 --- a/crates/ironhtml-parser/src/dom.rs +++ b/crates/ironhtml-parser/src/dom.rs @@ -381,8 +381,9 @@ impl Element { // Escape attribute value for c in attr.value.chars() { match c { - '"' => output.push_str("""), '&' => output.push_str("&"), + '"' => output.push_str("""), + '\'' => output.push_str("'"), '<' => output.push_str("<"), '>' => output.push_str(">"), _ => output.push(c), @@ -521,4 +522,23 @@ mod tests { let html = doc.to_html(); assert!(html.starts_with("")); } + + #[test] + fn test_escape_single_quotes_in_attributes() { + let mut elem = Element::new("div"); + elem.set_attribute("data-msg", "it's a test"); + + assert_eq!(elem.to_html(), r#"
"#); + } + + #[test] + fn test_escape_all_special_chars_in_attributes() { + let mut elem = Element::new("div"); + elem.set_attribute("data-val", r#"a&bd"e'f"#); + + assert_eq!( + elem.to_html(), + r#"
"# + ); + } } From f3c4efabbd0a6acbae2f7b750ba35ad7bb563871 Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sun, 8 Feb 2026 17:31:52 -0300 Subject: [PATCH 2/3] fix: add raw text state for script/style/textarea/title Add a RawText tokenizer state that prevents content inside script, style, textarea, and title elements from being parsed as HTML. The tokenizer now scans for the matching end tag before resuming normal HTML parsing. Closes #53 --- crates/ironhtml-parser/src/tokenizer.rs | 153 +++++++++++++++++++++ crates/ironhtml-parser/src/tree_builder.rs | 36 +++++ 2 files changed, 189 insertions(+) diff --git a/crates/ironhtml-parser/src/tokenizer.rs b/crates/ironhtml-parser/src/tokenizer.rs index 3b27cc2..64c3cd6 100644 --- a/crates/ironhtml-parser/src/tokenizer.rs +++ b/crates/ironhtml-parser/src/tokenizer.rs @@ -60,6 +60,7 @@ enum State { DoctypeName, AfterDoctypeName, BogusComment, + RawText, } /// HTML5 tokenizer. @@ -76,6 +77,8 @@ pub struct Tokenizer<'a> { current_comment: String, current_doctype_name: Option, pending_tokens: Vec, + /// Tag name for raw text mode (script, style, textarea, title, etc.) + raw_text_tag: String, } impl<'a> Tokenizer<'a> { @@ -95,6 +98,7 @@ impl<'a> Tokenizer<'a> { current_comment: String::new(), current_doctype_name: None, pending_tokens: Vec::new(), + raw_text_tag: String::new(), } } @@ -117,6 +121,12 @@ impl<'a> Tokenizer<'a> { if is_end { Token::EndTag { name } } else { + // Switch to raw text mode for elements whose content is not HTML + if !self_closing && Self::is_raw_text_element(&name) { + self.raw_text_tag.clone_from(&name); + self.state = State::RawText; + } + Token::StartTag { name, attributes: attrs, @@ -125,6 +135,11 @@ impl<'a> Tokenizer<'a> { } } + /// Check if an element uses raw text content (no child HTML parsing). + fn is_raw_text_element(tag: &str) -> bool { + matches!(tag, "script" | "style" | "textarea" | "title") + } + fn emit_current_attr(&mut self) { if !self.current_attr_name.is_empty() { let name = core::mem::take(&mut self.current_attr_name).to_ascii_lowercase(); @@ -551,6 +566,51 @@ impl<'a> Tokenizer<'a> { } } + State::RawText => { + // Look for `` (case-insensitive) to end raw text + let remaining = + &self.input[self.chars.peek().map_or(self.input.len(), |(i, _)| *i)..]; + let close_tag = { + let mut s = String::from("' or whitespace or '/' + let after = pos + close_tag.len(); + let valid_end = after >= remaining.len() + || matches!( + remaining.as_bytes().get(after), + Some(b'>' | b' ' | b'\t' | b'\n' | b'/' | 0x0C) + ); + + if valid_end { + // Emit all characters before the close tag + for _ in 0..pos { + if let Some(c) = self.consume() { + self.pending_tokens.push(Token::Character(c)); + } + } + // Now let the normal tokenizer handle `` + self.raw_text_tag.clear(); + self.state = State::Data; + + if !self.pending_tokens.is_empty() { + return Some(self.pending_tokens.remove(0)); + } + continue; + } + } + + // No close tag found — emit rest as characters + if let Some(c) = self.consume() { + return Some(Token::Character(c)); + } + self.raw_text_tag.clear(); + self.state = State::Data; + return Some(Token::Eof); + } + State::BogusComment => match self.consume() { Some('>') => { self.state = State::Data; @@ -694,4 +754,97 @@ mod tests { }) ); } + + // ── Raw text element tests ────────────────────────────────────── + + #[test] + fn test_script_raw_text() { + let tokens: Vec<_> = Tokenizer::new("").collect(); + assert_eq!( + tokens[0], + Token::StartTag { + name: "script".into(), + attributes: vec![], + self_closing: false, + } + ); + // Content should be raw characters, not parsed as tags + let text: String = tokens[1..tokens.len() - 1] + .iter() + .filter_map(|t| match t { + Token::Character(c) => Some(*c), + _ => None, + }) + .collect(); + assert_eq!(text, "var x = '
';"); + assert_eq!( + *tokens.last().unwrap(), + Token::EndTag { + name: "script".into() + } + ); + } + + #[test] + fn test_style_raw_text() { + let tokens: Vec<_> = Tokenizer::new("").collect(); + assert_eq!( + tokens[0], + Token::StartTag { + name: "style".into(), + attributes: vec![], + self_closing: false, + } + ); + let text: String = tokens[1..tokens.len() - 1] + .iter() + .filter_map(|t| match t { + Token::Character(c) => Some(*c), + _ => None, + }) + .collect(); + assert_eq!(text, "p > .cls { color: red; }"); + } + + #[test] + fn test_script_with_inner_script_reference() { + // " end tag + let tokens: Vec<_> = + Tokenizer::new(r#""#).collect(); + let text: String = tokens[1..tokens.len() - 1] + .iter() + .filter_map(|t| match t { + Token::Character(c) => Some(*c), + _ => None, + }) + .collect(); + assert_eq!(text, r#"var s = "not a tag";"#); + } + + #[test] + fn test_textarea_raw_text() { + let tokens: Vec<_> = Tokenizer::new("").collect(); + let text: String = tokens[1..tokens.len() - 1] + .iter() + .filter_map(|t| match t { + Token::Character(c) => Some(*c), + _ => None, + }) + .collect(); + assert_eq!(text, "bold & stuff"); + } + + #[test] + fn test_title_raw_text() { + let tokens: Vec<_> = Tokenizer::new("A <em>page</em>").collect(); + let text: String = tokens[1..tokens.len() - 1] + .iter() + .filter_map(|t| match t { + Token::Character(c) => Some(*c), + _ => None, + }) + .collect(); + assert_eq!(text, "A page"); + } } diff --git a/crates/ironhtml-parser/src/tree_builder.rs b/crates/ironhtml-parser/src/tree_builder.rs index 56b3bdd..7bd3afa 100644 --- a/crates/ironhtml-parser/src/tree_builder.rs +++ b/crates/ironhtml-parser/src/tree_builder.rs @@ -902,4 +902,40 @@ mod tests { let p = body.find_element("p").unwrap(); assert_eq!(p.text_content(), Some("Hello".into())); } + + // ── raw text element tests ────────────────────────────────────── + + #[test] + fn test_script_content_not_parsed_as_html() { + let nodes = parse_fragment("

After

"); + assert_eq!(nodes.len(), 1); + let div = nodes[0].as_element().unwrap(); + let script = div.find_element("script").unwrap(); + assert_eq!(script.text_content(), Some("var x = 'hi';".into())); + //

should be a sibling of ", + ); + let head = doc.head().unwrap(); + let script = head.find_element("script").unwrap(); + assert_eq!(script.text_content(), Some("alert('')".into())); + } } From 26c9a674fb44c7164e874bf4037f1a6842766b64 Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sun, 8 Feb 2026 17:32:00 -0300 Subject: [PATCH 3/3] fix: validate tag names and attribute names Add runtime validation to Element::new, attr, and bool_attr to reject invalid tag names (must be ASCII alphanumeric + hyphens, start with a letter) and invalid attribute names (must not contain whitespace, quotes, angle brackets, slash, equals, or null). Closes #25 Closes #27 --- Cargo.lock | 519 ++++++++++++++++++++++++++++++++++++- crates/ironhtml/src/lib.rs | 152 ++++++++++- 2 files changed, 666 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 590f37a..731cb03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,10 +2,198 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "ironhtml" version = "1.0.0" dependencies = [ + "criterion", "ironhtml-attributes", "ironhtml-elements", "ironhtml-macro", @@ -32,8 +220,6 @@ version = "1.0.0" name = "ironhtml-macro" version = "1.0.0" dependencies = [ - "ironhtml", - "ironhtml-elements", "proc-macro2", "quote", "syn", @@ -46,6 +232,103 @@ dependencies = [ "ironhtml-elements", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -64,6 +347,113 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "syn" version = "2.0.114" @@ -75,8 +465,133 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "unicode-ident" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" diff --git a/crates/ironhtml/src/lib.rs b/crates/ironhtml/src/lib.rs index 2480f4c..f2d18a7 100644 --- a/crates/ironhtml/src/lib.rs +++ b/crates/ironhtml/src/lib.rs @@ -261,9 +261,20 @@ pub struct Html { impl Element { /// Create a new element with the given tag name. + /// + /// # Panics + /// + /// Panics if `tag` is empty, starts with a non-letter, or contains + /// characters other than ASCII alphanumerics and hyphens. #[must_use] pub fn new(tag: impl Into) -> Self { let tag = tag.into(); + validate_tag_name(&tag); + Self::new_unchecked(tag) + } + + /// Internal constructor that skips tag name validation. + fn new_unchecked(tag: String) -> Self { let self_closing = matches!( tag.as_str(), "area" @@ -289,16 +300,28 @@ impl Element { } /// Add an attribute to this element. + /// + /// # Panics + /// + /// Panics if `name` is empty or contains invalid attribute name characters. #[must_use] pub fn attr(mut self, name: impl Into, value: impl Into) -> Self { - self.attrs.push((name.into(), value.into())); + let name = name.into(); + validate_attr_name(&name); + self.attrs.push((name, value.into())); self } /// Add a boolean attribute (no value, e.g., `disabled`, `checked`). + /// + /// # Panics + /// + /// Panics if `name` is empty or contains invalid attribute name characters. #[must_use] pub fn bool_attr(mut self, name: impl Into) -> Self { - self.attrs.push((name.into(), String::new())); + let name = name.into(); + validate_attr_name(&name); + self.attrs.push((name, String::new())); self } @@ -361,7 +384,7 @@ impl Element { F: Fn(I::Item, Self) -> Self, { for item in items { - let child = f(item, Self::new("")); + let child = f(item, Self::new_unchecked(String::new())); if !child.tag.is_empty() { self.children.push(Node::Element(child)); } @@ -483,6 +506,52 @@ impl Html { } } +/// Validate that a tag name contains only valid characters. +/// +/// # Panics +/// +/// Panics if the tag name is empty or contains invalid characters. +fn validate_tag_name(name: &str) { + assert!(!name.is_empty(), "tag name must not be empty"); + assert!( + name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-'), + "tag name contains invalid characters: {name:?}" + ); + assert!( + name.as_bytes()[0].is_ascii_alphabetic(), + "tag name must start with a letter: {name:?}" + ); +} + +/// Validate that an attribute name contains only valid characters. +/// +/// Rejects names with whitespace, quotes, `<`, `>`, `/`, `=`, or null +/// bytes — characters that could break out of the attribute context. +/// +/// # Panics +/// +/// Panics if the attribute name is empty or contains invalid characters. +fn validate_attr_name(name: &str) { + assert!(!name.is_empty(), "attribute name must not be empty"); + assert!( + !name.bytes().any(|b| matches!( + b, + b' ' | b'\t' + | b'\n' + | b'\r' + | b'\x0C' + | b'"' + | b'\'' + | b'>' + | b'<' + | b'/' + | b'=' + | b'\0' + )), + "attribute name contains invalid characters: {name:?}" + ); +} + /// Escape special HTML characters in text content. #[must_use] pub fn escape_html(s: &str) -> String { @@ -744,4 +813,81 @@ mod tests { r#"Hello

Hello, World!

"# ); } + + // ── tag name validation tests ─────────────────────────────────── + + #[test] + fn test_valid_tag_names() { + // Standard elements + let _ = Element::new("div"); + let _ = Element::new("h1"); + let _ = Element::new("br"); + // Custom elements with hyphens + let _ = Element::new("my-component"); + let _ = Element::new("x-widget"); + } + + #[test] + #[should_panic(expected = "tag name must not be empty")] + fn test_empty_tag_name_panics() { + let _ = Element::new(""); + } + + #[test] + #[should_panic(expected = "invalid characters")] + fn test_tag_name_with_space_panics() { + let _ = Element::new("div onclick"); + } + + #[test] + #[should_panic(expected = "invalid characters")] + fn test_tag_name_with_angle_bracket_panics() { + let _ = Element::new("div>