CVE-2026-79077
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/enterprise/connectors/common.cc |
modified | |
TEST_Fchrome/browser/enterprise/connectors/common_unittest.cc |
modified | |
CollectFrameUrlschrome/browser/safe_browsing/download_protection/download_item_metadata.cc |
modified |
Files Changed
chrome/browser/enterprise/connectors/common.ccchrome/browser/enterprise/connectors/common.hchrome/browser/enterprise/connectors/common_unittest.ccchrome/browser/safe_browsing/download_protection/download_item_metadata.ccchrome/browser/safe_browsing/download_protection/download_protection_service_unittest.cc
Patch
From 694b8bf5831d7ae747382efdc3ad07d776f904ce Mon Sep 17 00:00:00 2001 From: Haihan Chen <[email protected]> Date: Tue, 07 Jul 2026 12:29:10 -0700 Subject: [PATCH] [Fortify] Fix frame context bypass for downloads and fsa Enterprise dlp rules could potentially be bypassed because the url chain relied on the current focused frame. If an operation is triggered programmatically from an iframe, the DLP server would receive an empty frame url chain. We fallback to the focused frame if no initiating frame is provided. Bug: 501416859 Change-Id: I7dce25d87eee64e5d98ae0d84c00f4a1c5e978bf Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8036561 Reviewed-by: Yaw Frempong <[email protected]> Commit-Queue: Haihan Chen <[email protected]> Reviewed-by: Rakina Zata Amni <[email protected]> Cr-Commit-Position: refs/heads/main@{#1658202} --- diff --git a/chrome/browser/enterprise/connectors/common.cc b/chrome/browser/enterprise/connectors/common.cc index 21556f2a..b6797ccc 100644 --- a/chrome/browser/enterprise/connectors/common.cc +++ b/chrome/browser/enterprise/connectors/common.cc @@ -8,15 +8,17 @@ #include "build/build_config.h" #include "chrome/browser/browser_process.h" #include "chrome/browser/enterprise/connectors/analysis/content_analysis_downloads_delegate.h" -#include "chrome/grit/generated_resources.h" -#include "ui/base/l10n/l10n_util.h" #include "chrome/browser/enterprise/connectors/connectors_service.h" #include "chrome/browser/enterprise/util/affiliation.h" #include "chrome/browser/policy/chrome_browser_policy_connector.h" #include "chrome/browser/policy/dm_token_utils.h" #include "chrome/browser/signin/identity_manager_factory.h" +#include "chrome/grit/generated_resources.h" #include "components/enterprise/connectors/core/features.h" +#include "content/public/browser/render_frame_host.h" +#include "content/public/browser/web_contents.h" #include "extensions/buildflags/buildflags.h" +#include "ui/base/l10n/l10n_util.h" #if BUILDFLAG(ENABLE_EXTENSIONS_CORE) #include "extensions/common/constants.h" @@ -53,14 +55,29 @@ constexpr int kMaxFrameUrls = 10; google::protobuf::RepeatedPtrField<std::string> CollectFrameUrlsImpl( - content::WebContents* web_contents) { + content::WebContents* web_contents, + std::optional<content::GlobalRenderFrameHostId> initiating_frame_id) { google::protobuf::RepeatedPtrField<std::string> frame_urls; if (!web_contents) { return frame_urls; } - content::RenderFrameHost* current_frame = web_contents->GetFocusedFrame(); + content::RenderFrameHost* current_frame = nullptr; + if (initiating_frame_id.has_value()) { + current_frame = + content::RenderFrameHost::FromID(initiating_frame_id.value()); + if (!current_frame) { + // If an explicit initiating frame was expected but is no longer + // available, return an empty chain. + // TODO(crbug.com/531669028): Returning an empty chain for transient + // iframes allows them to bypass DLP rules by being evaluated as a + // main-frame action. + return frame_urls; + } + } else { + current_frame = web_contents->GetFocusedFrame(); + } // Traverse upwards and add URLs to the chain, stopping before the outermost // frame. @@ -162,14 +179,15 @@ google::protobuf::RepeatedPtrField<std::string> CollectFrameUrls( content::WebContents* web_contents, - DeepScanAccessPoint access_point) { + DeepScanAccessPoint access_point, + std::optional<content::GlobalRenderFrameHostId> initiating_frame_id) { #if BUILDFLAG(ENTERPRISE_CONTENT_ANALYSIS) if (!base::FeatureList::IsEnabled(kEnterpriseIframeDlpRulesSupport)) { return google::protobuf::RepeatedPtrField<std::string>(); } google::protobuf::RepeatedPtrField<std::string> frame_urls = - CollectFrameUrlsImpl(web_contents); + CollectFrameUrlsImpl(web_contents, initiating_frame_id); // For the histogram, we count the tab URL to differentiate between cases // where there is no tab and tabs with no iframes. diff --git a/chrome/browser/enterprise/connectors/common.h b/chrome/browser/enterprise/connectors/common.h index 6750ca2..e8506a04 100644 --- a/chrome/browser/enterprise/connectors/common.h +++ b/chrome/browser/enterprise/connectors/common.h @@ -5,6 +5,7 @@ #ifndef CHROME_BROWSER_ENTERPRISE_CONNECTORS_COMMON_H_ #define CHROME_BROWSER_ENTERPRISE_CONNECTORS_COMMON_H_ +#include <optional> #include <string> #include "base/functional/callback_forward.h" @@ -14,6 +15,7 @@ #include "components/enterprise/connectors/core/common.h" #include "components/safe_browsing/buildflags.h" #include "content/public/browser/download_manager_delegate.h" +#include "content/public/browser/global_routing_id.h" #if BUILDFLAG(SAFE_BROWSING_AVAILABLE) #include "chrome/browser/safe_browsing/cloud_content_scanning/deep_scanning_utils.h" // nogncheck crbug.com/40147906 @@ -70,9 +72,14 @@ // Returns the list of URLs from the current frame all the way to the outermost // frame URL. Above the `kMaxFrameUrls` limit, we skip the rest of the chain and // take the outermost URL for performance considerations. +// +// The chain is collected starting from `initiating_frame_id` if provided. +// If the frame ID is provided but the frame is dead, it returns an empty chain. google::protobuf::RepeatedPtrField<std::string> CollectFrameUrls( content::WebContents* web_contents, - DeepScanAccessPoint access_point); + DeepScanAccessPoint access_point, + std::optional<content::GlobalRenderFrameHostId> initiating_frame_id = + std::nullopt); #if BUILDFLAG(SAFE_BROWSING_AVAILABLE) diff --git a/chrome/browser/enterprise/connectors/common_unittest.cc b/chrome/browser/enterprise/connectors/common_unittest.cc index e71cb730..453675f9 100644 --- a/chrome/browser/enterprise/connectors/common_unittest.cc +++ b/chrome/browser/enterprise/connectors/common_unittest.cc @@ -169,6 +169,33 @@ histogram_tester.ExpectBucketCount( "Enterprise.IframeDlpRulesSupport.Download.UrlChainSize", 0, 1); } + +TEST_F(CollectFrameUrlsTest, InitiatingFrame) { + base::HistogramTester histogram_tester; + + content::RenderFrameHostTester* rfh_tester = + content::RenderFrameHostTester::For( + web_contents()->GetPrimaryMainFrame()); + + // Create an iframe and navigate it to a URL. + GURL child_frame_url1("https://foo.com/"); + content::RenderFrameHost* child_frame1 = + rfh_tester->AppendChild("child_frame1"); + child_frame1 = content::NavigationSimulator::NavigateAndCommitFromDocument( + child_frame_url1, child_frame1); + + // Focus the main frame, but pass the child frame as the initiating frame. + content::FocusWebContentsOnFrame(web_contents(), + web_contents()->GetPrimaryMainFrame()); + + google::protobuf::RepeatedPtrField<std::string> frame_urls = + CollectFrameUrls(web_contents(), DeepScanAccessPoint::DOWNLOAD, + std::make_optional(child_frame1->GetGlobalId())); + + // The child frame should be included because it is the initiating frame. + ASSERT_EQ(1, frame_urls.size()); + EXPECT_EQ(child_frame_url1.spec(), frame_urls[0]); +} #endif // BUILDFLAG(ENTERPRISE_CONTENT_ANALYSIS) } // namespace enterprise_connectors diff --git a/chrome/browser/safe_browsing/download_protection/download_item_metadata.cc b/chrome/browser/safe_browsing/download_protection/download_item_metadata.cc index 91e2305..59fa9b8 100644 --- a/chrome/browser/safe_browsing/download_protection/download_item_metadata.cc +++ b/chrome/browser/safe_browsing/download_protection/download_item_metadata.cc @@ -13,6 +13,7 @@ #include "components/enterprise/connectors/core/reporting_utils.h" #include "components/enterprise/obfuscation/core/download_obfuscator.h" #include "content/public/browser/download_item_utils.h" +#include "content/public/browser/render_frame_host.h" #if !BUILDFLAG(IS_ANDROID) #include "chrome/browser/download/bubble/download_bubble_ui_controller.h" @@ -231,7 +232,9 @@ DownloadItemMetadata::CollectFrameUrls() const { return enterprise_connectors::CollectFrameUrls( content::DownloadItemUtils::GetWebContents(item_), - enterprise_connectors::DeepScanAccessPoint::DOWNLOAD); + enterprise_connectors::DeepScanAccessPoint::DOWNLOAD, + std::make_optional( + content::DownloadItemUtils::GetRenderFrameHostId(item_))); } content::WebContents* DownloadItemMetadata::web_contents() const { diff --git a/chrome/browser/safe_browsing/download_protection/download_protection_service_unittest.cc b/chrome/browser/safe_browsing/download_protection/download_protection_service_unittest.cc index b30c9cb..76c0a1b 100644 --- a/chrome/browser/safe_browsing/download_protection/download_protection_service_unittest.cc +++ b/chrome/browser/safe_browsing/download_protection/download_protection_service_unittest.cc @@ -818,6 +818,8 @@ result->sha256_hash = hash_; result->size = 100; result->frame_url = GURL("https://example.com/foo/bar"); + result->initiating_frame_id =
Regression Test / PoC
diff --git a/chrome/browser/enterprise/connectors/common_unittest.cc b/chrome/browser/enterprise/connectors/common_unittest.cc
index e71cb730..453675f9 100644
--- a/chrome/browser/enterprise/connectors/common_unittest.cc
+++ b/chrome/browser/enterprise/connectors/common_unittest.cc
@@ -169,6 +169,33 @@
histogram_tester.ExpectBucketCount(
"Enterprise.IframeDlpRulesSupport.Download.UrlChainSize", 0, 1);
}
+
+TEST_F(CollectFrameUrlsTest, InitiatingFrame) {
+ base::HistogramTester histogram_tester;
+
+ content::RenderFrameHostTester* rfh_tester =
+ content::RenderFrameHostTester::For(
+ web_contents()->GetPrimaryMainFrame());
+
+ // Create an iframe and navigate it to a URL.
+ GURL child_frame_url1("https://foo.com/");
+ content::RenderFrameHost* child_frame1 =
+ rfh_tester->AppendChild("child_frame1");
+ child_frame1 = content::NavigationSimulator::NavigateAndCommitFromDocument(
+ child_frame_url1, child_frame1);
+
+ // Focus the main frame, but pass the child frame as the initiating frame.
+ content::FocusWebContentsOnFrame(web_contents(),
+ web_contents()->GetPrimaryMainFrame());
+
+ google::protobuf::RepeatedPtrField<std::string> frame_urls =
+ CollectFrameUrls(web_contents(), DeepScanAccessPoint::DOWNLOAD,
+ std::make_optional(child_frame1->GetGlobalId()));
+
+ // The child frame should be included because it is the initiating frame.
+ ASSERT_EQ(1, frame_urls.size());
+ EXPECT_EQ(child_frame_url1.spec(), frame_urls[0]);
+}
#endif // BUILDFLAG(ENTERPRISE_CONTENT_ANALYSIS)
} // namespace enterprise_connectors
diff --git a/chrome/browser/safe_browsing/download_protection/download_protection_service_unittest.cc b/chrome/browser/safe_browsing/download_protection/download_protection_service_unittest.cc
index b30c9cb..76c0a1b 100644
--- a/chrome/browser/safe_browsing/download_protection/download_protection_service_unittest.cc
+++ b/chrome/browser/safe_browsing/download_protection/download_protection_service_unittest.cc
@@ -818,6 +818,8 @@
result->sha256_hash = hash_;
result->size = 100;
result->frame_url = GURL("https://example.com/foo/bar");
+ result->initiating_frame_id =
+ web_contents()->GetPrimaryMainFrame()->GetGlobalId();
result->has_user_gesture = true;
result->web_contents = web_contents()->GetWeakPtr();
result->browser_context = profile();
@@ -832,6 +834,7 @@
result->sha256_hash = in->sha256_hash;
result->size = in->size;
result->frame_url = in->frame_url;
+ result->initiating_frame_id = in->initiating_frame_id;
result->has_user_gesture = in->has_user_gesture;
result->web_contents = in->web_contents;
result->browser_context = in->browser_context;
diff --git a/content/browser/file_system_access/file_system_access_file_writer_impl_unittest.cc b/content/browser/file_system_access/file_system_access_file_writer_impl_unittest.cc
index 3180acc2..65e472a 100644
--- a/content/browser/file_system_access/file_system_access_file_writer_impl_unittest.cc
+++ b/content/browser/file_system_access/file_system_access_file_writer_impl_unittest.cc
@@ -656,6 +656,8 @@
Field(&FileSystemAccessWriteItem::sha256_hash, Eq(expected_hash)),
Field(&FileSystemAccessWriteItem::size, Eq(3)),
Field(&FileSystemAccessWriteItem::frame_url, Eq(kTestURL)),
+ Field(&FileSystemAccessWriteItem::initiating_frame_id,
+ Eq(kFrameId)),
Field(&FileSystemAccessWriteItem::has_user_gesture, Eq(false))),
kFrameId, _))
.WillOnce(base::test::RunOnceCallback<2>(
diff --git a/content/browser/file_system_access/file_system_access_safe_move_helper_unittest.cc b/content/browser/file_system_access/file_system_access_safe_move_helper_unittest.cc
index a689e70..5ee99a5 100644
--- a/content/browser/file_system_access/file_system_access_safe_move_helper_unittest.cc
+++ b/content/browser/file_system_access/file_system_access_safe_move_helper_unittest.cc
@@ -530,6 +530,8 @@
Field(&FileSystemAccessWriteItem::sha256_hash, Eq(expected_hash)),
Field(&FileSystemAccessWriteItem::size, Eq(3)),
Field(&FileSystemAccessWriteItem::frame_url, Eq(kTestURL)),
+ Field(&FileSystemAccessWriteItem::initiating_frame_id,
+ Eq(kFrameId)),
Field(&FileSystemAccessWriteItem::has_user_gesture, Eq(false))),
kFrameId, _))
.WillOnce(base::test::RunOnceCallback<2>(
Original Bug Report
Enterprise DLP bypass via GetFocusedFrame() for downloads and FSA
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.
Overview: Enterprise DLP rules for downloads and File System Access (FSA) can potentially be bypassed because the system relies on the currently focused frame instead of the initiating frame to populate the URL context signal. If an operation is triggered programmatically from an iframe without that iframe gaining focus, the DLP server receives an empty frame URL chain. This causes the DLP server to evaluate the request based solely on the main frame’s URL, bypassing iframe-specific policies.
Affected files:
chrome/browser/enterprise/connectors/common.ccchrome/browser/safe_browsing/download_protection/download_item_metadata.ccchrome/browser/safe_browsing/download_protection/file_system_access_metadata.cc
Estimated timestamp from git blame: 2025-08-20
Description
A potential logic error in the Enterprise DLP implementation allows a bypass of iframe-aware rules during file downloads and File System Access (FSA) operations. The vulnerability arises because the frame_url_chain proto field, which provides origin context to the DLP cloud service, is populated based on the currently focused frame rather than the frame that actually initiated the action.
Technical Details
The function enterprise_connectors::CollectFrameUrlsImpl in chrome/browser/enterprise/connectors/common.cc is responsible for generating the frame_url_chain. It begins traversing the frame hierarchy from web_contents->GetFocusedFrame() (line 160).
While focus is an appropriate signal for features like ‘Copy and Paste’, it is semantically unrelated to download or FSA write initiation. A programmatic download (e.g., via anchorElement.click()) or an FSA operation triggered from an iframe does not require focus to be on that frame.
Due to the implementation of the traversal loop (lines 164-181), if the main frame is focused (which is the default state) or if no frame is currently focused, CollectFrameUrlsImpl terminates before adding any URLs to the chain. The resulting frame_url_chain is sent to the DLP server as an empty list, making a request from an iframe indistinguishable from a request initiated by the main frame.
Impacted Paths
- Downloads: In
chrome/browser/safe_browsing/download_protection/download_item_metadata.cc,DownloadItemMetadata::CollectFrameUrlscalls the flawed logic. It fails to consult the browser-authoritative initiating frame stored in theDownloadItem(retrievable viaDownloadItemUtils::GetRenderFrameHost(item_)). - File System Access: In
chrome/browser/safe_browsing/download_protection/file_system_access_metadata.cc,FileSystemAccessMetadata::CollectFrameUrlssimilarly relies onGetFocusedFrame(), despite having access to the correct initiatingframe_urlwithin theFileSystemAccessWriteItemmetadata.
Potential Attack Steps
Note: These are suggested steps based on code analysis; a working proof-of-concept has not been executed.
- An attacker controls a malicious origin (e.g.,
attacker.com) and embeds it as an iframe within a trusted site (e.g.,trusted.example.com). - An enterprise user navigates to
trusted.example.com. By default, the main frame holds focus. - The attacker’s JavaScript within the iframe programmatically initiates a download (e.g., creating an
<a>tag with a malicious payload and calling.click()). The script ensures the iframe does not request focus. - The browser intercepts the download and initiates a deep scan via
DeepScanningRequest. - When collecting metadata,
CollectFrameUrlsImplis called. It fetchesGetFocusedFrame(), which returns the main frame (trusted.example.com). - Because the main frame has no parent, the traversal loop breaks immediately, resulting in an empty
frame_url_chain. - The DLP cloud service receives the request with the trusted tab URL but no frame context. It applies the permissive policy of the main frame and allows the malicious download to proceed.
Suggested Fix
CollectFrameUrlsImpl and its callers should be updated to use the actual RenderFrameHost that initiated the action rather than the focused frame.
- Modify
enterprise_connectors::CollectFrameUrlsto accept aRenderFrameHost* initiating_frameparameter instead of, or in addition to,WebContents. - For downloads, retrieve the initiating frame using
content::DownloadItemUtils::GetRenderFrameHost(item_)and pass it to the collection function. - For FSA, the metadata already contains the authoritative
frame_urlwhich should be used to reconstruct the chain context.
Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b
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