Overview

Critical
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in iOSWeb
DescriptionInsufficient validation of untrusted input in iOSWeb
ComponentChromium
Bug ClassInput Validation
Action tierwatch — dos-class only — no demonstrated corruption path — factors set by independent review by AI
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Independent review by AIConfirmed
Reviewer notes: none. All citations check out against the pre-fix source and diff. Downstream TFLite/AnnotationsTabHelper processing is report-only and correctly marked inferred. The UTF-16 vs UTF-8 unit remark is reasoning, not shown code.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker513128566
Fix commit49b5cf8e1749 (chromium/src) +361/-13
CWECWE-20 (Improper input validation)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-05-14 (per vendor advisory)
Fix landed2026-05-19 (public git)
Disclosed2026-06-30
Patch gap42 days — fix public in source before the advisory

Background

`AnnotationsJavaScriptFeature`
The iOS web-layer feature that injects the text_main script into the main frame’s isolated content world and receives its annotations script messages (ios/web/annotations/annotations_java_script_feature.mm:27-34, :172-175).
`ScriptMessageReceived`
The browser-side handler that parses each page-originated message dictionary (annotations.extractedText, annotations.decoratingComplete, annotations.onClick) and forwards the values to AnnotationsTextManagerImpl (ios/web/annotations/annotations_java_script_feature.mm:98-170).
`kMaxAnnotationsTextLength`
A 65535-character limit on extracted page text; before the fix it was a file-static in annotations_text_manager_impl.mm:19 that was passed to ExtractText and then ignored.
`TextExtractor`
The TypeScript visitor in text_extractor.ts that concatenates visible text nodes into parts and sections and advances index to build the string that is sent back to native code.

Root Cause Analysis

Re-analyzed on 2026-10-03. The fix commit 49b5cf8e1749 was always correctly mapped, but the earlier analysis was written from a truncated copy of its diff that left out the changed product source (annotations_text_manager_impl.mm, text_extractor.ts and text_main.ts) and showed only build/test files. This analysis is written from the complete diff.
The intended 64 KiB limit on annotation text was declared but not enforced on either side of the JS-to-native boundary. AnnotationsTextManagerImpl::StartExtractingText passes kMaxAnnotationsTextLength to ExtractText (ios/web/annotations/annotations_text_manager_impl.mm:74-75). However, AnnotationsJavaScriptFeature::ExtractText drops maximum_text_length and calls annotations.start with an empty parameters list (ios/web/annotations/annotations_java_script_feature.mm:44-55, specifically :53-54). On the JS side, the removed visibleTextNode code pushed every non-blank textNode.textContent! and advanced this.index with no cap. The commit message notes that the limit “was removed when the extraction was migrated to viewport extraction”. On receipt, the annotations.extractedText branch only checks that text, seqId and metadata exist (annotations_java_script_feature.mm:131-137) before calling manager->OnTextExtracted(...) (:138-139). That call passes the text to every observer (annotations_text_manager_impl.mm:105-117), so text and metadata strings of any size reached browser-side consumers. The commit 49b5cf8e1749 restores the limit in three places. Native code now rejects text->size() > kMaxAnnotationsTextLength, caps htmlLang/httpContentLanguage at kMaxAnnotationsMetadataLength (256), and returns early on any unexpected top-level or metadata key for all three commands. ExtractText now does parameters.Append(maximum_text_length). start(maxTextLength) passes the limit to TextExtractor, which truncates text and stops adding spacers and section breaks once extractionLimit (maxTextLength - extraCharactersAtEnd * 2) is reached.
Key insight
A size limit that exists only as an unused argument does not limit anything. The browser trusted the page-side extractor to bound the data, and the extractor never received the bound. The fix enforces the limit where the data crosses into the browser (ScriptMessageReceived) and also passes it to the JS extractor so that well-behaved extraction stays within it.

Attack Path

  1. Serve oversized content A web page contains very large visible text, per the reporter’s trigger; on successful load PageLoaded posts StartExtractingText (annotations_text_manager_impl.mm:85-96).
  2. Unbounded extraction annotations.start runs without a length argument (annotations_java_script_feature.mm:53-54), so TextExtractor concatenates all visible text.
  3. Unchecked receipt The annotations.extractedText message passes only presence checks (annotations_java_script_feature.mm:131-137) and is sent to observers through OnTextExtracted (annotations_text_manager_impl.mm:114-116). A compromised web-content process could also send arbitrary text/metadata directly (inferred).
  4. Browser-side processing Per the reporter, observers such as AnnotationsTabHelper::OnTextExtracted pass the string to ios::provider::ExtractTextAnnotationFromText and a TFLite classifier in the browser process (inferred; that code is not in the material).

Impact Assessment

A malicious page can push arbitrarily large, attacker-controlled strings from web content into the Chrome for iOS browser process. The shown code proves only the missing bound, which at minimum allows memory or CPU exhaustion there. Memory corruption in the downstream tokenizer or TFLite classifier is the reporter’s speculation and is (inferred). The advisory rates it critical and does not report in-the-wild exploitation.

Changed Functions

FunctionChangeNotes
TestAnnotationTextObserver
ios/web/annotations/annotations_inttest.mm
modified
text_extracted_calls_
ios/web/annotations/annotations_inttest.mm
modified
TEST_F
ios/web/annotations/annotations_inttest.mm
modified
if
ios/web/annotations/annotations_java_script_feature.mm
modified
for
ios/web/annotations/annotations_java_script_feature.mm
modified

Files Changed

  • ios/web/annotations/annotations_inttest.mm
  • ios/web/annotations/annotations_java_script_feature.h
  • ios/web/annotations/annotations_java_script_feature.mm
  • ios/web/annotations/annotations_text_manager_impl.mm
  • ios/web/annotations/resources/text_extractor.ts
  • ios/web/annotations/resources/text_extractor_test.ts
  • ios/web/annotations/resources/text_main.ts

Audit Directions

  • Unused limit parameters
    Look for native-to-JS calls in ios/web features that receive a size or count limit and do not forward it in the CallJavaScriptFunction parameter list, and for script-message handlers that rely on JS-side limits instead of enforcing them at receipt.
  • Unit mismatch at the boundary
    The JS limit counts UTF-16 code units, but the native check uses std::string::size() (UTF-8 bytes), so legitimate non-ASCII pages near the limit may be dropped. Check that other handlers apply consistent units and do not accidentally loosen a limit.
  • Unbounded fields in other commands
    annotations.onClick data/text and the decoratingComplete cancelled list are now key-allowlisted but still have no size limit. Audit these and the other ScriptMessage handlers for the same pattern of untrusted data of any length.
diff --git a/ios/web/annotations/annotations_inttest.mm b/ios/web/annotations/annotations_inttest.mm
index 7198ea3..6fabc5c 100644
--- a/ios/web/annotations/annotations_inttest.mm
+++ b/ios/web/annotations/annotations_inttest.mm
@@ -20,6 +20,7 @@
 #import "ios/web/public/annotations/annotations_text_observer.h"
 #import "ios/web/public/js_messaging/web_frame.h"
 #import "ios/web/public/js_messaging/web_frames_manager.h"
+#import "ios/web/public/test/js_test_util.h"
 #import "ios/web/public/test/web_test_with_web_state.h"
 #import "ios/web/public/web_state.h"
 #import "ios/web/public/web_state_observer.h"
@@ -65,7 +66,11 @@
 class TestAnnotationTextObserver : public AnnotationsTextObserver {
  public:
   TestAnnotationTextObserver()
-      : successes_(0), annotations_(0), clicks_(0), decoration_calls_(0) {}
+      : successes_(0),
+        annotations_(0),
+        clicks_(0),
+        decoration_calls_(0),
+        text_extracted_calls_(0) {}
 
   TestAnnotationTextObserver(const TestAnnotationTextObserver&) = delete;
   TestAnnotationTextObserver& operator=(const TestAnnotationTextObserver&) =
@@ -75,6 +80,7 @@
                        const std::string& text,
                        int seq_id,
                        const base::DictValue& metadata) override {
+    text_extracted_calls_++;
     extracted_text_ = text;
     EXPECT_GE(seq_id, 1);
     seq_id_ = seq_id;
@@ -100,7 +106,10 @@
     click_data_ = data;
   }
 
-  void Reset() { seq_id_ = 0; }
+  void Reset() {
+    seq_id_ = 0;
+    text_extracted_calls_ = 0;
+  }
 
   const std::string& extracted_text() const { return extracted_text_; }
   int successes() const { return successes_; }
@@ -112,10 +121,18 @@
   const std::string& click_data() const { return click_data_; }
   void SetAnnotations(int count) { annotations_ = count; }
   int decoration_calls() const { return decoration_calls_; }
+  int text_extracted_calls() const { return text_extracted_calls_; }
 
  private:
-  std::string extracted_text_, click_data_;
-  int successes_, failures_, annotations_, clicks_, seq_id_, decoration_calls_;
+  std::string extracted_text_;
+  std::string click_data_;
+  int successes_;
+  int failures_;
+  int annotations_;
+  int clicks_;
+  int seq_id_;
+  int decoration_calls_;
+  int text_extracted_calls_;
   base::DictValue metadata_;
 };
 
@@ -755,4 +772,263 @@
   ASSERT_TRUE(observer()->click_data() == "type1-annotation");
 }
 
+TEST_F(AnnotationTextManagerViewportTest, AcceptValidMessage) {
+  ASSERT_TRUE(LoadHtml("<html><body></body></html>"));
+  ASSERT_TRUE(WaitForWebFramesCount(1));
+
+  observer()->Reset();
+
+  web::test::ExecuteJavaScriptForFeature(
+      web_state(),
+      @"window.webkit.messageHandlers.annotations.postMessage({"
+       "  command: 'annotations.extractedText',"
+       "  text: 'valid_text',"
+       "  seqId: 42,"
+       "  metadata: { htmlLang: 'en', httpContentLanguage: 'en' }"
+       "});",
+      AnnotationsJavaScriptFeature::GetInstance());
+
+  // Wait and check that it was received.
+  EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForActionTimeout, ^{
+    return observer()->seq_id() == 42;
+  }));
+  EXPECT_EQ("valid_text", observer()->extracted_text());
+}
+
+TEST_F(AnnotationTextManagerViewportTest, RejectOversizedText) {
+  ASSERT_TRUE(LoadHtml("<html><body></body></html>"));
+  ASSERT_TRUE(WaitForWebFramesCount(1));
+
+  observer()->Reset();
+
+  // Create a string of size kMaxAnnotationsTextLength + 1
+  std::string huge_text(kMaxAnnotationsTextLength + 1, 'a');
+  NSString* js =
+      [NSString stringWithFormat:
+                    @"window.webkit.messageHandlers.annotations.postMessage({"
+                     "  command: 'annotations.extractedText',"
+                     "  text: '%s',"
+                     "  seqId: 43,"
+                     "  metadata: { htmlLang: 'en', httpContentLanguage: 'en' }"
+                     "});"
+                     "window.webkit.messageHandlers.annotations.postMessage({"
+                     "  command: 'annotations.extractedText',"
+                     "  text: 'sync_text',"
+                     "  seqId: 999,"
+                     "  metadata: { htmlLang: 'en', httpContentLanguage: 'en' }"
+                     "});",
+                    huge_text.c_str()];
+
+  web::test::ExecuteJavaScriptForFeature(
+      web_state(), js, AnnotationsJavaScriptFeature::GetInstance());
+
+  // Once the second (valid) message is processed, we are guaranteed that the
+  // first (invalid) message was already processed and discarded.
+  EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForActionTimeout, ^{
+    return observer()->seq_id() == 999;
+  }));
+  EXPECT_EQ("sync_text", observer()->extracted_text());
+  EXPECT_EQ(1, observer()->text_extracted_calls());
+}
+
+TEST_F(AnnotationTextManagerViewportTest, RejectOversizedHtmlLang) {
+  ASSERT_TRUE(LoadHtml("<html><body></body></html>"));
+  ASSERT_TRUE(WaitForWebFramesCount(1));
+
+  observer()->Reset();
+
+  // Create a string of size kMaxAnnotationsMetadataLength + 1
+  std::string huge_lang(kMaxAnnotationsMetadataLength + 1, 'a');
+  NSString* js =
+      [NSString stringWithFormat:
+                    @"window.webkit.messageHandlers.annotations.postMessage({"
+                     "  command: 'annotations.extractedText',"
+                     "  text: 'valid_text',"
+                     "  seqId: 44,"
+                     "  metadata: { htmlLang: '%s', httpContentLanguage: 'en' }"
+                     "});"
+                     "window.webkit.messageHandlers.annotations.postMessage({"
+                     "  command: 'annotations.extractedText',"
+                     "  text: 'sync_text',"
+                     "  seqId: 999,"
+                     "  metadata: { htmlLang: 'en', httpContentLanguage: 'en' }"
+                     "});",
+                    huge_lang.c_str()];
+
+  web::test::ExecuteJavaScriptForFeature(
+      web_state(), js, AnnotationsJavaScriptFeature::GetInstance());
+
+  // Once the second (valid) message is processed, we are guaranteed that the
+  // first (invalid) message was already processed and discarded.
+  EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForActionTimeout, ^{
+    return observer()->seq_id() == 999;
+  }));
+  EXPECT_EQ("sync_text", observer()->extracted_text());
+  EXPECT_EQ(1, observer()->text_extracted_calls());
+}
+
+TEST_F(AnnotationTextManagerViewportTest, RejectOversizedHttpContentLanguage) {
+  ASSERT_TRUE(LoadHtml("<html><body></body></html>"));
+  ASSERT_TRUE(WaitForWebFramesCount(1));
+
+  observer()->Reset();
+
+  // Create a string of size kMaxAnnotationsMetadataLength + 1
+  std::string huge_lang(kMaxAnnotationsMetadataLength + 1, 'a');
+  NSString* js =
+      [NSString stringWithFormat:
+                    @"window.webkit.messageHandlers.annotations.postMessage({"
+                     "  command: 'annotations.extractedText',"
+                     "  text: 'valid_text',"
+                     "  seqId: 45,"
+                     "  metadata: { htmlLang: 'en', httpContentLanguage: '%s' }"
+                     "});"
+                     "window.webkit.messageHandlers.annotations.postMessage({"
+                     "  command: 'annotations.extractedText',"
+                     "  text: 'sync_text',"
+                     "  seqId: 999,"
+                     "  metadata: { htmlLang: 'en', httpContentLanguage: 'en' }"
+                     "});",
+                    huge_lang.c_str()];
+
+  web::test::ExecuteJavaScriptForFeature(
+      web_state(), js, AnnotationsJavaScriptFeature::GetInstance());
+
+  // Once the second (valid) message is processed, we are guaranteed that the
+  // first (invalid) message was already processed and discarded.
+  EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForActionTimeout, ^{
+    return observer()->seq_id() == 999;
+  }));
+  EXPECT_EQ("sync_text", observer()->extracted_text());
+  EXPECT_EQ(1, observer()->text_extracted_calls());
+}
+
+TEST_F(AnnotationTextManagerViewportTest,
+       RejectMessageWithUnexpectedKeyInExtractedText) {
+  ASSERT_TRUE(LoadHtml("<html><body></body></html>"));
+  ASSERT_TRUE(WaitForWebFramesCount(1));
+
+  observer()->Reset();
+
+  NSString* js = @"window.webkit.messageHandlers.annotations.postMessage({"
+                  "  command: 'annotations.extractedText',"
+                  "  text: 'invalid_text',"
+                  "  seqId: 46,"
+                  "  metadata: { htmlLang: 'en', httpContentLanguage: 'en' },"
+                  "  unexpectedKey: 'malicious'"
+                  "});"
+                  "window.webkit.messageHandlers.annotations.postMessage({"
+                  "  command: 'annotations.extractedText',"
+                  "  text: 'sync_text',"
+                  "  seqId: 999,"
+                  "  metadata: { htmlLang: 'en', httpContentLanguage: 'en' }"
+                  "});";
+
+  web::test::ExecuteJavaScriptForFeature(
+      web_state(), js, AnnotationsJavaScriptFeature::GetInstance());
+
+  EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForActionTimeout, ^{
+    return observer()->seq_id() == 999;
+  }));
+  EXPECT_EQ("sync_text", observer()->extracted_text());
+  EXPECT_EQ(1, observer()->text_extracted_calls());
+}
+
+TEST_F(AnnotationTextManagerViewportTest,
+       RejectMessageWithUnexpectedKeyInMetadata) {
+  ASSERT_TRUE(LoadHtml("<html><body></body></html>"));
+  ASSERT_TRUE(WaitForWebFramesCount(1));
+
+  observer()->Reset();
+
+  NSString* js = @"window.webkit.messageHandlers.annotations.postMessage({"
+                  "  command: 'annotations.extractedText',"
+                  "  text: 'invalid_text',"
+                  "  seqId: 47,"
+                  "  metadata: { htmlLang: 'en', httpContentLanguage: 'en', "
+                  "unexpectedKey: 'malicious' }"
+                  "});"
+                  "window.webkit.messageHandlers.annotations.postMessage({"
+                  "  command: 'annotations.extractedText',"
+                  "  text: 'sync_text',"
+                  "  seqId: 999,"
+                  "  metadata: { htmlLang: 'en', httpContentLanguage: 'en' }"
+                  "});";
+
+  web::test::ExecuteJavaScriptForFeature(
+      web_state(), js, AnnotationsJavaScriptFeature::GetInstance());
+
+  EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForActionTimeout, ^{
+    return observer()->seq_id() == 999;
+  }));
+  EXPECT_EQ("sync_text", observer()->extracted_text());
+  EXPECT_EQ(1, observer()->text_extracted_calls());
+}
+
+TEST_F(AnnotationTextManagerViewportTest,
+       RejectMessageWithUnexpectedKeyInDecoratingComplete) {
+  ASSERT_TRUE(LoadHtml("<html><body></body></html>"));
+  ASSERT_TRUE(WaitForWebFramesCount(1));
+
+  observer()->Reset();
+  int decoration_calls = observer()->decoration_calls();
+
+  NSString* js = @"window.webkit.messageHandlers.annotations.postMessage({"
+                  "  command: 'annotations.decoratingComplete',"
+                  "  annotations: 1,"
+                  "  successes: 1,"
+                  "  failures: 0,"
+                  "  cancelled: [],"
+                  "  unexpectedKey: 'malicious'"
+                  "});"
+                  "window.webkit.messageHandlers.annotations.postMessage({"
+                  "  command: 'annotations.decoratingComplete',"
+                  "  annotations: 1,"
+                  "  successes: 1,"
+                  "  failures: 0,"
+                  "  cancelled: []"
+                  "});";
+
+  web::test::ExecuteJavaScriptForFeature(
+      web_state(), js, AnnotationsJavaScriptFeature::GetInstance());
+
+  EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForActionTimeout, ^{
+    return observer()->decoration_calls() > decoration_calls;
+  }));
+  EXPECT_EQ(1, observer()->decoration_calls() - decoration_calls);
+}
+
+TEST_F(AnnotationTextManagerViewportTest,
+       RejectMessageWithUnexpectedKeyInOnClick) {
+  ASSERT_TRUE(LoadHtml("<html><body></body></html>"));
+  ASSERT_TRUE(WaitForWebFramesCount(1));
+
+  observer()->Reset();
+  int clicks = observer()->clicks();
+
+  NSString* js = @"window.webkit.messageHandlers.annotations.postMessage({"
+                  "  command: 'annotations.onClick',"
+                  "  data: 'some_data',"
+                  "  rect: { x: 0, y: 0, width: 10, height: 10 },"
+                  "  text: 'some_text',"
+                  "  cancel: false,"
+                  "  unexpectedKey: 'malicious'"
+                  "});"
+                  "window.webkit.messageHandlers.annotations.postMessage({"
+                  "  command: 'annotations.onClick',"
+                  "  data: 'some_data',"
+                  "  rect: { x: 0, y: 0, width: 10, height: 10 },"
+                  "  text: 'some_text',"
+                  "  cancel: false"
+                  "});";
+
+  web::test::ExecuteJavaScriptForFeature(
+      web_state(), js, AnnotationsJavaScriptFeature::GetInstance());
+
+  EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForActionTimeout, ^{
+    return observer()->clicks() > clicks;
+  }));
+  EXPECT_EQ(1, observer()->clicks() - clicks);
+}
+
 }  // namespace web
diff --git a/ios/web/annotations/annotations_java_script_feature.h b/ios/web/annotations/annotations_java_script_feature.h
index aca0f9b..74464d9 100644
--- a/ios/web/annotations/annotations_java_script_feature.h
+++ b/ios/web/annotations/annotations_java_script_feature.h
@@ -14,6 +14,9 @@
 
 namespace web {
 
+extern const int kMaxAnnotationsTextLength;
+extern const int kMaxAnnotationsMetadataLength;
+
 /**
  * Handles JS communication for the annotations feature.
  */
diff --git a/ios/web/annotations/annotations_java_script_feature.mm b/ios/web/annotations/annotations_java_script_feature.mm
index a2938df..e4f8c729 100644
--- a/ios/web/annotations/annotations_java_script_feature.mm
+++ b/ios/web/annotations/annotations_java_script_feature.mm
@@ -24,6 +24,9 @@
 
 namespace web {
 
+const int kMaxAnnotationsTextLength = 65535;
+const int kMaxAnnotationsMetadataLength = 256;
+
 AnnotationsJavaScriptFeature::AnnotationsJavaScriptFeature()
     : JavaScriptFeature(
           ContentWorld::kIsolatedWorld,
@@ -51,6 +54,7 @@
   }
 
   base::ListValue parameters;
+  parameters.Append(maximum_text_length);
   CallJavaScriptFunction(frame, "annotations.start", parameters);
 }
 
@@ -129,15 +133,50 @@
   }
 
   if (*command == "annotations.extractedText") {
+    for (const auto pair : dict) {
+      const std::string& key = pair.first;
+      if (key != "command" && key != "text" && key != "seqId" &&
+          key != "metadata") {
+        return;
+      }
+    }
     const std::string* text = dict.FindString("text");
     std::optional<double> seq_id = dict.FindDouble("seqId");
     const base::DictValue* metadata = dict.FindDict("metadata");
     if (!text || !seq_id || !metadata) {
       return;
     }
+    for (const auto pair : *metadata) {
+      const std::string& key = pair.first;
+      if (key != "htmlLang" && key != "httpContentLanguage" &&
+          key != "wkNoTelephone" && key != "wkNoEmail" &&
+          key != "wkNoAddress" && key != "wkNoDate" && key != "wkNoUnit") {
+        return;
+      }
+    }
+    if (text->size() > kMaxAnnotationsTextLength) {
+      return;
+    }
+    const std::string* html_lang = metadata->FindString("htmlLang");
+    if (html_lang && html_lang->size() > kMaxAnnotationsMetadataLength) {
+      return;
+    }
+    const std::string* http_content_language =
+        metadata->FindString("httpContentLanguage");
... (file diff truncated)
Loading diff…

Original Bug Report

reported by [email protected]

Potential Missing Validation for Web-Controlled Text Extraction in iOS Annotations

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 iOS Annotations feature fails to enforce intended length limits for text extracted from web pages and sent to the browser process. A malicious website can bypass the 64 KiB limit, sending unbounded strings from a sandboxed WebContent process to a privileged browser process. This data is subsequently processed by a TFLite-based classifier, potentially leading to memory corruption or resource exhaustion.

Affected files:

  • ios/web/annotations/annotations_java_script_feature.mm
  • ios/web/annotations/annotations_text_manager_impl.mm
  • ios/chrome/browser/web/model/annotations/annotations_tab_helper.mm
  • ios/web/annotations/resources/text_main.ts
  • ios/web/annotations/resources/text_extractor.ts

Estimated timestamp from git blame: 2024-01-17

Description

A potential validation gap in the iOS Annotations pipeline allows a malicious webpage to send unbounded text and metadata from the web content (WKWebView) to the browser process. While a 64 KiB limit (kMaxAnnotationsTextLength) is defined in the native code, it is not enforced during extraction or upon receipt. This results in arbitrarily large strings reaching a TFLite-based classifier running in the privileged iOS browser process.

Root Cause Analysis

The vulnerability stems from multiple missing validation steps in the communication pipeline between the injected JavaScript and the native browser code:

  1. Failure to Pass Limits to JavaScript: In ios/web/annotations/annotations_java_script_feature.mm, the ExtractText function receives a maximum_text_length parameter (65535) but fails to include it in the parameters list passed to the annotations.start JavaScript function (lines 44-55).

  2. Unrestricted JavaScript Extraction: In ios/web/annotations/resources/text_extractor.ts, the extraction logic collects and concatenates text nodes without enforcing a cumulative length limit (lines 122-128).

  3. Missing Native-Side Validation: In ios/web/annotations/annotations_java_script_feature.mm, the ScriptMessageReceived handler processes the annotations.extractedText command. It extracts the text string but does not verify its length before passing it to the AnnotationsTextManagerImpl (lines 131-139).

  4. Processing in Privileged Process: The unbounded strings are ultimately passed to ios::provider::ExtractTextAnnotationFromText via AnnotationsTabHelper::OnTextExtracted (ios/chrome/browser/web/model/annotations/annotations_tab_helper.mm:137). This function executes in the main application process (the browser process) and utilizes a complex TFLite model for text classification.

Potential Impact

On iOS, although all applications are sandboxed by the OS, the browser process acts as the privileged coordinator for the application. Exposing this process to unbounded, web-controlled input increases the attack surface:

  • Resource Exhaustion: A malicious page could cause the browser process to consume excessive memory by sending very large text nodes, potentially leading to an out-of-memory (OOM) condition.
  • Memory Corruption: The unbounded strings are processed by the in-process annotator. Any vulnerability in the tokenizer or the TFLite inference engine could be leveraged to achieve memory corruption within the browser process tier.

Suggested Potential Steps to Trigger

  1. Host an HTTPS page containing a very large text node (e.g., >100 MB).
  2. Navigate to this page using Chrome on iOS.
  3. The AnnotationsTextManagerImpl will trigger text extraction automatically on page load.
  4. The injected JavaScript will send the full, unbounded text back to the browser process via sendWebKitMessage.
  5. The browser process will receive and attempt to process the large payload in AnnotationsTabHelper::OnTextExtracted and the subsequent ThreadPool task.
  1. Update AnnotationsJavaScriptFeature::ExtractText to pass the maximum_text_length to the annotations.start JavaScript function.
  2. Modify the JavaScript extraction logic in text_extractor.ts to respect the provided length limit.
  3. Implement explicit size and content validation for the text string and metadata fields in AnnotationsJavaScriptFeature::ScriptMessageReceived before further processing.

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