Skip to content

Keep a table open across content a browser would put in front of it (#342) - #481

Merged
jmanico merged 5 commits into
mainfrom
fix/342-foster-parented-table-content
Sep 11, 2026
Merged

Keep a table open across content a browser would put in front of it (#342)#481
jmanico merged 5 commits into
mainfrom
fix/342-foster-parented-table-content

Conversation

@jmanico

@jmanico jmanico commented Sep 11, 2026

Copy link
Copy Markdown
Member

Fixes #342.

A browser puts content that cannot go inside a table, such as a div between its rows, in front of the table and keeps the table open, so the next row pops the content and carries on in the same table. The balancer closed the table to make room for the content, and when the row came it opened a new table inside the content, which then stayed open until its parent closed and swallowed every row and everything after the table. With the reporter's own HTML, the div with display:none wrapped the whole rest of the message.

<table><div>x<tr><td>y</td></tr></div></table>tail
before: <table></table><div>x<table><tbody><tr><td>y</td></tr></tbody></table>tail</div>
after:  <table></table><div>x</div><table><tbody><tr><td>y</td></tr></tbody></table>tail

Design

TagBalancingHtmlStreamEventReceiver gains a pushedOut bit set over its open-element stack. When content a browser would foster-parent arrives and the open table cannot hold it, the table and any row group and row are closed in the output but kept on the stack and marked. While marked they still bound end tags for elements below them, as in a browser, so </div> for a div enclosing the table is ignored until the table ends. When a part of the table arrives, the pushed-out content is closed, the marked entries that cannot hold the part are dropped, and the rest are written again as a new table for the part to go in. A table start tag pops the pushed-out table instead, as in a browser. The table's own end tag closes the content and pops the marked entries without writing anything. The return stops at a table-scope boundary, such as a template in the pushed-out content, where a browser would not look past it.

The element that holds the table is the element that holds the content, since that is where a browser puts it. So the push-out happens before anything asks what contains the content, and a container index skips a pushed-out run: the implied elements and the containment checks see the real container, and close it when it cannot hold the content.

The output cannot put anything in front of a tag already written, so the table is written twice, once empty and once with the later rows, and the second copy has no attributes, like a formatting element written again after being closed. Text pushed out of a table follows it rather than preceding it. Apart from those two things a browser now reads the output as it reads the input, which the tests check with the validator.nu parser.

Second commit: the review's findings

An independent review of the first commit found four shapes where content beside a pushed-out table was still handled as if the table contained it, three of them regressions against main. All four are fixed in 77fc910 with a test each, verified against a main build case by case:

Shape Was Now
<table>x<option>o</option></table>z bare <option>, not idempotent <select> wrapper restored, idempotent
<h1><table><tr><td>a</td></tr><h1>b</h1></table> nested h1, not idempotent outer h1 closes, idempotent
<table><col><tr><td>a</td></tr>tail</table> tail silently dropped tail kept, as on main
<table><thead>x<td>y</td></table>tail fresh table inside the re-opened thead, not idempotent cell returns to the table, idempotent

A 52-shape differential sweep against main now shows no idempotence or balance regressions, two fewer non-idempotent shapes than main, and two shapes where main loses text that this keeps. The two that remain non-idempotent are the pre-existing col mis-implication, filed as #483.

Links

Two guards, since nested links do not survive a browser's parse and would make the output read back differently: a link that ends the link open before it still does so through a table between them rather than pushing the table out (an existing test with a dropped caption caught this), and a link closed on the way back to a table is not written again around or inside another link. The second also fixes a table-free case on main, where <div><a href=u>x</div><a href=v>y produced nested links, and has its own regression test.

Known limitation

A formatting element pushed out of a table is queued by the pre-existing resume mechanism and can be written again inside the table's next cell, where a browser's cell marker would stop it: <table><b>x<tr><td>y</td></tr></table>z puts a <b> around the cell text. main has the cell text inside the outer <b> instead, so it renders the same either way, and the output is stable. Teaching the resume queue about formatting markers would change testTablesGuarded, which pins resumption across cells, so it is left alone.

Tests

Four at the balancer level and fifteen through PolicyFactory: the issue's shape, the reporter's shape with row groups and attributes, nesting inside pushed-out content, end tags below the table, the table's own end tag, a nested table, two levels of pushed-out tables, the scope boundary, every table part, pushed-out links and text, the four review shapes, the table-free link case, and the nesting limit with 300 pushed-out elements and 300 push-out cycles. All 583 tests pass on JDK 11, 17, 21 and 25, and the three fuzzers pass at six fixed seeds.

Out of scope, filed separately

A table part arriving in a container that was never pushed out still implies a new table there (#483), a form directly inside a table stays open as a container (#484), and the element at the nesting limit is written but its end tag is not (#485). Each is unchanged by this PR.

🤖 Generated with Claude Code

https://claude.ai/code/session_014Ydng7gBm6Ax5zfwt4vZip

A browser puts content that cannot go inside a table, such as a div
between its rows, in front of the table and keeps the table open, so
that the next row pops the content and carries on in the same table.
The tag balancer closed the table to make room for the content, and when
the row came it opened a new table inside the content, which then stayed
open until its parent closed and swallowed every row and everything after
the table (#342).

The balancer now keeps the table, and any row group and row the content
was pushed out of, on its stack while closing them in the output, marked
as pushed out.  They still bound end tags for the elements below them, as
in a browser, and when a part of the table arrives they take it back: the
pushed-out content is closed, the entries that cannot hold the part are
dropped, and the rest are written again as a new table for the part to go
in.  A table arriving instead pops the open one, as in a browser.  The
output cannot put anything in front of a tag already written, so the
table is written twice, once empty and once with the later rows, and text
pushed out of a table follows it rather than preceding it.

A link pushed out of a table and closed when the table resumes is not
written again around another link or where one is open, since nested
links do not survive a browser's parse; and a link that ends the link
open before it does so through a table between them rather than pushing
the table out.

Fixes #342

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Ydng7gBm6Ax5zfwt4vZip
jmanico and others added 4 commits September 10, 2026 20:29
A browser looks for the table to return to within table scope only, so
a table or part arriving inside a template in the pushed-out content
stays there, and the pushed-out table waits for a part arriving outside
it.  The return used to close the template and jump back.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Ydng7gBm6Ax5zfwt4vZip
A review of the push-out found four shapes where content beside a
pushed-out table was handled as if the table still contained it, three of
them regressions against the behaviour before the push-out landed.

A browser puts content a table cannot hold in front of the table, so the
element that holds the table is the one that holds the content, and it is
what decides which elements are implied around the content and what has
to close.  The pushed-out entries stay on the stack, so every step after
the push-out was reading a table as the container:

* Implied elements were computed against the pushed-out table, so an
  option beside one lost the select the tables never leave to chance, and
  the output was not idempotent.
* Containment was not re-checked below the pushed-out run, so an element
  that cannot hold the content nested it instead of closing: a heading
  inside a heading, a button inside a button, neither of which survives a
  browser's parse.
* Text whose container could not hold text was written into it and
  dropped by the policy's text gate rather than closing it, losing the
  text after a table inside a colgroup.
* A cell returning to a pushed-out row group was given a fresh table
  inside the re-opened group, since a non-empty implied path was read as
  the group being able to hold the cell even when that path leads through
  a table.

The push-out now happens before anything asks what contains the content,
and only when the table cannot hold it with implied elements between; a
container index skips a pushed-out run, so the implied elements and the
close loop see the real container and close it when it cannot hold the
content; and the return to a pushed-out table keeps an entry only when
the implied path to the part runs through it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Ydng7gBm6Ax5zfwt4vZip
@jmanico
jmanico merged commit 86fe764 into main Sep 11, 2026
7 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.

Issue with HTML Sanitization: Improper Handling of <div> Tag Inside <table>

1 participant