Skip to content

Base focus permission on the document that asks - #5

Draft
ffiori wants to merge 3 commits into
mainfrom
user/ffiori/focus-setter-document
Draft

ffiori wants to merge 3 commits into
mainfrom
user/ffiori/focus-setter-document

Conversation

@ffiori

@ffiori ffiori commented Sep 15, 2026

Copy link
Copy Markdown
Owner

Description

The "focus-without-user-activation" feature currently asks whether the document being focused
is allowed to use it. That's the wrong question in both directions:

So the check now asks about the document initiating the change. Alternative to whatwg#11519;
discussion in whatwg#11839.

allow focus steps(focusSetterDocument):
  1. If focusSetterDocument is not fully active, return false.
  2. If focusSetterDocument is allowed to use the feature, return true.
  3. If focusSetterDocument's relevant global has transient activation, return true.
  4. Return has focus steps(focusSetterDocument).

has focus steps is true when focus is in the document or any descendant navigable, so an
embedded 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's
node 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 only
algorithm consulting allow focus steps that is reachable cross-origin, so it's the only one where
the 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.

Focus is in Caller Call main This PR
A delegating focus into B
A A iframeB.focus() — the element is in A allow allow
A A bWin.focus() deny allow
A A bDoc.input.focus() — same-origin, element is in B deny deny
B managing focus once it holds it
B B elementInB.focus() deny allow
B B iframeC.focus() or cWin.focus() deny allow
C B elementInB.focus() — taking it back deny allow
B B parent.focus() allow allow
B B dWin.focus() — sibling deny allow
B without focus — what the feature is for
A B elementInB.focus() deny deny
A B window.focus() on itself deny deny
A B parent.focus() allow deny
A B dWin.focus() — only D has activation allow deny
D B elementInB.focus() — reclaiming after handoff deny deny
A B dWin.focus() — B has activation deny allow

Seven rows change. The two that matter most are A | B | parent.focus() and
A | B | dWin.focus() with D activated — both allowed today, and both the reason a target-based
check 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

  1. Gates taking focus, not receiving it. allowed to use asks whether a Document may
    use 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.

  2. 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 main allows.

  3. 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; relevant yields the target, i.e.
    the model being replaced. Precedent is Window.close() — also cross-origin callable, also a
    permission decision, also from the incumbent. Otherwise the fallback is to leave Window.focus()
    checking the target, losing bWin.focus() delegation and the parent.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 Window still carries transient activation is now refused, which is unobservable
since focusing it does nothing.

Window.focus() changes both ways by design: a restricted document can no longer move focus by
aiming 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 fragment and potentially reset the focus run the focusing steps without
consulting allow focus steps, and a cross-origin actor can reach those by writing to a Window's
location, which is cross-origin settable. That is unchanged from today and orthogonal to which
document 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.)

ffiori and others added 3 commits September 11, 2026 16:59
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>
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