Critical watch CVSS 5.3 chrome Input Validation 📄 Reporter bug report 🔧 Commit mapped

Overview

Critical
Severity
5.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in DataTransfer
DescriptionInsufficient validation of untrusted input in DataTransfer
ComponentDataTransfer
Bug ClassInput Validation
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.
Tracker496393078
Fix commit988ac5cb880b (chromium/src) +609/-358
CWECWE-20 (Improper input validation)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-03-26 (per vendor advisory)
Fix landed2026-04-01 (public git)
Disclosed2026-05-12
Patch gap41 days — fix public in source before the advisory

Background

`DownloadURL` drag-out
a web page can put drag data of the form mime:filename:url (DropData::download_metadata) on a drag. When the user drops it onto the OS desktop or a file manager, the browser downloads that URL to the drop location through DragDownloadFile.
`DragDownloadFile` / `DragDownloadFileUI`
the browser-side classes that build DownloadUrlParameters for the drag-out download, including the frame it runs in and the request initiator.
`WeakDocumentPtr` / `DocumentToken`
browser-side handles to one specific document. AsRenderFrameHostIfValid() returns its RenderFrameHost only while that document is still current, unlike a (process id, routing id) pair, which refers to whatever frame occupies that slot.
Remote Cocoa (`app_shim_remote_cocoa`)
the macOS NSView layer, which may run out of process (in app shims). It hosts WebDragSource and calls back into the browser with DragPromisedFileTo.

Root Cause Analysis

Before the patch, the drag-out download trusted values carried alongside the drag instead of deriving them from the document that started it. DragDownloadFile always issued the download from web_contents->GetPrimaryMainFrame(); a TODO(crbug.com/40470366) in the removed code admitted it should use the frame actually containing the dragged link. The request initiator was taken from a caller-supplied initiator_origin, which the removed comment says came from OSExchangeDataProvider::GetRendererTaintedOrigin, or on macOS from the sourceOrigin / _downloadURL held by WebDragSource. The download_metadata string was parsed late, in the platform drag source, with ParseDownloadMetadata. On macOS that parsing happened in the Remote Cocoa layer, which then sent the resulting download_url and source_origin to the browser through DragPromisedFileTo.

The fix threads the source document’s identity (ChildProcessId + DocumentToken, or a WeakDocumentPtr) through the whole drag pipeline: StartDragging now takes a RenderFrameHost& source_rfh. DragDownloadFileUI resolves that exact document, bails out if it is gone, and sets initiator to host->GetLastCommittedOrigin(). download_metadata becomes a structured optional (mime_type, url, suggested_file_name) instead of an ad-hoc string. ParseDownloadMetadata is removed from the drag source, so parsing moves earlier, presumably into the browser’s data_transfer_util.cc conversion, which is truncated in this diff (inferred).

Key insight
The browser attributed a page-initiated drag-out download to the wrong principal: the tab’s main frame plus an origin and URL ferried through tainted drag plumbing, rather than the browser-verified document that started the drag. The fix binds the download to the originating document’s token and uses that document’s committed origin as the initiator.

Attack Path

  1. Seed drag data Attacker-controlled content, such as a cross-origin subframe, starts a drag whose DataTransfer carries a crafted DownloadURL / download_metadata (MIME type, filename, URL).
  2. User drops outside the browser The user drags the item to the desktop or Finder, which triggers DragPromisedFileTo (macOS) or the DragDownloadFile path on other platforms.
  3. Misattributed download Before the patch, the browser created the download in the context of the primary main frame, with an initiator taken from drag-carried data, not from the source document. The removed browser test DragDownloadFileTest_Initiator shows that an arbitrary https://initiator.example.com origin flowed straight into the request.
  4. Policy confusion Cross-site protections that depend on the initiator or frame, such as Sec-Fetch-Site, SameSite cookies and download policy checks, are evaluated against the wrong principal. The exact abuse the vendor considered critical is not shown in the diff (inferred).

Impact Assessment

A malicious page, or a frame within one, could cause the browser to fetch and save a file under a different, more-trusted principal than the document that started the drag, with renderer-influenced URL and metadata. One required user gesture is dragging content out of the browser. The vendor rates this critical, but the shown diff demonstrates only initiator/frame misattribution and late, untrusted-side metadata parsing; any credentialed cross-site data access or local-file consequence is (inferred). The advisory does not report in-the-wild exploitation.

Changed Functions

FunctionChangeNotes
if
content/app_shim_remote_cocoa/web_drag_source_mac.mm
modified
DragDownloadFile
content/browser/download/drag_download_file.cc
modified
render_frame_id_
content/browser/download/drag_download_file.cc
modified
referrer_encoding_
content/browser/download/drag_download_file.cc
modified
WebContents
content/browser/download/drag_download_file.h
modified

Files Changed

  • components/download/public/common/download_url_parameters.h
  • content/app_shim_remote_cocoa/web_contents_ns_view_bridge.h
  • content/app_shim_remote_cocoa/web_contents_ns_view_bridge.mm
  • content/app_shim_remote_cocoa/web_contents_view_cocoa.h
  • content/app_shim_remote_cocoa/web_contents_view_cocoa.mm
  • content/app_shim_remote_cocoa/web_drag_source_mac.h
  • content/app_shim_remote_cocoa/web_drag_source_mac.mm
  • content/app_shim_remote_cocoa/window_occlusion_browsertest_mac.mm
  • content/browser/download/drag_download_file.cc
  • content/browser/download/drag_download_file.h
  • content/browser/download/drag_download_file_browsertest.cc
  • content/browser/download/drag_download_util.cc
  • content/browser/download/drag_download_util.h
  • content/browser/renderer_host/data_transfer_util.cc
  • content/browser/renderer_host/render_frame_host_impl.cc
  • content/browser/renderer_host/render_frame_host_impl.h
  • content/browser/renderer_host/render_view_host_delegate_view.h
  • content/browser/renderer_host/render_view_host_unittest.cc
  • content/browser/renderer_host/render_widget_host_impl.cc
  • content/browser/renderer_host/render_widget_host_impl.h
  • content/browser/renderer_host/render_widget_host_unittest.cc
  • content/browser/renderer_host/render_widget_host_view_android.cc
  • content/browser/renderer_host/render_widget_host_view_android.h
  • content/browser/renderer_host/render_widget_host_view_base.h
  • content/browser/renderer_host/render_widget_host_view_child_frame.cc
  • content/browser/renderer_host/render_widget_host_view_child_frame.h
  • content/browser/web_contents/web_contents_view_android.cc
  • content/browser/web_contents/web_contents_view_android.h
  • content/browser/web_contents/web_contents_view_android_unittest.cc
  • content/browser/web_contents/web_contents_view_aura.cc
  • content/browser/web_contents/web_contents_view_aura.h
  • content/browser/web_contents/web_contents_view_aura_unittest.cc
  • content/browser/web_contents/web_contents_view_child_frame.cc
  • content/browser/web_contents/web_contents_view_child_frame.h
  • content/browser/web_contents/web_contents_view_mac.h
  • content/browser/web_contents/web_contents_view_mac.mm
  • content/browser/web_contents/web_contents_view_mac_unittest.mm
  • content/browser/web_contents/web_drag_source_mac_unittest.mm
  • content/common/BUILD.gn
  • content/common/child_process_id_mojom_traits.h
  • content/common/web_contents_ns_view_bridge.mojom
  • content/public/common/common_param_traits_macros.h
  • content/public/common/drop_data.cc
  • content/public/common/drop_data.h
  • content/test/test_render_view_host.h

Audit Directions

  • Main-frame-as-proxy attribution
    Search for browser code that uses GetPrimaryMainFrame() or WebContents to stand in for the frame that actually initiated an action (drags, downloads, clipboard writes, popups); a subframe’s action should be attributed to that subframe’s document.
  • Tainted origins carried in side data
    Track origins and URLs that travel inside DropData, OSExchangeData, or out-of-process (Remote Cocoa) callbacks and are later used as a request initiator; they should be recomputed from a RenderFrameHost the browser trusts.
  • Late parsing of renderer strings
    Look for renderer-supplied micro-formats (DownloadURL, custom MIME payloads) parsed at use time in platform layers instead of being validated once, at the IPC boundary, into typed structs.
diff --git a/components/download/public/common/download_url_parameters.h b/components/download/public/common/download_url_parameters.h
index 1e640c02..1e71a0bf 100644
--- a/components/download/public/common/download_url_parameters.h
+++ b/components/download/public/common/download_url_parameters.h
@@ -123,8 +123,8 @@
 
   // The origin of the context which initiated the request. See
   // net::URLRequest::initiator().
-  void set_initiator(const std::optional<url::Origin>& initiator) {
-    initiator_ = initiator;
+  void set_initiator(std::optional<url::Origin> initiator) {
+    initiator_ = std::move(initiator);
   }
 
   // If this is a request for resuming an HTTP/S download, |last_modified|
diff --git a/content/app_shim_remote_cocoa/web_contents_ns_view_bridge.h b/content/app_shim_remote_cocoa/web_contents_ns_view_bridge.h
index 3e4bdae..096cfb5 100644
--- a/content/app_shim_remote_cocoa/web_contents_ns_view_bridge.h
+++ b/content/app_shim_remote_cocoa/web_contents_ns_view_bridge.h
@@ -58,8 +58,10 @@
   void SetVisible(bool visible) override;
   void MakeFirstResponder() override;
   void TakeFocus(bool reverse) override;
-  void StartDrag(const content::DropData& drop_data,
+  void StartDrag(content::ChildProcessId render_process_id,
+                 const blink::DocumentToken& document_token,
                  const url::Origin& source_origin,
+                 const content::DropData& drop_data,
                  uint32_t operation_mask,
                  const gfx::ImageSkia& image,
                  const gfx::Vector2d& image_offset,
diff --git a/content/app_shim_remote_cocoa/web_contents_ns_view_bridge.mm b/content/app_shim_remote_cocoa/web_contents_ns_view_bridge.mm
index d9cbcd75..91b83cc 100644
--- a/content/app_shim_remote_cocoa/web_contents_ns_view_bridge.mm
+++ b/content/app_shim_remote_cocoa/web_contents_ns_view_bridge.mm
@@ -123,14 +123,20 @@
     [[ns_view_ window] selectNextKeyView:ns_view_];
 }
 
-void WebContentsNSViewBridge::StartDrag(const content::DropData& drop_data,
-                                        const url::Origin& source_origin,
-                                        uint32_t operation_mask,
-                                        const gfx::ImageSkia& image,
-                                        const gfx::Vector2d& image_offset,
-                                        bool is_privileged) {
+void WebContentsNSViewBridge::StartDrag(
+    content::ChildProcessId render_process_id,
+    const blink::DocumentToken& document_token,
+    const url::Origin& source_origin,
+    const content::DropData& drop_data,
+    uint32_t operation_mask,
+    const gfx::ImageSkia& image,
+    const gfx::Vector2d& image_offset,
+    bool is_privileged) {
   NSPoint offset = gfx::PointAtOffsetFromOrigin(image_offset).ToCGPoint();
+  // TODO(dcheng): Check if is_privileged still needs to be passed here.
   [ns_view_ startDragWithDropData:drop_data
+                  renderProcessId:render_process_id
+                    documentToken:document_token
                      sourceOrigin:source_origin
                 dragOperationMask:operation_mask
                             image:gfx::NSImageFromImageSkia(image)
diff --git a/content/app_shim_remote_cocoa/web_contents_view_cocoa.h b/content/app_shim_remote_cocoa/web_contents_view_cocoa.h
index 85bc3fad..bf346098 100644
--- a/content/app_shim_remote_cocoa/web_contents_view_cocoa.h
+++ b/content/app_shim_remote_cocoa/web_contents_view_cocoa.h
@@ -8,6 +8,7 @@
 #include "base/memory/raw_ptr.h"
 #include "content/common/content_export.h"
 #include "content/common/web_contents_ns_view_bridge.mojom.h"
+#include "content/public/common/child_process_id.h"
 #import "ui/base/cocoa/base_view.h"
 #import "ui/base/cocoa/views_hostable.h"
 
@@ -44,6 +45,8 @@
 - (instancetype)initWithViewsHostableView:(ui::ViewsHostableView*)v;
 - (void)registerDragTypes;
 - (void)startDragWithDropData:(const content::DropData&)dropData
+              renderProcessId:(content::ChildProcessId)renderProcessId
+                documentToken:(const blink::DocumentToken&)documentToken
                  sourceOrigin:(const url::Origin&)sourceOrigin
             dragOperationMask:(NSDragOperation)operationMask
                         image:(NSImage*)image
diff --git a/content/app_shim_remote_cocoa/web_contents_view_cocoa.mm b/content/app_shim_remote_cocoa/web_contents_view_cocoa.mm
index 1ef2c90..da2786e0 100644
--- a/content/app_shim_remote_cocoa/web_contents_view_cocoa.mm
+++ b/content/app_shim_remote_cocoa/web_contents_view_cocoa.mm
@@ -224,6 +224,8 @@
 }
 
 - (void)startDragWithDropData:(const DropData&)dropData
+              renderProcessId:(content::ChildProcessId)renderProcessId
+                documentToken:(const blink::DocumentToken&)documentToken
                  sourceOrigin:(const url::Origin&)sourceOrigin
             dragOperationMask:(NSDragOperation)operationMask
                         image:(NSImage*)image
@@ -244,8 +246,10 @@
                                           pressure:1.0];
 
   _dragSource = [[WebDragSource alloc] initWithHost:_host
-                                           dropData:dropData
+                                    renderProcessId:renderProcessId
+                                      documentToken:documentToken
                                        sourceOrigin:sourceOrigin
+                                           dropData:dropData
                                        isPrivileged:isPrivileged];
   NSDraggingItem* draggingItem =
       [[NSDraggingItem alloc] initWithPasteboardWriter:_dragSource];
diff --git a/content/app_shim_remote_cocoa/web_drag_source_mac.h b/content/app_shim_remote_cocoa/web_drag_source_mac.h
index 0a2ce41f..59c5a39 100644
--- a/content/app_shim_remote_cocoa/web_drag_source_mac.h
+++ b/content/app_shim_remote_cocoa/web_drag_source_mac.h
@@ -10,7 +10,9 @@
 #include "base/files/file_path.h"
 #include "base/memory/raw_ptr.h"
 #include "content/common/content_export.h"
+#include "content/public/common/child_process_id.h"
 #include "content/public/common/drop_data.h"
+#include "third_party/blink/public/common/tokens/tokens.h"
 
 namespace content {
 struct DropData;
@@ -31,8 +33,10 @@
 
 // Initialize a WebDragSource object for a drag.
 - (instancetype)initWithHost:(remote_cocoa::mojom::WebContentsNSViewHost*)host
-                    dropData:(const content::DropData&)dropData
+             renderProcessId:(content::ChildProcessId)renderProcessId
+               documentToken:(const blink::DocumentToken&)documentToken
                 sourceOrigin:(const url::Origin&)sourceOrigin
+                    dropData:(const content::DropData&)dropData
                 isPrivileged:(BOOL)privileged;
 
 // Call when the WebContents is gone.
diff --git a/content/app_shim_remote_cocoa/web_drag_source_mac.mm b/content/app_shim_remote_cocoa/web_drag_source_mac.mm
index 55cfdf77..e7bf29bc 100644
--- a/content/app_shim_remote_cocoa/web_drag_source_mac.mm
+++ b/content/app_shim_remote_cocoa/web_drag_source_mac.mm
@@ -46,6 +46,10 @@
   // The drop data.
   content::DropData _dropData;
 
+  // The (child process ID, token) of the document that started the drag.
+  content::ChildProcessId _renderProcessId;
+  blink::DocumentToken _documentToken;
+
   // The source origin the drop data came from.
   url::Origin _sourceOrigin;
 
@@ -63,14 +67,18 @@
 }
 
 - (instancetype)initWithHost:(remote_cocoa::mojom::WebContentsNSViewHost*)host
-                    dropData:(const content::DropData&)dropData
+             renderProcessId:(content::ChildProcessId)renderProcessId
+               documentToken:(const blink::DocumentToken&)documentToken
                 sourceOrigin:(const url::Origin&)sourceOrigin
+                    dropData:(const content::DropData&)dropData
                 isPrivileged:(BOOL)privileged {
   if ((self = [super init])) {
     _host = host;
     _dropData = dropData;
     _sourceOrigin = sourceOrigin;
     _privileged = privileged;
+    _renderProcessId = renderProcessId;
+    _documentToken = documentToken;
   }
 
   return self;
@@ -105,12 +113,10 @@
 
   // File.
   if (!_dropData.file_contents.empty() ||
-      !_dropData.download_metadata.empty()) {
+      _dropData.download_metadata.has_value()) {
     std::string mimeType;
 
-    // TODO(crbug.com/40599578): The |downloadFileName_| and
-    // |downloadURL_| values should be computed by the caller.
-    if (_dropData.download_metadata.empty()) {
+    if (!_dropData.download_metadata.has_value()) {
       std::optional<base::FilePath> suggestedFilename =
           _dropData.GetSafeFilenameForImageFileContents();
       if (suggestedFilename) {
@@ -118,23 +124,19 @@
         net::GetMimeTypeFromFile(_downloadFileName, &mimeType);
       }
     } else {
-      std::u16string mimeType16;
-      base::FilePath filename;
-      if (content::ParseDownloadMetadata(_dropData.download_metadata,
-                                         &mimeType16, &filename,
-                                         &_downloadURL)) {
-        // Generate the file name based on both mime type and proposed file
-        // name.
-        std::string defaultName = content::GetContentClient()->browser()
-                                      ? content::GetContentClient()
-                                            ->browser()
-                                            ->GetDefaultDownloadName()
-                                      : std::string();
-        mimeType = base::UTF16ToUTF8(mimeType16);
-        _downloadFileName =
-            net::GenerateFileName(_downloadURL, std::string(), std::string(),
-                                  filename.value(), mimeType, defaultName);
-      }
+      mimeType = _dropData.download_metadata->mime_type;
+      _downloadURL = _dropData.download_metadata->url;
+
+      // Generate the file name based on both mime type and proposed file
+      // name.
+      std::string defaultName =
+          content::GetContentClient()->browser()
+              ? content::GetContentClient()->browser()->GetDefaultDownloadName()
+              : std::string();
+      _downloadFileName = net::GenerateFileName(
+          _downloadURL, std::string(), std::string(),
+          _dropData.download_metadata->suggested_file_name, mimeType,
+          defaultName);
     }
 
     if (!mimeType.empty()) {
@@ -264,8 +266,8 @@
     base::FilePath filePath =
         base::apple::NSURLToFilePath([NSURL URLWithString:dropDestination]);
     filePath = filePath.Append(_downloadFileName);
-    _host->DragPromisedFileTo(filePath, _dropData, _downloadURL, _sourceOrigin,
-                              &filePath);
+    _host->DragPromisedFileTo(_renderProcessId, _documentToken, filePath,
+                              _dropData, &filePath);
 
     // The process of writing the file may have altered the value of
     // `filePath` if, say, an existing file at the drop site already had that
diff --git a/content/browser/download/drag_download_file.cc b/content/browser/download/drag_download_file.cc
index 0855f955..436578e8 100644
--- a/content/browser/download/drag_download_file.cc
+++ b/content/browser/download/drag_download_file.cc
@@ -14,12 +14,14 @@
 #include "build/build_config.h"
 #include "components/download/public/common/download_item.h"
 #include "components/download/public/common/download_stats.h"
+#include "content/browser/renderer_host/render_frame_host_impl.h"
 #include "content/public/browser/browser_context.h"
 #include "content/public/browser/browser_task_traits.h"
 #include "content/public/browser/browser_thread.h"
 #include "content/public/browser/download_request_utils.h"
 #include "content/public/browser/render_frame_host.h"
 #include "content/public/browser/render_process_host.h"
+#include "content/public/browser/weak_document_ptr.h"
 #include "content/public/browser/web_contents.h"
 #include "net/traffic_annotation/network_traffic_annotation.h"
 
@@ -35,22 +37,17 @@
 class DragDownloadFile::DragDownloadFileUI
     : public download::DownloadItem::Observer {
  public:
-  DragDownloadFileUI(const GURL& url,
+  DragDownloadFileUI(WeakDocumentPtr source_document,
+                     const GURL& url,
                      const Referrer& referrer,
                      const std::string& referrer_encoding,
-                     std::optional<url::Origin> initiator_origin,
-                     int render_process_id,
-                     int render_frame_id,
                      OnCompleted on_completed)
       : on_completed_(std::move(on_completed)),
+        source_document_(std::move(source_document)),
         url_(url),
         referrer_(referrer),
-        referrer_encoding_(referrer_encoding),
-        initiator_origin_(initiator_origin),
-        render_process_id_(render_process_id),
-        render_frame_id_(render_frame_id) {
+        referrer_encoding_(referrer_encoding) {
     DCHECK(on_completed_);
-    DCHECK_GE(render_frame_id_, 0);
     // May be called on any thread.
     // Do not call weak_ptr_factory_.GetWeakPtr() outside the UI thread.
   }
@@ -62,12 +59,9 @@
                         const base::FilePath& file_path) {
     DCHECK_CURRENTLY_ON(BrowserThread::UI);
 
-    RenderFrameHost* host =
-        RenderFrameHost::FromID(render_process_id_, render_frame_id_);
+    RenderFrameHost* host = source_document_.AsRenderFrameHostIfValid();
     if (!host)
       return;
-    // TODO(crbug.com/40470366) This should use the frame actually
-    // containing the link being dragged rather than the main frame of the tab.
     net::NetworkTrafficAnnotationTag traffic_annotation =
         net::DefineNetworkTrafficAnnotation("drag_download_file", R"(
         semantics {
@@ -97,10 +91,7 @@
         Referrer::ReferrerPolicyForUrlRequest(referrer_.policy));
     params->set_referrer_encoding(referrer_encoding_);
 
-    // `initiator_origin_` (coming from
-    // `OSExchangeDataProvider::GetRendererTaintedOrigin`) is more accurate than
-    // `host` (coming from `WebContents::GetPrimaryMainFrame`).
-    params->set_initiator(initiator_origin_);
+    params->set_initiator(host->GetLastCommittedOrigin());
 
     params->set_callback(base::BindOnce(&DragDownloadFileUI::OnDownloadStarted,
                                         weak_ptr_factory_.GetWeakPtr()));
@@ -178,31 +169,26 @@
   }
 
   OnCompleted on_completed_;
+  WeakDocumentPtr source_document_;
   GURL url_;
   Referrer referrer_;
   std::string referrer_encoding_;
-  std::optional<url::Origin> initiator_origin_;
-  int render_process_id_;
-  int render_frame_id_;
   raw_ptr<download::DownloadItem> download_item_ = nullptr;
 
   // Only used in the callback from DownloadManager::DownloadUrl().
   base::WeakPtrFactory<DragDownloadFileUI> weak_ptr_factory_{this};
 };
 
-DragDownloadFile::DragDownloadFile(const base::FilePath& file_path,
+DragDownloadFile::DragDownloadFile(WeakDocumentPtr source_document,
+                                   const base::FilePath& file_path,
                                    base::File file,
                                    const GURL& url,
                                    const Referrer& referrer,
-                                   const std::string& referrer_encoding,
-                                   std::optional<url::Origin> initiator_origin,
-                                   WebContents* web_contents)
+                                   const std::string& referrer_encoding)
     : file_path_(file_path), file_(std::move(file)) {
   DCHECK_CURRENTLY_ON(BrowserThread::UI);
-  RenderFrameHost* host = web_contents->GetPrimaryMainFrame();
   drag_ui_ = new DragDownloadFileUI(
-      url, referrer, referrer_encoding, initiator_origin,
-      host->GetProcess()->GetDeprecatedID(), host->GetRoutingID(),
+      std::move(source_document), url, referrer, referrer_encoding,
       base::BindOnce(&DragDownloadFile::DownloadCompleted,
                      weak_ptr_factory_.GetWeakPtr()));
   DCHECK(!file_path_.empty());
diff --git a/content/browser/download/drag_download_file.h b/content/browser/download/drag_download_file.h
index 21245de9..8d01a17 100644
--- a/content/browser/download/drag_download_file.h
+++ b/content/browser/download/drag_download_file.h
@@ -17,14 +17,14 @@
 #include "components/download/public/common/download_item.h"
 #include "content/common/content_export.h"
 #include "content/public/browser/download_manager.h"
+#include "content/public/browser/weak_document_ptr.h"
 #include "content/public/common/referrer.h"
+#include "third_party/blink/public/common/tokens/tokens.h"
 #include "ui/base/dragdrop/download_file_interface.h"
 #include "url/gurl.h"
 
 namespace content {
 
-class WebContents;
-
 // This class implements downloading a file via dragging virtual files out of
 // the browser:
 // http://lists.whatwg.org/htdig.cgi/whatwg-whatwg.org/2009-August/022118.html
@@ -35,13 +35,12 @@
   // download into a file that has already been created, so only the UI
   // thread is involved. |file| must be null on windows but non-null on
   // posix systems. |file_path| is an absolute path on all systems.
-  DragDownloadFile(const base::FilePath& file_path,
+  DragDownloadFile(WeakDocumentPtr source_document,
+                   const base::FilePath& file_path,
                    base::File file,
                    const GURL& url,
                    const Referrer& referrer,
-                   const std::string& referrer_encoding,
-                   std::optional<url::Origin> initiator_origin,
-                   WebContents* web_contents);
+                   const std::string& referrer_encoding);
 
   DragDownloadFile(const DragDownloadFile&) = delete;
   DragDownloadFile& operator=(const DragDownloadFile&) = delete;
diff --git a/content/browser/download/drag_download_util.cc b/content/browser/download/drag_download_util.cc
index bc829ec..185b410 100644
--- a/content/browser/download/drag_download_util.cc
+++ b/content/browser/download/drag_download_util.cc
@@ -23,37 +23,6 @@
 
 namespace content {
 
-bool ParseDownloadMetadata(const std::u16string& metadata,
-                           std::u16string* mime_type,
-                           base::FilePath* file_name,
-                           GURL* url) {
-  const char16_t separator = L':';
-
-  size_t mime_type_end_pos = metadata.find(separator);
-  if (mime_type_end_pos == std::u16string::npos)
-    return false;
-
-  size_t file_name_end_pos = metadata.find(separator, mime_type_end_pos + 1);
-  if (file_name_end_pos == std::u16string::npos)
-    return false;
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/app_shim_remote_cocoa/window_occlusion_browsertest_mac.mm b/content/app_shim_remote_cocoa/window_occlusion_browsertest_mac.mm
index b40cfd9d..0ef8bdf 100644
--- a/content/app_shim_remote_cocoa/window_occlusion_browsertest_mac.mm
+++ b/content/app_shim_remote_cocoa/window_occlusion_browsertest_mac.mm
@@ -306,18 +306,18 @@
   void PerformDragOperation(DraggingInfoPtr dragging_info,
                             PerformDragOperationCallback callback) override {}
 
-  bool DragPromisedFileTo(const ::base::FilePath& file_path,
+  bool DragPromisedFileTo(content::ChildProcessId render_process_id,
+                          const blink::DocumentToken& document_token,
+                          const ::base::FilePath& file_path,
                           const ::content::DropData& drop_data,
-                          const ::GURL& download_url,
-                          const ::url::Origin& source_origin,
                           ::base::FilePath* out_file_path) override {
     return false;
   }
 
-  void DragPromisedFileTo(const ::base::FilePath& file_path,
+  void DragPromisedFileTo(content::ChildProcessId render_process_id,
+                          const blink::DocumentToken& document_token,
+                          const ::base::FilePath& file_path,
                           const ::content::DropData& drop_data,
-                          const ::GURL& download_url,
-                          const ::url::Origin& source_origin,
                           DragPromisedFileToCallback callback) override {}
 
   void EndDrag(uint32_t drag_operation,
diff --git a/content/browser/download/drag_download_file_browsertest.cc b/content/browser/download/drag_download_file_browsertest.cc
index 279d091..dc97f7b 100644
--- a/content/browser/download/drag_download_file_browsertest.cc
+++ b/content/browser/download/drag_download_file_browsertest.cc
@@ -15,10 +15,12 @@
 #include "base/threading/thread_restrictions.h"
 #include "content/browser/download/download_manager_impl.h"
 #include "content/browser/download/drag_download_util.h"
+#include "content/browser/renderer_host/render_frame_host_impl.h"
 #include "content/browser/web_contents/web_contents_impl.h"
 #include "content/public/browser/browser_task_traits.h"
 #include "content/public/browser/browser_thread.h"
 #include "content/public/browser/content_browser_client.h"
+#include "content/public/browser/render_process_host.h"
 #include "content/public/common/content_client.h"
 #include "content/public/common/content_paths.h"
 #include "content/public/test/browser_test.h"
@@ -103,8 +105,8 @@
   Referrer referrer;
   std::string referrer_encoding;
   auto file = std::make_unique<DragDownloadFile>(
-      name, base::File(), url, referrer, referrer_encoding, std::nullopt,
-      shell()->web_contents());
+      shell()->web_contents()->GetPrimaryMainFrame()->GetWeakDocumentPtr(),
+      name, base::File(), url, referrer, referrer_encoding);
   scoped_refptr<MockDownloadFileObserver> observer(
       new MockDownloadFileObserver());
   EXPECT_CALL(*observer.get(), OnDownloadAborted())
@@ -122,8 +124,8 @@
   Referrer referrer;
   std::string referrer_encoding;
   auto file = std::make_unique<DragDownloadFile>(
-      name, base::File(), url, referrer, referrer_encoding, std::nullopt,
-      shell()->web_contents());
+      shell()->web_contents()->GetPrimaryMainFrame()->GetWeakDocumentPtr(),
+      name, base::File(), url, referrer, referrer_encoding);
   scoped_refptr<MockDownloadFileObserver> observer(
       new MockDownloadFileObserver());
   EXPECT_CALL(*observer.get(), OnDownloadCompleted(_))
@@ -138,13 +140,11 @@
   base::FilePath name(
       downloads_directory().AppendASCII("DragDownloadFileTest_Initiator.txt"));
   GURL url = embedded_test_server()->GetURL("/echoheader?sec-fetch-site");
-  url::Origin initiator =
-      url::Origin::Create(GURL("https://initiator.example.com"));
   Referrer referrer;
   std::string referrer_encoding;
   auto file = std::make_unique<DragDownloadFile>(
-      name, base::File(), url, referrer, referrer_encoding, initiator,
-      shell()->web_contents());
+      shell()->web_contents()->GetPrimaryMainFrame()->GetWeakDocumentPtr(),
+      name, base::File(), url, referrer, referrer_encoding);
   base::FilePath downloaded_path;
   scoped_refptr<MockDownloadFileObserver> observer(
       new MockDownloadFileObserver());
@@ -174,8 +174,8 @@
   Referrer referrer;
   std::string referrer_encoding;
   auto file = std::make_unique<DragDownloadFile>(
-      name, base::File(), url, referrer, referrer_encoding, std::nullopt,
-      shell()->web_contents());
+      shell()->web_contents()->GetPrimaryMainFrame()->GetWeakDocumentPtr(),
+      name, base::File(), url, referrer, referrer_encoding);
   scoped_refptr<MockDownloadFileObserver> observer(
       new MockDownloadFileObserver());
   ON_CALL(*observer.get(), OnDownloadAborted())
diff --git a/content/browser/renderer_host/render_view_host_unittest.cc b/content/browser/renderer_host/render_view_host_unittest.cc
index 5d6e974..9ff6cb1 100644
--- a/content/browser/renderer_host/render_view_host_unittest.cc
+++ b/content/browser/renderer_host/render_view_host_unittest.cc
@@ -85,14 +85,14 @@
     : public RenderViewHostDelegateView {
  public:
   ~MockDraggingRenderViewHostDelegateView() override {}
-  void StartDragging(const DropData& drop_data,
-                     const url::Origin& source_origin,
-                     blink::DragOperationsMask allowed_ops,
-                     const gfx::ImageSkia& image,
-                     const gfx::Vector2d& cursor_offset,
-                     const gfx::Rect& drag_obj_rect,
-                     const blink::mojom::DragEventSourceInfo& event_info,
-                     RenderWidgetHostImpl* source_rwh) override {
+  void StartDragging(
+      RenderFrameHost& source_rfh,
+      const DropData& drop_data,
+      blink::DragOperationsMask allowed_ops,
+      const gfx::ImageSkia& image,
+      const gfx::Vector2d& cursor_offset,
+      const gfx::Rect& drag_obj_rect,
+      const blink::mojom::DragEventSourceInfo& event_info) override {
     drag_url_ = drop_data.url_infos.front().url;
     html_base_url_ = drop_data.html_base_url;
   }
diff --git a/content/browser/renderer_host/render_widget_host_unittest.cc b/content/browser/renderer_host/render_widget_host_unittest.cc
index 631f858..db5e430 100644
--- a/content/browser/renderer_host/render_widget_host_unittest.cc
+++ b/content/browser/renderer_host/render_widget_host_unittest.cc
@@ -46,6 +46,7 @@
 #include "content/public/common/content_features.h"
 #include "content/public/common/content_switches.h"
 #include "content/public/common/drop_data.h"
+#include "content/public/common/url_constants.h"
 #include "content/public/test/browser_task_environment.h"
 #include "content/public/test/mock_render_process_host.h"
 #include "content/public/test/test_browser_context.h"
@@ -55,6 +56,7 @@
 #include "content/test/stub_render_widget_host_owner_delegate.h"
 #include "content/test/test_render_view_host.h"
 #include "content/test/test_render_widget_host.h"
+#include "content/test/test_web_contents.h"
 #include "mojo/public/cpp/bindings/pending_receiver.h"
 #include "mojo/public/cpp/bindings/pending_remote.h"
 #include "mojo/public/cpp/bindings/remote.h"
@@ -68,6 +70,7 @@
 #include "third_party/blink/public/mojom/drag/drag.mojom.h"
 #include "third_party/blink/public/mojom/input/input_handler.mojom-shared.h"
 #include "third_party/blink/public/mojom/input/touch_event.mojom.h"
+#include "ui/base/clipboard/clipboard_constants.h"
 #include "ui/base/cursor/cursor.h"
 #include "ui/display/display_util.h"
 #include "ui/display/screen.h"
@@ -284,21 +287,24 @@
   ~MockRenderViewHostDelegateView() override = default;
 
   int start_dragging_count() const { return start_dragging_count_; }
+  const DropData& drop_data() const { return drop_data_; }
 
   // RenderViewHostDelegateView:
-  void StartDragging(const DropData& drop_data,
-                     const url::Origin& source_origin,
-                     blink::DragOperationsMask allowed_ops,
-                     const gfx::ImageSkia& image,
-                     const gfx::Vector2d& cursor_offset,
-                     const gfx::Rect& drag_obj_rect,
-                     const blink::mojom::DragEventSourceInfo& event_info,
-                     RenderWidgetHostImpl* source_rwh) override {
+  void StartDragging(
+      RenderFrameHost& source_rfh,
+      const DropData& drop_data,
+      blink::DragOperationsMask allowed_ops,
+      const gfx::ImageSkia& image,
+      const gfx::Vector2d& cursor_offset,
+      const gfx::Rect& drag_obj_rect,
+      const blink::mojom::DragEventSourceInfo& event_info) override {
     ++start_dragging_count_;
+    drop_data_ = drop_data;
   }
 
  private:
   int start_dragging_count_ = 0;
+  DropData drop_data_;
 };
 
 // FakeRenderFrameMetadataObserver -----------------------------------------
@@ -2264,38 +2270,141 @@
             visual_properties.compositor_viewport_pixel_rect);
 }
 
-// Make sure no dragging occurs after renderer exited. See crbug.com/704832.
-TEST_F(RenderWidgetHostTest, RendererExitedNoDrag) {
-  host_->SetView(new TestView(host_.get()));
+class DragTestContentBrowserClient : public ContentBrowserClient {
+ public:
+  // The default implementation returns `false`, but this means that
+  // `CanRequestURL()` for a URL with a file scheme ends up returning true,
+  // since `ChildProcessSecurityPolicy` assumes that unhandled schemes are
+  // external protocols.
+  bool IsHandledURL(const GURL& url) override {
+    return url.SchemeIs(url::kFileScheme);
+  }
+};
 
-  EXPECT_EQ(delegate_->mock_delegate_view()->start_dragging_count(), 0);
+class RenderWidgetHostDragTest : public RenderViewHostImplTestHarness {
+ public:
+  RenderWidgetHostDragTest() {
+    old_browser_client_ = SetBrowserClientForTesting(&drag_browser_client_);
+  }
+
+  ~RenderWidgetHostDragTest() override {
+    SetBrowserClientForTesting(old_browser_client_);
+  }
+
+  void SetUp() override {
+    RenderViewHostImplTestHarness::SetUp();
+    contents()->set_delegate_view(&mock_delegate_view_);
+    main_test_rfh()->InitializeRenderFrameIfNeeded();
+  }
+
+  void StartDragWithDropData(const DropData& drop_data) {
+    StartDragWithDragData(
+        DropDataToDragData(drop_data, GetFileSystemAccessManager(),
+                           main_test_rfh()->GetProcess()->GetDeprecatedID(),
+                           GetChromeBlobStorageContext()));
+  }
+
+  void StartDragWithDragData(blink::mojom::DragDataPtr drag_data) {
+    GetRenderWidgetHost()->StartDragging(
+        *main_test_rfh(), std::move(drag_data), blink::kDragOperationEvery,
+        SkBitmap(), gfx::Vector2d(), gfx::Rect(),
+        blink::mojom::DragEventSourceInfo::New());
+  }
+
+  RenderWidgetHostImpl* GetRenderWidgetHost() {
+    return static_cast<RenderWidgetHostImpl*>(
+        main_test_rfh()->GetRenderWidgetHost());
+  }
+
+  FileSystemAccessManagerImpl* GetFileSystemAccessManager() {
+    return static_cast<StoragePartitionImpl*>(
+               contents()->GetBrowserContext()->GetDefaultStoragePartition())
+        ->GetFileSystemAccessManager();
+  }
+
+  scoped_refptr<ChromeBlobStorageContext> GetChromeBlobStorageContext() {
+    return ChromeBlobStorageContext::GetFor(contents()->GetBrowserContext());
+  }
+
+  int start_dragging_count() const {
+    return mock_delegate_view_.start_dragging_count();
+  }
+
+  const DropData& drop_data() const { return mock_delegate_view_.drop_data(); }
+
+ private:
+  DragTestContentBrowserClient drag_browser_client_;
+  raw_ptr<ContentBrowserClient> old_browser_client_;
+  MockRenderViewHostDelegateView mock_delegate_view_;
+};
+
+// Make sure no dragging occurs after renderer exited. See crbug.com/704832.
+TEST_F(RenderWidgetHostDragTest, RendererExitedNoDrag) {
+  EXPECT_EQ(start_dragging_count(), 0);
 
   GURL http_url = GURL("http://www.domain.com/index.html");
   DropData drop_data;
   drop_data.url_infos = {ui::ClipboardUrlInfo{http_url, u""}};
   drop_data.html_base_url = http_url;
-  FileSystemAccessManagerImpl* file_system_manager =
-      static_cast<StoragePartitionImpl*>(process_->GetStoragePartition())
-          ->GetFileSystemAccessManager();
-  blink::DragOperationsMask drag_operation = blink::kDragOperationEvery;
-  host_->StartDragging(
-      DropDataToDragData(
-          drop_data, file_system_manager, process_->GetDeprecatedID(),
-          ChromeBlobStorageContext::GetFor(process_->GetBrowserContext())),
-      url::Origin(), drag_operation, SkBitmap(), gfx::Vector2d(), gfx::Rect(),
-      blink::mojom::DragEventSourceInfo::New());
-  EXPECT_EQ(delegate_->mock_delegate_view()->start_dragging_count(), 1);
+
+  StartDragWithDropData(drop_data);
+  EXPECT_EQ(start_dragging_count(), 1);
 
   // Simulate that renderer exited due navigation to the next page.
-  host_->RendererExited();
-  EXPECT_FALSE(host_->GetView());
-  host_->StartDragging(
-      DropDataToDragData(
-          drop_data, file_system_manager, process_->GetDeprecatedID(),
-          ChromeBlobStorageContext::GetFor(process_->GetBrowserContext())),
-      url::Origin(), drag_operation, SkBitmap(), gfx::Vector2d(), gfx::Rect(),
-      blink::mojom::DragEventSourceInfo::New());
-  EXPECT_EQ(delegate_->mock_delegate_view()->start_dragging_count(), 1);
+  GetRenderWidgetHost()->RendererExited();
+  EXPECT_FALSE(GetRenderWidgetHost()->GetView());
+
+  StartDragWithDropData(drop_data);
+  EXPECT_EQ(start_dragging_count(), 1);
+}
+
+TEST_F(RenderWidgetHostDragTest, NonFileUrlSpecifiesDownloadUrlWithFileUrl) {
... (truncated)
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential Local File Disclosure via Drag-and-Drop Frame Confusion

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: A malicious subframe embedded in a privileged page can bypass process isolation to steal local files via HTML5 drag-and-drop. This occurs due to missing IPC validation of download_metadata in StartDragging and frame confusion in DragDownloadFile, which incorrectly evaluates download permissions using the primary main frame’s privileges.

Affected files:

  • content/browser/renderer_host/render_widget_host_impl.cc
  • content/browser/download/drag_download_file.cc

Estimated timestamp from git blame: 2026-01-23

Summary

A potential security gap exists in the handling of drag-and-drop operations, allowing an unprivileged subframe to steal local files or access restricted URLs if embedded within a privileged main frame (such as a file:// page or an extension). The issue stems from a combination of missing IPC validation for drag metadata and a frame confusion bug where the browser uses the primary main frame’s process ID for security checks instead of the actual IPC sender’s process ID.

Technical Details

There are two interlinked flaws that make this attack possible:

  1. Missing IPC Validation in StartDragging In content/browser/renderer_host/render_widget_host_impl.cc, the StartDragging IPC handler converts blink::mojom::DragData into DropData. It performs ChildProcessSecurityPolicy checks and FilterURL filtering on various fields (like url_infos, filenames, and html_base_url). However, it completely fails to validate or apply FilterURL to drop_data.download_metadata. This allows a compromised or malicious renderer to pass highly privileged URLs (e.g., file:///C:/secret.txt) across the IPC boundary.

  2. Frame Confusion in DragDownloadFile When the drag operation converts the metadata into a download, a DragDownloadFile object is created (content/browser/download/drag_download_file.cc). Its constructor explicitly uses web_contents->GetPrimaryMainFrame() to determine the render_process_id and render_frame_id:

RenderFrameHost* host = web_contents->GetPrimaryMainFrame();
drag_ui_ = new DragDownloadFileUI(
    url, referrer, referrer_encoding, initiator_origin,
    host->GetProcess()->GetDeprecatedID(), host->GetRoutingID(),
    ...);

When the drop triggers the download, DownloadManagerImpl::BeginDownloadInternal evaluates the request’s safety via DownloadRequestUtils::IsURLSafe. Because DragDownloadFile provided the primary main frame’s process ID, ChildProcessSecurityPolicy::CanRequestURL evaluates the file access against the main frame’s permissions. If the main frame is a file:// document, the check passes, and the browser copies the local file to a temporary drag directory.

When the drop completes over the attacker’s subframe, the browser explicitly grants the subframe read access to this temporary file (via GrantFileAccessFromDropData), and the file is passed to the attacker’s JavaScript via event.dataTransfer.files.

Suggested Attacker Steps

Note: These are potential steps as this was discovered by an LLM agent without a live execution environment.

  1. An attacker convinces a user to save an HTML file locally and open it (running in the file:// context). This file embeds the attacker’s remote page as an iframe.
  2. The attacker’s subframe contains a draggable element with a dragstart event listener.
  3. When the user drags the element, the attacker’s JavaScript executes: event.dataTransfer.setData("DownloadURL", "text/plain:secret.txt:file:///C:/target_secret.txt");
  4. The user completes the drag by dropping the item back onto the attacker’s subframe.
  5. The browser initiates a download using the main frame’s privileged context, copying the restricted local file to a temporary directory.
  6. The browser passes the temporary file to the subframe’s drop event.
  7. The attacker reads the file contents via the standard HTML5 File API (await event.dataTransfer.files[0].text();) and exfiltrates it.

Suggested Fix

  1. Validate Metadata IPC: In RenderWidgetHostImpl::StartDragging, parse the drop_data.download_metadata URL and apply GetProcess()->FilterURL() to it, discarding the metadata if the initiating process lacks the required permissions.
  2. Fix Frame Confusion: Modify DragDownloadFile to accept the actual initiating RenderFrameHost (or its IDs) rather than hardcoding web_contents->GetPrimaryMainFrame(). Note that there is already a TODO(crbug.com/40470366) in DragDownloadFileUI::InitiateDownload acknowledging this exact requirement.

Evaluated with Chrome root at commit: 0eb4855bda702feaaa8b899336664f97e3df88b8


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. Please feel free to reach out to me if you have concerns or feedback.

View on issue tracker