Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Permissions
DescriptionInappropriate implementation in Permissions
ComponentPermissions
Bug ClassLogic Error
Tracker519078527
Fix commit123c4561c0cc (chromium/src) +270/-44
CISA KEVNot listed
CreditedTech Division (@taiphung) - Mobifone Digital Payment
Disclosed2026-07-29

Changed Functions

FunctionChangeNotes
PDFExtensionOopifBlockPdfFrameNavigationTest
chrome/browser/pdf/pdf_extension_test.cc
modified

Files Changed

  • chrome/browser/media/webrtc/permission_bubble_media_access_handler.cc
  • chrome/browser/pdf/pdf_extension_test.cc
From 123c4561c0ccaf77f295381b860f5264700f671b Mon Sep 17 00:00:00 2001
From: Maksim Sisov <[email protected]>
Date: Tue, 16 Jun 2026 01:36:18 -0700
Subject: [PATCH] [mime-handler] Key permission embedding origin on the requesting frame

When a web page embeds a same-origin PDF and requests a delegated
permission such as geolocation, the request was attributed to the
built-in PDF Viewer extension instead of the page. Delegated permissions
are canonicalized to the embedding origin, so the grant was stored
against the extension origin, and a different web origin that reproduced
the same frame shape then observed it as already granted without a new
prompt.

The embedding-origin override located the MIME handler extension OOPIF
subtree by scanning the frame tree for a frame whose origin matched the
requesting origin. It received only the origin and the WebContents, not
the requesting frame, so it could not tell the outer page apart from the
embedded PDF's content frame, which commits to the original URL and
therefore shares the outer page's origin while living inside the
extension subtree.

Plumb the requesting RenderFrameHost to GetEmbeddingOriginOverride() and
key on the requester's frame-tree position: attribute to the extension
only when the requesting frame is itself inside the extension OOPIF
subtree. The requesting frame is already available at every call site.

Bug: 519078527, 495538206
Change-Id: I6ddea6e7d84b6d1514d42b9609a9067a198d3bbf
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7913222
Reviewed-by: Andy Phan <[email protected]>
Commit-Queue: Maksim Sisov <[email protected]>
Reviewed-by: Judith Hemp <[email protected]>
Reviewed-by: Guido Urdaneta <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1647393}
---

diff --git a/chrome/browser/media/webrtc/permission_bubble_media_access_handler.cc b/chrome/browser/media/webrtc/permission_bubble_media_access_handler.cc
index b8ed1ed..33ab110 100644
--- a/chrome/browser/media/webrtc/permission_bubble_media_access_handler.cc
+++ b/chrome/browser/media/webrtc/permission_bubble_media_access_handler.cc
@@ -93,7 +93,8 @@
 
   GURL embedding_origin =
       permissions::PermissionsClient::Get()
-          ->GetEmbeddingOriginOverride(request.security_origin, web_contents)
+          ->GetEmbeddingOriginOverride(request.security_origin,
+                                       render_frame_host)
           .value_or(permissions::PermissionUtil::GetLastCommittedOriginAsURL(
               render_frame_host->GetMainFrame()));
 
diff --git a/chrome/browser/pdf/pdf_extension_test.cc b/chrome/browser/pdf/pdf_extension_test.cc
index 62a9b54..5306324 100644
--- a/chrome/browser/pdf/pdf_extension_test.cc
+++ b/chrome/browser/pdf/pdf_extension_test.cc
@@ -41,6 +41,7 @@
 #include "chrome/browser/pdf/pdf_extension_test_base.h"
 #include "chrome/browser/pdf/pdf_extension_test_util.h"
 #include "chrome/browser/pdf/test_mime_handler_stream_manager.h"
+#include "chrome/browser/permissions/chrome_permissions_client.h"
 #include "chrome/browser/plugins/plugin_test_utils.h"
 #include "chrome/browser/profiles/profile.h"
 #include "chrome/browser/renderer_context_menu/render_view_context_menu_browsertest_util.h"
@@ -65,6 +66,8 @@
 #include "components/pdf/browser/pdf_frame_util.h"
 #include "components/pdf/common/constants.h"
 #include "components/pdf/common/pdf_util.h"
+#include "components/permissions/permission_request_manager.h"
+#include "components/permissions/test/mock_permission_prompt_factory.h"
 #include "components/policy/core/browser/browser_policy_connector.h"
 #include "components/policy/core/common/mock_configuration_policy_provider.h"
 #include "components/policy/core/common/policy_map.h"
@@ -108,11 +111,13 @@
 #include "extensions/browser/guest_view/mime_handler_view/test_mime_handler_view_guest.h"
 #include "extensions/browser/mime_handler/mime_handler_stream_manager.h"
 #include "extensions/browser/mime_handler/stream_container.h"
+#include "extensions/common/constants.h"
 #include "extensions/test/result_catcher.h"
 #include "extensions/test/test_extension_dir.h"
 #include "net/test/embedded_test_server/controllable_http_response.h"
 #include "net/test/embedded_test_server/embedded_test_server.h"
 #include "pdf/pdf_features.h"
+#include "services/device/public/cpp/test/scoped_geolocation_overrider.h"
 #include "services/network/public/cpp/features.h"
 #include "testing/gmock/include/gmock/gmock.h"
 #include "testing/gtest/include/gtest/gtest.h"
@@ -4494,6 +4499,113 @@
   EXPECT_EQ("0px", embedder_margin);
 }
 
+// Regression test for crbug.com/519078527: when an outer page embeds a
+// same-origin PDF, a permission request from the outer page must not be
+// attributed to the PDF Viewer extension, while a request from inside the
+// extension subtree must be.
+IN_PROC_BROWSER_TEST_F(PDFExtensionOopifTest,
+                       OuterPageSameOriginPdfDoesNotInheritExtensionOrigin) {
+  // Outer a.com page embeds a same-origin a.com PDF.
+  const GURL outer_url =
+      embedded_test_server()->GetURL("a.com", "/pdf/test-iframe.html");
+  ASSERT_TRUE(LoadPdfInFirstChild(outer_url));
+
+  content::WebContents* web_contents = GetActiveWebContents();
+  content::RenderFrameHost* extension_host =
+      pdf_extension_test_util::GetOnlyPdfExtensionHost(web_contents);
+  ASSERT_TRUE(extension_host);
+  content::RenderFrameHost* content_frame =
+      pdf_extension_test_util::GetOnlyPdfPluginFrame(web_contents);
+  ASSERT_TRUE(content_frame);
+
+  // The PDF content frame commits to the original URL, so it shares the outer
+  // page's origin while living inside the extension OOPIF subtree.
+  content::RenderFrameHost* outer_frame = web_contents->GetPrimaryMainFrame();
+  EXPECT_EQ(outer_frame->GetLastCommittedOrigin(),
+            content_frame->GetLastCommittedOrigin());
+  EXPECT_EQ(extensions::kExtensionScheme,
+            extension_host->GetLastCommittedOrigin().scheme());
+
+  // The outer top-level frame is not a descendant of the extension OOPIF, so
+  // it keeps its own embedding origin (no override).
+  std::optional<GURL> outer_override =
+      ChromePermissionsClient::GetInstance()->GetEmbeddingOriginOverride(
+          outer_frame->GetLastCommittedOrigin().GetURL(), outer_frame);
+  EXPECT_FALSE(outer_override.has_value());
+
+  // A requester inside the subtree (the content frame) is attributed to the
+  // extension, even though it shares the outer page's origin.
+  std::optional<GURL> inner_override =
+      ChromePermissionsClient::GetInstance()->GetEmbeddingOriginOverride(
+          content_frame->GetLastCommittedOrigin().GetURL(), content_frame);
+  ASSERT_TRUE(inner_override.has_value());
+  EXPECT_EQ(extension_host->GetLastCommittedOrigin().GetURL(),
+            inner_override.value());
+}
+
+// End-to-end reproduction for crbug.com/519078527: an outer page that embeds
+// a same-origin PDF can have a delegated permission decision attributed to
+// the built-in PDF Viewer extension. A second, unrelated web origin that
+// reproduces the same frame shape then inherits the grant without a prompt.
+//
+// Geolocation is a delegated permission that requires a secure context;
+// 127.0.0.1 and localhost are distinct origins that are both potentially
+// trustworthy, mirroring the two-site reproduction over plain http.
+IN_PROC_BROWSER_TEST_F(PDFExtensionOopifTest,
+                       SameOriginPdfDoesNotLeakGeolocationAcrossOrigins) {
+  static constexpr char kRequestGeolocation[] = R"(
+    new Promise(resolve => {
+      navigator.geolocation.getCurrentPosition(
+          () => resolve('granted'),
+          e => resolve('error:' + e.code));
+    });
+  )";
+  static constexpr char kQueryGeolocation[] = R"(
+    navigator.permissions.query({name: 'geolocation'}).then(r => r.state);
+  )";
+
+  device::ScopedGeolocationOverrider geolocation_overrider(
+      /*latitude=*/0, /*longitude=*/0);
+
+  // Site A embeds a same-origin PDF and asks for geolocation once. The user
+  // allows it.
+  ASSERT_TRUE(LoadPdfInFirstChild(
+      embedded_test_server()->GetURL("127.0.0.1", "/pdf/test-iframe.html")));
+  content::WebContents* web_contents = GetActiveWebContents();
+  content::RenderFrameHost* site_a_main = web_contents->GetPrimaryMainFrame();
+
+  permissions::MockPermissionPromptFactory prompt_factory(
+      permissions::PermissionRequestManager::FromWebContents(web_contents));
+  prompt_factory.set_response_type(
+      permissions::PermissionRequestManager::ACCEPT_ALL);
+
+  // Exploit precondition: the PDF's content frame commits to the original URL,
+  // so it shares Site A's origin while living inside the extension OOPIF
+  // subtree. Without this the test would pass vacuously.
+  ASSERT_TRUE(pdf_extension_test_util::GetOnlyPdfExtensionHost(web_contents));
+  content::RenderFrameHost* content_frame =
+      pdf_extension_test_util::GetOnlyPdfPluginFrame(web_contents);
+  ASSERT_TRUE(content_frame);
+  ASSERT_EQ(site_a_main->GetLastCommittedOrigin(),
+            content_frame->GetLastCommittedOrigin());
+
+  EXPECT_EQ("granted", content::EvalJs(site_a_main, kRequestGeolocation));
+  EXPECT_EQ(1, prompt_factory.TotalRequestCount());
+
+  // Site B is a different web origin with the same same-origin-PDF frame
+  // shape.
+  ASSERT_TRUE(LoadPdfInFirstChild(
+      embedded_test_server()->GetURL("localhost", "/pdf/test-iframe.html")));
+  content::RenderFrameHost* site_b_main =
+      GetActiveWebContents()->GetPrimaryMainFrame();
+
+  // Site B must not inherit Site A's decision: a different web origin gets
+  // its own state and its own prompt.
+  EXPECT_EQ("prompt", content::EvalJs(site_b_main, kQueryGeolocation));
+  EXPECT_EQ("granted", content::EvalJs(site_b_main, kRequestGeolocation));
+  EXPECT_EQ(2, prompt_factory.TotalRequestCount());
+}
+
 class PDFExtensionOopifBlockPdfFrameNavigationTest
     : public PDFExtensionOopifTest {
  public:
diff --git a/chrome/browser/permissions/BUILD.gn b/chrome/browser/permissions/BUILD.gn
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/pdf/pdf_extension_test.cc b/chrome/browser/pdf/pdf_extension_test.cc
index 62a9b54..5306324 100644
--- a/chrome/browser/pdf/pdf_extension_test.cc
+++ b/chrome/browser/pdf/pdf_extension_test.cc
@@ -41,6 +41,7 @@
 #include "chrome/browser/pdf/pdf_extension_test_base.h"
 #include "chrome/browser/pdf/pdf_extension_test_util.h"
 #include "chrome/browser/pdf/test_mime_handler_stream_manager.h"
+#include "chrome/browser/permissions/chrome_permissions_client.h"
 #include "chrome/browser/plugins/plugin_test_utils.h"
 #include "chrome/browser/profiles/profile.h"
 #include "chrome/browser/renderer_context_menu/render_view_context_menu_browsertest_util.h"
@@ -65,6 +66,8 @@
 #include "components/pdf/browser/pdf_frame_util.h"
 #include "components/pdf/common/constants.h"
 #include "components/pdf/common/pdf_util.h"
+#include "components/permissions/permission_request_manager.h"
+#include "components/permissions/test/mock_permission_prompt_factory.h"
 #include "components/policy/core/browser/browser_policy_connector.h"
 #include "components/policy/core/common/mock_configuration_policy_provider.h"
 #include "components/policy/core/common/policy_map.h"
@@ -108,11 +111,13 @@
 #include "extensions/browser/guest_view/mime_handler_view/test_mime_handler_view_guest.h"
 #include "extensions/browser/mime_handler/mime_handler_stream_manager.h"
 #include "extensions/browser/mime_handler/stream_container.h"
+#include "extensions/common/constants.h"
 #include "extensions/test/result_catcher.h"
 #include "extensions/test/test_extension_dir.h"
 #include "net/test/embedded_test_server/controllable_http_response.h"
 #include "net/test/embedded_test_server/embedded_test_server.h"
 #include "pdf/pdf_features.h"
+#include "services/device/public/cpp/test/scoped_geolocation_overrider.h"
 #include "services/network/public/cpp/features.h"
 #include "testing/gmock/include/gmock/gmock.h"
 #include "testing/gtest/include/gtest/gtest.h"
@@ -4494,6 +4499,113 @@
   EXPECT_EQ("0px", embedder_margin);
 }
 
+// Regression test for crbug.com/519078527: when an outer page embeds a
+// same-origin PDF, a permission request from the outer page must not be
+// attributed to the PDF Viewer extension, while a request from inside the
+// extension subtree must be.
+IN_PROC_BROWSER_TEST_F(PDFExtensionOopifTest,
+                       OuterPageSameOriginPdfDoesNotInheritExtensionOrigin) {
+  // Outer a.com page embeds a same-origin a.com PDF.
+  const GURL outer_url =
+      embedded_test_server()->GetURL("a.com", "/pdf/test-iframe.html");
+  ASSERT_TRUE(LoadPdfInFirstChild(outer_url));
+
+  content::WebContents* web_contents = GetActiveWebContents();
+  content::RenderFrameHost* extension_host =
+      pdf_extension_test_util::GetOnlyPdfExtensionHost(web_contents);
+  ASSERT_TRUE(extension_host);
+  content::RenderFrameHost* content_frame =
+      pdf_extension_test_util::GetOnlyPdfPluginFrame(web_contents);
+  ASSERT_TRUE(content_frame);
+
+  // The PDF content frame commits to the original URL, so it shares the outer
+  // page's origin while living inside the extension OOPIF subtree.
+  content::RenderFrameHost* outer_frame = web_contents->GetPrimaryMainFrame();
+  EXPECT_EQ(outer_frame->GetLastCommittedOrigin(),
+            content_frame->GetLastCommittedOrigin());
+  EXPECT_EQ(extensions::kExtensionScheme,
+            extension_host->GetLastCommittedOrigin().scheme());
+
+  // The outer top-level frame is not a descendant of the extension OOPIF, so
+  // it keeps its own embedding origin (no override).
+  std::optional<GURL> outer_override =
+      ChromePermissionsClient::GetInstance()->GetEmbeddingOriginOverride(
+          outer_frame->GetLastCommittedOrigin().GetURL(), outer_frame);
+  EXPECT_FALSE(outer_override.has_value());
+
+  // A requester inside the subtree (the content frame) is attributed to the
+  // extension, even though it shares the outer page's origin.
+  std::optional<GURL> inner_override =
+      ChromePermissionsClient::GetInstance()->GetEmbeddingOriginOverride(
+          content_frame->GetLastCommittedOrigin().GetURL(), content_frame);
+  ASSERT_TRUE(inner_override.has_value());
+  EXPECT_EQ(extension_host->GetLastCommittedOrigin().GetURL(),
+            inner_override.value());
+}
+
+// End-to-end reproduction for crbug.com/519078527: an outer page that embeds
+// a same-origin PDF can have a delegated permission decision attributed to
+// the built-in PDF Viewer extension. A second, unrelated web origin that
+// reproduces the same frame shape then inherits the grant without a prompt.
+//
+// Geolocation is a delegated permission that requires a secure context;
+// 127.0.0.1 and localhost are distinct origins that are both potentially
+// trustworthy, mirroring the two-site reproduction over plain http.
+IN_PROC_BROWSER_TEST_F(PDFExtensionOopifTest,
+                       SameOriginPdfDoesNotLeakGeolocationAcrossOrigins) {
+  static constexpr char kRequestGeolocation[] = R"(
+    new Promise(resolve => {
+      navigator.geolocation.getCurrentPosition(
+          () => resolve('granted'),
+          e => resolve('error:' + e.code));
+    });
+  )";
+  static constexpr char kQueryGeolocation[] = R"(
+    navigator.permissions.query({name: 'geolocation'}).then(r => r.state);
+  )";
+
+  device::ScopedGeolocationOverrider geolocation_overrider(
+      /*latitude=*/0, /*longitude=*/0);
+
+  // Site A embeds a same-origin PDF and asks for geolocation once. The user
+  // allows it.
+  ASSERT_TRUE(LoadPdfInFirstChild(
+      embedded_test_server()->GetURL("127.0.0.1", "/pdf/test-iframe.html")));
+  content::WebContents* web_contents = GetActiveWebContents();
+  content::RenderFrameHost* site_a_main = web_contents->GetPrimaryMainFrame();
+
+  permissions::MockPermissionPromptFactory prompt_factory(
+      permissions::PermissionRequestManager::FromWebContents(web_contents));
+  prompt_factory.set_response_type(
+      permissions::PermissionRequestManager::ACCEPT_ALL);
+
+  // Exploit precondition: the PDF's content frame commits to the original URL,
+  // so it shares Site A's origin while living inside the extension OOPIF
+  // subtree. Without this the test would pass vacuously.
+  ASSERT_TRUE(pdf_extension_test_util::GetOnlyPdfExtensionHost(web_contents));
+  content::RenderFrameHost* content_frame =
+      pdf_extension_test_util::GetOnlyPdfPluginFrame(web_contents);
+  ASSERT_TRUE(content_frame);
+  ASSERT_EQ(site_a_main->GetLastCommittedOrigin(),
+            content_frame->GetLastCommittedOrigin());
+
+  EXPECT_EQ("granted", content::EvalJs(site_a_main, kRequestGeolocation));
+  EXPECT_EQ(1, prompt_factory.TotalRequestCount());
+
+  // Site B is a different web origin with the same same-origin-PDF frame
+  // shape.
+  ASSERT_TRUE(LoadPdfInFirstChild(
+      embedded_test_server()->GetURL("localhost", "/pdf/test-iframe.html")));
+  content::RenderFrameHost* site_b_main =
+      GetActiveWebContents()->GetPrimaryMainFrame();
+
+  // Site B must not inherit Site A's decision: a different web origin gets
+  // its own state and its own prompt.
+  EXPECT_EQ("prompt", content::EvalJs(site_b_main, kQueryGeolocation));
+  EXPECT_EQ("granted", content::EvalJs(site_b_main, kRequestGeolocation));
+  EXPECT_EQ(2, prompt_factory.TotalRequestCount());
+}
+
 class PDFExtensionOopifBlockPdfFrameNavigationTest
     : public PDFExtensionOopifTest {
  public:
diff --git a/chrome/browser/permissions/chrome_permissions_client_unittest.cc b/chrome/browser/permissions/chrome_permissions_client_unittest.cc
index a79a59606..2ec9b25 100644
--- a/chrome/browser/permissions/chrome_permissions_client_unittest.cc
+++ b/chrome/browser/permissions/chrome_permissions_client_unittest.cc
@@ -6,9 +6,11 @@
 
 #include <memory>
 
+#include "build/build_config.h"
 #include "chrome/test/base/chrome_render_view_host_test_harness.h"
 #include "components/permissions/permission_request_manager.h"
 #include "components/permissions/test/mock_permission_request.h"
+#include "extensions/buildflags/buildflags.h"
 #include "testing/gtest/include/gtest/gtest.h"
 
 #if !BUILDFLAG(IS_ANDROID)
@@ -22,6 +24,23 @@
 #include "components/unified_consent/pref_names.h"
 #endif
 
+#if BUILDFLAG(ENABLE_EXTENSIONS) && !BUILDFLAG(IS_ANDROID)
+#include <optional>
+#include <string>
+
+#include "content/public/browser/render_frame_host.h"
+#include "content/public/test/browser_test_utils.h"
+#include "content/public/test/navigation_simulator.h"
+#include "content/public/test/test_renderer_host.h"
+#include "extensions/browser/mime_handler/mime_handler_stream_manager.h"
+#include "extensions/browser/mime_handler/mime_handler_test_helpers.h"
+#include "extensions/browser/mime_handler/mock_mime_handler_stream_delegate.h"
+#include "extensions/browser/mime_handler/stream_container.h"
+#include "testing/gmock/include/gmock/gmock.h"
+#include "url/gurl.h"
+#include "url/origin.h"
+#endif  // BUILDFLAG(ENABLE_EXTENSIONS) && !BUILDFLAG(IS_ANDROID)
+
 class ChromePermissionsClientTest : public ChromeRenderViewHostTestHarness {
  public:
   void SetUp() override {
@@ -50,6 +69,110 @@
 
   EXPECT_FALSE(message_ui);
 }
+#elif BUILDFLAG(ENABLE_EXTENSIONS)
+namespace {
+
+constexpr char kExtensionOrigin[] =
+    "chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+
+}  // namespace
+
+// Fabricates a MIME handler OOPIF frame tree -- without loading a real
+// extension -- to unit-test `GetEmbeddingOriginOverride()` in isolation.
+class ChromePermissionsClientMimeHandlerTest
+    : public ChromeRenderViewHostTestHarness {
+ protected:
+  void TearDown() override {
+    web_contents()->RemoveUserData(
+        extensions::mime_handler::MimeHandlerStreamManager::UserDataKey());
+    ChromeRenderViewHostTestHarness::TearDown();
+  }
+
+  // Commits `url` on `host` and ensures the `MimeHandlerStreamManager` exists.
+  content::RenderFrameHost* NavigateAndCommit(content::RenderFrameHost* host,
+                                              const GURL& url) {
+    content::RenderFrameHost* committed =
+        content::NavigationSimulator::NavigateAndCommitFromDocument(url, host);
+    extensions::mime_handler::MimeHandlerStreamManager::Create(web_contents());
+    return committed;
+  }
+
+  content::RenderFrameHost* AppendChild(content::RenderFrameHost* parent,
+                                        const std::string& name) {
+    auto* tester = content::RenderFrameHostTester::For(parent);
+    tester->InitializeRenderFrameIfNeeded();
+    return tester->AppendChild(name);
+  }
+};
+
+// The embedding origin must be keyed on the requesting frame's position in the
+// MIME handler subtree, not on its origin. A frame inside the extension OOPIF
+// subtree resolves to the extension origin; a same-origin frame outside the
+// subtree (the outer page that embedded a same-origin PDF) does not -- so it
+// cannot inherit the extension's permission attribution. Regression test for
+// crbug.com/519078527.
+TEST_F(ChromePermissionsClientMimeHandlerTest,
+       OnlyRequestersInsideExtensionSubtreeGetExtensionOrigin) {
+  // The sample stream's original URL; the embedder must commit to it for the
+  // manager to recognize the extension host beneath it.
+  const GURL original_url("https://original_url1");
+
+  // embedder(origin A) -> extension host(chrome-extension) -> content(origin
+  // A). The embedder is same-origin with the content frame and stands in for
+  // the outer page that embedded the PDF.
+  content::RenderFrameHost* embedder = AppendChild(main_rfh(), "embedder");
+  embedder = NavigateAndCommit(embedder, original_url);
+
+  content::RenderFrameHost* extension_host = AppendChild(embedder, "extension");
+  content::OverrideLastCommittedOrigin(
+      extension_host, url::Origin::Create(GURL(kExtensionOrigin)));
+
+  content::RenderFrameHost* content_host =
+      AppendChild(extension_host, "content");
+  // In production the content frame commits to the original URL; fake that
+  // committed origin here so the content frame shares the embedder/outer
+  // origin -- the exploit precondition. (A real cross-origin navigation under
+  // the chrome-extension:// parent isn't representable in this unit harness.)
+  content::OverrideLastCommittedOrigin(content_host,
+                                       url::Origin::Create(original_url));
+
+  // Register the stream so the manager treats `extension_host` as the extension
+  // OOPIF under `embedder` (mirrors what NavigateToExtensionUrl() does in
+  // production).
+  auto* manager =
+      extensions::mime_handler::MimeHandlerStreamManager::FromWebContents(
+          web_contents());
+  ASSERT_TRUE(manager);
+  manager->AddStreamContainer(
+      embedder->GetFrameTreeNodeId(), "internal_id",
+      extensions::mime_handler::GenerateSampleStreamContainer(1),
+      std::make_unique<testing::NiceMock<
+          extensions::mime_handler::MockMimeHandlerStreamDelegate>>());
+  manager->ClaimStreamInfoForTesting(embedder);
+  manager->SetExtensionFrameTreeNodeIdForTesting(
+      embedder, extension_host->GetFrameTreeNodeId());
+  ASSERT_TRUE(manager->IsExtensionHost(extension_host));
+
+  auto* client = ChromePermissionsClient::GetInstance();
+
+  // The embedder (outer page) and the content frame committed to the same
+  // origin; only their frame-tree position differs.
+  ASSERT_EQ(embedder->GetLastCommittedOrigin(),
+            content_host->GetLastCommittedOrigin());
+
+  // Requester inside the subtree (the content frame) is attributed to the
+  // extension.
+  std::optional<GURL> inside = client->GetEmbeddingOriginOverride(
+      content_host->GetLastCommittedOrigin().GetURL(), content_host);
+  ASSERT_TRUE(inside.has_value());
+  EXPECT_EQ(GURL(kExtensionOrigin), *inside);
+
+  // Same-origin requester outside the subtree (the embedder/outer page) is not
+  // overridden, so it keeps its own origin and cannot inherit the grant.
+  std::optional<GURL> outside = client->GetEmbeddingOriginOverride(
+      embedder->GetLastCommittedOrigin().GetURL(), embedder);
+  EXPECT_FALSE(outside.has_value());
+}
 #endif  // BUILDFLAG(IS_ANDROID)
 
 #if !BUILDFLAG(IS_ANDROID)
... (truncated)
Loading diff…

Original Bug Report

reported by [email protected]

MIME handler origin confusion stores delegated permissions on Chrome PDF Viewer


Report description

MIME handler origin confusion stores delegated permissions on Chrome PDF Viewer


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/+/refs/tags/150.0.7865.2


The problem

Please describe the technical details of the vulnerability

Summary

A web page can make a delegated permission request, such as geolocation, be attributed to the built-in Chrome PDF Viewer extension instead of the requesting web origin.

The page only needs to embed a same-origin PDF. In the vulnerable OOPIF PDF frame shape, Chromium has both:

outer page: http://127.0.0.1:8000
  PDF extension frame: chrome-extension://mhjfbmdgcfjbbpaeojofohoefgiehjai
    PDF content frame: http://127.0.0.1:8000/poc.pdf

ChromePermissionsClient::GetEmbeddingOriginOverride() receives only requesting_origin and WebContents. It scans the frame tree by origin, sees the same origin again inside the MIME handler subtree, and returns the PDF Viewer extension origin. PermissionManager then canonicalizes the delegated permission decision to that extension origin.

This turns one misleading user allow decision on Site A into a reusable permission grant for a different web origin Site B, as long as Site B creates the same same-origin PDF frame shape. Site B observes permissionState: "granted" and can call geolocation without a second prompt.

Affect code

File: https://chromium.googlesource.com/chromium/src/+/refs/tags/150.0.7865.2/chrome/browser/permissions/chrome_permissions_client.cc#779-#825

#if BUILDFLAG(ENABLE_EXTENSIONS) && !BUILDFLAG(IS_ANDROID)
  // When a MIME handler extension is rendered as an OOPIF, key the
  // embedding origin to the extension so prompts and content settings
  // are attributed to the extension that produced the streamed content,
  // not the navigation host that triggered it.
  //
  // The override applies only when `requesting_origin` lies inside the
  // extension OOPIF subtree. Without this scoping, a top-level page
  // (e.g. evil.com) that embeds an invisible PDF in an iframe would
  // have its own permission prompts (e.g. camera) attributed to the
  // MIME handler extension, because `MimeHandlerStreamManager` is
  // attached to the WebContents the moment any PDF in the tab is
  // intercepted -- not only when the extension itself is the requester.
  // Inside the extension subtree the override applies to every
  // descendant -- including cross-origin children -- because those
  // frames only exist because the extension embedded them. When two
  // frames share `requesting_origin` (e.g. the host site is also
  // iframed by the extension), BFS picks the first match; this is
  // best-effort because the API receives only the origin, not the
  // specific RenderFrameHost.
  if (auto* manager =
          extensions::mime_handler::MimeHandlerStreamManager::FromWebContents(
              web_contents)) {
    GURL extension_origin;
    web_contents->GetPrimaryMainFrame()->ForEachRenderFrameHostWithAction(
        [&](content::RenderFrameHost* rfh) {
          if (rfh->GetLastCommittedOrigin().GetURL() != requesting_origin) {
            return content::RenderFrameHost::FrameIterationAction::kContinue;
          }
          // The candidate frame's origin matches the requester. Walk
          // its ancestors (including itself) and return the nearest
          // MIME handler extension OOPIF if one is present. If none
          // is, the requester lives outside any extension subtree --
          // do not override.
          for (content::RenderFrameHost* ancestor = rfh; ancestor;
               ancestor = ancestor->GetParent()) {
            if (manager->IsExtensionHost(ancestor)) {
              extension_origin = ancestor->GetLastCommittedOrigin().GetURL();
              return content::RenderFrameHost::FrameIterationAction::kStop;
            }
          }
          return content::RenderFrameHost::FrameIterationAction::kContinue;
        });
    if (!extension_origin.is_empty()) {
      return extension_origin;
    }
  }

file: https://chromium.googlesource.com/chromium/src/+/refs/tags/150.0.7865.2/components/permissions/permission_manager.cc#43

GURL GetEmbeddingOrigin(content::RenderFrameHost* const render_frame_host,
                        const GURL& requesting_origin) {
  content::WebContents* const web_contents =
      content::WebContents::FromRenderFrameHost(render_frame_host);
  return PermissionsClient::Get()
      ->GetEmbeddingOriginOverride(requesting_origin, web_contents)
      .value_or(PermissionUtil::GetLastCommittedOriginAsURL(
          render_frame_host->GetMainFrame()));
...
  const GURL embedding_origin = GetEmbeddingOrigin(
      render_frame_host, request_description.requesting_origin);
  for (size_t i = 0; i < request_description.permissions.size(); ++i) {
    const ContentSettingsType permission =
        PermissionUtil::PermissionTypeToContentSettingsType(
            blink::PermissionDescriptorToPermissionType(
                request_description.permissions[i]));
    const GURL canonical_requesting_origin = PermissionUtil::GetCanonicalOrigin(
        permission, request_description.requesting_origin, embedding_origin);
...

File: https://chromium.googlesource.com/chromium/src/+/refs/tags/150.0.7865.2/components/permissions/permission_util.cc#677

GURL PermissionUtil::GetCanonicalOrigin(ContentSettingsType permission,
                                        const GURL& requesting_origin,
                                        const GURL& embedding_origin) {
  std::optional<GURL> override_origin =
      PermissionsClient::Get()->GetCanonicalOriginOverride(requesting_origin,
                                                           embedding_origin);
  if (override_origin) {
    return override_origin.value();
  }
  switch (GetPermissionDelegationMode(permission)) {
    case PermissionDelegationMode::kDelegated:
      return embedding_origin;
    case PermissionDelegationMode::kDoubleKeyed:
    case PermissionDelegationMode::kUndelegated:
      return requesting_origin;

File: https://chromium.googlesource.com/chromium/src/+/refs/tags/150.0.7865.2/extensions/browser/mime_handler/mime_handler_stream_manager.cc#278

bool MimeHandlerStreamManager::IsContentHost(
    const content::RenderFrameHost* render_frame_host) const {
  // The content host should always have a parent host.
  content::RenderFrameHost* parent_host = render_frame_host->GetParent();
  if (!parent_host) {
    return false;
  }
  // The parent host should always be the extension host.
  if (!IsExtensionHost(parent_host)) {
    return false;
  }
  // The extension host should always have a parent host (the embedder host).
  content::RenderFrameHost* embedder_host = parent_host->GetParent();
  CHECK(embedder_host);
  return IsContentFrameTreeNodeId(embedder_host,
                                  render_frame_host->GetFrameTreeNodeId());

The actual MIME handler content RenderFrameHost commits to the original URL, meaning an embedded same-origin PDF can create a frame inside the extension subtree with the same origin as the outer page.

ASAN build args:

target_os = "linux"
target_cpu = "x64"

is_debug = false
is_asan = true
is_component_build = false
symbol_level = 1
dcheck_always_on = true
enable_full_stack_frames_for_profiling = true

blink_symbol_level = 0
v8_symbol_level = 0

Steps To Reproduce

  1. Save the attached serve.py.
  2. Start the local PoC server:
python3 serve.py 8000
  1. Start Google Chrome Dev/Unstable 150 with a fresh profile. No test harness, CDP, browser test shell, or feature flag is required. For an installed Dev package, the minimized command line is:
google-chrome-unstable --user-data-dir=/tmp/chrome-mime-permission-poc \
  http://127.0.0.1:8000/poc.html

My local video was recorded from an extracted .deb package, not an installed package. That extracted binary aborts without a configured setuid sandbox:

The SUID sandbox helper binary was found, but is not configured correctly.
... chrome-sandbox is owned by root and has mode 4755.

For that extracted-package recording only, I used --no-sandbox. The bug is not caused by the sandbox flag; the flag is only needed to run the extracted package on this host without root installation. Removing the invalid extracted chrome-sandbox helper also did not allow a sandboxed run on this host because Chrome aborted with No usable sandbox.

  1. On Site A, click Request geolocation from the outer page.
  2. Observe that the prompt is attributed to Chrome PDF Viewer:
Chrome PDF Viewer wants to
Know your location
  1. Click Allow while visiting the site.
  2. Click Open the other origin, which opens:
http://localhost:8000/poc.html
  1. Observe that Site B immediately reports the permission as granted without a new prompt:
{
  "site": "Site B localhost",
  "origin": "http://localhost:8000",
  "permissionState": "granted",
  "event": "state"
}
  1. On Site B, click Request geolocation from the outer page.
  2. Observe that Site B calls geolocation without a second prompt:
{
  "site": "Site B localhost",
  "origin": "http://localhost:8000",
  "permissionState": "granted",
  "event": "probe",
  "before": "granted",
  "after": "granted"
}
  1. Close Chrome so the profile preferences are flushed.
  2. Inspect the profile content settings:
jq '.profile.content_settings.exceptions.geolocation' \
  /tmp/chrome-mime-permission-poc/Default/Preferences

Expected Result

The permission request is issued by the outer web page. The decision should be prompted, stored, and reused only for the requesting web origin, for example:

http://127.0.0.1:8000

If the browser cannot distinguish whether requesting_origin refers to the outer page frame or a same-origin PDF content frame under the MIME handler extension subtree, it should fail closed and avoid returning the MIME handler extension origin.

After granting Site A, a different origin Site B should not inherit that permission through Chrome PDF Viewer and should receive its own prompt.

Attachments

Please attach these files directly to the Chrome VRP report as separate files:

serve.py
poc.mp4

Do not zip the files and do not provide them only as website links.

serve.py is a single local Python server. It serves /poc.html and /poc.pdf from both http://127.0.0.1:8000 and http://localhost:8000 so the same file can demonstrate both Site A and Site B.

Impact analysis

This crosses the browser’s web permission boundary.

A malicious site can make a permission prompt appear to come from Chrome PDF Viewer instead of the requesting site. If the user allows it once, the grant is stored for the PDF Viewer extension origin. A second, different web origin can then create the same PDF frame shape and reuse the extension-origin grant without a new prompt.

For a real web attack, the attacker would use HTTPS origins because geolocation requires a secure context. The local PoC uses 127.0.0.1 and localhost because they are trustworthy local origins and make the two-origin reproduction self-contained.

The PoC proves this with geolocation. I have not included camera or microphone claims as proven impact in this report. The same delegated permission canonicalization path appears relevant to other powerful permissions, but those should be evaluated separately.

The practical exploitation requirement is realistic but not zero-click: the attacker must get the user to allow one permission prompt. The issue then weakens the prompt’s origin attribution and broadens the resulting grant across origins that can trigger the same MIME handler frame shape.


The cause

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

150.0.7865.2 dev

No, it is not related to a crash.

Choose the type of vulnerability

Permissions Bypass

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

Tech Division (@taiphung) - Mobifone Digital Payment

View on issue tracker