CVE-2026-79038
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fchrome/browser/enterprise/data_protection/data_protection_navigation_observer_unittest.cc |
modified |
Files Changed
chrome/browser/enterprise/data_protection/data_protection_navigation_observer.ccchrome/browser/enterprise/data_protection/data_protection_navigation_observer_unittest.cc
Patch
From b51681e1da0913ced74890eb06419bdd3e581e30 Mon Sep 17 00:00:00 2001
From: Haihan Chen <haihan@google.com>
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 <alshawwa@chromium.org>
Commit-Queue: Haihan Chen <haihan@google.com>
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();
Regression Test / PoC
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();
Original Bug Report
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.ccchrome/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:
- The state flag
is_navigation_finished_is set totrueunconditionally at the very beginning of the function (data_protection_navigation_observer.cc:426). - A
base::ScopedClosureRunneris created to callMaybeCleanup()when the function exits (lines 427-429). - 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). - 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.
- The user navigates to an attacker-controlled page (
attacker.com). - The attacker’s page runs JavaScript that continuously triggers rapid subframe navigations (e.g., creating an iframe and rapidly changing its
srctoabout:blankor adata:URI). - 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. DataProtectionNavigationObserveris created for the new main frame navigation. Assuming no real-time URL check is required,is_verdict_received_is set totrue.- The previous page (
attacker.com) and its iframes remain active while the new main frame is waiting for its network response. - One of the rapid iframe navigations completes.
DataProtectionNavigationObserver::DidFinishNavigationis called for the iframe. It incorrectly setsis_navigation_finished_ = trueand triggersMaybeCleanup().- The observer is destroyed prematurely.
- The main frame navigation to
https://internal.example.comcommits. 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