← All reports

[6] Masonry fieldset legend passes isSubgrid() despite being excluded from parent grid

MediumWebCore renderingOOB

ca8c809

Medium — reachable from any page with zero JavaScript, and it produces an out-of-range grid line index inside layout state plus a reachable debug assertion. It stops short of higher because both consumers visible in the surrounding source clamp before indexing, so no read/write primitive could be substantiated.

CSS Grid Level 3 masonry lays items out along one axis into a fixed set of tracks on the other, and a nested grid can declare itself a subgrid — adopting its parent's track lines over the area it occupies rather than defining its own. Two objects meet in this diff: RenderGrid, the render-tree object for a grid container, and GridMasonryLayout, the helper that runs masonry placement over the grid axis and maintains a per-track running-position vector sized to the track count. Subgrid-ness presupposes that the child actually occupies a span in the parent's grid, and placement math presupposes that an item's requested span fits the tracks that exist.

The angle: a page with no script can declare a fieldset legend as a subgrid it was never placed into, or ask for a ten-track span in a one-track grid, and drive masonry placement into an out-of-range line index — a debug-build assertion and, in release, a GridArea whose containment rests on downstream clamps.

The commit message names both halves: RenderGrid::isSubgrid() does not check isExcludedFromNormalLayout(), so a fieldset legend styled with grid-template-rows: subgrid is excluded from the fieldset's grid item placement but still passes all subgrid checks — and when the legend's layout queries the parent grid for its span, the legend is not found, hitting ASSERT(m_gridItemArea.contains(item)) in debug builds and causing an unsigned underflow in gridAreaForIndefiniteGridAxisItem in release. The fix returns false from isSubgrid() for excluded elements and clamps the item span to the grid-axis track count per CSS Grid Level 3 §4.4.

Source/WebCore/rendering/RenderGrid.cpp

bool RenderGrid::isSubgrid(Style::GridTrackSizingDirection direction) const
{
// https://drafts.csswg.org/css-grid-2/#subgrid-listing
if (establishesIndependentFormattingContextIgnoringDisplayType(style()))
return false;
+ if (isExcludedFromNormalLayout())
+ return false;
if (!style().gridTemplateList(direction).subgrid)
return false;
auto* renderGrid = dynamicDowncast<RenderGrid>(parent());

Source/WebCore/rendering/GridMasonryLayout.cpp

GridArea GridMasonryLayout::gridAreaForIndefiniteGridAxisItem(const RenderBox& item)
{
- auto itemSpanLength = Style::GridPositionsResolver::spanSizeForAutoPlacedItem(item, gridAxisDirection());
+ auto itemSpanLength = std::min<unsigned>(Style::GridPositionsResolver::spanSizeForAutoPlacedItem(item, gridAxisDirection()), m_gridAxisTracksCount);
auto gridAxisLines = m_gridAxisTracksCount + 1;

LayoutTests/fast/css-grid-layout/masonry-subgrid-excluded-legend-crash.html

+<style>
+*:not(style):not(script) {
+ display: grid-lanes;
+ grid-template-rows: subgrid repeat(auto-fill, []) repeat(4, []);
+}
+</style>
+<fieldset><legend><div></div></legend></fieldset>

LayoutTests/fast/css-grid-layout/masonry-span-exceeds-grid-axis-tracks-crash.html

+.outer { display: grid-lanes; grid-template-rows: 10px; }
+.inner { display: grid-lanes; grid-template-rows: subgrid; }
+.item { grid-row: span 10; }
+<div class="outer"><div class="inner"><div class="item"></div></div></div>

RenderGrid::isSubgrid(Style::GridTrackSizingDirection) gains an early if (isExcludedFromNormalLayout()) return false; guard, inserted after the existing establishesIndependentFormattingContextIgnoringDisplayType(style()) check and before the style().gridTemplateList(direction).subgrid check. A renderer excluded from normal layout now reports false for subgrid-ness even when its computed style says grid-template-rows: subgrid.

GridMasonryLayout::gridAreaForIndefiniteGridAxisItem(const RenderBox&) clamps the resolved auto-placement span to the grid-axis track count via std::min<unsigned>(..., m_gridAxisTracksCount), where previously the raw spanSizeForAutoPlacedItem() result was used directly.

Collateral: two new layout tests plus their -expected.txt files — masonry-subgrid-excluded-legend-crash.html, whose universal selector applies display: grid-lanes; grid-template-rows: subgrid ... so a <fieldset><legend> chain hits the excluded-legend path, and masonry-span-exceeds-grid-axis-tracks-crash.html, a grid-lanes container with one row track containing a nested grid-lanes subgrid with an item declaring grid-row: span 10.

A participation predicate answered from declared configuration rather than actual membership, paired with an externally-supplied span consumed by index arithmetic without being bounded against capacity.

CSS Grid subgrid. A nested grid container declared with grid-template-rows: subgrid (or -columns) does not define its own tracks in that axis; it adopts the parent grid's track lines over the span it occupies in the parent. This presupposes that the child actually occupies a span in the parent — that it is one of the parent's grid items.

Masonry / display: grid-lanes. CSS Grid Level 3 masonry lays items out along one axis (the masonry axis) into a fixed set of tracks on the other (the grid axis). GridMasonryLayout::m_gridAxisTracksCount is the number of grid-axis tracks; m_runningPositions is a Vector<LayoutUnit> sized to that count, holding the current fill height of each track.

Definite vs. indefinite grid-axis position. GridMasonryLayout::placeMasonryItems() routes each item to gridAreaForDefiniteGridAxisItem() if it has an explicit grid-axis placement, otherwise to gridAreaForIndefiniteGridAxisItem(), which auto-places it and derives a start line from the item's requested span and the available lines.

spanSizeForAutoPlacedItem(). A Style::GridPositionsResolver helper that resolves how many tracks an auto-placed item requests, e.g. from grid-row: span 10. Its value comes from author CSS.

isExcludedFromNormalLayout(). A RenderObject-level predicate marking renderers that the parent's normal layout does not lay out itself — the fieldset <legend> is the canonical case, since the fieldset renderer positions the legend specially rather than as an ordinary in-flow child.

Grid::m_gridItemArea. A HashMap<SingleThreadWeakRef<const RenderBox>, GridArea> mapping each placed item to its area. Grid::gridItemArea() asserts the key is present and otherwise returns HashMap::get()'s default — for GridArea that is the default constructor, which sets both rows and columns to GridSpan::indefiniteGridSpan().

GridSpan. A start/end line pair with distinct indefinite and translated-definite states; clamp() bounds it to a supplied track count.

Unsigned index arithmetic in C++. Subtracting a larger unsigned from a smaller one wraps modulo 2^N rather than producing a negative value, so a size comparison that would catch a negative result never fires.

There are two independent missing invariants here, and the commit closes both — each test exercises one, which is the strongest evidence that they are distinct entry points rather than one bug seen twice.

The first is a predicate/participation mismatch. isSubgrid() answered from computed style alone plus the independent-formatting-context test. A fieldset <legend> is excluded from the fieldset's normal layout, taking a special layout path rather than being laid out as an ordinary in-flow child — yet pre-fix it could still answer yes, I am a subgrid of my parent. Subgrid-ness is only meaningful for a renderer that actually occupies a span in the parent's grid. What the excluded legend's subgrid answer then feeds into is an inference: no caller of RenderGrid::isSubgrid(direction) and no fieldset/legend placement code is in the supplied context. The plausible consumer shape is a span query through Grid::gridItemArea(), which is ASSERT(m_gridItemArea.contains(item)); return m_gridItemArea.get(item); — verified from the supplied Grid.cpp / Grid.h / GridArea.h. If the legend is absent from the map, that would trip the assertion in debug builds while release builds receive a default-constructed GridArea carrying indefinite spans, consumed as if it were a real placement.

The second is an unbounded author-controlled span. Independently of the first, gridAreaForIndefiniteGridAxisItem() took spanSizeForAutoPlacedItem() — driven directly by author CSS such as grid-row: span 10 — and used it without relating it to m_gridAxisTracksCount. The next line computes auto gridAxisLines = m_gridAxisTracksCount + 1;, and the placement math derives a start/end line from those two values. The body that consumes itemSpanLength is not in the supplied source, so the precise failure mode follows from the shape of the fix rather than being shown. What the clamp establishes is unambiguous: a span larger than the grid-axis track count previously reached placement math that cannot accommodate it. Given that the surrounding code works in unsigned, the likely mechanism is an unsigned subtraction of the span from the available line count wrapping to a very large value instead of going negative, producing an out-of-range grid line index that flows into GridArea construction.

Reachability is unambiguous and requires no JavaScript — both regression tests are pure HTML and CSS.

For masonry-span-exceeds-grid-axis-tracks-crash.html: .outer is display: grid-lanes with a single 10px row track; .inner is a nested grid-lanes whose grid-template-rows: subgrid makes it derive its grid-axis track count from the parent, giving one track; .item is a child of .inner, so the GridMasonryLayout instance placing it belongs to .inner, running with m_gridAxisTracksCount == 1 and m_runningPositions sized to 1. .item declares grid-row: span 10 with no definite grid-axis position, so gridAreaForIndefiniteGridAxisItem() runs; pre-fix, spanSizeForAutoPlacedItem() returned 10 while gridAxisLines was 2.

For masonry-subgrid-excluded-legend-crash.html: the universal selector gives every element display: grid-lanes; grid-template-rows: subgrid repeat(auto-fill, []) repeat(4, []), so the <legend> inside the <fieldset> computes as a subgrid; pre-fix isSubgrid() answered true because it consulted only style and the independent-formatting-context test, even though the legend is excluded from normal layout. Whether the resulting indefinite area then reaches the same span-vs-capacity arithmetic as the span 10 case could not be established from the supplied source; the two paths are separately patched, which is at least consistent with them being distinct.

Escalation beyond a stability issue is not supported by the supplied context. On the paths visible here the out-of-range line index is not consumed by an unguarded raw memory access: Grid::insert() clamps clampedArea.rows / .columns to m_maxRows / m_maxColumns when those are set, and GridMasonryLayout::updateRunningPositions() calls gridAxisSpan.clamp(m_runningPositions.size()) before the m_runningPositions[line] loads. If some other consumer of the resulting GridArea were to index a Vector without an equivalent clamp, a wrapped line value could produce a far out-of-bounds index; the supplied source does not show such a consumer, so this remains an unrealized projection. Grid::ensureGridSize() reached from insert() would also attempt m_grid.grow(maximumRowSize) on an unclamped huge row count, which could surface as an allocation failure rather than as controlled corruption.

This vulnerability weakens memory-safety-adjacent invariants inside the renderer's layout engine: that a renderer answering isSubgrid() is actually a participant in its parent's grid, and that a resolved item span is bounded by the grid-axis track count. What an attacker could plausibly obtain is an author-influenced out-of-range grid line index propagating through masonry placement — a debug-build assertion failure and, in release, a GridArea whose containment depends on downstream clamps rather than on the placement math being correct. Realistically this is a layout-correctness and renderer-stability issue reachable from any web page with no scripting required.

The two hunks illustrate a defence-in-depth pairing worth internalizing: one fix corrects the predicate so it reflects actual layout participation rather than declared style, the other hardens the arithmetic consumer so it clamps regardless of how it was reached. The predicate fix alone would have closed the legend path; the clamp alone would have closed the span 10 path; the author landed both with a regression test each. display: grid-lanes is young code and shows the characteristic signature of an area still stabilizing — updateRunningPositions() has a real clamp() guarded by an ASSERT that the clamp is unnecessary, and Grid::gridItemArea() asserts map membership while release builds silently fall back to a default-constructed indefinite GridArea. Every place where an ASSERT documents an invariant that release builds paper over with a legal-looking default value is a place where a predicate mismatch converts into silent bad state rather than a crash.