Repository navigation
Give a free option or list item its wrapper under every container, and push the select out of a kept table - #497
Merged
Merged
Conversation
The containment metadata wraps a free option or optgroup in a select, a free list item in a list, and a free cell in table structure, under any container that is not one of the few that hold the element directly. Those few are kept as a bit set only as long as its highest member, and an ancestor with an index past its end was read as one of them: an option under a span, a th or a ul, or a list item under a var, got no wrapper. Under a ul the list's own item was implied instead, and the next pass, seeing the option in that item, added the select, so <ul><option>x was not a fixed point; an option in a table cell the policy dropped came out bare the same way, which #490 worked around for option but not for optgroup. An ancestor past the end of the set is not in it. Two expectations pinned the old path: a caption under a ul had its table implied outside the list, where the list's empty item stood in for the wrapper, and a col under a table mapped to a list got that empty item too. Both now get the table structure the wrapper asks for, inside the list's item or suppressed with the rest. Part of #492 (item 9). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rts under a dropped template where it stood Correcting the bound for every wrapper changed how table parts balance in tables the policy dropped, where the #484 and #490 handling was built on the old reading: a form in a dropped table came to hold the text its policy disallowed, a part under a u opened a second table, and a caption in a kept template got a table a browser does not make there. The corrected bound now applies to the select and list wrappers, which is what item 9 is about; a table part past the end of its set keeps the old reading, and the balancer returns it to a table in scope first as before. The two expectations changed by the first commit are as they were. Item 2 of #492 is the same gap for a caption or column group under a template the policy dropped: a template holds them directly, so no table was implied, and the dropped template left the part where the template was, an orphan that a browser drops and the next pass wraps. A table part whose container is a dropped template is now judged where the template was, so Sanitizers.TABLES emits on the first pass the table it emitted on the second, and a template the policy keeps still holds the part directly. Part of #492 (items 9 and 2). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…er-parent the select out of a kept table A caption or column group under a template the policy dropped is now judged against the output's container, not the stack's, so a template under a dropped container gets the caption its table under TABLES. An option or optgroup whose cell the policy dropped inside a kept row or table closes the dropped cell and prepares its select as an explicit one, which pushes it out of the table as a browser foster-parents it, instead of emitting the select inside the row for the next pass to move. A template holds an option directly. The hostile-payload assertions check the output rather than the expectation. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s a foster-parented select An option or optgroup under a template the policy dropped or renamed is judged where the template stood in the output, as a caption or column group already was, so it gets its select there on the first pass rather than the second. A dropped template or cell whose nearest emitted ancestor is table structure counts as a dropped part of that table even when dropped rows lie between, so the select is pushed out of the table instead of being emitted inside it. Tests cover the dropped, renamed and kept template, with hostile payloads. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The caption and column group under a dropped template, item 2 of #492, get their own change; this one keeps the option and optgroup rule, the free wrappers' bound and the select's foster-parenting out of a kept table. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…stor, keep the dropped cell's end tag The implied select for an option under dropped entries of any name, a cell, a template or an inline element, is pushed out of the table when the nearest entry below with output is table structure; the dropped entries are set aside for the push-out and put back, so the cell's end tag still closes the option and text after the cell is not the option's. A container renamed into table structure is closed instead. One helper reads the output's container for the three rules that ask for it, the wrapper flag is named for what it does, and the change log no longer claims a list under a dropped list. Tests cover a dropped template or inline element in a dropped cell, text after the cell, a template renamed to a table, and a hostile link the policy allows. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The scan for the nearest emitted ancestor of an option under dropped entries stops at an entry with no output that was never sent to the policy, the list item a select keeps for content it cannot hold, instead of reading its output index; the probe threw on every option under a dropped template inside a select. Tests cover that shape under three policies. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ries The implied select for an option under dropped entries no longer sets those entries aside and stacks them again, which re-sent no open events, lost their per-entry state, queued their formatting for resumption and closed forwarded elements inside them: the table is pushed out from the nearest emitted table-structure entry below, and the dropped entries stay where they are, so the cell's end tag still closes the option. A container renamed into table structure is closed only when it is not table structure itself. The dropped-template rule for an option does not apply in foreign output. One helper finds the start of an implied path after its container; the dangling doc comment on the wrapper flag is gone; the browser-tree assertions check the select's parent in the output instead of parsing the expected string twice, and cover a kept element around the table, a kept formatting element inside the dropped cell and a row holding the option directly. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This was referenced Sep 19, 2026
jmanico
added a commit
that referenced
this pull request
Sep 19, 2026
|
🎉 This issue has been resolved in |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Item 9 of #492: an
optionunder a dropped table cell is emitted without itsselectand is not a fixed point.What broke
The issue's shape,
<table><th><option>tailunder a policy that drops the table parts, has been a fixed point since a late #490 commit worked around it foroptionin a dropped cell. Its class had not gone away. On main, under a policy that keepsselect,optionandoptgroupand drops the table parts,<table><th><optgroup>tailgives<optgroup>tail</optgroup>and the next pass<select><optgroup>tail</optgroup></select>;<table><tr><td><optgroup>a</optgroup><option>bgives<optgroup>a</optgroup><select><option>b</option></select>and then a select inside a select. With the table kept and only the cell dropped,<table><th><option>tailgives<table><tbody><tr><select><option>tail</option></select></tr></tbody></table>, a select inside a row, and the next pass moves it out. With everything allowed,<ul><option>xgives<ul><li><option>x</option></li></ul>and the next pass adds the select, while<ul><li><option>xgets it at once;<span><option>x,<u><optgroup>x,<td>x<option>yand<var><li>xget no wrapper at all. Under a policy that dropstemplate,<u><template><option>xgives<u><option>x</option></u>and the next pass wraps it. The probe on main flags 7,307 non-idempotent rows whose input holds anoptionoroptgroup.Why
HtmlElementTables.impliedElementswraps a freeoptionoroptgroupin aselect, a freeliin aul, and a free cell in table structure, under any container that is not one of the few that hold the element directly. Those few are kept as aboolean[]only as long as its highest member, and the check read an ancestor whose index lies past the end of the array as one of them. Element indices are alphabetical, so every name afterselect(span,td,th,tt,u,ul,var, ...) was an "allowed container" for an option and every name afterul(var,video) for a list item. Under aulthe list's own rule then implied anli, and the next pass, seeing the option in theli, applied the wrapper: not a fixed point. The comment above the code says what was meant: "we make sure we never produce an<li>or<td>without a corresponding LIST or TABLE scope element on the stack".Two more things stood between the wrapper and a fixed point once the bound was right. The balancer judged the option against its logical container, so a cell the policy dropped inside a kept row held the select in the output's row, and a
templatethe policy dropped held the option bare on the first pass only. Andtemplatereally does hold an option directly, so the wrapper's set has to name it.What changed
HtmlElementTables.impliedElements: an ancestor past the end of theselectandulwrappers' sets is not in them. The table-part wrappers keep their old reading of an ancestor past the end, under a flag named for that (pastEndCountsAsAllowed): the probe showed that changing them moves text out of forms and nests table parts differently on hundreds of inputs, and two existing tests pin their behaviour, so that is left alone and noted in the code.templatejoins theoptionandoptgroupwrappers' sets, as a browser's template holds an option directly.TagBalancingHtmlStreamEventReceiver: anoptionoroptgroupunder atemplatethe policy dropped or renamed is judged against the output's container, where the option lands, instead of the template. The select implied for an option under entries the policy dropped, a cell, a caption, a template or an inline element, whatever their names and however many, is judged by the nearest entry below with output: if that is table structure, which holds no select, the table is pushed out from that entry, as a browser foster-parents the select before the table, while the dropped entries stay where they are, so that the cell's end tag still closes the option it holds and text after the cell is not the option's. A container renamed into table structure, and not table structure itself, is closed instead. One helper reads the output's container for the three rules that ask for it, and one finds the start of an implied path after its container for the three loops that did.HtmlElementTablesTest.testImpliedElementspins the wrapper underul,span,tdandvar, the containers that hold the element directly, and the table parts' unchanged reading;HtmlSanitizerTestcovers the shapes above under a permissive policy, under the issue's table-dropping policy, under a policy that keeps the table and drops only the cell (dropped inline elements and a dropped template between the cell and the option, text after the cell's end tag, a cell after the option), under policies that drop, rename (touand totable) or keep thetemplate, and inside aselect, with hostile payloads:<style>and<script>inside an option, which stay text as CVE-2021-42575 requires, animgwith an event handler, and ajavascript:link under a policy that allows links so that the URL policy is what removes it. Existing tests are unchanged.change_log.mdbullet.Before and after
<table><th><optgroup>tail<optgroup>tail</optgroup>, then<select><optgroup>tail</optgroup></select><select><optgroup>tail</optgroup></select><table><tr><td><optgroup>a</optgroup><option>b<optgroup>a</optgroup><select><option>b</option></select>, then a select in a select<select><optgroup>a</optgroup><option>b</option></select><table><th><option>tail<table><tbody><tr></tr></tbody></table><option>tail</option>, then with<select><table><tbody><tr></tr></tbody></table><select><option>tail</option></select><table><caption><option>tail<table><select><option>tail</option></select></table>, then<table></table><select>...<table></table><select><option>tail</option></select><table><th><option>a</th>b<table><tbody><tr></tr></tbody></table><option>a</option>b, then with<select><table><tbody><tr></tr></tbody></table><select><option>a</option></select>b<table><th><p><option>a<table><tbody><tr></tr></tbody></table><option>a</option>, then with<select><table><tbody><tr></tr></tbody></table><select><option>a</option></select><ul><option>x<ul><li><option>x</option></li></ul>, then with<select><ul><li><select><option>x</option></select></li></ul><span><option>x<span><option>x</option></span><span><select><option>x</option></select></span><var><li>x<var><li>x</li></var><var><ul><li>x</li></ul></var><table><td>x<option>y...<td>x<option>y</option></td>......<td>x<select><option>y</option></select></td>...<u><template><option>x<u><option>x</option></u>, then with<select><u><select><option>x</option></select></u><table><template><option>x<table></table><option>x</option>, then with<select><table></table><select><option>x</option></select><u><template><option>xu<u><u><select><option>x</option></select></u></u><u><template><option>x<u><template><option>x</option></template></u>Every output is a fixed point. The prepackaged policies allow no
option,optgrouporliwithout their containers being allowed too, so nothing inSanitizerswidens.Verification
./mvnw -o -ntp -B clean verifyon the final commit8b6c865, with main (Keep the list's implied item out of a forwarded foreign root #495 merged) merged in: build success, 686 tests, 0 failures; existing tests unchanged.probes-review3/ReviewProbe, 15,430 inputs x 44 policies,sanandeventsmodes, main75ab9c0(with Keep the list's implied item out of a forwarded foreign root #495) against the final commit, which has that main merged in):san: 678,920 rows compared. 10,372 differ in output. 4,176 rows lose every flag (non-idempotentandtree-mutatesunder the policies that droptemplate, drop table parts, drop raw-text elements or disallow text somewhere; the option's wrapper on the first pass), 596 keep fewer flags, no row throws, and 28 rows on 13 distinct inputs gain one. Each is a consequence of the wrapper now applying under a container that was past the bound:fidelity(the output's browser tree differs from the input's), 8 rows on 8 inputs, all under the policy that allows everything: anoptionoroptgroupunderu,strong,th,o:p,foo, or aspaninside a select, gets the select a browser does not make there. The same normalisation<div><option>xhad on main.text-changed, 15 rows on 1 input:<u>...y<optgroup>...<svg><o:p><textArea>...under every policy that keepsselect; the optgroup's select now holds the rest of the input, where the output parser's select rules drop the start tags a select cannot hold, so the browser text of the output changes though the sanitizer's text does not.text-in-formandtext-in-foo, 3 rows on 3 inputs, under the policies that disallow text informor infoo: two are anoptionwhose implied select is closed by a stray</select>later in the input, which puts a followingformoutside the SVG root that main kept it in; one is<style>inside an option inside aformin a table, where the select's rule that astyleinside it holds no text turns the stylesheet into escaped text. Both rules applied underdivon main.Sanitizers.TABLES: anoptionunder a droppedtemplatein a table holds anoscriptwith acolthat gets no table on main either (<option><noscript><col>gives a bare<col />on main), and with the select pushed out of the table the bare col now lands after it instead of inside it.events(the receiver alone, at nesting limits 0..8): 36,063 rows, 278 differ, 47 lose their limit flags, 3 gain one: at limit 8 the option's select is one more level, so a list item for text that fit on main is dropped at the limit and added by the second pass at the default limit; at a dynamic limit of 6 the extra level makessetNestingLimitthrow its documentedIllegalStateExceptionwhere main had one level fewer open.templatefidelity); narrowing it to the select and list wrappers, and judging the dropped template and cell by the output, brought it to the above. The run before the reviews' fixes had 30 rows on 14 inputs against the previous main; a first version of the set-aside threw on every option under a dropped template inside a select, which the probe caught and a test now pins; the final push-out has the same 28 rows as the set-aside had.Review
/code-reviewfound the select still emitted inside the row when a droppedtemplateor a dropped inline element stood between the dropped cell and the option (the check looked at the immediate container's name), the dropped cell's stack entry removed before the select was implied, so that its end tag matched nothing and text after the cell was folded into the option, atemplaterenamed into table structure judged by the output for the wrapper but by the stack for where the select fits, the change log claiming a list under a dropped list, tests missing those shapes and a hostile link the policy did not allow anyway, three copies of the output-container lookup, and a wrapper flag named for the element rather than what it does. All fixed above, each with a test where behaviour changed: the nearest emitted ancestor decides, the dropped entries are set aside and put back, the renamed container is closed, the claim is narrowed, one helper does the lookup, and the flag ispastEndCountsAsAllowed. Its suggestion to route the implied select through the policy's prepared-start path instead of the special case is left for the follow-up that reworks the implied-element loop.A second
/code-reviewfound the set-aside itself unsound: the dropped entries were stacked again without their open events, so a nested cell's end tag could match an outer cell in the policy; entries above the container thatcontainerIndexskips were closed and not restored; per-entry state (form pointer marks, implied-table bits) was lost;closeStackFromqueued the dropped formatting for resumption and cleared the resume queue for a template; forwarded elements inside the cell were closed early; and the renamed-container branch fired for a genuine row. It suggested pushing the table out directly from the emitted entry, which is what the branch now does, with the dropped entries untouched; the other findings, the missing foreign guard on the dropped-template rule, the copy-pasted start-position loop, the dangling doc comment, the tautological tree assertions and the test comment, are fixed above. Its wish to size the wrappers' sets to the element count instead of keeping the table parts' past-end reading is the same change the first probe run rejected, and stays out.Left out
selectlike an option anywhere else, so<td><option>xreads<td><select><option>x</option></select></td>, where a browser keeps the bare option. That is the free wrapper's normalisation, applied consistently; it was applied underdivand every name beforeselectalready. The probe'sfidelityrows are this, underu,strong,thand custom elements.<ol><li>xwitholdropped andulkept, comes out bare and gets aulon the next pass, as on main; the change log says so. Judging it by the output as the option now is would be its own change.template, still has the select fit judged by its logical name.</select>closes the implied select, as it closes the one implied for<div><option>on main; a<style>,<script>or<textarea>inside the option's select holds no text, as CVE-2021-42575 requires and as it did underdiv. Both now apply under the containers that were past the bound.<table><th><option>a</option></th><td>bwith the cell dropped gives the select between two tables.<option><noscript><col>gives a bare<col />on main, and still does; underSanitizers.TABLESone probe input has it after a table the select is now pushed out of, where main left it inside.<table><th><foo><option>x</foo>ygives<select><option>xy</option></select>after the table. A form there takes the in-table rule of A form directly inside a table stays open as a container #484 and is popped, so the option runs on past</form>too. Neither is this item's; both are fixed points.<colgroup><foo><li>ximplies the list inside the column group on the first pass and beside the table on the second, on main too; one probe input reaches it through a droppedtemplate.template(item 2) is a separate change.Part of #492 (item 9)
🤖 Generated with Claude Code