Conversation
The "focus-without-user-activation" feature asked whether the document being
focused was allowed to use it. That answers the wrong question in both
directions.
It does not stop the thing the feature exists to stop. A restricted frame that
wants to move the user's focus only has to aim at a document that is allowed:
calling parent.focus() from a restricted iframe is permitted today, because the
top-level document is always allowed under the 'self' default allowlist. The
check cannot bind when the caller chooses the target.
And it makes the TPAC 2024 resolution -- that a parent may programmatically
focus a child navigable -- impossible to express, because the restricted child's
own policy decides.
The allow focus steps now take the document responsible for the change, and
grant permission when that document is allowed to use the feature, has transient
activation, or already holds the focus. Holding the focus includes holding it in
a descendant navigable, so an embedded document can manage its own controls,
delegate into nested content, and take focus back from it; once focus leaves
that subtree the allowance ends.
Which document is responsible is determined at each call site rather than by
consulting an ambient global:
* focus() on an element, the autofocus steps, and the dialog and popover
focusing steps use the element's or subject's node document, which is what
they already passed. Reaching one of those elements requires same-origin
access to the document that contains it, so that document is both the
natural attribution and one the caller cannot forge.
* focus() on a Window uses the incumbent global object's associated document.
This is the only focus-producing API reachable cross-origin, so it is the
only one where the caller cannot be read off the arguments, and the only one
where the check has anything to bind against.
Deriving that one from the incumbent follows Location's navigate steps, which
obtain sourceDocument the same way. The algorithm itself consults no global, so
it remains well defined on paths that run without script on the stack -- notably
flush autofocus candidates, where the incumbent is explicitly unavailable.
An autofocus candidate is re-checked when candidates are flushed. A candidate
authorized while its document held the focus is discarded if that no longer
holds, so a queued autofocus cannot reclaim focus after it has been handed away.
For every call site other than Window.focus() this is a strict superset of the
previous behavior: the same document is passed, and the added steps only grant.
Window.focus() changes in both directions by design -- a restricted document can
no longer move focus by aiming at an allowed one, and an allowed document can now
delegate into a restricted child.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The previous commit granted permission to whoever holds the focus, without relating the destination to the document asking. Since focus() on a Window is reachable cross-origin, that let a restricted document push the user's focus into an unrelated cross-origin one -- something the previous text refuses. T (top-level, allowed) |- F (restricted, cross-origin) \- V (restricted, cross-origin) T delegates focus into F, which is the case this change exists to permit. F then calls parent.frames['V'].focus(). F is not allowed to use the feature and has no transient activation, but F holds the focus, so the allowance applied and the focus landed in V. V's own policy was never consulted, and V is neither an ancestor nor a descendant of F. Previously this is refused, because the check was made against V. A single user click into F arms it the same way, and unlike transient activation the allowance does not expire. The allow focus steps now take the destination as well, and the focus-derived step requires it to be on the setter's own chain -- in the setter's inclusive ancestor navigables or its inclusive descendant navigables. A document that holds the focus may therefore move it within its own subtree and hand it back toward its embedders, but cannot place it sideways into an unrelated navigable. The destination is an optional argument defaulting to the target, so the four call sites where the document asking is the document being focused are unchanged, and an external caller written against the one-argument form keeps its previous meaning. Only focus() on a Window passes both, which is also the only call site that can name a destination outside the caller's chain. The first two grants are deliberately left unconditional. Being allowed to use the feature, or having just been interacted with, are entitlements the destination does not qualify; only the focus-derived grant is transferable, and only it is confined. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…able chain" This reverts commit c3c5175.
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.
Description
The "
focus-without-user-activation" feature currently asks whether the document being focusedis allowed to use it. That's the wrong question in both directions:
parent.focus()—allowed today, no activation needed, because the top-level document is always allowed under the
'self'default allowlist. A target check can't bind when the caller picks the target. ([focus-without-user-activation] Restricted frames should not be authorized by the target frame's user activation whatwg/html#12470)that a parent may focus a child navigable, since the restricted child's own policy decides.
([focus-without-user-activation] Parent frame should be able to focus a child with policy denied whatwg/html#12032)
So the check now asks about the document initiating the change. Alternative to whatwg#11519;
discussion in whatwg#11839.
has focus stepsis true when focus is in the document or any descendant navigable, so anembedded app can manage its controls, delegate into nested content, and take focus back from it.
Once focus leaves that subtree, the allowance ends.
Only
Window.focus()changes. The other four call sites already pass the element's or subject'snode document — now read as the document asking, which is sound because reaching such an
element requires same origin-domain access to the document holding it.
Window.focus()is the onlyalgorithm consulting
allow focus stepsthat is reachable cross-origin, so it's the only one wherethe caller isn't in the arguments; it uses the incumbent. Autofocus candidates are re-checked at
flush time, so an expired activation can't still claim focus.
Behaviour
Top-level A is allowed; B and D are restricted children of A; C is a restricted
child of B. No transient activation unless stated. Permission only — focusability and
access-control rules still apply.
mainiframeB.focus()— the element is in AbWin.focus()bDoc.input.focus()— same-origin, element is in BelementInB.focus()iframeC.focus()orcWin.focus()elementInB.focus()— taking it backparent.focus()dWin.focus()— siblingelementInB.focus()window.focus()on itselfparent.focus()dWin.focus()— only D has activationelementInB.focus()— reclaiming after handoffdWin.focus()— B has activationSeven rows change. The two that matter most are
A | B | parent.focus()andA | B | dWin.focus()with D activated — both allowed today, and both the reason a target-basedcheck can't work.
A | A | bWin.focus()is the TPAC delegation case.Note that A delegating by focusing an element inside B is denied, in both versions: the element's
node document is B, which is restricted and doesn't hold focus. A delegates by focusing the frame,
and B decides where inside itself the focus lands.
Three decisions for review
Gates taking focus, not receiving it.
allowed to useasks whether aDocumentmayuse a feature, and every other policy gates the caller. So a document can't protect itself by
denying the feature to itself — that would need its own target-side opt-out.
No destination check. Once B holds focus it can place it anywhere it can reach a
Window,including a cross-origin sibling, which today's text refuses. B could reach those with one
gesture anyway, and since handing focus over makes the recipient the holder, cooperating frames
can pass it along. A "destination must be on the setter's chain" step is written if preferred —
but it is a destination check, and would refuse two things
mainallows.Uses the incumbent global object at one call site, which the standard discourages.
someWindow.focus()is the same call whether a parent makes it on a child or a child on itself,so the caller must be identified and no argument carries it;
relevantyields the target, i.e.the model being replaced. Precedent is
Window.close()— also cross-origin callable, also apermission decision, also from the incumbent. Otherwise the fallback is to leave
Window.focus()checking the target, losing
bWin.focus()delegation and theparent.focus()fix.Compatibility and scope
For the four unchanged call sites this grants at least as much as today for fully-active documents:
same document passed, added step only grants. Not a strict superset — a document that isn't fully
active but whose
Windowstill carries transient activation is now refused, which is unobservablesince focusing it does nothing.
Window.focus()changes both ways by design: a restricted document can no longer move focus byaiming at an allowed one; an allowed document can now delegate into a restricted child.
Same-origin access rules are unchanged; this doesn't try to restrict every focus effect reachable
through same-origin DOM manipulation. Nor does it gate focus changes that aren't programmatic focus
calls:
scroll to the fragmentandpotentially reset the focusrun the focusing steps withoutconsulting
allow focus steps, and a cross-origin actor can reach those by writing to aWindow'slocation, which is cross-origin settable. That is unchanged from today and orthogonal to whichdocument the check consults, but it does bound what this feature can promise. Declarative
invocations are attributed to their invoking document. Sequential focus navigation and focus
restoration aren't newly policy-gated.
No fullscreen-specific rule: a guard would only bite for a passive viewer, since interacting with
fullscreen content gives every ancestor transient activation without consuming it — that looks like
whatwg/fullscreen#15, but I'll add one if
preferred.
Tests
Existing WPTs:
setter policy ·
target activation ignored ·
focused document and descendants ·
cross-origin recapture.
Still needed: outward handoff then denied reacquisition; popup/opener; cross-document dialogs and
popovers; inactive destinations; autofocus reauthorisation at flush time.
(See WHATWG Working Mode: Changes for more details.)