CVE-2026-8516
Overview
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 throughDragDownloadFile. - `DragDownloadFile` / `DragDownloadFileUI`
- the browser-side classes that build
DownloadUrlParametersfor the drag-out download, including the frame it runs in and the requestinitiator. - `WeakDocumentPtr` / `DocumentToken`
- browser-side handles to one specific document.
AsRenderFrameHostIfValid()returns itsRenderFrameHostonly 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
WebDragSourceand calls back into the browser withDragPromisedFileTo.
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).
Attack Path
- Seed drag data
Attacker-controlled content, such as a cross-origin subframe, starts a drag whose
DataTransfercarries a craftedDownloadURL/download_metadata(MIME type, filename, URL). - User drops outside the browser
The user drags the item to the desktop or Finder, which triggers
DragPromisedFileTo(macOS) or theDragDownloadFilepath on other platforms. - Misattributed download
Before the patch, the browser created the download in the context of the primary main frame, with an
initiatortaken from drag-carried data, not from the source document. The removed browser testDragDownloadFileTest_Initiatorshows that an arbitraryhttps://initiator.example.comorigin flowed straight into the request. - 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
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/app_shim_remote_cocoa/web_drag_source_mac.mm |
modified | |
DragDownloadFilecontent/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 | |
WebContentscontent/browser/download/drag_download_file.h |
modified |
Files Changed
components/download/public/common/download_url_parameters.hcontent/app_shim_remote_cocoa/web_contents_ns_view_bridge.hcontent/app_shim_remote_cocoa/web_contents_ns_view_bridge.mmcontent/app_shim_remote_cocoa/web_contents_view_cocoa.hcontent/app_shim_remote_cocoa/web_contents_view_cocoa.mmcontent/app_shim_remote_cocoa/web_drag_source_mac.hcontent/app_shim_remote_cocoa/web_drag_source_mac.mmcontent/app_shim_remote_cocoa/window_occlusion_browsertest_mac.mmcontent/browser/download/drag_download_file.cccontent/browser/download/drag_download_file.hcontent/browser/download/drag_download_file_browsertest.cccontent/browser/download/drag_download_util.cccontent/browser/download/drag_download_util.hcontent/browser/renderer_host/data_transfer_util.cccontent/browser/renderer_host/render_frame_host_impl.cccontent/browser/renderer_host/render_frame_host_impl.hcontent/browser/renderer_host/render_view_host_delegate_view.hcontent/browser/renderer_host/render_view_host_unittest.cccontent/browser/renderer_host/render_widget_host_impl.cccontent/browser/renderer_host/render_widget_host_impl.hcontent/browser/renderer_host/render_widget_host_unittest.cccontent/browser/renderer_host/render_widget_host_view_android.cccontent/browser/renderer_host/render_widget_host_view_android.hcontent/browser/renderer_host/render_widget_host_view_base.hcontent/browser/renderer_host/render_widget_host_view_child_frame.cccontent/browser/renderer_host/render_widget_host_view_child_frame.hcontent/browser/web_contents/web_contents_view_android.cccontent/browser/web_contents/web_contents_view_android.hcontent/browser/web_contents/web_contents_view_android_unittest.cccontent/browser/web_contents/web_contents_view_aura.cccontent/browser/web_contents/web_contents_view_aura.hcontent/browser/web_contents/web_contents_view_aura_unittest.cccontent/browser/web_contents/web_contents_view_child_frame.cccontent/browser/web_contents/web_contents_view_child_frame.hcontent/browser/web_contents/web_contents_view_mac.hcontent/browser/web_contents/web_contents_view_mac.mmcontent/browser/web_contents/web_contents_view_mac_unittest.mmcontent/browser/web_contents/web_drag_source_mac_unittest.mmcontent/common/BUILD.gncontent/common/child_process_id_mojom_traits.hcontent/common/web_contents_ns_view_bridge.mojomcontent/public/common/common_param_traits_macros.hcontent/public/common/drop_data.cccontent/public/common/drop_data.hcontent/test/test_render_view_host.h
Audit Directions
- Main-frame-as-proxy attributionSearch for browser code that uses
GetPrimaryMainFrame()orWebContentsto 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 dataTrack origins and URLs that travel inside
DropData,OSExchangeData, or out-of-process (Remote Cocoa) callbacks and are later used as a requestinitiator; they should be recomputed from aRenderFrameHostthe browser trusts. - Late parsing of renderer stringsLook 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.
Patch
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)
Regression Test / PoC
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)
Original Bug Report
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.cccontent/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:
-
Missing IPC Validation in
StartDraggingIncontent/browser/renderer_host/render_widget_host_impl.cc, theStartDraggingIPC handler convertsblink::mojom::DragDataintoDropData. It performsChildProcessSecurityPolicychecks andFilterURLfiltering on various fields (likeurl_infos,filenames, andhtml_base_url). However, it completely fails to validate or applyFilterURLtodrop_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. -
Frame Confusion in
DragDownloadFileWhen the drag operation converts the metadata into a download, aDragDownloadFileobject is created (content/browser/download/drag_download_file.cc). Its constructor explicitly usesweb_contents->GetPrimaryMainFrame()to determine therender_process_idandrender_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.
- 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 aniframe. - The attacker’s subframe contains a draggable element with a
dragstartevent listener. - When the user drags the element, the attacker’s JavaScript executes:
event.dataTransfer.setData("DownloadURL", "text/plain:secret.txt:file:///C:/target_secret.txt"); - The user completes the drag by dropping the item back onto the attacker’s subframe.
- The browser initiates a download using the main frame’s privileged context, copying the restricted local file to a temporary directory.
- The browser passes the temporary file to the subframe’s
dropevent. - The attacker reads the file contents via the standard HTML5 File API (
await event.dataTransfer.files[0].text();) and exfiltrates it.
Suggested Fix
- Validate Metadata IPC: In
RenderWidgetHostImpl::StartDragging, parse thedrop_data.download_metadataURL and applyGetProcess()->FilterURL()to it, discarding the metadata if the initiating process lacks the required permissions. - Fix Frame Confusion: Modify
DragDownloadFileto accept the actual initiatingRenderFrameHost(or its IDs) rather than hardcodingweb_contents->GetPrimaryMainFrame(). Note that there is already aTODO(crbug.com/40470366)inDragDownloadFileUI::InitiateDownloadacknowledging 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.