CVE-2026-8516
Overview
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.mm
Patch
From 988ac5cb880ba06ce8ddd8fe6ebc60dab03ec972 Mon Sep 17 00:00:00 2001 From: Daniel Cheng <[email protected]> Date: Wed, 01 Apr 2026 14:24:12 -0700 Subject: [PATCH] Filter DownloadURL when starting drags from Blink RenderWidgetHostImpl::StartDragging() already filters other fields that contain URLs, but DownloadURL was previously missed since it wasn't parsed until later, inside RenderWidgetHostDelegateView. The primary change here consist of several parts: 1. Change `content::DropData` to hold the optional, already-parsed DownloadURL metadata and move the parsing earlier on the browser side. This is technically a rule of 2 violation, but it is pre-existing and the parser itself is simple enough. Ideally, the renderer would pass the already-parsed form over IPC, but that doesn't quite fit with how drag items are represented today. Refactoring this is left for a followup in https://crbug.com/497928951. 2. Change `RenderWidgetHostImpl::StartDrag()` to actually filter the URL, and to reset the download URL metadata to be empty if it fails the check. 3. Add some unit tests to ensure this filtering actually happens. The other part of this change fixes a long-standing bug to use the source RenderFrameHost rather than always using the primary main frame. Where possible, functions have been simplified to avoid passing redundant parameters, e.g. `RenderWidgetHostImpl::StartDragging()` only takes a source RenderFrameHost now, since the initiator origin and the source RenderWidgetHost can both be derived from the source RenderFrameHost. This plumbing ends up being somewhat involved, due to various platform-specific quirks: 1. `DragDownloadFile`'s constructor takes a `WeakDocumentPtr` instead of a `RenderFrameHost&` or `GlobalRenderFrameHostToken`, since this better matches the desired semantics of RenderDocument. 2. On Mac, drags may need to be proxied to the out-of-process app shim. Pointers should not be passed between processes, so pass a child process ID and document token pair, which requires adding Mojo plumbing and a new `RenderFrameHostImpl::FromDocumentToken()` overload that takes a ChildProcessID. The existing overload is marked as deprecated; followups will remove use of the deprecated overload. 3. On Android, InputOnViz adds another wrinkle since there are times that the browser may need to request input back from Viz–so drags may start asynchronously. Similarly, plumb through a WeakDocumentPtr here to match the desired semantics of RenderDocument. gemini-cli was used to debug and implement portions of tests, as well as add boilerplate plumbing, e.g. for Mojo. Bug: 40470366, 496393078 Change-Id: I747e1d472888f0f93219dd194b8d49dea8b4dffe Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7708800 Reviewed-by: Kartar Singh <[email protected]> Reviewed-by: Min Qin <[email protected]> Commit-Queue: Daniel Cheng <[email protected]> Reviewed-by: Charlie Reis <[email protected]> Cr-Commit-Position: refs/heads/main@{#1608777} --- 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
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.