Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in WebProtect
DescriptionIncorrect authorization in WebProtect
ComponentWebProtect
Bug ClassLogic Error
Tracker511806043
Fix commitb51681e1da09 (chromium/src) +52/-0
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Changed Functions

FunctionChangeNotes
TEST_F
chrome/browser/enterprise/data_protection/data_protection_navigation_observer_unittest.cc
modified

Files Changed

  • chrome/browser/enterprise/data_protection/data_protection_navigation_observer.cc
  • chrome/browser/enterprise/data_protection/data_protection_navigation_observer_unittest.cc
From b51681e1da0913ced74890eb06419bdd3e581e30 Mon Sep 17 00:00:00 2001
From: Haihan Chen <[email protected]>
Date: Thu, 09 Jul 2026 11:31:17 -0700
Subject: [PATCH] [Fortify] Fix DataProtectionNavigationObserver subframe bypass

Although the observer is only constructed for the primary main frame
navigation, the WebContentsObserver callbacks are fired for all
navigations in the tab. As a result, a fast subframe navigation could
trigger `DidFinishNavigation` on the observer, causing the observer to
delete itself before the primary main frame navigation completes.

We fix this by always checking that the navigation id matches the
main_frame navigation id. Test added to verify that subframe navigations
do not destroy the observer.

Bug: 511806043
Change-Id: Ibc91a1ed2a9449414acda9fb60c7776f8c8c1d4b
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8046162
Reviewed-by: Nasser Al-shawwa <[email protected]>
Commit-Queue: Haihan Chen <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1659715}
---

diff --git a/chrome/browser/enterprise/data_protection/data_protection_navigation_observer.cc b/chrome/browser/enterprise/data_protection/data_protection_navigation_observer.cc
index f00a85e7..06c1ff2 100644
--- a/chrome/browser/enterprise/data_protection/data_protection_navigation_observer.cc
+++ b/chrome/browser/enterprise/data_protection/data_protection_navigation_observer.cc
@@ -397,6 +397,10 @@
 
 void DataProtectionNavigationObserver::DidRedirectNavigation(
     content::NavigationHandle* navigation_handle) {
+  if (navigation_handle->GetNavigationId() != navigation_id_) {
+    return;
+  }
+
   DCHECK_CURRENTLY_ON(content::BrowserThread::UI);
   DCHECK(!is_from_cache_);
 
@@ -423,6 +427,10 @@
 
 void DataProtectionNavigationObserver::DidFinishNavigation(
     content::NavigationHandle* navigation_handle) {
+  if (navigation_handle->GetNavigationId() != navigation_id_) {
+    return;
+  }
+
   is_navigation_finished_ = true;
   base::ScopedClosureRunner done(
       base::BindOnce(&DataProtectionNavigationObserver::MaybeCleanup,
diff --git a/chrome/browser/enterprise/data_protection/data_protection_navigation_observer_unittest.cc b/chrome/browser/enterprise/data_protection/data_protection_navigation_observer_unittest.cc
index 1daddbad..ab1edd0 100644
--- a/chrome/browser/enterprise/data_protection/data_protection_navigation_observer_unittest.cc
+++ b/chrome/browser/enterprise/data_protection/data_protection_navigation_observer_unittest.cc
@@ -257,6 +257,12 @@
 
   void DidStartNavigation(
       content::NavigationHandle* navigation_handle) override {
+    // Actual controller only instantiates observer for primary main
+    // navigations.
+    if (!navigation_handle->IsInPrimaryMainFrame() ||
+        navigation_handle->IsSameDocument()) {
+      return;
+    }
     EXPECT_EQ(web_contents(), navigation_handle->GetWebContents());
     auto navigation_observer =
         std::make_unique<DataProtectionNavigationObserver>(
@@ -566,6 +572,44 @@
   }
 }
 
+TEST_F(DataProtectionNavigationObserverTest,
+       SubframeNavigation_DoesNotDestroyObserver) {
+  // Disable real-time check so the verdict is received immediately upon
+  // observer creation.
+  profile()->GetPrefs()->SetInteger(
+      enterprise_connectors::kEnterpriseRealTimeUrlCheckMode,
+      enterprise_connectors::REAL_TIME_CHECK_DISABLED);
+
+  SetContents(CreateTestWebContents());
+
+  auto simulator = content::NavigationSimulator::CreateRendererInitiated(
+      GURL("https://example.com"), web_contents()->GetPrimaryMainFrame());
+
+  base::test::TestFuture<const UrlSettings&> future;
+  FakeDataProtectionNavigationController controller(
+      web_contents(), &lookup_service_, future.GetCallback());
+
+  // Start the main frame navigation. This creates the observer.
+  simulator->Start();
+
+  // Create a subframe and simulate a complete navigation on it.
+  content::RenderFrameHostTester* rfh_tester =
+      content::RenderFrameHostTester::For(main_rfh());
+  content::RenderFrameHost* subframe = rfh_tester->AppendChild("subframe");
+  auto subframe_simulator =
+      content::NavigationSimulator::CreateRendererInitiated(
+          GURL("https://subframe.com"), subframe);
+  subframe_simulator->Start();
+  subframe_simulator->Commit();
+
+  // Commit the main frame navigation. If the observer was prematurely destroyed
+  // by the subframe navigation, the callback would be dropped and this would
+  // hang/fail.
+  simulator->Commit();
+
+  EXPECT_TRUE(future.IsReady());
+}
+
 TEST_F(DataProtectionNavigationObserverTest, ApplyDataProtectionSettings) {
   enterprise_connectors::test::EventReportValidator validator(client_.get());
   validator.ExpectNoReport();
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/enterprise/data_protection/data_protection_navigation_observer_unittest.cc b/chrome/browser/enterprise/data_protection/data_protection_navigation_observer_unittest.cc
index 1daddbad..ab1edd0 100644
--- a/chrome/browser/enterprise/data_protection/data_protection_navigation_observer_unittest.cc
+++ b/chrome/browser/enterprise/data_protection/data_protection_navigation_observer_unittest.cc
@@ -257,6 +257,12 @@
 
   void DidStartNavigation(
       content::NavigationHandle* navigation_handle) override {
+    // Actual controller only instantiates observer for primary main
+    // navigations.
+    if (!navigation_handle->IsInPrimaryMainFrame() ||
+        navigation_handle->IsSameDocument()) {
+      return;
+    }
     EXPECT_EQ(web_contents(), navigation_handle->GetWebContents());
     auto navigation_observer =
         std::make_unique<DataProtectionNavigationObserver>(
@@ -566,6 +572,44 @@
   }
 }
 
+TEST_F(DataProtectionNavigationObserverTest,
+       SubframeNavigation_DoesNotDestroyObserver) {
+  // Disable real-time check so the verdict is received immediately upon
+  // observer creation.
+  profile()->GetPrefs()->SetInteger(
+      enterprise_connectors::kEnterpriseRealTimeUrlCheckMode,
+      enterprise_connectors::REAL_TIME_CHECK_DISABLED);
+
+  SetContents(CreateTestWebContents());
+
+  auto simulator = content::NavigationSimulator::CreateRendererInitiated(
+      GURL("https://example.com"), web_contents()->GetPrimaryMainFrame());
+
+  base::test::TestFuture<const UrlSettings&> future;
+  FakeDataProtectionNavigationController controller(
+      web_contents(), &lookup_service_, future.GetCallback());
+
+  // Start the main frame navigation. This creates the observer.
+  simulator->Start();
+
+  // Create a subframe and simulate a complete navigation on it.
+  content::RenderFrameHostTester* rfh_tester =
+      content::RenderFrameHostTester::For(main_rfh());
+  content::RenderFrameHost* subframe = rfh_tester->AppendChild("subframe");
+  auto subframe_simulator =
+      content::NavigationSimulator::CreateRendererInitiated(
+          GURL("https://subframe.com"), subframe);
+  subframe_simulator->Start();
+  subframe_simulator->Commit();
+
+  // Commit the main frame navigation. If the observer was prematurely destroyed
+  // by the subframe navigation, the callback would be dropped and this would
+  // hang/fail.
+  simulator->Commit();
+
+  EXPECT_TRUE(future.IsReady());
+}
+
 TEST_F(DataProtectionNavigationObserverTest, ApplyDataProtectionSettings) {
   enterprise_connectors::test::EventReportValidator validator(client_.get());
   validator.ExpectNoReport();
Loading diff…

Original Bug Report

reported by [email protected]

Enterprise Data Controls Policy Bypass via Iframe Navigation Race

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: A logic error in DataProtectionNavigationObserver causes it to prematurely destroy itself when any subframe navigation completes, instead of waiting for the primary navigation it is monitoring. This allows an attacker to bypass screenshot and watermark protections by racing a subframe navigation against a protected page load. The bypass occurs because the observer is deleted before it can apply the enterprise data policies.

Affected files:

  • chrome/browser/enterprise/data_protection/data_protection_navigation_observer.cc
  • chrome/browser/enterprise/data_protection/data_protection_navigation_controller.cc

Estimated timestamp from git blame: 2025-06-20

Summary

A potential logic vulnerability in DataProtectionNavigationObserver allows for a bypass of Enterprise Data Controls (such as screenshot blocking and watermarking). The observer incorrectly signals its own destruction when any navigation finishes within the WebContents, even if it is a subframe navigation and not the main frame navigation being observed. By triggering a fast subframe navigation (e.g., using a data: URL iframe) while a protected page is loading, an attacker can cause the observer to be destroyed before it applies the required security policies.

Technical Details

DataProtectionNavigationObserver is designed to monitor a specific navigation (identified by a navigation_id_) and apply data protection settings once that navigation finishes and a policy verdict is received. It is instantiated in DataProtectionNavigationController::DidStartNavigation for new main frame navigations.

Because it inherits from content::WebContentsObserver, it receives DidFinishNavigation calls for every navigation in the tab, including those in subframes.

In DataProtectionNavigationObserver::DidFinishNavigation, the following problematic sequence occurs:

  1. The state flag is_navigation_finished_ is set to true unconditionally at the very beginning of the function (data_protection_navigation_observer.cc:426).
  2. A base::ScopedClosureRunner is created to call MaybeCleanup() when the function exits (lines 427-429).
  3. The method checks if the navigation belongs to the primary main frame (!navigation_handle->IsInPrimaryMainFrame()). If it is a subframe, the method returns early (lines 438-441).
  4. Upon early return, MaybeCleanup() is executed by the closure runner.

MaybeCleanup() checks if is_navigation_finished_ and is_verdict_received_ are both true. If they are, it instructs its delegate (DataProtectionNavigationController) to erase the observer, destroying it.

Crucially, if the Enterprise ‘DataControlsRules’ are used without Real-Time URL filtering (a common configuration for local screenshot blocking policies), is_verdict_received_ is set to true immediately in the observer’s constructor (line 367).

Therefore, if any subframe navigation finishes while is_verdict_received_ is true, the observer sets is_navigation_finished_ = true and triggers its own destruction. The pending_navigation_callback_ (responsible for applying the policy) is destroyed along with it. When the protected main frame navigation eventually commits, no observer exists to apply the policy, leaving the page unprotected.

Potential Exploitation Steps

Note: These are potential steps as they have not been verified with a live PoC.

  1. The user navigates to an attacker-controlled page (attacker.com).
  2. The attacker’s page runs JavaScript that continuously triggers rapid subframe navigations (e.g., creating an iframe and rapidly changing its src to about:blank or a data: URI).
  3. While these subframe navigations are occurring, a top-level navigation is initiated to a protected enterprise URL (e.g., https://internal.example.com), either by user action or script.
  4. DataProtectionNavigationObserver is created for the new main frame navigation. Assuming no real-time URL check is required, is_verdict_received_ is set to true.
  5. The previous page (attacker.com) and its iframes remain active while the new main frame is waiting for its network response.
  6. One of the rapid iframe navigations completes.
  7. DataProtectionNavigationObserver::DidFinishNavigation is called for the iframe. It incorrectly sets is_navigation_finished_ = true and triggers MaybeCleanup().
  8. The observer is destroyed prematurely.
  9. The main frame navigation to https://internal.example.com commits. Because the observer is gone, the enterprise policies (screenshot blocking/watermarking) are never applied.

Recommendation

Modify DataProtectionNavigationObserver::DidFinishNavigation to verify that the incoming navigation_handle’s ID matches the navigation_id_ stored in the observer before altering state.

The is_navigation_finished_ flag should only be set, and MaybeCleanup should only be scheduled, if the navigation IDs match.

void DataProtectionNavigationObserver::DidFinishNavigation(
    content::NavigationHandle* navigation_handle) {
  if (navigation_handle->GetNavigationId() != navigation_id_) {
    return;
  }

  is_navigation_finished_ = true;
  base::ScopedClosureRunner done(
      base::BindOnce(&DataProtectionNavigationObserver::MaybeCleanup,
                     weak_factory_.GetWeakPtr()));
  // ... rest of the method
}

Evaluated with Chrome root at commit: eca8648a4e1cdfdda68c495a6003059fed641955


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.

Raised in root component due to access or custom field issues on 1208119

View on issue tracker
Links in the report