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

Overview

Low
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in PDF
DescriptionInappropriate implementation in PDF
ComponentPDF
Bug ClassLogic Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker481882038
Fix commitad6cd119f61b (chromium/src) +101/-48
CWECWE-358
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:N
CISA KEVNot listed
CreditedPovcfe of Tencent Security Xuanwu Lab
Reported2026-02-05 · bounty $1000 (per vendor advisory)
Fix landed2026-02-20 (public git)
Disclosed2026-04-07
Patch gap46 days — fix public in source before the advisory

Background

`DownloadUrlParameters`
the parameter object (components/download/public/common/download_url_parameters.h) that describes a programmatic download, including the initiating process/frame IDs and the request initiator origin.
Download initiator
the url::Origin recorded as the site that started a download, used downstream for security decisions (e.g. site-isolation / network-isolation-key attribution and policy checks); it is set via set_initiator.
`RenderFrameHost::CreateDownloadUrlParameters`
the new factory method added in this CL that builds a DownloadUrlParameters from a frame, computing the process ID, routing ID, and initiator consistently in one place.

Root Cause Analysis

Before this CL each callsite that triggered a download constructed DownloadUrlParameters “manually,” passing the process and routing IDs itself and separately deciding whether to call set_initiator. Several callsites — notably chrome/browser/plugins/plugin_observer.cc and (per the commit message) content/browser/download/download_request_utils.cc — never set the initiator at all, so the download was recorded with no originating origin, and chrome/browser/renderer_context_menu/render_view_context_menu.cc derived the initiator from params_.frame_url rather than the real committed frame origin. The commit message states the missing/incorrect initiator on these paths is exactly what crbug.com/481882038 (and its variants) is about.

The fix centralizes the computation in RenderFrameHost::CreateDownloadUrlParameters, which derives the IDs and the initiator directly from the frame, so every converted callsite now attaches a correct initiator origin. The change is a correctness/consistency refactor: it does not tighten a single conditional but removes the duplication that let some callers omit the initiator.

Key insight
The core mistake was duplicated, manual construction of download parameters that let some callsites omit or mis-derive the download initiator origin; the fix funnels construction through one RenderFrameHost factory that always computes the initiator from the frame.

Attack Path

  1. Trigger an initiator-less download path Content (e.g. a PDF/plugin resource handled through plugin_observer.cc) causes the browser to start a download via one of the callsites that previously built DownloadUrlParameters without set_initiator.
  2. Initiator recorded as empty/wrong Because the pre-patch code never set the initiator (or, in the context-menu case, used url::Origin::Create(params_.frame_url) instead of the committed frame origin), the download is attributed to no origin or the wrong origin.
  3. Downstream attribution misled Security logic that consumes the initiator origin (policy/attribution decisions) then operates on incorrect provenance for the download (inferred); the advisory classes this as an inappropriate-implementation logic error rather than a memory-safety bug.

Impact Assessment

The vendor advisory rates this low and describes it as “Inappropriate implementation in PDF.” The attacker gains incorrect attribution of a browser-initiated download (missing or wrong initiator origin) on specific download paths, which can mislead origin-based download handling; any concrete security-decision consequence beyond the incorrect initiator value is (inferred). There is no indication in the patch of memory corruption, and no exploited-in-the-wild claim is made.

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

Audit Directions

  • Duplicated parameter construction
    Grep for remaining direct new DownloadUrlParameters( / make_unique<DownloadUrlParameters> callsites and verify each sets a correct initiator, rather than relying on the new factory.
  • Initiator omission on browser-initiated requests
    Audit other browser-process request builders (downloads, navigations, fetches) for paths that skip setting the initiator origin or derive it from a URL string instead of the committed frame origin.
  • PassKey-gated constructors
    Where a new base::PassKey constructor concentrates security-relevant computation, confirm every legitimate caller migrated and no legacy path still bypasses it.
From ad6cd119f61bec193122b7e082810fdee06d1e49 Mon Sep 17 00:00:00 2001
From: Lukasz Anforowicz <[email protected]>
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 <[email protected]>
Reviewed-by: Emilia Paz <[email protected]>
Commit-Queue: Łukasz Anforowicz <[email protected]>
Reviewed-by: Avi Drissman <[email protected]>
Reviewed-by: Nasko Oskov <[email protected]>
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 [email protected]

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