Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in ScriptInjections
DescriptionInappropriate implementation in ScriptInjections
ComponentScriptInjections
Bug ClassLogic Error
Tracker513274039
Fix commitb6dbf0cd09de (chromium/src) +35/-6
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Changed Functions

FunctionChangeNotes
ExecuteJavaScript
ios/web/js_messaging/java_script_feature_inttest.mm
modified
TEST_F
ios/web/js_messaging/java_script_feature_inttest.mm
modified
for
ios/web/js_messaging/page_script_util.mm
modified

Files Changed

  • ios/web/js_messaging/java_script_feature_inttest.mm
  • ios/web/js_messaging/page_script_util.mm
From b6dbf0cd09debe92c3aaba07944a227723c66cc2 Mon Sep 17 00:00:00 2001
From: Olivier Robin <[email protected]>
Date: Wed, 27 May 2026 03:16:54 -0700
Subject: [PATCH] Stop using array when filtering script origin

Array prototype can be poisoned, so compare the origin directly

Fixed: 513274039
Change-Id: Iaf0f751eb82a5f16648a632264ede1c897a468d6
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7866063
Auto-Submit: Olivier Robin <[email protected]>
Commit-Queue: Olivier Robin <[email protected]>
Reviewed-by: Mike Dougherty <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1636814}
---

diff --git a/ios/web/js_messaging/java_script_feature_inttest.mm b/ios/web/js_messaging/java_script_feature_inttest.mm
index a5fcab2..bba44e3 100644
--- a/ios/web/js_messaging/java_script_feature_inttest.mm
+++ b/ios/web/js_messaging/java_script_feature_inttest.mm
@@ -490,6 +490,32 @@
       web_state(), kFakeJavaScriptFeatureLoadedText, base::Seconds(1)));
 }
 
+// Tests that a malicious page cannot bypass the private origin check of a
+// JavaScriptFeature by poisoning Array.prototype.includes and calling
+// document.open().
+TEST_F(JavaScriptFeaturePrivateTest,
+       OriginGateBypassViaPrototypePoisoningAndDocumentOpen) {
+  LoadHtml(kPageHTML, GURL("http://invalid.test"));
+  ASSERT_TRUE(test::WaitForWebViewContainingText(web_state(), "contents1"));
+
+  // Confirm the private script did not run initially.
+  EXPECT_FALSE(test::WaitForWebViewContainingText(
+      web_state(), kFakeJavaScriptFeatureLoadedText, base::Seconds(1)));
+
+  // Poison Array.prototype.includes and force script re-injection via
+  // document.open()
+  ExecuteJavaScript(@"Array.prototype.includes = function() { return true; };"
+                     "document.open();"
+                     "document.write('<html><body><div "
+                     "id=\"div\">contents1</div></body></html>');"
+                     "document.close();");
+
+  // Verify that the private script still was NOT injected/executed after
+  // re-injection on the unauthorized origin.
+  EXPECT_FALSE(test::WaitForWebViewContainingText(
+      web_state(), kFakeJavaScriptFeatureLoadedText, base::Seconds(1)));
+}
+
 // Tests that a private JavaScriptFeature can call JavaScript when on an
 // authorized page.
 TEST_F(JavaScriptFeaturePrivateTest, CallFunctionOnAllowedPage) {
diff --git a/ios/web/js_messaging/page_script_util.mm b/ios/web/js_messaging/page_script_util.mm
index f7d85d3..bd8b716 100644
--- a/ios/web/js_messaging/page_script_util.mm
+++ b/ios/web/js_messaging/page_script_util.mm
@@ -39,12 +39,15 @@
 }
 
 NSString* MakeScriptPrivate(NSArray<NSString*>* filter, NSString* script) {
-  CHECK(filter);
-  NSString* kPrivateTemplate =
-      @"if (['%@'].includes(window.location.origin)) { %@ }";
-  return [NSString stringWithFormat:kPrivateTemplate,
-                                    [filter componentsJoinedByString:@"','"],
-                                    script];
+  CHECK(filter.count);
+  NSMutableArray<NSString*>* conditions = [NSMutableArray array];
+  for (NSString* origin in filter) {
+    [conditions
+        addObject:[NSString stringWithFormat:@"window.location.origin === '%@'",
+                                             origin]];
+  }
+  NSString* conditionString = [conditions componentsJoinedByString:@" || "];
+  return [NSString stringWithFormat:@"if (%@) { %@ }", conditionString, script];
 }
 
 }  // namespace web
Loading diff…

Original Bug Report

reported by [email protected]

Potential iOS JavaScript origin gate bypass via Array.prototype.includes poisoning

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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: The origin gate for private JavaScript features on iOS can be potentially bypassed by poisoning the global Array.prototype.includes method and triggering a script re-injection via document.open(). This allows scripts restricted to specific origins to execute on unauthorized malicious pages when they are configured to run in the page’s content world.

Affected files:

  • ios/web/js_messaging/page_script_util.mm
  • ios/web/js_messaging/java_script_feature.mm

Estimated timestamp from git blame: 2026-02-17

Potential Root Cause

The function web::MakeScriptPrivate() in ios/web/js_messaging/page_script_util.mm implements an origin-based gate for non-public FeatureScript instances. This gate is implemented as a JavaScript-side check that relies on the Array.prototype.includes method:

// ios/web/js_messaging/page_script_util.mm
NSString* MakeScriptPrivate(NSArray<NSString*>* filter, NSString* script) {
  CHECK(filter);
  NSString* kPrivateTemplate =
      @"if (['%@'].includes(window.location.origin)) { %@ }";
  return [NSString stringWithFormat:kPrivateTemplate,
                                    [filter componentsJoinedByString:@"','"],
                                    script];
}

When a JavaScriptFeature is configured with ContentWorld::kPageContentWorld, this script executes in the same JavaScript realm as the page’s own scripts. While these scripts are initially injected at kDocumentStart (before page JS runs), WebKit re-evaluates WKUserScript instances whenever a document is replaced via document.open() without re-creating the global JavaScript context (realm).

Because Array.prototype.includes is writable and configurable by page JavaScript, a malicious page can poison this method to always return true. When the script is re-injected following a document.open() call, the gate consults the poisoned prototype, allowing the private script body to execute even on an unauthorized origin.

Potential Step-by-Step Sequence

  1. A user navigates to an attacker-controlled page (e.g., https://evil.example).
  2. A WKUserScript with an origin gate (restricted to google.com) is injected into the page world at kDocumentStart. The gate initially evaluates to false because window.location.origin is https://evil.example and the prototype is not yet poisoned.
  3. The malicious page script executes and poisons the prototype: Array.prototype.includes = () => true;.
  4. The page calls document.open(), which clears the document but preserves the JavaScript global state and the poisoned prototype.
  5. WebKit re-injects the same WKUserScript into the existing realm for the new document.
  6. The gate now calls the poisoned includes(), which returns true, causing the private script body to execute within the context of https://evil.example.

Potential Impact

An attacker may force the execution of private JavaScript features on unauthorized origins. This can lead to:

  • Disclosure of the private script’s source code, including embedded constants, API endpoints, or tokens substituted via placeholders.
  • Exposure of internal __gCrWeb functions or DOM hooks installed by the private script, which can then be manipulated by the attacker.

Note that native-side checks like ShouldHandleMessageFromOrigin() still validate the origin using WKSecurityOrigin, so this vulnerability does not directly bypass message-handler origin restrictions for JS-to-native communication. It is primarily a script-source and internal API disclosure issue.

Suggested Fix

The JavaScript-side origin check should be hardened against prototype poisoning. Instead of relying on mutable prototypes, the check should use a mechanism that does not consult the prototype chain, such as strict equality comparisons against window.location.origin:

if (window.location.origin === 'https://allowed.origin') { ... }

Alternatively, ensuring that all features with an OriginFilter use kIsolatedWorld would mitigate this risk by preventing the page script from accessing the feature’s prototype chain.

Evaluated with Chrome root at commit: b3153093eb3c78c3e88ccf562bcbc20437a04b0e


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

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
Links in the report