Fix potential use-after-free with PerformanceObserver.

TL;DR - use page.arena instead of page.call_arena

This probably comes from copying the implementation of MutationObserver and/or
IntersectionObserver. But those dispatches are different in that they directly
dispatch a slice (e.g. of MutationRecords) which gets mapped to a v8::Array when
doing the callback. The MutationRecords exist on the heap, not in
_pending_records, so the call_arena is fine.

PerformanceObserver returns an Zig object, not a slice. Therefore it gets mapped
to a v8::Object which references the Zig object. The state of that object, the
_entries list, has to exist for the lifetime of that object, not the call_arena.
This commit is contained in:
Karl Seguin
2026-01-17 15:57:43 +08:00
parent 744311f107
commit 30d052db99
3 changed files with 56 additions and 1 deletions

View File

@@ -112,3 +112,28 @@
});
});
</script>
<script id="microtask_access_to_records">
testing.async(async () => {
let savedRecords;
const promise = new Promise((resolve) => {
const element = document.createElement('div');
const observer = new MutationObserver((records) => {
// Save the records array itself
savedRecords = records;
resolve();
observer.disconnect();
});
observer.observe(element, { attributes: true });
element.setAttribute('test', 'value');
});
await promise;
// Force arena reset by making a Zig call
document.getElementsByTagName('*');
testing.expectEqual(1, savedRecords.length);
testing.expectEqual('attributes', savedRecords[0].type);
testing.expectEqual('test', savedRecords[0].attributeName);
});
</script>

View File

@@ -36,3 +36,31 @@
performance.mark("operationEnd", { startTime: 34.0 });
}
</script>
<script id="microtask_access_to_list">
{
let savedList;
const promise = new Promise((resolve) => {
const observer = new PerformanceObserver((list, observer) => {
savedList = list;
resolve();
observer.disconnect();
});
observer.observe({ type: "mark" });
performance.mark("testMark");
});
testing.async(async () => {
await promise;
// force a call_depth reset, which will clear the call_arena
document.getElementsByTagName('*');
const entries = savedList.getEntries();
testing.expectEqual(true, entries instanceof Array, {script_id: 'microtask_access_to_list'});
testing.expectEqual(1, entries.length);
testing.expectEqual("testMark", entries[0].name);
testing.expectEqual("mark", entries[0].entryType);
});
}
</script>

View File

@@ -126,7 +126,9 @@ pub fn disconnect(self: *PerformanceObserver, page: *Page) void {
/// Returns the current list of PerformanceEntry objects
/// stored in the performance observer, emptying it out.
pub fn takeRecords(self: *PerformanceObserver, page: *Page) ![]*Performance.Entry {
const records = try page.call_arena.dupe(*Performance.Entry, self._entries.items);
// Use page.arena instead of call_arena because this slice is wrapped in EntryList
// and may be accessed later.
const records = try page.arena.dupe(*Performance.Entry, self._entries.items);
self._entries.clearRetainingCapacity();
return records;
}