Skip to content

Don't escape script and style text in inner_html - #334

Merged
adamreichold merged 1 commit into
rust-scraper:masterfrom
kdrag0n:inner-html-raw-text
Nov 11, 2026
Merged

adamreichold merged 1 commit into
rust-scraper:masterfrom
kdrag0n:inner-html-raw-text

Conversation

@kdrag0n

@kdrag0n kdrag0n commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

inner_html() on a <script> or <style> element returns its text HTML-escaped, so JSON or CSS read that way is corrupted. html() on the same element and inner_html() on the parent return it as is:

let html = Html::parse_document(
    r#"<script type="application/json">{"url":"/search?a=1&b=2","note":"1 < 2"}</script><style>a > b { color: red }</style>"#,
);
let script = html.select(&Selector::parse("script").unwrap()).next().unwrap();
let style = html.select(&Selector::parse("style").unwrap()).next().unwrap();
println!("{}", script.inner_html());
println!("{}", script.html());
println!("{}", style.inner_html());

Before:

{"url":"/search?a=1&amp;b=2","note":"1 &lt; 2"}
<script type="application/json">{"url":"/search?a=1&b=2","note":"1 < 2"}</script>
a &gt; b { color: red }

After:

{"url":"/search?a=1&b=2","note":"1 < 2"}
<script type="application/json">{"url":"/search?a=1&b=2","note":"1 < 2"}</script>
a > b { color: red }

The same happens for every raw text element (xmp, iframe, noembed, noframes, plaintext, and noscript now that scripting is enabled).

html5ever's serializer already leaves text in those elements unescaped, but it decides that from the parent on its stack, and when only the children are serialized the bottom of the stack is whatever name is passed in TraversalScope::ChildrenOnly:

https://github.com/servo/html5ever/blob/d7232d746d3112f08926d169591576bd12fe2fdf/html5ever/src/serialize/mod.rs#L248-L260

https://github.com/servo/html5ever/blob/d7232d746d3112f08926d169591576bd12fe2fdf/html5ever/src/serialize/mod.rs#L78-L82

inner_html() always passes None, so the text has no parent and gets escaped:

/// Returns the inner HTML of this element.
pub fn inner_html(&self) -> String {
self.serialize(TraversalScope::ChildrenOnly(None))
}

This passes the element's name instead, so html5ever applies the same rules as for html(). Two details:

  • node/serializable.rs only skipped the root for ChildrenOnly(None), so it now does that for any ChildrenOnly. Otherwise the element's own tags would end up in the output (this also affects anyone passing ChildrenOnly(Some(_)) to html5ever's serialize with an ElementRef or Html).

  • The name is only passed for HTML elements. When html5ever pushes an element it only records the name of HTML elements, but it doesn't check the namespace of the scope's name, so passing an SVG <style> would leave its text unescaped. Chromium and Firefox escape it.

    https://github.com/servo/html5ever/blob/d7232d746d3112f08926d169591576bd12fe2fdf/html5ever/src/serialize/mod.rs#L158-L161

I compared the results with innerHTML in Chromium 158 and Firefox 159 for all of the raw text elements, title/textarea/p, attribute values, nested elements and SVG style/script, and they match. The new tests in element_ref/mod.rs cover the same cases; the raw text and nested ones fail without the change. cargo test --all-features, cargo clippy --all-targets --all-features -- --deny warnings and cargo fmt -- --check pass (rustc 1.99.1, Linux x86-64), and the commit applies cleanly on top of #332 and passes its lints too.

inner_html() serialized with ChildrenOnly(None), so html5ever saw the
children's text without a parent and escaped it, even inside <script>,
<style> and the other raw text elements, unlike html() and inner_html()
on the parent. Pass the element's name as the parent instead (only for
HTML elements, as an SVG <style> is escaped) and skip the root for any
ChildrenOnly scope.
@kdrag0n
kdrag0n force-pushed the inner-html-raw-text branch from 76e16d9 to ade5b46 Compare November 11, 2026 14:34

@adamreichold adamreichold left a comment

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.

LGTM.

Please try to reign in your model's cover letters. Commit message would have been sufficient instead of the wall of text.

@adamreichold
adamreichold merged commit b520ada into rust-scraper:master Nov 11, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants