Skip to content

finish realm-safety for the activeElement paths - #164

Draft
myabc wants to merge 5 commits into
bigskysoftware:mainfrom
myabc:fix/realm-safe-active-element
Draft

finish realm-safety for the activeElement paths#164
myabc wants to merge 5 commits into
bigskysoftware:mainfrom
myabc:fix/realm-safe-active-element

Conversation

@myabc

@myabc myabc commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

This stacks on #163. Until that lands, the diff shown here includes its commit (read ownerDocument off the Node prototype), which introduces the ownerDocumentOf helper this PR consumes. Only the last three commits belong to this PR.

A drive-by from a non-maintainer, so please treat it as a suggestion rather than a request — happy to split, reshape or drop any part of it.

#156 made the node type checks realm-safe and 8fba6e9 hardened them against form named-property shadowing. This finishes both jobs in the places they were not applied. Three independent parts, one commit each.

1. document.activeElement still resolved against the host document

74fc41b routed saveAndRestoreFocus through the target's document, but four reads were left behind:

  • morphNode — the ignoreActive check
  • ignoreAttribute — the ignoreActiveValue branch
  • ignoreValueOfActiveElement — which also compares against document.body
  • createActiveElementAndParents — the algorithmic focus preservation

Focusing an element inside an iframe sets the host document's activeElement to the <iframe> element, so every one of those comparisons is false and both options are silently inert when morphing inside another realm:

scenario cross-realm (before) same-realm
ignoreActiveValue on a focused input value overwritten by the server value value preserved
ignoreActive on a focused element element morphed, attribute lost element left alone
algorithmic focus preservation focus lost on reparent focus preserved

Each read now goes through ownerDocumentOf(...), which is shadow-safe and realm-safe.

2. findIdElements throws when the morph root is a shadowing form

Pre-existing — not a regression from #155 or #156, and not anybody's fault in those PRs; it is just the same class of bug one line below a comment that already warns about it.

<form> is [LegacyOverrideBuiltIns], so <input name="querySelectorAll"> installs an own property that shadows the method. The existing ?. guard only catches null/undefined; a shadowed name is a truthy non-callable, so it gets called:

TypeError: rootElt.querySelectorAll is not a function

Reproduced by morphing <form id="f"><input name="querySelectorAll"><span id="s">Foo</span></form>.

The method is now called off Element.prototype, gated on is.element(root) so the existing tolerance for text, comment, document and fragment roots is preserved — ?. conflated "this root genuinely has no querySelectorAll" with "this root is an Element whose method is shadowed", and those two need different handling. The three getAttribute?.("id") reads become a named idAttributeOf() helper for the same reason.

I probed whether calling off the prototype survives a realm boundary before relying on it. It does, for the same reason the ownerDocument getter does — the method brand-checks the internal slot rather than the realm:

probe on a node from an iframe document result
foreignElement instanceof Element false
Element.prototype.querySelectorAll.call(foreignElement, "[id]") works, returns the matches
Element.prototype.getAttribute.call(foreignElement, "id") works, returns the id

Known remaining exposure, deliberately not fixed here: a control named getAttribute still breaks the morph later, in morphAttributes, which reads getAttribute, hasAttribute, setAttributeNode, removeAttribute and attributes straight off the element. Hardening all of those is a much larger change than this PR wants to be, so it only covers the sites that were already trying to be shadow-safe. Happy to open a separate issue for the rest if that would be useful.

3. Restore the dropped rationale for the is helpers

db324da moved the eight duck-typed checks into the is namespace and dropped the comment explaining why they exist and why instanceof is tried first. Parts 1 and 2 above are precisely the mistakes that comment prevents, which is the argument for putting a tightened version back.

Tests

Five new tests, each confirmed to fail on the parent commit and pass with the fix:

  • ignores the active element of the target's document when ignoreActive is set — before: expected '<input id="i1">' to equal '<input id="i1" data-keep="1">'
  • ignores the active input's value in the target's document when ignoreActiveValue is set — before: expected 'server2' to equal 'typed-by-user'
  • ignores the active textarea's value in the target's document when ignoreActiveValue is set — before: expected 'server2' to equal 'typed-by-user'
  • preserves focus algorithmically when morphing inside another document — before: expected false to equal true
  • can morph a form root whose control shadows querySelectorAll — before: TypeError: rootElt.querySelectorAll is not a function

One incidental gotcha for anyone writing more cross-realm tests here: chai.expect(foreignNode).to.equal(...) hangs the test run (chai-dom inspecting a node from another realm), and .should is not on cross-realm objects at all. The existing (a === b).should.equal(true) idiom in test/restore-focus.js is the one that works.

Verification

npm run typecheck        # silent
npm run format:check     # All matched files use Prettier code style!
npm run test:chrome      # 219 passed, 0 failed, 5 skipped; Code coverage: 100 %
node test/lib/ensure-full-coverage.js   # exit 0

219 = 214 on the parent commit plus the five new tests. Nothing in dist/ is touched; no rebuild is needed for these changes.

Left alone on purpose: the !oldNode.append innerHTML guard (shadowable, but a form can have children, so the false negative lands on correct behaviour) and the bare throw "string" sites, which are existing house style.

`saveAndRestoreFocus` resolves the document that owns the focus by
reading `ctx.target.ownerDocument`. `<form>` is [LegacyOverrideBuiltIns],
so a named control such as `<input name="ownerDocument">` installs an own
property on the form that shadows `Node.prototype.ownerDocument`.

When the morph target is such a form, `doc` is an `HTMLInputElement`,
`doc.activeElement` is `undefined`, and the function early-returns, so
focus and selection restoration silently stop happening.

Add an `ownerDocumentOf` helper that reads through the prototype getter,
caching the descriptor once at module scope. The getter is also
realm-safe: it brand-checks the internal slot rather than the realm.
74fc41b routed saveAndRestoreFocus through the target's document, but
four other reads still resolve against the host document:

  - morphNode's `ignoreActive` check
  - ignoreAttribute's `ignoreActiveValue` branch
  - ignoreValueOfActiveElement, which also compares to `document.body`
  - createActiveElementAndParents

Focusing an element inside an iframe sets the host document's
activeElement to the `<iframe>` element itself, so all four comparisons
are false and `ignoreActive`, `ignoreActiveValue` and the algorithmic
focus preservation are silently inert when morphing across realms.

Resolve each through ownerDocumentOf() instead, which is both
shadow-safe and realm-safe.
`<form>` is [LegacyOverrideBuiltIns], so a control named
`querySelectorAll` installs an own property that shadows the method.
findIdElements guards with `?.`, which only catches null/undefined: a
shadowed name is a truthy non-callable, so it is called and throws
`TypeError: rootElt.querySelectorAll is not a function` whenever the
morph root is such a form.

Call the method off `Element.prototype` instead, gated on is.element()
so the existing tolerance for text, comment, document and fragment roots
is kept: `?.` conflated "not an Element" with "Element whose method is
shadowed", and the two need different handling. Calling off the
prototype is realm-safe for the same reason the `ownerDocument` getter
is: it brand-checks the internal slot, not the realm.

The three `getAttribute?.("id")` reads become a named idAttributeOf()
helper for the same reason.
db324da moved the eight duck-typed checks into the `is` namespace and
dropped the comment explaining why they exist at all. Put a tightened
version back, including why `instanceof` is tried first: duck-typing is
itself shadowable (8fba6e9).
ignoreValueOfActiveElement runs once per morphed node, and resolved the
owner document before testing ignoreActiveValue, which is off by default.
Test the option first so the common path costs nothing.
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.

1 participant