← All issues

::first-letter Selection Fix: New rendererAndOffset() Position Resolver

1f01e40

Source/WebCore/dom/Position.cpp

+std::pair<RenderObject*, int> Position::rendererAndOffset() const
+{
+ Node* node = deprecatedNode();
+ if (!node)
+ return { nullptr, 0 };
+ auto* renderer = node->renderer();
+ if (!renderer)
+ return { nullptr, 0 };
+ int offset = deprecatedEditingOffset();
+ if (auto* fragment = dynamicDowncast<RenderTextFragment>(*renderer)) {
+ if (auto* firstLetterRenderer = fragment->firstLetterRenderer()) {
+ if (offset < fragment->start())
+ return { firstLetterRenderer, offset };
+ }
+ return { fragment, offset - fragment->start() };
+ }
+ return { renderer, offset };
+}

This fixes a 20-year-old bug where ::first-letter styled characters were unselectable and caret positioning failed. WebKit's ::first-letter implementation splits one DOM text node into two renderers: an anonymous RenderText for the first letter under a RenderInline(::first-letter) pseudo-element, and a RenderTextFragment for the remainder. Every DOM offset operation assumed a 1:1 node-to-renderer mapping and always consulted the wrong renderer. The fix introduces Position::rendererAndOffset() — a central helper that resolves the correct renderer and fragment-local offset for any DOM position.

DOM:                     RenderTree:
 TextNode "Hello"         RenderBlock (div)
  offset 0 = 'H'   ──►      RenderInline (::first-letter)
  offset 1 = 'e'              RenderText "H"      (local offset 0)
  offset 2 = 'l'   ──►      RenderTextFragment "ello" (local offset 0-3)
  ...

Before fix:                  After fix:
 node->renderer()            Position::rendererAndOffset()
   always returns              offset 0 → RenderText "H",  local 0
   RenderTextFragment          offset 1 → RenderTextFragment, local 0
   (misses offset 0)           offset 2 → RenderTextFragment, local 1

The change threads through 10+ source files: caret placement, selection painting, TextIterator, BiDi boundary adjustment, and upstream/downstream canonicalization. A new crossesFirstLetterBoundary function prevents VisiblePosition canonicalization from collapsing distinct caret positions across the first-letter split.

This is a wide-ranging refactor of the DOM position/selection subsystem — every call site that resolves DOM offsets to inline boxes now carries new invariants around the first-letter split, creating meaningful attack surface for off-by-one, use-after-free, and state confusion bugs.

The rendererAndOffset() helper performs offset - fragment->start() to convert DOM offsets to fragment-local offsets. Any call site that passes the result back to a DOM-level operation without reconversion would silently use a wrong offset. The commit explicitly adds a null-check for firstLetterRenderer in SimplifiedBackwardsTextIterator::handleFirstLetter because "the first-letter renderer can be destroyed by DOM mutations while the backwards iterator is still referencing the text node" — a live UAF pattern. The single-character first-letter edge case (entire text is one character, RenderTextFragment is empty) creates a degenerate case where off-by-one in offset routing would misroute to the wrong renderer.

🔒

New offset routing logic across multiple editing subsystems — edge cases in the first-letter split and mutation-during-iteration patterns are worth investigating.

Subscribe to read more