← All issues

[NavigationScheduler] history.back/forward/go(n) calls don't coalesce per spec when queued synchronously

04c8ceb

NavigationScheduler is WebCore's per-frame pending-navigation slot: it holds one ScheduledNavigation in m_redirect and fires it at the next task boundary. The HTML spec, however, models history traversals as tasks on a single session-history-traversal queue keyed by the top-level traversable, so all history.back/forward/go calls queued synchronously across any frame must coalesce into one net delta before anything navigates. WebKit had no such queue: each scheduleHistoryNavigation() called schedule(), which called cancel() and replaced m_redirect, so a second call silently evicted the first.

Source/WebCore/loader/NavigationScheduler.cpp

ScheduledNavigation::AccumulateResult ScheduledHistoryNavigation::accumulateHistorySteps(int additionalSteps)
{
if (!additionalSteps) // go(0) is reload, not a delta
return AccumulateResult::NotHandled;
m_steps += additionalSteps;
m_historyItem = nullptr; // recompute target at fire() time
return AccumulateResult::Handled;
}
 
void NavigationScheduler::scheduleHistoryNavigation(Frame& originatingFrame, int steps)
{
if (auto* topLocalFrame = topLevelLocalFrame(); topLocalFrame != &m_frame) {
originatingFrame.loader().completed();
cancel();
topLocalFrame->navigationScheduler().scheduleHistoryNavigation(originatingFrame, steps);
return;
}
if (m_redirect) {
auto result = m_redirect->accumulateHistorySteps(steps);
if (result == ScheduledNavigation::AccumulateResult::Handled) {
if (!m_redirect->steps()) // net delta == 0: going nowhere
cancel();
return;
}
}
schedule(makeUnique<ScheduledHistoryNavigation>(steps));
}

The fix adds an accumulateHistorySteps virtual hook so steps accumulate numerically onto the already-pending ScheduledHistoryNavigation rather than replacing it, and forwards all subframe history traversals to the top-level LocalFrame's scheduler so iframe and main-frame calls coalesce at the correct scope. back();forward() now nets to zero and cancel()s instead of firing a spurious navigation; back();back() accumulates to -2 and fires once, skipping the intermediate entry. go(0) is explicitly excluded to preserve reload semantics.

This rewires how WebKit dispatches and merges cross-frame history navigations — a privileged path that determines which documents load, when load events fire, and how the back-forward list advances. The new iframe-scheduler-to-top-frame forwarding path and the step-accumulation boundary conditions around same-document traversals are novel logic on a security-sensitive control plane.

The originating frame's scheduler is cancelled and its loader completed before forwarding; verify cancel() cannot leave partially-initialised state that the top-frame fire() then touches through the originating-frame pointer threaded into accumulateHistorySteps. The same-document check returns NotHandled so hash/pushState traversals fall through to schedule()/cancel(), but that check runs at the calling frame's scope even after forwarding — probe what happens when an iframe's cross-document traversal looks same-document from the main frame's perspective. adjustPendingHistoryNavigationForNewBackForwardEntry now also forwards to the top frame; its early-return path must be safe when m_redirect is null after a scheduler has already forwarded. Finally, the commit explicitly leaves cross-process top frames (Site Isolation) unhandled, so an attacker-controlled cross-site iframe still observes the old eviction behaviour versus same-site iframes seeing coalescing — an observable timing channel until the follow-up UIProcess queue lands.