Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Keyboard
DescriptionInappropriate implementation in Keyboard
ComponentKeyboard
Bug ClassLogic Error
Tracker501485453
Fix commitf384aed680f1 (chromium/src) +71/-34
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/modules/keyboard/keyboard_layout.cc
modified
switch
third_party/blink/renderer/modules/keyboard/keyboard_layout.cc
modified
DOMException
third_party/blink/renderer/modules/keyboard/keyboard_layout.h
modified
ExceptionState
third_party/blink/renderer/modules/keyboard/keyboard_layout.h
modified
KeyboardLayoutMap
third_party/blink/renderer/modules/keyboard/keyboard_layout.h
modified
KeyboardLayout
third_party/blink/renderer/modules/keyboard/keyboard_layout.h
modified

Files Changed

  • third_party/blink/renderer/modules/keyboard/keyboard_layout.cc
  • third_party/blink/renderer/modules/keyboard/keyboard_layout.h
  • third_party/blink/web_tests/external/wpt/keyboard-map/navigator-keyboard-map-two-sequential-requests.https.html
From f384aed680f15739a44f6a2083ce3f121de28277 Mon Sep 17 00:00:00 2001
From: Joe Downing <joedow@google.com>
Date: Wed, 15 Apr 2026 22:42:12 -0700
Subject: [PATCH] Fix cross-world Promise leak in navigator.keyboard.getLayoutMap()

This CL refactors KeyboardLayout::GetKeyboardLayoutMap to use
ScriptPromiseProperty instead of maintaining a HeapVector of
ScriptPromiseResolvers.

This change aligns the implementation with Blink's preferred patterns
for Promise-returning methods while continuing to satisfy the
Keyboard API specifications:
  - Concurrent requests within the same world are automatically handled
    by ScriptPromiseProperty to return the same pending promise
    instance.
  - To ensure sequential calls return new promise instances (as
    required by the spec), `layout_map_property_` is now explicitly
    cleared once it has reached a resolved or rejected state.

Bug: 501485453
Change-Id: Ia19bc3408e61ec160764f6229b6158fa5a912c3f
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7751051
Reviewed-by: Yuki Shiino <yukishiino@chromium.org>
Commit-Queue: Joe Downing <joedow@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1615641}
---

diff --git a/third_party/blink/renderer/modules/keyboard/keyboard_layout.cc b/third_party/blink/renderer/modules/keyboard/keyboard_layout.cc
index 3464e4a8..e2b697b7 100644
--- a/third_party/blink/renderer/modules/keyboard/keyboard_layout.cc
+++ b/third_party/blink/renderer/modules/keyboard/keyboard_layout.cc
@@ -7,12 +7,12 @@
 #include "third_party/blink/public/platform/browser_interface_broker_proxy.h"
 #include "third_party/blink/public/platform/platform.h"
 #include "third_party/blink/public/platform/task_type.h"
-#include "third_party/blink/renderer/bindings/core/v8/script_promise_resolver.h"
-#include "third_party/blink/renderer/bindings/core/v8/v8_throw_dom_exception.h"
 #include "third_party/blink/renderer/core/dom/dom_exception.h"
 #include "third_party/blink/renderer/core/execution_context/execution_context.h"
 #include "third_party/blink/renderer/core/frame/local_dom_window.h"
 #include "third_party/blink/renderer/core/frame/local_frame.h"
+#include "third_party/blink/renderer/modules/keyboard/keyboard_layout_map.h"
+#include "third_party/blink/renderer/platform/bindings/dom_wrapper_world.h"
 #include "third_party/blink/renderer/platform/bindings/exception_state.h"
 #include "third_party/blink/renderer/platform/heap/garbage_collected.h"
 #include "third_party/blink/renderer/platform/wtf/functional.h"
@@ -41,10 +41,6 @@
     ExceptionState& exception_state) {
   DCHECK(script_state);
 
-  if (script_promise_resolver_) {
-    return script_promise_resolver_->Promise();
-  }
-
   if (!IsLocalFrameAttached()) {
     exception_state.ThrowDOMException(DOMExceptionCode::kInvalidStateError,
                                       kKeyboardMapFrameDetachedErrorMsg);
@@ -57,13 +53,19 @@
     return EmptyPromise();
   }
 
-  script_promise_resolver_ =
-      MakeGarbageCollected<ScriptPromiseResolver<KeyboardLayoutMap>>(
-          script_state, exception_state.GetContext());
-  service_->GetKeyboardLayoutMap(
-      script_promise_resolver_->WrapCallbackInScriptScope(BindOnce(
-          &KeyboardLayout::GotKeyboardLayoutMap, WrapPersistent(this))));
-  return script_promise_resolver_->Promise();
+  if (!layout_map_property_) {
+    layout_map_property_ = MakeGarbageCollected<LayoutMapProperty>(
+        ExecutionContext::From(script_state));
+  }
+
+  auto promise = layout_map_property_->Promise(script_state->World());
+
+  if (!is_request_pending_) {
+    is_request_pending_ = true;
+    service_->GetKeyboardLayoutMap(
+        BindOnce(&KeyboardLayout::GotKeyboardLayoutMap, WrapPersistent(this)));
+  }
+  return promise;
 }
 
 bool KeyboardLayout::IsLocalFrameAttached() {
@@ -72,8 +74,9 @@
 
 bool KeyboardLayout::EnsureServiceConnected() {
   if (!service_.is_bound()) {
-    if (!DomWindow())
+    if (!DomWindow()) {
       return false;
+    }
     DomWindow()->GetBrowserInterfaceBroker().GetInterface(
         service_.BindNewPipeAndPassReceiver(
             DomWindow()->GetTaskRunner(TaskType::kMiscPlatformAPI)));
@@ -83,33 +86,31 @@
 }
 
 void KeyboardLayout::GotKeyboardLayoutMap(
-    ScriptPromiseResolver<KeyboardLayoutMap>* resolver,
     mojom::blink::GetKeyboardLayoutMapResultPtr result) {
-  DCHECK(script_promise_resolver_);
+  DCHECK(layout_map_property_);
+  DCHECK(is_request_pending_);
+  is_request_pending_ = false;
 
   switch (result->status) {
     case mojom::blink::GetKeyboardLayoutMapStatus::kSuccess:
-      resolver->Resolve(
+      layout_map_property_->Resolve(
           MakeGarbageCollected<KeyboardLayoutMap>(result->layout_map));
       break;
     case mojom::blink::GetKeyboardLayoutMapStatus::kFail:
-      resolver->Reject(V8ThrowDOMException::CreateOrDie(
-          resolver->GetScriptState()->GetIsolate(),
+      layout_map_property_->Reject(MakeGarbageCollected<DOMException>(
           DOMExceptionCode::kInvalidStateError,
           kKeyboardMapRequestFailedErrorMsg));
       break;
     case mojom::blink::GetKeyboardLayoutMapStatus::kDenied:
-      resolver->Reject(V8ThrowDOMException::CreateOrDie(
-          resolver->GetScriptState()->GetIsolate(),
+      layout_map_property_->Reject(MakeGarbageCollected<DOMException>(
           DOMExceptionCode::kSecurityError, kFeaturePolicyBlocked));
       break;
   }
-
-  script_promise_resolver_ = nullptr;
+  layout_map_property_ = nullptr;
 }
 
 void KeyboardLayout::Trace(Visitor* visitor) const {
-  visitor->Trace(script_promise_resolver_);
+  visitor->Trace(layout_map_property_);
   visitor->Trace(service_);
   ExecutionContextClient::Trace(visitor);
 }
diff --git a/third_party/blink/renderer/modules/keyboard/keyboard_layout.h b/third_party/blink/renderer/modules/keyboard/keyboard_layout.h
index 0a1bf182..8faa8d8 100644
--- a/third_party/blink/renderer/modules/keyboard/keyboard_layout.h
+++ b/third_party/blink/renderer/modules/keyboard/keyboard_layout.h
@@ -7,15 +7,16 @@
 
 #include "third_party/blink/public/mojom/keyboard_lock/keyboard_lock.mojom-blink.h"
 #include "third_party/blink/renderer/bindings/core/v8/script_promise.h"
-#include "third_party/blink/renderer/bindings/core/v8/script_promise_resolver.h"
+#include "third_party/blink/renderer/bindings/core/v8/script_promise_property.h"
 #include "third_party/blink/renderer/core/execution_context/execution_context_lifecycle_observer.h"
-#include "third_party/blink/renderer/modules/keyboard/keyboard_layout_map.h"
 #include "third_party/blink/renderer/platform/mojo/heap_mojo_remote.h"
 #include "third_party/blink/renderer/platform/mojo/heap_mojo_wrapper_mode.h"
 
 namespace blink {
 
+class DOMException;
 class ExceptionState;
+class KeyboardLayoutMap;
 
 class KeyboardLayout final : public GarbageCollected<KeyboardLayout>,
                              public ExecutionContextClient {
@@ -39,10 +40,13 @@
   // Returns true if |service_| is initialized and ready to be called.
   bool EnsureServiceConnected();
 
-  void GotKeyboardLayoutMap(ScriptPromiseResolver<KeyboardLayoutMap>*,
-                            mojom::blink::GetKeyboardLayoutMapResultPtr);
+  void GotKeyboardLayoutMap(mojom::blink::GetKeyboardLayoutMapResultPtr);
 
-  Member<ScriptPromiseResolver<KeyboardLayoutMap>> script_promise_resolver_;
+  using LayoutMapProperty =
+      ScriptPromiseProperty<KeyboardLayoutMap, DOMException>;
+  Member<LayoutMapProperty> layout_map_property_;
+
+  bool is_request_pending_ = false;
 
   HeapMojoRemote<mojom::blink::KeyboardLockService> service_;
 };
diff --git a/third_party/blink/web_tests/external/wpt/keyboard-map/navigator-keyboard-map-two-sequential-requests.https.html b/third_party/blink/web_tests/external/wpt/keyboard-map/navigator-keyboard-map-two-sequential-requests.https.html
index c7f6b9d77..8afb6d5 100644
--- a/third_party/blink/web_tests/external/wpt/keyboard-map/navigator-keyboard-map-two-sequential-requests.https.html
+++ b/third_party/blink/web_tests/external/wpt/keyboard-map/navigator-keyboard-map-two-sequential-requests.https.html
@@ -5,10 +5,12 @@
 'use strict';
 
 promise_test(() => {
-  return navigator.keyboard.getLayoutMap()
-      .then(() => {
-        return navigator.keyboard.getLayoutMap();
-      });
-}, '[Keyboard Map] getLayoutMap() called twice sequentially');
+  const p1 = navigator.keyboard.getLayoutMap();
+  return p1.then(() => {
+    const p2 = navigator.keyboard.getLayoutMap();
+    assert_not_equals(p1, p2);
+    return p2;
+  });
+}, '[Keyboard Map] getLayoutMap() returns a new promise for sequential calls');
 
 </script>
diff --git a/third_party/blink/web_tests/wpt_internal/keyboard-map/keyboard-layout-leak.https.html b/third_party/blink/web_tests/wpt_internal/keyboard-map/keyboard-layout-leak.https.html
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/web_tests/external/wpt/keyboard-map/navigator-keyboard-map-two-sequential-requests.https.html b/third_party/blink/web_tests/external/wpt/keyboard-map/navigator-keyboard-map-two-sequential-requests.https.html
index c7f6b9d77..8afb6d5 100644
--- a/third_party/blink/web_tests/external/wpt/keyboard-map/navigator-keyboard-map-two-sequential-requests.https.html
+++ b/third_party/blink/web_tests/external/wpt/keyboard-map/navigator-keyboard-map-two-sequential-requests.https.html
@@ -5,10 +5,12 @@
 'use strict';
 
 promise_test(() => {
-  return navigator.keyboard.getLayoutMap()
-      .then(() => {
-        return navigator.keyboard.getLayoutMap();
-      });
-}, '[Keyboard Map] getLayoutMap() called twice sequentially');
+  const p1 = navigator.keyboard.getLayoutMap();
+  return p1.then(() => {
+    const p2 = navigator.keyboard.getLayoutMap();
+    assert_not_equals(p1, p2);
+    return p2;
+  });
+}, '[Keyboard Map] getLayoutMap() returns a new promise for sequential calls');
 
 </script>
diff --git a/third_party/blink/web_tests/wpt_internal/keyboard-map/keyboard-layout-leak.https.html b/third_party/blink/web_tests/wpt_internal/keyboard-map/keyboard-layout-leak.https.html
new file mode 100644
index 0000000..d1522b2
--- /dev/null
+++ b/third_party/blink/web_tests/wpt_internal/keyboard-map/keyboard-layout-leak.https.html
@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+<html>
+<head>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+</head>
+<body>
+<script>
+promise_test(async () => {
+  if (!window.testRunner) {
+    assert_unreached("This test requires testRunner");
+  }
+
+  // 1. Call getLayoutMap in isolated world first.
+  testRunner.evaluateScriptInIsolatedWorld(1, `
+    window.isolated_promise = navigator.keyboard.getLayoutMap();
+    window.isolated_promise.leaked_marker = true;
+  `);
+
+  // 2. Call getLayoutMap in main world.
+  let main_promise = navigator.keyboard.getLayoutMap();
+
+  // 3. Check if main_promise is the leaked one.
+  assert_false("leaked_marker" in main_promise, "Main world promise should not be the same as isolated world promise");
+
+  await main_promise;
+}, "navigator.keyboard.getLayoutMap() does not leak promises from isolated world to main world");
+</script>
+</body>
+</html>
Loading diff…

Original Bug Report

reported by vm...@google.com

Cross-world Promise leak in navigator.keyboard.getLayoutMap() allows isolated world bypass

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team.

Overview: The navigator.keyboard.getLayoutMap() implementation caches a ScriptPromiseResolver without verifying the caller’s V8 Context. This allows a malicious page to obtain a reference to a v8::Promise created in an extension’s isolated world, potentially leading to arbitrary script execution in the isolated world context (UXSS).

Affected files:

  • third_party/blink/renderer/modules/keyboard/keyboard_layout.cc
  • third_party/blink/renderer/modules/keyboard/keyboard_layout.h
  • third_party/blink/renderer/modules/keyboard/keyboard.cc

Estimated timestamp from git blame: 2024-03-26

Summary

KeyboardLayout is a per-LocalDOMWindow singleton (owned by Keyboard, a Supplement to Navigator) that caches a ScriptPromiseResolver while a Mojo call to the browser process is in flight. Because this singleton is shared across different v8::Contexts (e.g., the page’s main world and an extension’s isolated world), it can leak a v8::Promise from one world to another.

A malicious page can exploit this to gain access to an extension’s isolated world context if the extension calls navigator.keyboard.getLayoutMap(). Since native v8::Promise objects do not trigger cross-world access checks when passed between contexts in the same isolate, the page can use prototype walking to reach the isolated world’s Function constructor and execute arbitrary script.

Technical Details

In third_party/blink/renderer/modules/keyboard/keyboard_layout.cc, the GetKeyboardLayoutMap method handles requests for the keyboard layout map:

ScriptPromise<KeyboardLayoutMap> KeyboardLayout::GetKeyboardLayoutMap(
    ScriptState* script_state,
    ExceptionState& exception_state) {
  if (script_promise_resolver_) {
    return script_promise_resolver_->Promise();
  }
  // ... Mojo request initiated ...
  script_promise_resolver_ =
      MakeGarbageCollected<ScriptPromiseResolver<KeyboardLayoutMap>>(
          script_state, exception_state.GetContext());
  // ...
}

The script_promise_resolver_ is cached as a member variable. If a second call to getLayoutMap() occurs while a Mojo request is pending, the method returns the promise from the existing resolver.

However, ScriptPromiseResolver initializes its underlying v8::Promise in the ScriptState (V8 Context) of the caller that first triggered its creation. If an extension’s content script calls getLayoutMap() first, script_promise_resolver_ holds a promise belonging to the extension’s isolated world. If the page then calls getLayoutMap() before the Mojo response is processed, it receives the exact same v8::Promise object because the method lacks any check to ensure the caller’s ScriptState matches the cached resolver’s ScriptState.

Blink returns ScriptPromise objects to V8 by extracting the raw v8::Promise (via .V8Promise()), bypassing wrapper-based isolation. Because the page now holds a reference to an object from the isolated world, it can navigate the prototype chain to access the isolated world’s Function constructor: leaked_promise.constructor.constructor('...arbitrary script...')()

Potential Attack Scenario

Note: These are suggested steps based on code analysis; a working proof-of-concept has not yet been executed in a live environment.

  1. A victim visits a malicious website and has an extension installed that calls navigator.keyboard.getLayoutMap() in response to a DOM event (e.g., a keydown listener in a content script).
  2. The malicious page’s main script synchronously triggers the event: document.dispatchEvent(new KeyboardEvent('keydown')).
  3. EventTarget::DispatchEvent processes listeners synchronously, switching to the content script’s isolated world to invoke its listener.
  4. The content script calls getLayoutMap(), creating the cached ScriptPromiseResolver bound to its isolated world.
  5. The synchronous dispatch finishes, returning control to the malicious main-world script.
  6. In the same microtask (before the asynchronous Mojo response can clear the cached resolver), the main page calls navigator.keyboard.getLayoutMap().
  7. The page receives the isolated-world v8::Promise and uses it to execute script in the isolated world, granting access to extension privileges (e.g., chrome.runtime).

Suggested Fix

Do not cache a single ScriptPromiseResolver across different contexts. Either:

  1. Maintain a list/queue of ScriptPromiseResolver objects (one for each pending call) and resolve them all when the Mojo request completes.
  2. Add a check to verify that the script_state passed into GetKeyboardLayoutMap matches the ScriptState of the currently cached script_promise_resolver_. If it does not match, either create a separate request or reject the new promise.

Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker