16adc8a864640a71d11d7d67a8a337c467eb0b1b [soft navs] Only consider FullyTrusted events
Triage note: Renderer crash reachable from web content in default config via label->form-control click forwarding. Logic/assert bug, not memory corruption, so watch.
The bug at a glance
A web-reachable NOTREACHED() in the renderer, tripped by ordinary markup (a pointing at a form control), is a reliable same-origin renderer crash (DoS) in a build where NOTREACHED is fatal. That is the whole demonstrated impact: no memory corruption, no info leak, no cross-origin effect — the author sets exploitable=false and the crash is a controlled CHECK-class abort, not memory unsafety. The fix itself downgrades the NOTREACHED to NotFatalUntil::M149 and adds a real fallthrough implementation, explicitly acknowledging the crash was the payload and defusing it. That combination — trivial trigger but bounded to renderer self-DoS with a controlled abort — is a clean low / 5.3. It stays a security bug (isSecurity=true) because it is a violated engine invariant reachable from untrusted content and because the trust-filter gap it exposes (forwarded label events are always isTrusted) could matter to other trusted-only paths, which the commit message itself flags (EventTiming as a follow-up).
The angle is a subtle definition of ’trusted’ in the DOM event model. isTrusted() is true for user-generated events but is ALSO true for the synthetic event a forwards to its associated form control — even when the label’s own click was script-generated (element.click()). So the isTrusted() filter that SoftNavigationHeuristics used to exclude simulated input does not exclude a script-driven label click that gets re-dispatched to the control. IsFullyTrusted() walks the underlying event chain and returns false in that case. Any security or metrics gate that trusts isTrusted() to mean ‘genuine user input’ shares this blind spot; the commit explicitly names EventTiming as also affected.
Root cause
SoftNavigationHeuristics starts an EventScope only for input events it considers user-initiated, gating on event.isTrusted() in EventScopeTypeFromInputEvent. When script calls label.click() on a , the label forwards a synthetic click to the associated control (); that forwarded event is marked isTrusted=true by the label-forwarding machinery regardless of the originating click’s trust. So isTrusted() passes, a soft-navigation EventScope is created, and a SoftNavigationContext is established for a task that the scheduler considers a plain web-scheduling task/continuation. Later, WebSchedulingTaskState::ForkAndSetVariable is invoked with a non-null soft_navigation_context. That function’s entire body was NOTREACHED() on the assumption that a SoftNavigationContext can never be the outermost async variable in a web-scheduling task — an invariant task attribution relies on. The forwarded-label case violates it, so the code hits NOTREACHED() and crashes. The fix has two parts: (1) EventScopeTypeFromInputEvent now gates on event.IsFullyTrusted() instead of event.isTrusted(); IsFullyTrusted() considers the event chain and returns false for the script-originated-then-forwarded case, so no bogus SoftNavigationContext is created. (2) Defense in depth: ForkAndSetVariable’s NOTREACHED() is downgraded to NOTREACHED(base::NotFatalUntil::M149) and a correct implementation is added below it — it builds a TaskAttributionInfoImpl carrying next_task_id, the soft_navigation_context, and the inherited ResourceTimingContext, and returns a new WebSchedulingTaskState. So even if another unforeseen path reaches it before M149, the renderer no longer crashes and behaves sanely.
Key code
Root-cause one-liner plus the defense-in-depth downgrade of the reachable NOTREACHED
// soft_navigation_heuristics.cc — the real fix
- if (!event.isTrusted()) {
+ if (!event.IsFullyTrusted()) {
return std::nullopt;
}
// web_scheduling_task_state.cc — belt-and-suspenders
- // `SoftNavigationContext` is known not be created in web scheduling tasks...
- NOTREACHED();
+ NOTREACHED(base::NotFatalUntil::M149);
+ scheduler::TaskAttributionInfo* current_task_attribution_info =
+ MakeGarbageCollected<TaskAttributionInfoImpl>(
+ next_task_id, soft_navigation_context,
+ GetTaskAttributionInfo()
+ ? GetTaskAttributionInfo()->GetResourceTimingContext()
+ : nullptr);
+ return MakeGarbageCollected<WebSchedulingTaskState>(
+ current_task_attribution_info, GetSchedulerTaskContext());
Patch walkthrough
third_party/blink/renderer/core/timing/soft_navigation_heuristics.cc— The one-line root-cause fix: gate EventScope creation on IsFullyTrusted() rather than isTrusted(). This stops a script-originated label click (forwarded to a form control and re-marked trusted) from being treated as user input, so no SoftNavigationContext is created for it and the downstream invariant holds.third_party/blink/renderer/core/scheduler/web_scheduling_task_state.cc— Defense in depth. Converts the unconditional NOTREACHED() into NOTREACHED(base::NotFatalUntil::M149) and supplies a working fallthrough that forks a proper TaskAttributionInfo/WebSchedulingTaskState instead of crashing. Intended to flush out any remaining trust-filter gaps without shipping a live crash; the TODO says to delete the body once confidence is established.third_party/blink/web_tests/wpt_internal/task-tracking/soft-navigation-heuristics/non-fully-trusted-click.html— Regression test. Inside requestIdleCallback it calls target.click() on a associated with ; pre-fix this forwarded a trusted click, created a SoftNavigationContext in a scheduling task, and crashed via the NOTREACHED. The test asserts no crash.
Background
isTrusted vs IsFullyTrusted — Event.isTrusted is true for user-generated events and for certain synthetic-but-privileged dispatches, including a label forwarding a click to its associated control. IsFullyTrusted() walks the event chain and is false when the originating action was scripted, giving the true ‘genuine user input’ answer.
Label event forwarding — Clicking a (or calling label.click()) dispatches a synthetic click to the form control named by its ‘for’ attribute. The HTML spec marks that forwarded event trusted, which is why script-initiated label clicks slip past an isTrusted filter.
SoftNavigationHeuristics — Blink machinery that detects SPA ‘soft navigations’ by attributing DOM changes to the user interaction that caused them. It only wants real user input, hence the trust filter; a false positive plants a SoftNavigationContext where task attribution does not expect one.
Task attribution invariant / NotFatalUntil — WebSchedulingTaskState::ForkAndSetVariable assumed a SoftNavigationContext is never the outermost async variable in a web-scheduling task. base::NotFatalUntil::Mxxx is Chromium’s mechanism to keep a CHECK/NOTREACHED reporting-but-nonfatal until a milestone, so residual violations are surfaced via crash reports without crashing users.
Vulnerability window
- origin — ForkAndSetVariable shipped as an unconditional NOTREACHED() encoding the ’no SoftNavigationContext in scheduling tasks’ invariant; SoftNavigationHeuristics filtered on isTrusted(), missing forwarded-label events.
- report — crbug.com/475261410: a scripted label.click() forwarded to a form control created a SoftNavigationContext and tripped the NOTREACHED, crashing the renderer.
- fix — 2026-01-14, Scott Haseley lands 16adc8a8 (m@{#1569171}): switch to IsFullyTrusted(), downgrade to NotFatalUntil::M149, add fallthrough + WPT regression test.
- follow-up — Commit message flags EventTiming as likely sharing the same isTrusted blind spot; recommends migrating it to IsFullyTrusted as a follow-up.
Proof of concept
A JS regression test ships with the commit and is a faithful reproducer of the crash (not an exploit). It attaches a to a , and inside requestIdleCallback calls target.click(); the scripted label click is forwarded to the select as a trusted click, creating a SoftNavigationContext inside a scheduling-task continuation and hitting the NOTREACHED pre-fix. The demonstrated impact is a controlled renderer abort (DoS), nothing more; no memory-safety exploit exists or is invented. The essential recipe: from untrusted page script, call .click() on a label whose ‘for’ points at a focusable form control, on a build where soft-navigation heuristics are active, so the forwarded trusted event enters the soft-nav path during a scheduling task.
<label id="target" for="foo"></label>
<select id="foo"></select>
<script>
async_test(t => {
requestIdleCallback(t.step_func_done(() => {
// Regression test for crbug.com/475261410. This should not crash.
target.click();
}));
}, "Test that an untrusted click causing a trusted click on a form control" +
" doesn't crash");
</script>
Exploitation
- trigger — Untrusted page script calls label.click() on a . The forwarded click to the control is marked isTrusted, passing the soft-nav input filter. No user interaction or privilege needed.
- reached primitive — A SoftNavigationContext is created for a web-scheduling task; ForkAndSetVariable is invoked with it and hits NOTREACHED(), aborting the renderer (same-origin DoS) in fatal-NOTREACHED builds.
- escalation — None demonstrated. The crash is a controlled CHECK-class abort with no memory-unsafety, no cross-origin or cross-process effect. Ceiling is renderer self-DoS; the fix also removes even that by making the path non-fatal.
Detection & hunting
For defenders and detection engineers:
- NOTREACHED crash in WebSchedulingTaskState::ForkAndSetVariable —
- Soft-nav attribution on scripted label clicks —
Audit directions
- isTrusted() consumers across Blink —
- Other label/synthetic-forwarding trust escalations —
- NotFatalUntil::M149 disposition —