Skip to content

fix(dom): bound children() and ancestors() walks against cyclic chains - #589

Open
mnaza wants to merge 1 commit into
h4ckf0r0day:mainfrom
mnaza:fix/dom-cap-children-ancestors-walk-582
Open

fix(dom): bound children() and ancestors() walks against cyclic chains#589
mnaza wants to merge 1 commit into
h4ckf0r0day:mainfrom
mnaza:fix/dom-cap-children-ancestors-walk-582

Conversation

@mnaza

@mnaza mnaza commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

descendants() already caps its walk at nodes.len() as defense-in-depth against a corrupted/cyclic node graph, but children() (sibling chain) and ancestors() (parent chain) walked pointers with no bound — one corrupted next_sibling/parent would hang these walkers and every caller forever. The public mutation guards prevent such cycles, so this is hardening; it mirrors the descendants() bound in both loops.

TDD: tests forge a sibling cycle and a parent cycle by writing the node arena directly and assert each walk stays bounded. RED hung (SIGTERM after ~100s); GREEN terminates in microseconds. All 81 obscura-dom tests pass.

Closes #582

markjacksoncerberus added a commit to markjacksoncerberus/obscura that referenced this pull request Aug 14, 2026
… COULD NOT NAVIGATE TO A FRAGMENT (location.hash had only a getter, pushState was a stub, window.open returned null, a dispatched click on <a href> navigated NOTHING, fonts.load resolved before the font arrived, and blur/focus fired UNTRUSTED) — Quests h4ckf0r0day#580h4ckf0r0day#589 (ten quests, focus realm 208 -> 267/275, 0 regressions over 331 ritual rows)

h4ckf0r0day#583 Same-document navigation exists: location.hash (+ the writable Location
     partials), fragment navigation with :target sync (a[name] included in the
     Rust selector), hashchange/popstate on a task, viewport-fallback unfocus
     for a real target only; history.pushState/replaceState with clone and
     same-origin checks; Node.moveBefore; a dispatched trusted click follows
     hyperlinks through the shared _followHyperlink.
h4ckf0r0day#584 The sequential focus navigation starting point is a POSITION: flat-tree
     anchor chains captured at focus time survive removals, moved parents,
     slots and shadow hosts; clicks/blur/fixup/fragment set it; it outranks
     the focused element; backward navigation never lands ON a container it
     sits inside. sequential-focus-navigation-starting-point 0/20 -> 20/20.
h4ckf0r0day#585 Autofocus is a QUEUE in temporal insertion order with a fragment-target
     gate and per-top-context settling; frame documents focus their host
     iframe chain (frame nodes' ownerDocument lies — routing walks the tree).
h4ckf0r0day#586 The focus fixup rule, fully: fieldset-disabled with the first-legend
     escape, visibility from live inline declarations, style-write and
     insertion triggers, end-of-update-the-rendering timing. 1/8 -> 8/8.
h4ckf0r0day#587 blur/focus/focusin/focusout dispatch TRUSTED as real FocusEvents with
     relatedTarget; focus({focusVisible}) overrides the modality heuristic;
     hasFocus() is false without a browsing context.
h4ckf0r0day#588 window.open opens a WINDOW: a popup is a second top-level browsing
     context built from the frame machinery without a host element; its
     scripts run, load fires there, and focus never climbs into the opener.
h4ckf0r0day#582 document.fonts.load()/.ready actually wait: a render-side resource pump
     op folds landed bytes and reports in-flight fetches; geometry re-ships
     once settled.
h4ckf0r0day#580/h4ckf0r0day#581/h4ckf0r0day#589 (fork) empty <li> gets its marker's line box; marker layouts
     rebuild when a webfont arrives; ::before{display:list-item} generates a
     marker. marker-hit-testing 1/20 -> 7/20, marker-computed-size 3/8 -> 4/8.

The region diff caught two real regressions and both were closed in-session:
sandboxed and cross-origin frames' autofocus focused the iframe — the old code
had no frame autofocus at all, so nothing had ever needed the gates. A new
capability inherits every restriction the old incapacity was accidentally
enforcing.

Zero regressions: 331-row ritual pre/post diff (wpt_batch_par.sh, 4 shards);
the one flagged row is the documented flaky img file, re-proven 201/167/210 on
the same binary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@SGavrl SGavrl closed this Aug 14, 2026
@SGavrl SGavrl reopened this Aug 14, 2026
descendants() already caps its walk at nodes.len() as defense-in-depth
against a corrupted/cyclic node graph, but children() (sibling chain) and
ancestors() (parent chain) looped over next_sibling / parent pointers
with no bound. A single corrupted pointer would hang these walkers — and
every caller — forever, while descendants() would recover.

The append_child / insert_before guards prevent such cycles through the
public API, so this is hardening, not a reachable bug. Mirror the
descendants() bound in both loops: stop once the collected count exceeds
nodes.len().

Adds tests that forge a sibling cycle and a parent cycle by writing the
node arena directly and assert each walk stays bounded instead of
hanging.

Closes h4ckf0r0day#582
@mnaza
mnaza force-pushed the fix/dom-cap-children-ancestors-walk-582 branch from a0b3d53 to 01bc142 Compare August 15, 2026 16:35
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.

fix(dom): children() and ancestors() lack the cycle cap that descendants() has

2 participants