Medium watch CVSS 6.5 chrome Authorization Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in WebProtect
DescriptionIncorrect authorization in WebProtect
ComponentWebProtect
Sub-componentFortify
Bug ClassAuthorization Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
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.
Tracker511806043
Fix commitb51681e1da09 (chromium/src) +52/-0
CWECWE-863
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-10 (per vendor advisory)
Fix landed2026-07-09 (public git)
Disclosed2026-08-25
Patch gap47 days — fix public in source before the advisory

Background

`DataProtectionNavigationObserver`
an enterprise data-protection component (chrome/browser/enterprise/data_protection/) that, for a primary-main-frame navigation, obtains a URL verdict and reports the resulting UrlSettings (data-protection settings for the page) through a callback, then cleans itself up.
`WebContentsObserver` callbacks
DidStartNavigation, DidRedirectNavigation and DidFinishNavigation fire for every navigation in the tab, including subframe navigations, not just the one an observer was created for.
`navigation_id_`
the observer’s stored ID of the primary-main-frame navigation it is tracking, comparable against NavigationHandle::GetNavigationId().
Enterprise real-time URL check
policy-driven lookup (controlled by prefs such as enterprise_connectors::kEnterpriseRealTimeUrlCheckMode) that determines which data-protection settings apply to a URL.

Root Cause Analysis

The commit message states that although the controller constructs DataProtectionNavigationObserver only for the primary main-frame navigation, its WebContentsObserver overrides received callbacks for all navigations in the tab and did not filter by navigation.

Pre-patch, DataProtectionNavigationObserver::DidFinishNavigation unconditionally set is_navigation_finished_ = true and scheduled MaybeCleanup via a base::ScopedClosureRunner, so a fast subframe navigation finishing first caused the observer to delete itself before the tracked main-frame navigation completed, dropping the pending UrlSettings callback. Likewise DataProtectionNavigationObserver::DidRedirectNavigation would process redirects belonging to unrelated navigations.

The fix adds an early return in both methods when navigation_handle->GetNavigationId() != navigation_id_, placed before any state change, so only the tracked main-frame navigation can advance or tear down the observer. This tightens the callbacks to the invariant the controller already assumed: one observer, one navigation.

Key insight
Tab-wide WebContentsObserver callbacks were treated as if they belonged to the single navigation the observer was created for; filtering on navigation_id_ prevents unrelated subframe navigations from finishing (and destroying) the observer early.

Attack Path

  1. Policy precondition The browser is enterprise-managed with data-protection URL settings in effect, so the controller creates a DataProtectionNavigationObserver for a primary-main-frame navigation.
  2. Concurrent subframe navigation While that main-frame navigation is pending, a subframe in the tab starts and completes a navigation (the regression test appends a child frame and commits a navigation in it before the main-frame commit).
  3. Premature teardown Pre-patch, the subframe’s DidFinishNavigation marked the observer finished and ran MaybeCleanup, destroying it before the main-frame navigation committed.
  4. Settings not applied The main-frame navigation’s verdict callback is dropped, so the data-protection settings intended for the destination page are not delivered (inferred: protections such as watermarking or screenshot/clipboard restrictions would not take effect for that page).

Impact Assessment

The bug lets page-driven subframe activity defeat an enterprise data-protection control in the browser process, so a page that policy says should be protected may be displayed without its configured UrlSettings (the specific protections lost are inferred). It only matters on managed browsers where enterprise data-protection/URL-check policy is enabled, and requires a subframe navigation to finish during the window of a pending main-frame navigation; it is not a memory-safety bug. The advisory classifies it as “Incorrect authorization in WebProtect” and does not state in-the-wild exploitation.

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_unittest.cc
  • chrome/browser/enterprise/data_protection/data_protection_navigation_observer.cc

Audit Directions

  • Per-navigation observers on tab-wide events
    Search for WebContentsObserver subclasses created for a specific NavigationHandle that self-delete or change state in DidFinishNavigation/DidRedirectNavigation without checking GetNavigationId() or IsInPrimaryMainFrame().
  • Security decisions tied to a callback that can be dropped
    Review enterprise connectors, Safe Browsing and other policy enforcers where a dropped callback results in “no restriction” rather than failing closed.
  • Frame-scope confusion
    Check other enforcement paths (prerender, fenced frames, same-document navigations) for the assumption that any finished navigation is the one being enforced.
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