CVE-2026-79077
Overview
Background
- Enterprise content analysis (DLP) connectors
- admin-configured policies under
chrome/browser/enterprise/connectorsthat send download and file-write events to a deep-scanning service, which allows or blocks them according to the organisation’s rules. - Iframe DLP rules / frame URL chain
- when
kEnterpriseIframeDlpRulesSupportis enabled,CollectFrameUrlssends the list of frame URLs from the acting frame up to (but excluding) the outermost frame, so rules can match content embedded in iframes and not only the tab URL. - `GlobalRenderFrameHostId`
- a browser-side identifier for a specific
RenderFrameHost;RenderFrameHost::FromIDresolves it back to a live frame or returns null if the frame is gone. - `FileSystemAccessWriteItem`
- the record handed to after-write checks for File System Access API writes, carrying
frame_url,outermost_main_frame_idand, after this patch,initiating_frame_id.
Root Cause Analysis
Before the patch, CollectFrameUrlsImpl always started its upward walk from web_contents->GetFocusedFrame(), the frame that currently had keyboard focus, not the frame that actually started the download or File System Access write. As the commit message says, an operation triggered programmatically from an iframe (with focus elsewhere, for example on the main frame) produced an empty or wrong frame URL chain, so the DLP service evaluated it as a main-frame action and iframe-scoped rules could be bypassed.
The patch threads the initiating frame identity through to the URL collection: CollectFrameUrls and CollectFrameUrlsImpl take an optional initiating_frame_id, DownloadItemUtils::GetRenderFrameHostId exposes the download’s originating frame, the File System Access write path stores context_.frame_id in the new FileSystemAccessWriteItem::initiating_frame_id field, and both metadata adapters pass that identity in. Collection now starts at the frame resolved from that ID, and falls back to the focused frame only when no ID is supplied.
The fix is partial by its own admission: if the supplied initiating frame no longer exists, the function returns an empty chain, and the added TODO(crbug.com/531669028) notes that transient iframes can still bypass DLP rules this way.
GetFocusedFrame()) and not from the provenance of the operation, so a non-focused iframe could act without its URL appearing in the DLP request. The fix binds the frame chain to the recorded initiating GlobalRenderFrameHostId.Attack Path
- Enterprise DLP deployment
The victim browser is managed with enterprise content-analysis connectors and iframe-aware DLP rules (
kEnterpriseIframeDlpRulesSupportenabled) that block downloads or file writes based on frame URLs. Without this enterprise configuration the code path has no security effect. - Embedded content acts without focus Content in an iframe programmatically starts a download or a File System Access write while focus is on a different frame, such as the top-level page.
- Wrong chain collected
DownloadItemMetadata::CollectFrameUrlsorFileSystemAccessMetadata::CollectFrameUrlscalledenterprise_connectors::CollectFrameUrls, which walked from the focused frame and so omitted the initiating iframe’s URL, leaving the chain empty. - Policy evaluated as main-frame action The deep-scan request did not match the iframe-scoped rule, so the file transfer was allowed despite policy (consequence stated in the commit message).
Impact Assessment
TODO shows a residual bypass for frames that are destroyed before the check.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/enterprise/connectors/common.cc |
modified | |
CollectFrameUrlschrome/browser/safe_browsing/download_protection/download_item_metadata.cc |
modified | |
CollectFrameUrlschrome/browser/safe_browsing/download_protection/file_system_access_metadata.cc |
modified | |
ifcontent/browser/download/download_item_utils.cc |
modified | |
TEST_Fchrome/browser/enterprise/connectors/common_unittest.cc |
modified |
Files Changed
chrome/browser/safe_browsing/download_protection/download_protection_service_unittest.cccontent/browser/file_system_access/file_system_access_safe_move_helper_unittest.ccchrome/browser/safe_browsing/download_protection/file_system_access_metadata.ccchrome/browser/enterprise/connectors/common_unittest.ccchrome/browser/enterprise/connectors/common.hcontent/public/browser/download_item_utils.hchrome/browser/enterprise/connectors/common.cccontent/browser/download/download_item_utils.cccontent/browser/file_system_access/file_system_access_file_writer_impl_unittest.ccchrome/browser/safe_browsing/download_protection/download_item_metadata.cccontent/public/browser/file_system_access_write_item.hcontent/browser/file_system_access/file_system_access_safe_move_helper.cc
Audit Directions
- Ambient focus used as provenanceSearch for
GetFocusedFrame(),GetFocusedWebContents()or “active tab” lookups in enterprise connectors, Safe Browsing and permission code where the decision should depend on the frame that started the action. - Dead-frame fail-openFind places where a missing or destroyed
RenderFrameHostproduces an empty URL list,nulloptor default context that the policy engine then treats as a less restricted top-level action; such paths should fail closed (compare thecrbug.com/531669028TODO). - Other DLP access pointsCheck clipboard paste, print, drag-and-drop and upload connectors for the same pattern of computing frame context at scan time and not at operation time.
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/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/file_system_access_metadata.cc b/chrome/browser/safe_browsing/download_protection/file_system_access_metadata.cc index dcf6045..9da57079 100644 --- a/chrome/browser/safe_browsing/download_protection/file_system_access_metadata.cc +++ b/chrome/browser/safe_browsing/download_protection/file_system_access_metadata.cc @@ -162,7 +162,8 @@ FileSystemAccessMetadata::CollectFrameUrls() const { return enterprise_connectors::CollectFrameUrls( item_->web_contents.get(), - enterprise_connectors::DeepScanAccessPoint::DOWNLOAD); + enterprise_connectors::DeepScanAccessPoint::DOWNLOAD, + std::make_optional(item_->initiating_frame_id)); } content::WebContents* FileSystemAccessMetadata::web_contents() const { diff --git a/content/browser/download/download_item_utils.cc b/content/browser/download/download_item_utils.cc index 49c2c10..113e6a13c 100644 --- a/content/browser/download/download_item_utils.cc +++ b/content/browser/download/download_item_utils.cc @@ -119,6 +119,16 @@ } // static +GlobalRenderFrameHostId DownloadItemUtils::GetRenderFrameHostId( + const download::DownloadItem* download_item) { + DownloadItemData* data = DownloadItemData::Get(download_item); + if (!data) { + return GlobalRenderFrameHostId(); + } + return data->id(); +} + +// static WebContents* DownloadItemUtils::GetOriginalWebContents( const download::DownloadItem* download_item) { DownloadItemData* data = DownloadItemData::Get(download_item); diff --git a/content/browser/file_system_access/file_system_access_safe_move_helper.cc b/content/browser/file_system_access/file_system_access_safe_move_helper.cc index 1710b3b..3ee586f 100644 --- a/content/browser/file_system_access/file_system_access_safe_move_helper.cc +++ b/content/browser/file_system_access/file_system_access_safe_move_helper.cc @@ -254,6 +254,7 @@ item->size = size; item->frame_url = context_.url; item->outermost_main_frame_id = outermost_main_frame_id; + item->initiating_frame_id = context_.frame_id; item->has_user_gesture = has_transient_user_activation_; manager_->permission_context()->PerformAfterWriteChecks( std::move(item), context_.frame_id, diff --git a/content/public/browser/download_item_utils.h b/content/public/browser/download_item_utils.h index 10cee249..265f679 100644 --- a/content/public/browser/download_item_utils.h +++ b/content/public/browser/download_item_utils.h @@ -33,6 +33,10 @@ static RenderFrameHost* GetRenderFrameHost( const download::DownloadItem* downloadItem); + // Helper method to get the GlobalRenderFrameHostId from a DownloadItem. + static GlobalRenderFrameHostId GetRenderFrameHostId( + const download::DownloadItem* downloadItem); + // Get the original WebContents that triggers the download. The returned // WebContents might be different from that of calling GetWebContents(). If // the primary page of the WebContents changes, GetWebContents() will return diff --git a/content/public/browser/file_system_access_write_item.h b/content/public/browser/file_system_access_write_item.h index aa4209c..0d84b5b 100644 --- a/content/public/browser/file_system_access_write_item.h +++ b/content/public/browser/file_system_access_write_item.h @@ -46,6 +46,9 @@ // id of the outermost main frame in which the write operation took place. GlobalRenderFrameHostId outermost_main_frame_id; + // id of the frame in which the write operation took place. + GlobalRenderFrameHostId initiating_frame_id; + // True iff the frame had a transient user activation when the writer was // created. bool has_user_gesture = false; 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>(
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