CVE-2026-2313
Overview
Background
- Route matching
- a Blink feature (
third_party/blink/renderer/core/route_matching/) backing CSS navigation routes, where aRouteMapholds namedRouteobjects whose URL patterns are matched against the current/next navigation URLs. - `<script type="routemap">`
- a JSON block that declares routes; parsing it calls
RouteMap::ParseAndApplyRoutes, which adds routes and patterns to the map. - `activate` / `deactivate` events
RouteEvents dispatched on aRoutewhen its “matches at” status changes, running page JavaScript listeners synchronously.- `RouteMap::routes_` / `anonymous_routes_`
- Oilpan heap hash maps of routes that
RouteMap::UpdateActiveRoutesiterates to refresh each route’s match status.
Root Cause Analysis
Before the patch, RouteMap::UpdateActiveRoutes iterated routes_ and anonymous_routes_ and called the route’s match-status update for each entry, and that update (in route.cc; the function name is not shown in the hunk but it is the bool method that sets matches_at_, called as route.UpdateMatchStatus(previous_url_, next_url_)) synchronously dispatched an activate/deactivate RouteEvent via DispatchEvent. A listener could therefore run script in the middle of the iteration; as the regression test shows, an activate handler that appends a new <script type="routemap"> reached RouteMap::ParseAndApplyRoutes, which added a route and then called UpdateActiveRoutes() again, re-entering it while the outer loop was still iterating. The commit message states this recursion could modify (rehash) the route set while it was being iterated, which leaves the outer loop using an invalidated iterator into a freed hash-table backing store (inferred, consistent with the advisory’s “Use after free”).
The fix removes event dispatch from the per-route update, has the new RouteMap::UpdateMatchStatus helper only collect routes whose kAt match flipped into a local routes_needing_event vector, and dispatches the events only after both map iterations finish. It also stops ParseAndApplyRoutes from calling UpdateActiveRoutes(); new patterns instead update just their own route’s status in the new RouteMap::AddPatternToRoute without firing events, and a DCHECK(!is_updating_active_routes_) guards against future recursion.
UpdateActiveRoutes() call from route parsing.Attack Path
- Declare a route
A page declares a route via
<script type="routemap">whose pattern will match a URL the page can navigate to. - Register a re-entrant listener
The page adds an
activatelistener on thatRoutethat inserts another<script type="routemap">, adding a new route. - Trigger matching
A same-document navigation such as
history.pushStatecausesUpdateActiveRoutesto run and the route’s status change to dispatchactivatefrom within the iteration overroutes_. - Mutation during iteration
The handler’s new routemap re-enters
ParseAndApplyRoutes→UpdateActiveRoutes, inserting into the map being iterated and potentially rehashing it. - Use after free When control returns, the outer loop continues with an iterator into the old, freed backing store, giving a renderer use-after-free (inferred).
Impact Assessment
<script type="routemap">, and a same-document navigation such as history.pushState, with no user gesture shown in the regression test. The diff does not show whether route matching is enabled by default in stock builds; the test lives under wpt_internal and the commit calls <script type="routemap"> likely to go away, suggesting an experimental feature, so reachability may depend on a runtime feature being enabled (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
forthird_party/blink/renderer/core/route_matching/route_map.cc |
modified | |
ifthird_party/blink/renderer/core/route_matching/route_map.cc |
modified |
Files Changed
third_party/blink/renderer/core/route_matching/route.ccthird_party/blink/renderer/core/route_matching/route_map.ccthird_party/blink/renderer/core/route_matching/route_map.h
Audit Directions
- Event dispatch inside container iterationSearch Blink for
DispatchEvent(or other script-running callbacks) reached from loops overHeapHashMap/HeapHashSet/HeapVectormembers, where a listener can add or remove entries. - Parse-then-apply re-entrancyAudit other declarative-script or style parsers (e.g. speculation rules, import maps, the planned
@routerule) that synchronously trigger state updates which fire events, allowing nested insertion during processing. - Release-mode recursion guardsThe new re-entrancy check is DCHECK-only; review similar
UpdateXmethods to ensure correctness does not rely on debug-only guards and that deferred-event patterns are used consistently.
Patch
From 7710041f98212312f5810c831adca08157348975 Mon Sep 17 00:00:00 2001
From: Morten Stenshorne <mstensho@chromium.org>
Date: Wed, 10 Dec 2025 08:53:37 -0800
Subject: [PATCH] [RouteMatching] Avoid recursion.
If another route was added in a route event handler, we'd end up
invoking RouteMap::UpdateMatchStatus() while already inside it. This is
bad. Fixing it by making a local copy of the routes and then walking
that set in UpdateMatchStatus() to prevent the set from being modified
(rehashed) while iterating it might seem compelling, but recursion here
doesn't seem good for correctness and code maintainability anyway.
Instead, fire route events as a separate step after having updated the
match status for all routes. Also do NOT trigger UpdateMatchStatus()
when adding new routes from <script>. Just update the match status with
no events being fired. Be sure to trigger a lifecycle update if anything
has changed, though.
<script type="routemap"> may be going away, in favor of the new @route
rule. See https://drafts.csswg.org/css-navigation-1/#at-route , and as
part of that task we should integrate route matching nicely into the
document lifecycle machinery.
Also add a DCHECK that fails on recursion.
Bug: 467297219
Change-Id: I604d3b29bf4a1cabf83a5c13f32c1dc01dbe84e8
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7246274
Commit-Queue: Morten Stenshorne <mstensho@chromium.org>
Reviewed-by: Noam Rosenthal <nrosenthal@google.com>
Cr-Commit-Position: refs/heads/main@{#1556793}
---
diff --git a/third_party/blink/renderer/core/route_matching/route.cc b/third_party/blink/renderer/core/route_matching/route.cc
index 07987d6..bff1cbc 100644
--- a/third_party/blink/renderer/core/route_matching/route.cc
+++ b/third_party/blink/renderer/core/route_matching/route.cc
@@ -9,7 +9,6 @@
#include "third_party/blink/renderer/core/dom/document.h"
#include "third_party/blink/renderer/core/event_target_names.h"
#include "third_party/blink/renderer/core/execution_context/execution_context.h"
-#include "third_party/blink/renderer/core/route_matching/route_event.h"
#include "third_party/blink/renderer/core/url_pattern/url_pattern.h"
#include "third_party/blink/renderer/platform/weborigin/kurl.h"
@@ -78,10 +77,6 @@
}
matches_at_ = matches_at;
- AtomicString type(matches_at_ ? "activate" : "deactivate");
- auto* event = MakeGarbageCollected<RouteEvent>(type);
- event->SetTarget(this);
- DispatchEvent(*event);
return true;
}
diff --git a/third_party/blink/renderer/core/route_matching/route_map.cc b/third_party/blink/renderer/core/route_matching/route_map.cc
index 9e0c514..338e41e 100644
--- a/third_party/blink/renderer/core/route_matching/route_map.cc
+++ b/third_party/blink/renderer/core/route_matching/route_map.cc
@@ -4,11 +4,13 @@
#include "third_party/blink/renderer/core/route_matching/route_map.h"
+#include "base/auto_reset.h"
#include "base/check_is_test.h"
#include "third_party/blink/renderer/core/css/style_engine.h"
#include "third_party/blink/renderer/core/dom/document.h"
#include "third_party/blink/renderer/core/execution_context/execution_context.h"
#include "third_party/blink/renderer/core/route_matching/route.h"
+#include "third_party/blink/renderer/core/route_matching/route_event.h"
#include "third_party/blink/renderer/core/url_pattern/url_pattern.h"
#include "third_party/blink/renderer/core/url_pattern/url_pattern_utils.h"
#include "third_party/blink/renderer/platform/json/json_parser.h"
@@ -18,20 +20,6 @@
namespace {
-RouteMap::ParseResult AddPatternToRoute(const Document& document,
- Route& route,
- const JSONValue& value) {
- base::expected<URLPattern*, String> pattern =
- ParseURLPatternFromJSON(document.GetExecutionContext()->GetIsolate(),
- value, document.Url(), IGNORE_EXCEPTION);
- if (pattern.has_value()) {
- DCHECK(*pattern);
- route.AddPattern(*pattern);
- return RouteMap::ParseResult(RouteMap::ParseResult::kSuccess);
- }
- return RouteMap::ParseResult(RouteMap::ParseResult::kSyntaxError,
- pattern.error());
-}
} // anonymous namespace
@@ -80,12 +68,6 @@
RouteMap::ParseResult RouteMap::ParseAndApplyRoutes(
const String& route_map_text) {
- RouteMap::ParseResult result = ParseRoutes(route_map_text);
- UpdateActiveRoutes();
- return result;
-}
-
-RouteMap::ParseResult RouteMap::ParseRoutes(const String& route_map_text) {
constexpr char kPattern[] = "pattern";
std::unique_ptr<JSONValue> value = ParseJSON(route_map_text);
// TODO(crbug.com/436805487): Error reporting needs to be specced. Should we
@@ -136,8 +118,7 @@
"Missing pattern in route entry");
}
for (const JSONValue& pattern : *patterns) {
- ParseResult result =
- AddPatternToRoute(GetDocument(), *route, pattern);
+ ParseResult result = AddPatternToRoute(*route, pattern);
if (!result.IsSuccess()) {
return result;
}
@@ -149,7 +130,7 @@
return ParseResult(ParseResult::kTypeError,
"Missing pattern in route entry");
}
- ParseResult result = AddPatternToRoute(GetDocument(), *route, *pattern);
+ ParseResult result = AddPatternToRoute(*route, *pattern);
if (!result.IsSuccess()) {
return result;
}
@@ -188,15 +169,30 @@
}
void RouteMap::UpdateActiveRoutes() {
+#if DCHECK_IS_ON()
+ DCHECK(!is_updating_active_routes_);
+ base::AutoReset<bool> is_updating(&is_updating_active_routes_, true);
+#endif
+
+ HeapVector<Member<Route>> routes_needing_event;
bool changed = false;
for (const auto& entry : routes_) {
Route& route = *entry.value;
- changed = route.UpdateMatchStatus(previous_url_, next_url_) || changed;
+ changed |= UpdateMatchStatus(route, &routes_needing_event);
}
for (const auto& entry : anonymous_routes_) {
Route& route = *entry.value;
- changed = route.UpdateMatchStatus(previous_url_, next_url_) || changed;
+ changed |= UpdateMatchStatus(route, &routes_needing_event);
}
+
+ for (Route* route : routes_needing_event) {
+ bool matches_at = route->Matches(NavigationPreposition::kAt);
+ AtomicString type(matches_at ? "activate" : "deactivate");
+ auto* event = MakeGarbageCollected<RouteEvent>(type);
+ event->SetTarget(route);
+ route->DispatchEvent(*event);
+ }
+
if (changed) {
GetDocument().GetStyleEngine().NavigationsMayHaveChanged();
}
@@ -220,4 +216,36 @@
}
}
+RouteMap::ParseResult RouteMap::AddPatternToRoute(Route& route,
+ const JSONValue& value) {
+ base::expected<URLPattern*, String> pattern =
+ ParseURLPatternFromJSON(GetDocument().GetExecutionContext()->GetIsolate(),
+ value, GetDocument().Url(), IGNORE_EXCEPTION);
+ if (pattern.has_value()) {
+ DCHECK(*pattern);
+ route.AddPattern(*pattern);
+ // TODO(crbug.com/436805487): If we actually end up keeping support for
+ // <script type="routemap">, we're missing events here.
+ if (route.UpdateMatchStatus(previous_url_, next_url_)) {
+ GetDocument().GetStyleEngine().NavigationsMayHaveChanged();
+ }
+ return RouteMap::ParseResult(RouteMap::ParseResult::kSuccess);
+ }
+ return RouteMap::ParseResult(RouteMap::ParseResult::kSyntaxError,
+ pattern.error());
+}
+
+bool RouteMap::UpdateMatchStatus(
+ Route& route,
+ HeapVector<Member<Route>>* routes_needing_event) {
+ bool matched_at = route.Matches(NavigationPreposition::kAt);
+ if (!route.UpdateMatchStatus(previous_url_, next_url_)) {
+ return false;
+ }
+ if (matched_at != route.Matches(NavigationPreposition::kAt)) {
+ routes_needing_event->push_back(&route);
+ }
+ return true;
+}
+
} // namespace blink
diff --git a/third_party/blink/renderer/core/route_matching/route_map.h b/third_party/blink/renderer/core/route_matching/route_map.h
index 6023f3e..843ea1d 100644
--- a/third_party/blink/renderer/core/route_matching/route_map.h
Regression Test / PoC
diff --git a/third_party/blink/web_tests/wpt_internal/route/crashtests/add-route-on-activate.html b/third_party/blink/web_tests/wpt_internal/route/crashtests/add-route-on-activate.html
new file mode 100644
index 0000000..454b55d
--- /dev/null
+++ b/third_party/blink/web_tests/wpt_internal/route/crashtests/add-route-on-activate.html
@@ -0,0 +1,34 @@
+<!DOCTYPE html>
+<link rel="help" href="https://issues.chromium.org/issues/467297219">
+<script type="routemap">
+ {
+ "routes": [
+ {
+ "name": "initial-route",
+ "pattern": { "pathname": "/activate-me" }
+ }
+ ]
+ }
+</script>
+
+PASS
+
+<script>
+ const DYNAMIC_JSON = JSON.stringify({
+ "routes": [
+ {
+ "name": "dynamic-route",
+ "pattern": { "pathname": "/dynamic-path" }
+ }
+ ]
+ });
+
+ const route = document.routeMap.get('initial-route');
+ route.addEventListener('activate', (e) => {
+ const script = document.createElement('script');
+ script.type = "routemap";
+ script.textContent = DYNAMIC_JSON;
+ document.head.appendChild(script);
+ });
+ history.pushState({}, '', '/activate-me');
+</script>
Original Bug Report
Use-After-Poison in RouteMap::UpdateActiveRoutes
Steps to reproduce the problem
- open test.html with flag –enable-blink-features=RouteMatching
- click button
- UAP!
Problem Description
void RouteMap::UpdateActiveRoutes() {
bool changed = false;
for (const auto& entry : routes_) {
Route& route = *entry.value;
changed = route.UpdateMatchStatus(previous_url_, next_url_) || changed; //<= trigger JS
}
for (const auto& entry : anonymous_routes_) {
Route& route = *entry.value;
changed = route.UpdateMatchStatus(previous_url_, next_url_) || changed;
}
if (changed) {
GetDocument().GetStyleEngine().NavigationsMayHaveChanged();
}
}
// JS trigger this snippet
if (it == routes_.end()) {
routes_.insert(name, route); // <= insert to container, rehash happen and invalidate all iterators
}
Summary
Use-After-Poison in RouteMap::UpdateActiveRoutes
Custom Questions
Type of crash:
tab
Additional Data
Category: Security
Chrome Channel: Not sure
Regression: N/A \