Low watch CVSS 4.3 chrome Authorization Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Low
Severity
4.3
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.
Tracker501416859
Fix commit694b8bf5831d (chromium/src) +90/-9
CWECWE-863
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-04-10 (per vendor advisory)
Fix landed2026-07-07 (public git)
Disclosed2026-08-25
Patch gap49 days — fix public in source before the advisory

Background

Enterprise content analysis (DLP) connectors
admin-configured policies under chrome/browser/enterprise/connectors that 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 kEnterpriseIframeDlpRulesSupport is enabled, CollectFrameUrls sends 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::FromID resolves 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_id and, 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.

Key insight
The authorization input (which frame did this) was taken from ambient UI state (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

  1. Enterprise DLP deployment The victim browser is managed with enterprise content-analysis connectors and iframe-aware DLP rules (kEnterpriseIframeDlpRulesSupport enabled) that block downloads or file writes based on frame URLs. Without this enterprise configuration the code path has no security effect.
  2. 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.
  3. Wrong chain collected DownloadItemMetadata::CollectFrameUrls or FileSystemAccessMetadata::CollectFrameUrls called enterprise_connectors::CollectFrameUrls, which walked from the focused frame and so omitted the initiating iframe’s URL, leaving the chain empty.
  4. 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

An embedded page, or a user using one to move data, could get around an organisation’s iframe-scoped DLP rules for downloads and File System Access writes, which is a policy and authorization bypass in the browser process’s enterprise-connector logic and not a memory-safety issue. It only matters on enterprise-managed browsers with content-analysis connectors and iframe DLP rule support turned on, and it needs content running in a non-focused iframe. The advisory states “Incorrect authorization in WebProtect” with no in-the-wild status, and the commit’s own TODO shows a residual bypass for frames that are destroyed before the check.

Changed Functions

FunctionChangeNotes
if
chrome/browser/enterprise/connectors/common.cc
modified
CollectFrameUrls
chrome/browser/safe_browsing/download_protection/download_item_metadata.cc
modified
CollectFrameUrls
chrome/browser/safe_browsing/download_protection/file_system_access_metadata.cc
modified
if
content/browser/download/download_item_utils.cc
modified
TEST_F
chrome/browser/enterprise/connectors/common_unittest.cc
modified

Files Changed

  • chrome/browser/safe_browsing/download_protection/download_protection_service_unittest.cc
  • content/browser/file_system_access/file_system_access_safe_move_helper_unittest.cc
  • chrome/browser/safe_browsing/download_protection/file_system_access_metadata.cc
  • chrome/browser/enterprise/connectors/common_unittest.cc
  • chrome/browser/enterprise/connectors/common.h
  • content/public/browser/download_item_utils.h
  • chrome/browser/enterprise/connectors/common.cc
  • content/browser/download/download_item_utils.cc
  • content/browser/file_system_access/file_system_access_file_writer_impl_unittest.cc
  • chrome/browser/safe_browsing/download_protection/download_item_metadata.cc
  • content/public/browser/file_system_access_write_item.h
  • content/browser/file_system_access/file_system_access_safe_move_helper.cc

Audit Directions

  • Ambient focus used as provenance
    Search 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-open
    Find places where a missing or destroyed RenderFrameHost produces an empty URL list, nullopt or default context that the policy engine then treats as a less restricted top-level action; such paths should fail closed (compare the crbug.com/531669028 TODO).
  • Other DLP access points
    Check 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.
From 694b8bf5831d7ae747382efdc3ad07d776f904ce Mon Sep 17 00:00:00 2001
From: Haihan Chen <haihan@google.com>
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 <yawfrempong@google.com>
Commit-Queue: Haihan Chen <haihan@google.com>
Reviewed-by: Rakina Zata Amni <rakina@chromium.org>
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>(
Loading diff…

Regression Test / PoC

shipped with the fix
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>(
Loading diff…

Original Bug Report

reported by vm...@google.com

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.cc
  • chrome/browser/safe_browsing/download_protection/download_item_metadata.cc
  • chrome/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

  1. Downloads: In chrome/browser/safe_browsing/download_protection/download_item_metadata.cc, DownloadItemMetadata::CollectFrameUrls calls the flawed logic. It fails to consult the browser-authoritative initiating frame stored in the DownloadItem (retrievable via DownloadItemUtils::GetRenderFrameHost(item_)).
  2. File System Access: In chrome/browser/safe_browsing/download_protection/file_system_access_metadata.cc, FileSystemAccessMetadata::CollectFrameUrls similarly relies on GetFocusedFrame(), despite having access to the correct initiating frame_url within the FileSystemAccessWriteItem metadata.

Potential Attack Steps

Note: These are suggested steps based on code analysis; a working proof-of-concept has not been executed.

  1. 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).
  2. An enterprise user navigates to trusted.example.com. By default, the main frame holds focus.
  3. 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.
  4. The browser intercepts the download and initiates a deep scan via DeepScanningRequest.
  5. When collecting metadata, CollectFrameUrlsImpl is called. It fetches GetFocusedFrame(), which returns the main frame (trusted.example.com).
  6. Because the main frame has no parent, the traversal loop breaks immediately, resulting in an empty frame_url_chain.
  7. 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::CollectFrameUrls to accept a RenderFrameHost* initiating_frame parameter 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_url which 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

View on issue tracker