LibWeb: Fix use-after-free in live-collection filter captures

Problem: Holding form.elements while the form is detached + dropped hit
a use-after-free: the form is GC’ed while the collection’s still live.

Cause: HTMLCollection (and LiveNodeList too) was storing its filter as
an AK::Function — which the garbage collector doesn’t visit. When a
filter lambda captures a GC object (e.g. the form in form.elements) that
object has no GC edge keeping it alive. So it can be collected while the
collection using it’s still reachable — leaving a dangling pointer.

Fix: HTMLCollection and LiveNodeList are GC cells with their own
visit_edges. So, visit the filter’s (and sort’s) capture range there:
conservatively mark any GC object a captured lambda holds — to ensure
it’s kept alive as long as the collection’s reachable.

Fixes https://github.com/LadybirdBrowser/ladybird/issues/9948
This commit is contained in:
sideshowbarker 2026-06-07 18:58:05 +09:00 committed by Shannon Booth
parent 9a99b54036
commit efa9388adc
4 changed files with 35 additions and 0 deletions

View file

@ -50,6 +50,8 @@ void HTMLCollection::visit_edges(Cell::Visitor& visitor)
{
Base::visit_edges(visitor);
visitor.visit(m_root);
visitor.visit_possible_values(m_filter.raw_capture_range());
visitor.visit_possible_values(m_sort.raw_capture_range());
}
GC::Cell const& HTMLCollection::owner_cell(Badge<GC::Heap>) const

View file

@ -33,6 +33,7 @@ void LiveNodeList::visit_edges(Cell::Visitor& visitor)
{
Base::visit_edges(visitor);
visitor.visit(m_root);
visitor.visit_possible_values(m_filter.raw_capture_range());
}
GC::RootVector<Node*> LiveNodeList::collection() const

View file

@ -0,0 +1,2 @@
form alive while collection is held: true
collection.length: 0

View file

@ -0,0 +1,30 @@
<!DOCTYPE html>
<script src="include.js"></script>
<body></body>
<script>
// Regression test: a live HTMLFormControlsCollection captures its form in the filter, so the form must stay
// alive while the collection is reachable. Previously the form was garbage-collected out from under the live
// collection — leaving a dangling pointer in the filter (a use-after-free). See issue #9948.
asyncTest(done => {
let form = document.createElement("form");
form.appendChild(document.createElement("input"));
document.body.appendChild(form);
let collection = form.elements;
let weakForm = new WeakRef(form);
form.remove();
form = null;
setTimeout(() => {
internals.gc();
// Churn the heap to dislodge any stale references, then collect again.
for (let i = 0; i < 10000; ++i)
document.body.appendChild(document.createElement("div"));
internals.gc();
println(`form alive while collection is held: ${weakForm.deref() !== undefined}`);
println(`collection.length: ${collection.length}`);
done();
}, 0);
});
</script>