Skip to content

Close a void element that set_tag_name renames to a non-void name - #337

Open
robobun wants to merge 1 commit into
cloudflare:mainfrom
robobun:void-element-rename-end-tag
Open

robobun wants to merge 1 commit into
cloudflare:mainfrom
robobun:void-element-rename-end-tag

Conversation

@robobun

@robobun robobun commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Element::set_tag_name on an HTML void element (<img>, <br>, <input>, <hr>, ...) rewrites only the start tag. A void element has no end tag in the input, so nothing closes the new name. When a browser re-parses the output, every following sibling becomes a child of the renamed element, and after a rename to textarea, title, style or script the rest of the document becomes its text:

// element!("img", |el| { el.set_tag_name("picture")?; Ok(()) })
"<img src=a.png><p>next</p><p>last</p>"  =>  "<picture src=a.png><p>next</p><p>last</p>"
// element!("br", |el| { el.set_tag_name("textarea")?; Ok(()) })
"<br><p>next</p></body></html>"          =>  "<textarea><p>next</p></body></html>"

The DOM equivalent (createElement + copy attributes + replaceWith) gives an empty, closed element with its siblings intact. The doc comment asks for "the same content model", but the library already knows the element is void (can_have_content() is false), and the caller has no good way to close it: after("</picture>", ContentType::Html) is push_front, so it only works if it is the last after() call across all handlers, it leaves a stray end tag under remove()/replace(), and it is wrong for /> elements in foreign content.

This was found by differential fuzzing of Bun's HTMLRewriter against Chromium: 8 of 12 void-to-non-void renames diverged from the DOM (the 4 that agreed had no following siblings), while 157/157 other renames agreed.

Change

StartTag gets an emit_end_tag flag. Element::set_tag_name sets it when the element cannot have content, is in the HTML namespace, and the new name is not itself a void element name. serialize_self then writes </name> right after the start tag, which puts it before any after() content, and drops a decorative />. Unchanged:

  • a rename to another void name (hr -> br): no end tag, </br> would even parse as a second <br>;
  • a self-closing element in foreign content (<svg><circle/>): /> closes it whatever it is called;
  • remove(), remove_and_keep_content(), replace(): the start tag is not serialized, so neither is the end tag;
  • can_have_content() stays false, so prepend/append/set_inner_content remain no-ops and end_tag_handlers() stays None (the written end tag runs no handlers). The rename only keeps the element empty and closed; it does not turn it into a container.

The void-name list moves from selectors_vm::stack::is_void_element to Tag::is_void_html_element so both callers share it (the ESI special case stays in the stack).

Nine existing tests in element.rs rename a matched <img> as a marker (sanitizer_bypass1, void_element, ...); their expectations now include the end tag. New tests cover the cases above in UTF-8 and EUC-JP.

`Element::set_tag_name` on an HTML void element (`<img>`, `<br>`,
`<input>`, `<hr>`, ...) rewrote only the start tag. A void element has
no end tag in the input, so nothing closed the new name: every sibling
after `<img>` renamed to `picture` re-parsed as a child of `<picture>`,
and after a rename to `textarea` or `style` the rest of the document
became its text.

The start tag now carries an `emit_end_tag` flag. `set_tag_name` sets
it when the element cannot have content, is in the HTML namespace, and
the new name is not itself a void element name. The serializer then
writes `</name>` straight after the start tag, before any `after()`
content, and drops a decorative `/>`. A rename to another void name,
a rename of a self-closing element in foreign content (closed by its
`/>` whatever the name), and `remove()`/`replace()` are unchanged. The
element still reports `can_have_content() == false`, so `prepend`,
`append` and `set_inner_content` stay no-ops and no end tag handlers
run for the written tag.

The void-name list moves from `selectors_vm::stack` to
`Tag::is_void_html_element` so both callers share it.

Nine existing tests used an `<img>` rename as a match marker and now
expect the end tag too.
@robobun
robobun requested review from a team, Noah-Kennedy, jasnell and orium as code owners September 8, 2026 11:16
robobun added a commit to oven-sh/bun that referenced this pull request Sep 15, 2026
… name

Pin oven-sh/lol-html at 5816ca4 (oven-sh/lol-html#4, the bun-branch
port of cloudflare/lol-html#337). `el.tagName = "picture"` on `<img>`
rewrote only the start tag, so the following siblings re-parsed as
children of the unclosed `<picture>`. lol-html now writes `</picture>`
right after the start tag unless the new name is void too.

The pin also moves past ea6125b (descendant-selector matching linear
in depth), which #41479 pins on its own.
@robobun

robobun commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

@orium would you have time to look at this one? It comes from the same fuzzing run as #329, #331 and #332 (Bun's HTMLRewriter against Chromium). It changes the emitted bytes in one case only: an HTML void element renamed to a name that is not void. The nine updated expectations are tests that used an <img> rename as a match marker. If you prefer a different shape, for example an opt-in setting, I can rework it. #338 is the related question about ContentType::Text inside <script>/<style>, and it needs only a yes or no on the direction.

robobun added a commit to oven-sh/lol-html that referenced this pull request Sep 22, 2026
`Element::set_tag_name` on an HTML void element (`<img>`, `<br>`,
`<input>`, `<hr>`, ...) rewrote only the start tag. A void element has
no end tag in the input, so nothing closed the new name: every sibling
after `<img>` renamed to `picture` re-parsed as a child of `<picture>`,
and after a rename to `textarea` or `style` the rest of the document
became its text.

The start tag now carries an `emit_end_tag` flag. `set_tag_name` sets
it when the element cannot have content, is in the HTML namespace, and
the new name is not itself a void element name. The serializer then
writes `</name>` straight after the start tag, before any `after()`
content, and drops a decorative `/>`. A rename to another void name,
a rename of a self-closing element in foreign content (closed by its
`/>` whatever the name), and `remove()`/`replace()` are unchanged. The
element still reports `can_have_content() == false`, so `prepend`,
`append` and `set_inner_content` stay no-ops and no end tag handlers
run for the written tag.

The void-name list moves from `selectors_vm::stack` to
`Tag::is_void_html_element` so both callers share it.

Nine existing tests used an `<img>` rename as a match marker and now
expect the end tag too.

Port of cloudflare#337 (8dba2f7) to the `bun` branch. The
differences: the start tag is reached through `tag()`/`tag_mut()`, and
`StartTag::take_owned` copies the flag so that it survives a handler
suspension (new test `void_element_renamed_across_suspension`).
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.

1 participant