Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in PDF
DescriptionInappropriate implementation in PDF
ComponentPDF
Bug ClassLogic Error
Tracker481882038
Fix commitad6cd119f61b (chromium/src) +101/-48
CISA KEVNot listed
CreditedPovcfe of Tencent Security Xuanwu Lab
Disclosed2026-04-07

Changed Functions

FunctionChangeNotes
if
chrome/browser/extensions/api/downloads/downloads_api.cc
modified

Files Changed

  • chrome/browser/extensions/api/downloads/downloads_api.cc
  • chrome/browser/extensions/webstore_installer.cc
  • chrome/browser/plugins/plugin_observer.cc
  • chrome/browser/renderer_context_menu/render_view_context_menu.cc
  • chrome/browser/ui/webui/downloads/downloads_dom_handler.cc
  • components/download/public/common/download_url_parameters.cc
From ad6cd119f61bec193122b7e082810fdee06d1e49 Mon Sep 17 00:00:00 2001
From: Lukasz Anforowicz <lukasza@chromium.org>
Date: Thu, 19 Feb 2026 16:07:36 -0800
Subject: [PATCH] Construct `DownloadUrlParameters` directly from `RenderFrameHost`.

Before this CL, callers of the frame `DownloadUrlParameters` constructor
had to "manually" get frame's process ID, and the routing ID.
Additionally the callers had to consider whether a call to
`set_initiator` may also be needed.  The downside of this was code
duplication, and risk that the IDs and the initiator won't be correctly
computed based on the frame.

After this CL, the callers can call a new method of `RenderFrameHost`
takes care of computing the IDs and the initiator.

The refactoring in this CL has the following impact on the behavior of
callsites that construct `DownloadUrlParameters`:

* `chrome/browser/extensions/webstore_installer.cc`,
  `content/browser/download/drag_download_file.cc`,
  `content/browser/renderer_host/render_frame_host_impl.cc`, and
  `content/browser/web_contents/web_contents_impl.cc`
    - No change in behavior
* `chrome/browser/extensions/api/downloads/downloads_api.cc`
    - Before the CL: initiator wasn't set
    - After the CL: initiator set based on `rfh`, or (when no frame
      in scenarios handling service worker of an extension) to the
      extension origin
* `chrome/browser/plugins/plugin_observer.cc`,
  `content/browser/download/download_browsertest.cc`, and
  `content/browser/download/download_request_utils.cc`
    - Before the CL: initiator wasn't set
    - After the CL: initiator set based on `render_frame_host`.
      This addresses https://crbug.com/481882038 and its potential
      variants.
* `chrome/browser/renderer_context_menu/render_view_context_menu.cc`
    - Before the CL: initiator set based on `params_.frame_url`
    - After the CL: initiator set based on `render_frame_host`.
      This addresses a TODO.
* `chrome/browser/ui/webui/downloads/downloads_dom_handler.cc`
    - Before the CL: initiator set to `chrome://downloads`
    - After the CL: replaying initiator of `download::DownloadItem`
      (matching the comment)

Fixed: 481882038
Change-Id: Ie6680252643b9c82cf07f7169c8d4c3abfdc0c17
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7560378
Reviewed-by: Min Qin <qinmin@chromium.org>
Reviewed-by: Emilia Paz <emiliapaz@chromium.org>
Commit-Queue: Łukasz Anforowicz <lukasza@chromium.org>
Reviewed-by: Avi Drissman <avi@chromium.org>
Reviewed-by: Nasko Oskov <nasko@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1587495}
---

diff --git a/chrome/browser/extensions/api/downloads/downloads_api.cc b/chrome/browser/extensions/api/downloads/downloads_api.cc
index b3cdd56c..72df629 100644
--- a/chrome/browser/extensions/api/downloads/downloads_api.cc
+++ b/chrome/browser/extensions/api/downloads/downloads_api.cc
@@ -1101,11 +1101,17 @@
             }
           }
         })");
-  std::unique_ptr<download::DownloadUrlParameters> download_params(
-      new download::DownloadUrlParameters(
-          download_url, source_process_id(),
-          render_frame_host() ? render_frame_host()->GetRoutingID() : -1,
-          traffic_annotation));
+  std::unique_ptr<download::DownloadUrlParameters> download_params;
+  if (auto* rfh = render_frame_host(); rfh) {
+    download_params =
+        rfh->CreateDownloadUrlParameters(download_url, traffic_annotation);
+  } else {
+    // Service-worker-based extensions may have no associated `rfh`.
+    download_params = std::make_unique<download::DownloadUrlParameters>(
+        download_url, traffic_annotation);
+    download_params->set_render_process_host_id(source_process_id());
+    download_params->set_initiator(extension()->origin());
+  }
   base::FilePath creator_suggested_filename;
   if (options.filename) {
     // Strip "%" character as it affects environment variables.
diff --git a/chrome/browser/extensions/webstore_installer.cc b/chrome/browser/extensions/webstore_installer.cc
index 9e37d2b..56f53491 100644
--- a/chrome/browser/extensions/webstore_installer.cc
+++ b/chrome/browser/extensions/webstore_installer.cc
@@ -553,11 +553,6 @@
   // The download url for the given extension is contained in |download_url_|.
   // We will navigate the current tab to this url to start the download. The
   // download system will then pass the crx to the CrxInstaller.
-  int render_process_host_id = web_contents_->GetPrimaryMainFrame()
-                                   ->GetRenderViewHost()
-                                   ->GetProcess()
-                                   ->GetDeprecatedID();
-
   content::RenderFrameHost* render_frame_host =
       web_contents_->GetPrimaryMainFrame();
   net::NetworkTrafficAnnotationTag traffic_annotation =
@@ -590,11 +585,10 @@
             }
           }
         })");
-  std::unique_ptr<DownloadUrlParameters> params(new DownloadUrlParameters(
-      download_url_, render_process_host_id, render_frame_host->GetRoutingID(),
-      traffic_annotation));
+  std::unique_ptr<DownloadUrlParameters> params =
+      render_frame_host->CreateDownloadUrlParameters(download_url_,
+                                                     traffic_annotation);
   params->set_file_path(file);
-  params->set_initiator(render_frame_host->GetLastCommittedOrigin());
   if (controller.GetVisibleEntry()) {
     content::Referrer referrer = content::Referrer::SanitizeForRequest(
         download_url_,
diff --git a/chrome/browser/plugins/plugin_observer.cc b/chrome/browser/plugins/plugin_observer.cc
index f4afe3d..6ba8916 100644
--- a/chrome/browser/plugins/plugin_observer.cc
+++ b/chrome/browser/plugins/plugin_observer.cc
@@ -85,12 +85,7 @@
           }
         })");
   std::unique_ptr<download::DownloadUrlParameters> params =
-      std::make_unique<download::DownloadUrlParameters>(
-          url,
-          render_frame_host->GetRenderViewHost()
-              ->GetProcess()
-              ->GetDeprecatedID(),
-          render_frame_host->GetRoutingID(), traffic_annotation);
+      render_frame_host->CreateDownloadUrlParameters(url, traffic_annotation);
   params->set_referrer(referrer.url);
   params->set_referrer_policy(
       content::Referrer::ReferrerPolicyForUrlRequest(referrer.policy));
diff --git a/chrome/browser/renderer_context_menu/render_view_context_menu.cc b/chrome/browser/renderer_context_menu/render_view_context_menu.cc
index c8307bd..b2e84fd 100644
--- a/chrome/browser/renderer_context_menu/render_view_context_menu.cc
+++ b/chrome/browser/renderer_context_menu/render_view_context_menu.cc
@@ -4341,17 +4341,13 @@
           policy_exception_justification: "Not implemented."
         })");
 
-  auto dl_params = std::make_unique<DownloadUrlParameters>(
-      url, render_frame_host->GetProcess()->GetDeprecatedID(),
-      render_frame_host->GetRoutingID(), traffic_annotation);
+  auto dl_params =
+      render_frame_host->CreateDownloadUrlParameters(url, traffic_annotation);
   content::Referrer referrer = CreateReferrer(url, params_);
   dl_params->set_referrer(referrer.url);
   dl_params->set_referrer_policy(
       content::Referrer::ReferrerPolicyForUrlRequest(referrer.policy));
   dl_params->set_referrer_encoding(params_.frame_charset);
-  // TODO(crbug.com/40066346): use the actual origin here rather than
-  // pulling it out of the frame url.
-  dl_params->set_initiator(url::Origin::Create(params_.frame_url));
   dl_params->set_suggested_name(params_.suggested_filename);
   dl_params->set_prompt(true);
   dl_params->set_download_source(download::DownloadSource::CONTEXT_MENU);
diff --git a/chrome/browser/ui/webui/downloads/downloads_dom_handler.cc b/chrome/browser/ui/webui/downloads/downloads_dom_handler.cc
index e60b0d45..6615ac0 100644
--- a/chrome/browser/ui/webui/downloads/downloads_dom_handler.cc
+++ b/chrome/browser/ui/webui/downloads/downloads_dom_handler.cc
@@ -436,11 +436,10 @@
   // initial download request rather than treating it as initiated from the
   // chrome://downloads/ page. Thus we get the NIK from |file|, not from
   // |render_frame_host|.
-  auto dl_params = std::make_unique<download::DownloadUrlParameters>(
-      url, render_frame_host->GetProcess()->GetDeprecatedID(),
-      render_frame_host->GetRoutingID(), traffic_annotation);
+  auto dl_params =
+      render_frame_host->CreateDownloadUrlParameters(url, traffic_annotation);
   dl_params->set_content_initiated(true);
-  dl_params->set_initiator(url::Origin::Create(GURL("chrome://downloads")));
+  dl_params->set_initiator(file->GetRequestInitiator());
   dl_params->set_download_source(download::DownloadSource::RETRY);
 
   web_contents->GetBrowserContext()->GetDownloadManager()->DownloadUrl(
diff --git a/components/download/public/common/download_url_parameters.cc b/components/download/public/common/download_url_parameters.cc
index fb8ca34b..6181c4c0 100644
--- a/components/download/public/common/download_url_parameters.cc
+++ b/components/download/public/common/download_url_parameters.cc
@@ -4,15 +4,31 @@
 
 #include "components/download/public/common/download_url_parameters.h"
 
+#include "base/types/pass_key.h"
+
 namespace download {
 
 DownloadUrlParameters::DownloadUrlParameters(
     const GURL& url,
     const net::NetworkTrafficAnnotationTag& traffic_annotation)
-    : DownloadUrlParameters(url, -1, -1, traffic_annotation) {}
+    : DownloadUrlParameters(url, std::nullopt, -1, -1, traffic_annotation) {}
+
+DownloadUrlParameters::DownloadUrlParameters(
+    base::PassKey<content::RenderFrameHostImpl>,
+    const GURL& url,
+    std::optional<url::Origin> initiator,
+    int render_process_host_id,
+    int render_frame_host_routing_id,
+    const net::NetworkTrafficAnnotationTag& traffic_annotation)
+    : DownloadUrlParameters(url,
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/download/download_browsertest.cc b/content/browser/download/download_browsertest.cc
index b354903a..cc165fa 100644
--- a/content/browser/download/download_browsertest.cc
+++ b/content/browser/download/download_browsertest.cc
@@ -5428,9 +5428,8 @@
   // Create download parameters with the renderer process information from the
   // prerendered page and mark it as rendered-initiated, otherwise the download
   // won't be checked.
-  auto download_parameters = std::make_unique<download::DownloadUrlParameters>(
-      kDownloadUrl, render_frame_host->GetProcess()->GetDeprecatedID(),
-      render_frame_host->GetRoutingID(), TRAFFIC_ANNOTATION_FOR_TESTS);
+  auto download_parameters = render_frame_host->CreateDownloadUrlParameters(
+      kDownloadUrl, TRAFFIC_ANNOTATION_FOR_TESTS);
   download_parameters->set_content_initiated(true);
   download_manager->DownloadUrl(std::move(download_parameters));
Loading diff…

Original Bug Report

reported by po...@gmail.com

Cross-origin PDF placeholder download misclassified as browser-initiated request


Report description

Cross-origin PDF placeholder download misclassified as browser-initiated request


Bug location

Where do you want to report your vulnerability?

Chrome VRP – Report security issues affecting the Chrome browser. See program rules

Which URL (or repository) have you found the vulnerability in?

https://chromium.googlesource.com/chromium/src/


The problem

Please describe the technical details of the vulnerability

1. technical details

When the built-in PDF viewer is disabled and an embedded PDF falls back to a plugin placeholder with an “Open” button, the browser process initiates a download for the embedded PDF URL via PluginObserver::OpenPDF. In this code path, the download request is constructed without setting the initiator origin, so the network stack will treat the request as browser-initiated and omit the cross-site relationship in Fetch Metadata headers.

In the browser process, PluginObserver::OpenPDF creates download::DownloadUrlParameters for the embedded PDF resource but never populates the initiator origin:

// chromium/src/chrome/browser/plugins/plugin_observer.cc
void PluginObserver::OpenPDF(const GURL& url) {
  content::RenderFrameHost* render_frame_host =
      plugin_host_receivers_.GetCurrentTargetFrame();
  // WebViews should never trigger PDF downloads.
  if (extensions::WebViewGuest::FromRenderFrameHost(render_frame_host))
    return;

  content::Referrer referrer;
  if (!CanOpenPdfUrl(render_frame_host, url,
                     web_contents()->GetLastCommittedURL(), &referrer)) {
    return;
  }

  net::NetworkTrafficAnnotationTag traffic_annotation =
      net::DefineNetworkTrafficAnnotation("pdf_plugin_placeholder", R"(
        semantics {
          sender: "PDF Plugin Placeholder"
          description:
            "When the PDF Viewer is unavailable, a placeholder is shown for "
            "embedded PDFs. This placeholder allows the user to download and "
            "open the PDF file via a button."
          trigger:
            "The user clicks the 'View PDF' button in the PDF placeholder."
          data: "None."
          destination: WEBSITE
        }
        policy {
          cookies_allowed: NO
          setting:
            "This feature can be disabled via 'Download PDF files instead of "
            "automatically opening them in Chrome' in settings under content. "
            "The feature is disabled by default."
          chrome_policy {
            AlwaysOpenPdfExternally {
              AlwaysOpenPdfExternally: false
            }
          }
        })");
  std::unique_ptr<download::DownloadUrlParameters> params =
      std::make_unique<download::DownloadUrlParameters>(
          url,
          render_frame_host->GetRenderViewHost()
              ->GetProcess()
              ->GetDeprecatedID(),
          render_frame_host->GetRoutingID(), traffic_annotation);
  params->set_referrer(referrer.url);
  params->set_referrer_policy(
      content::Referrer::ReferrerPolicyForUrlRequest(referrer.policy));

  web_contents()->GetBrowserContext()->GetDownloadManager()->DownloadUrl(
      std::move(params));
}

DownloadUrlParameters defaults the initiator to empty unless explicitly set, and CreateResourceRequest copies this value into the network::ResourceRequest that is sent to the network service:

// chromium/src/components/download/internal/common/download_utils.cc
std::unique_ptr<network::ResourceRequest> CreateResourceRequest(
    DownloadUrlParameters* params) {
  DCHECK_GE(params->offset(), 0);

  std::unique_ptr<network::ResourceRequest> request(
      new network::ResourceRequest);
  request->method = params->method();
  request->url = params->url();
  request->request_initiator = params->initiator();
  request->trusted_params = network::ResourceRequest::TrustedParams();
  request->has_user_gesture = params->has_user_gesture();
  ...
}

In the network stack, the Fetch Metadata helper interprets a missing initiator origin on a browser-initiated request as a “no site” relation and maps it to Sec-Fetch-Site: none:

// chromium/src/services/network/sec_header_helpers.cc
std::optional<net::OriginRelation> GetInitiatorRelation(
    const net::URLRequest& request,
    base::optional_ref<const GURL> pending_redirect_url,
    const mojom::URLLoaderFactoryParams& factory_params,
    const cors::OriginAccessList& origin_access_list) {
  // Browser-initiated requests with no initiator origin will send
  // `Sec-Fetch-Site: None`.
  if (!request.initiator().has_value()) {
    // CorsURLLoaderFactory::IsValidRequest verifies that only the browser
    // process may initiate requests with no request initiator.
    DCHECK(factory_params.process_id.is_browser());

    return std::nullopt;
  }
  const url::Origin& initiator = request.initiator().value();
  ...
}

As a result, when a page at origin A embeds a PDF from origin B and the user clicks the “Open” button in the PDF placeholder, the resulting download request to origin B carries Sec-Fetch-Site: none even though it was actually triggered from a cross-origin page.

2. vulnerability reproduction

The PoC is provided in web/open_pdf_none. It simulates a page on one origin embedding a PDF resource served from another origin using different ports on the same IP address.

Environment:

  • Build and run the browser from this repository.
  • Configure the browser so that PDF files are downloaded instead of opened in the built-in PDF viewer (for example via the PDF documents content setting).

Steps:

  1. In a terminal, start both the attacker and PDF servers:

    • cd web/open_pdf_none
    • python3 run_servers.py
  2. In the browser, open the attacker page from one origin:

    • http://<ip-address>:8000/attacker.html
  3. Confirm that the embedded PDF area shows a gray placeholder with an “Open” style button instead of an inline PDF viewer.

  4. Click the “Open” button inside the placeholder to trigger the download of the cross-origin target.pdf.

  5. Observe the server output from run_servers.py for the request on port 8001. A typical log shows:

    === incoming request on 8001 ===
    PATH: /target.pdf
    Host: localhost:8001
    Sec-Fetch-Site: none
    Sec-Fetch-Mode: navigate
    Sec-Fetch-Dest: empty
    Referer: http://<ip-address>:8000/
    User-Agent: ...
    ...
    =================================
    

This demonstrates that a request to the PDF origin (port 8001) is triggered from a different origin (port 8000) but still carries Sec-Fetch-Site: none instead of being classified as a cross-site request.

Impact analysis

Any web origin that can cause the browser to render the PDF plugin placeholder for an embedded cross-origin PDF (for example by embedding a PDF from a different host or port while PDF viewing is disabled) can trigger this behavior. When a user clicks the placeholder’s “Open” button, the browser initiates a download to the cross-origin PDF URL with Sec-Fetch-Site: none, even though the request was indirectly triggered by the embedding page.

Servers that rely on Fetch Metadata, and specifically on rejecting Sec-Fetch-Site: cross-site for sensitive download or export endpoints, may incorrectly treat these requests as if they were direct browser navigations without a web initiator. This allows an embedding site to drive a user’s browser to access cross-origin PDF download or export endpoints that would otherwise be blocked by stricter Fetch Metadata-based policies, potentially undermining access control or CSRF-like protections that depend on accurate site relationship classification.


The cause

What version of Chrome have you found the security issue in?

146.0.7666.1/stable

No, it is not related to a crash.

Choose the type of vulnerability

Cross-site request forgery (CSRF)

How would you like to be publicly acknowledged for your report?

Povcfe of Tencent Security Xuanwu Lab

View on issue tracker