finish realm-safety for the activeElement paths - #164
Draft
myabc wants to merge 5 commits into
Draft
Conversation
`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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
8fba6e9hardened 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.activeElementstill resolved against the host document74fc41broutedsaveAndRestoreFocusthrough the target's document, but four reads were left behind:morphNode— theignoreActivecheckignoreAttribute— theignoreActiveValuebranchignoreValueOfActiveElement— which also compares againstdocument.bodycreateActiveElementAndParents— the algorithmic focus preservationFocusing an element inside an iframe sets the host document's
activeElementto the<iframe>element, so every one of those comparisons is false and both options are silently inert when morphing inside another realm:ignoreActiveValueon a focused inputignoreActiveon a focused elementEach read now goes through
ownerDocumentOf(...), which is shadow-safe and realm-safe.2.
findIdElementsthrows when the morph root is a shadowing formPre-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 catchesnull/undefined; a shadowed name is a truthy non-callable, so it gets called:Reproduced by morphing
<form id="f"><input name="querySelectorAll"><span id="s">Foo</span></form>.The method is now called off
Element.prototype, gated onis.element(root)so the existing tolerance for text, comment, document and fragment roots is preserved —?.conflated "this root genuinely has noquerySelectorAll" with "this root is an Element whose method is shadowed", and those two need different handling. The threegetAttribute?.("id")reads become a namedidAttributeOf()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
ownerDocumentgetter does — the method brand-checks the internal slot rather than the realm:foreignElement instanceof ElementfalseElement.prototype.querySelectorAll.call(foreignElement, "[id]")Element.prototype.getAttribute.call(foreignElement, "id")Known remaining exposure, deliberately not fixed here: a control named
getAttributestill breaks the morph later, inmorphAttributes, which readsgetAttribute,hasAttribute,setAttributeNode,removeAttributeandattributesstraight 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
ishelpersdb324damoved the eight duck-typed checks into theisnamespace and dropped the comment explaining why they exist and whyinstanceofis 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 truecan morph a form root whose control shadows querySelectorAll— before:TypeError: rootElt.querySelectorAll is not a functionOne 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.shouldis not on cross-realm objects at all. The existing(a === b).should.equal(true)idiom intest/restore-focus.jsis the one that works.Verification
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.appendinnerHTML guard (shadowable, but a form can have children, so the false negative lands on correct behaviour) and the barethrow "string"sites, which are existing house style.