CVE-2026-3928
Overview
Background
- External extension install
- an extension whose
ManifestLocationis one of the external locations (e.g.kExternalPref, as used by the regression test’sMockExternalProvider), i.e. installed by an external provider such as a preferences file or registry entry, not through the normal Web Store install flow (kInternal). - Privilege-increase check
- the logic that compares an extension’s previously granted permissions (
GetGrantedPermissionsunioned withGetRuntimeGrantedPermissions) against its currentactive_permissions()usingPermissionMessageProvider::IsPrivilegeIncrease, so that an update requesting new warning-bearing permissions is disabled until the user approves. - `DISABLE_PERMISSIONS_INCREASE`
- the disable reason applied to an extension whose update asks for more privileges than the user has granted; the new browser test expects this reason after an external extension update adds the
tabspermission. - Trusted locations
- component (
IsComponentLocation), policy (IsPolicyLocation) and unpacked (IsUnpackedLocation) installs, which the patched code treats as exempt from the user-approval check.
Root Cause Analysis
In chrome/browser/extensions/chrome_extension_registrar_delegate.cc, the permission-increase block (the enclosing function name is not shown in the hunk) only ran the comparison when extension->location() == ManifestLocation::kInternal && !auto_grant_permission. For every other location, including externally installed extensions, is_privilege_increase stayed false and no comparison was made. So an external extension could ship an update that requested new, warning-bearing permissions (the test adds "permissions": ["tabs"] in version 2.0) and stay enabled with them, without the user being asked.
The patch changes the gate to an allowlist of trusted locations: it skips the check only for component, policy and unpacked installs (or when auto_grant_permission is set). Every other location, external ones included, now goes through IsPrivilegeIncrease. For an external extension that is not yet loaded (!is_extension_loaded && is_external_install), the code calls PermissionsUpdater::GrantActivePermissions to record a baseline granted set, so later updates have something to be compared against.
kInternal only), so external installs silently skipped user re-approval on permission growth. The fix makes it opt-out (only component/policy/unpacked are exempt) and seeds a granted-permission baseline for external extensions at first load.Attack Path
- External extension present
an extension is installed through an external provider (an external location such as
kExternalPref) and is enabled with an initial, low-privilege manifest. - Permission-expanding update
the extension’s author, or whoever controls its update source, publishes a new version with the same ID that requests additional permissions (the test uses
tabs). - Check skipped on load
when the update is installed, the location is not
kInternal, so the pre-patch code never computedis_privilege_increaseand the extension was not disabled. - Silent privilege gain
the updated extension runs with the new permissions and no
DISABLE_PERMISSIONS_INCREASEprompt, so the user never consents to the expanded access (the specific data then accessed is (inferred)).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/extensions/chrome_extension_registrar_delegate.cc |
modified | |
ExternalExtensionInstallBrowserTestchrome/browser/extensions/external_extension_install_browsertest.cc |
modified |
Files Changed
chrome/browser/extensions/chrome_extension_registrar_delegate.ccchrome/browser/extensions/external_extension_install_browsertest.cc
Audit Directions
- Location-allowlisted security checkslook for other
extension->location() == kInternal(or similar single-location) conditions that gate security decisions, such as install verification, permission withholding or disable reasons. Check whether external,kExternalPrefDownloadorkExternalRegistryinstalls fall through without enforcement. - Missing granted-permission baselinesthe
TODO(crbug.com/435980394)notes that external installs do not get permissions granted at install time. Audit code that readsGetGrantedPermissionsfor extensions that may never have had a granted set recorded, because an empty or absent baseline can make comparisons pass or fail incorrectly. - Update paths vs. install pathscompare the checks applied when an extension is first installed with those applied when it is updated, for each install source (external providers, sideloading, policy), and look for flows where only one of the two runs the user-consent check.
Patch
From b846067dcb8e1024449a9682761a4867b85140a7 Mon Sep 17 00:00:00 2001 From: Solomon Kinard <[email protected]> Date: Wed, 14 Jan 2026 14:49:06 -0800 Subject: [PATCH] Extensions: Permissions: Disable external extensions on increase Disable externally installed extensions if their permissions have increased. Bug: 435980394 Change-Id: I39ab160ecade0cbd582d65801a3f47b72976ce7d Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7265369 Reviewed-by: Devlin Cronin <[email protected]> Commit-Queue: Solomon Kinard <[email protected]> Cr-Commit-Position: refs/heads/main@{#1569353} --- diff --git a/chrome/browser/extensions/chrome_extension_registrar_delegate.cc b/chrome/browser/extensions/chrome_extension_registrar_delegate.cc index bc49001..3c10b340 100644 --- a/chrome/browser/extensions/chrome_extension_registrar_delegate.cc +++ b/chrome/browser/extensions/chrome_extension_registrar_delegate.cc @@ -23,6 +23,7 @@ #include "chrome/browser/extensions/external_install_manager.h" #include "chrome/browser/extensions/install_verifier_factory.h" #include "chrome/browser/extensions/installed_loader.h" +#include "chrome/browser/extensions/managed_installation_mode.h" #include "chrome/browser/extensions/profile_util.h" #include "chrome/browser/extensions/updater/extension_updater.h" #include "chrome/browser/profiles/profile.h" @@ -45,6 +46,7 @@ #include "extensions/buildflags/buildflags.h" #include "extensions/common/crash_keys.h" #include "extensions/common/extension.h" +#include "extensions/common/manifest_constants.h" #include "extensions/common/manifest_handlers/incognito_info.h" #include "extensions/common/manifest_handlers/shared_module_info.h" #include "extensions/common/mojom/manifest.mojom-shared.h" @@ -517,42 +519,51 @@ } bool is_privilege_increase = false; - // We only need to compare the granted permissions to the current permissions - // if the extension has not been auto-granted its permissions above and is - // installed internally. - if (extension->location() == ManifestLocation::kInternal && - !auto_grant_permission) { - // Add all the recognized permissions if the granted permissions list - // hasn't been initialized yet. - std::unique_ptr<const PermissionSet> granted_permissions = - extension_prefs_->GetGrantedPermissions(extension->id()); - CHECK(granted_permissions.get()); - // We check the union of both granted permissions and runtime granted - // permissions as it is possible for permissions which were withheld during - // installation to have never entered the granted set, but to have later - // been granted as runtime permissions. - std::unique_ptr<const PermissionSet> runtime_granted_permissions = - extension_prefs_->GetRuntimeGrantedPermissions(extension->id()); - std::unique_ptr<const PermissionSet> total_permissions = - PermissionSet::CreateUnion(*granted_permissions, - *runtime_granted_permissions); - // Here, we check if an extension's privileges have increased in a manner - // that requires the user's approval. This could occur because the browser - // upgraded and recognized additional privileges, or an extension upgrades - // to a version that requires additional privileges. - is_privilege_increase = - PermissionMessageProvider::Get()->IsPrivilegeIncrease( - *total_permissions, - extension->permissions_data()->active_permissions(), - extension->GetType()); + // Identify extensions from inherently trusted locations. + bool is_trusted_location = + Manifest::IsComponentLocation(extension->location()) || + Manifest::IsPolicyLocation(extension->location()) || + Manifest::IsUnpackedLocation(extension->location()); - // If there was no privilege increase, the extension might still have new - // permissions (which either don't generate a warning message, or whose - // warning messages are suppressed by existing permissions). Grant the new - // permissions. - if (!is_privilege_increase) { + // Verify privilege increases for non-trusted and non-auto-granted extensions. + if (!is_trusted_location && !auto_grant_permission) { + bool is_external_install = + Manifest::IsExternalLocation(extension->location()); + if (!is_extension_loaded && is_external_install) { + // Grant initial permissions to establish a baseline for update checks. + // TODO(crbug.com/435980394): Grant for this extension type on install. PermissionsUpdater(profile_).GrantActivePermissions(extension); + } else { + // Add all the recognized permissions if the granted permissions list + // hasn't been initialized yet. Compare requested permissions against the + // existing granted set to detect a privilege increase. + std::unique_ptr<const PermissionSet> granted_permissions = + extension_prefs_->GetGrantedPermissions(extension->id()); + CHECK(granted_permissions.get()); + std::unique_ptr<const PermissionSet> runtime_granted_permissions = + extension_prefs_->GetRuntimeGrantedPermissions(extension->id()); + std::unique_ptr<const PermissionSet> total_permissions = + PermissionSet::CreateUnion(*granted_permissions, + *runtime_granted_permissions); + + // Check if an extension's privileges have increased in a manner that + // requires the user's approval. This could occur because the browser + // upgraded and recognized additional privileges, or an extension upgrades + // to a version that requires additional privileges. + is_privilege_increase = + PermissionMessageProvider::Get()->IsPrivilegeIncrease( + *total_permissions, + extension->permissions_data()->active_permissions(), + extension->GetType()); + + // If there was no privilege increase, the extension might still have new + // permissions (which either don't generate a warning message, or whose + // warning messages are suppressed by existing permissions). Grant the new + // permissions. + if (!is_privilege_increase) { + PermissionsUpdater(profile_).GrantActivePermissions(extension); + } } } diff --git a/chrome/browser/extensions/external_extension_install_browsertest.cc b/chrome/browser/extensions/external_extension_install_browsertest.cc new file mode 100644 index 0000000..23ff933 --- /dev/null +++ b/chrome/browser/extensions/external_extension_install_browsertest.cc @@ -0,0 +1,142 @@ +// Copyright 2026 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/files/file_util.h" +#include "base/one_shot_event.h" +#include "base/strings/stringprintf.h" +#include "base/test/bind.h" +#include "base/threading/thread_restrictions.h" +#include "chrome/browser/extensions/extension_browsertest.h" +#include "chrome/browser/extensions/extension_management_internal.h" +#include "chrome/browser/extensions/external_provider_manager.h" +#include "chrome/browser/extensions/mv2_experiment_stage.h" +#include "chrome/browser/profiles/profile.h" +#include "components/crx_file/id_util.h" +#include "content/public/test/browser_test.h" +#include "extensions/browser/extension_prefs.h" +#include "extensions/browser/extension_registrar.h" +#include "extensions/browser/extension_registry.h" +#include "extensions/browser/extension_system.h" +#include "extensions/browser/mock_external_provider.h" +#include "extensions/browser/pref_names.h" +#include "extensions/browser/test_extension_registry_observer.h" +#include "extensions/browser/unpacked_installer.h" +#include "extensions/common/extension.h" +#include "extensions/common/extension_features.h" +#include "extensions/common/feature_switch.h" +#include "extensions/common/mojom/manifest.mojom.h" +#include "extensions/test/test_extension_dir.h" +#include "testing/gmock/include/gmock/gmock.h" + +namespace extensions { + +class ExternalExtensionInstallBrowserTest : public ExtensionBrowserTest { + public: + ExternalExtensionInstallBrowserTest() = default; + ~ExternalExtensionInstallBrowserTest() override = default; + + // Reads private key from `private_key_path` and generates extension id using + // it. + std::string GetExtensionIdFromPrivateKeyFile( + const base::FilePath& private_key_path) { + base::ScopedAllowBlockingForTesting allow_file_io_in_scope; + std::string private_key_contents; + EXPECT_TRUE( + base::ReadFileToString(private_key_path, &private_key_contents)); + std::string private_key_bytes; + EXPECT_TRUE( + Extension::ParsePEMKeyBytes(private_key_contents, &private_key_bytes)); + auto signing_key = crypto::keypair::PrivateKey::FromPrivateKeyInfo( + base::as_byte_span(private_key_bytes)); + std::vector<uint8_t> public_key = signing_key->ToSubjectPublicKeyInfo(); + return crx_file::id_util::GenerateId(public_key); + } +}; + +// Verify that an externally installed extension is disabled on update if the +// permissions have increased. +IN_PROC_BROWSER_TEST_F(ExternalExtensionInstallBrowserTest, + AddPermissionOnUpdate) { + // Setup. + TestExtensionDir test_dir; + std::string manifest; + + // Write manifest. + manifest = R"({ + "name": "Test", + "version": "1.0", + "manifest_version": 3 + })"; + test_dir.WriteManifest(manifest); + + // Pack the v1 extension. This generates the CRX and a PEM (private key) file.
Regression Test / PoC
diff --git a/chrome/browser/extensions/external_extension_install_browsertest.cc b/chrome/browser/extensions/external_extension_install_browsertest.cc
new file mode 100644
index 0000000..23ff933
--- /dev/null
+++ b/chrome/browser/extensions/external_extension_install_browsertest.cc
@@ -0,0 +1,142 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "base/files/file_util.h"
+#include "base/one_shot_event.h"
+#include "base/strings/stringprintf.h"
+#include "base/test/bind.h"
+#include "base/threading/thread_restrictions.h"
+#include "chrome/browser/extensions/extension_browsertest.h"
+#include "chrome/browser/extensions/extension_management_internal.h"
+#include "chrome/browser/extensions/external_provider_manager.h"
+#include "chrome/browser/extensions/mv2_experiment_stage.h"
+#include "chrome/browser/profiles/profile.h"
+#include "components/crx_file/id_util.h"
+#include "content/public/test/browser_test.h"
+#include "extensions/browser/extension_prefs.h"
+#include "extensions/browser/extension_registrar.h"
+#include "extensions/browser/extension_registry.h"
+#include "extensions/browser/extension_system.h"
+#include "extensions/browser/mock_external_provider.h"
+#include "extensions/browser/pref_names.h"
+#include "extensions/browser/test_extension_registry_observer.h"
+#include "extensions/browser/unpacked_installer.h"
+#include "extensions/common/extension.h"
+#include "extensions/common/extension_features.h"
+#include "extensions/common/feature_switch.h"
+#include "extensions/common/mojom/manifest.mojom.h"
+#include "extensions/test/test_extension_dir.h"
+#include "testing/gmock/include/gmock/gmock.h"
+
+namespace extensions {
+
+class ExternalExtensionInstallBrowserTest : public ExtensionBrowserTest {
+ public:
+ ExternalExtensionInstallBrowserTest() = default;
+ ~ExternalExtensionInstallBrowserTest() override = default;
+
+ // Reads private key from `private_key_path` and generates extension id using
+ // it.
+ std::string GetExtensionIdFromPrivateKeyFile(
+ const base::FilePath& private_key_path) {
+ base::ScopedAllowBlockingForTesting allow_file_io_in_scope;
+ std::string private_key_contents;
+ EXPECT_TRUE(
+ base::ReadFileToString(private_key_path, &private_key_contents));
+ std::string private_key_bytes;
+ EXPECT_TRUE(
+ Extension::ParsePEMKeyBytes(private_key_contents, &private_key_bytes));
+ auto signing_key = crypto::keypair::PrivateKey::FromPrivateKeyInfo(
+ base::as_byte_span(private_key_bytes));
+ std::vector<uint8_t> public_key = signing_key->ToSubjectPublicKeyInfo();
+ return crx_file::id_util::GenerateId(public_key);
+ }
+};
+
+// Verify that an externally installed extension is disabled on update if the
+// permissions have increased.
+IN_PROC_BROWSER_TEST_F(ExternalExtensionInstallBrowserTest,
+ AddPermissionOnUpdate) {
+ // Setup.
+ TestExtensionDir test_dir;
+ std::string manifest;
+
+ // Write manifest.
+ manifest = R"({
+ "name": "Test",
+ "version": "1.0",
+ "manifest_version": 3
+ })";
+ test_dir.WriteManifest(manifest);
+
+ // Pack the v1 extension. This generates the CRX and a PEM (private key) file.
+ base::FilePath v1_crx_path = PackExtension(test_dir.UnpackedPath());
+ ASSERT_FALSE(v1_crx_path.empty());
+
+ // Locate the generated pem file (temp.pem) in the same directory.
+ base::FilePath pem_path = v1_crx_path.DirName().AppendASCII("temp.pem");
+ {
+ base::ScopedAllowBlockingForTesting allow_file_io_in_scope;
+ ASSERT_TRUE(base::PathExists(pem_path));
+ }
+
+ // Read the actual RSA key content, ensuring a perfect match.
+ const std::string extension_id = GetExtensionIdFromPrivateKeyFile(pem_path);
+
+ // Instantiate the necessary providers for an external extension installation.
+ ExternalProviderManager* external_provider_manager =
+ ExternalProviderManager::Get(profile());
+ TestExtensionRegistryObserver observer(extension_registry());
+ auto provider = std::make_unique<MockExternalProvider>(
+ external_provider_manager, mojom::ManifestLocation::kExternalPref);
+
+ // Store the raw pointer before moving the unique_ptr, for later reuse.
+ MockExternalProvider* provider_ptr = provider.get();
+
+ // Install the external extension.
+ provider->UpdateOrAddExtension(extension_id, "1.0", v1_crx_path);
+ external_provider_manager->AddProviderForTesting(std::move(provider));
+ external_provider_manager->CheckForExternalUpdates();
+
+ // Verify that the extension is installed.
+ auto extension = observer.WaitForExtensionInstalled();
+ EXPECT_EQ(extension->id(), extension_id);
+
+ // Verify that the extension is enabled.
+ ASSERT_TRUE(extension_registrar()->IsExtensionEnabled(extension_id));
+
+ // Update the extension by increasing the permission and bumping the version.
+ manifest = R"({
+ "name": "Test",
+ "version": "2.0",
+ "manifest_version": 3,
+ "permissions": ["tabs"]
+ })";
+ test_dir.WriteManifest(manifest);
+
+ // Define a new path for the V2 CRX to avoid overwriting V1 while it might be
+ // in use.
+ base::FilePath v2_crx_path = v1_crx_path.DirName().AppendASCII("v2.crx");
+
+ // Keep the same extension id by sharing the pem between versions.
+ PackExtensionWithOptions(test_dir.UnpackedPath(), v2_crx_path, pem_path,
+ /*pem_out_path=*/base::FilePath(),
+ extensions::ExtensionCreator::kOverwriteCRX);
+
+ // Install the updated extension.
+ provider_ptr->UpdateOrAddExtension(extension_id, "2.0", v2_crx_path);
+ ExternalProviderManager::Get(profile())->CheckForExternalUpdates();
+
+ // Verify the extension version bump.
+ extension = observer.WaitForExtensionInstalled();
+ EXPECT_EQ(extension->version().GetString(), "2.0");
+
+ // Verify that the permission increase does not enable the updated extension.
+ ASSERT_FALSE(extension_registrar()->IsExtensionEnabled(extension_id));
+ EXPECT_THAT(
+ ExtensionPrefs::Get(profile())->GetDisableReasons(extension_id),
+ testing::ElementsAre(disable_reason::DISABLE_PERMISSIONS_INCREASE));
+}
+
+} // namespace extensions
diff --git a/chrome/test/BUILD.gn b/chrome/test/BUILD.gn
index 496df0fd..d586627ce 100644
--- a/chrome/test/BUILD.gn
+++ b/chrome/test/BUILD.gn
@@ -4795,6 +4795,7 @@
"../browser/extensions/extension_websocket_apitest.cc",
"../browser/extensions/extension_webui_apitest.cc",
"../browser/extensions/extensions_disabled_browsertest.cc",
+ "../browser/extensions/external_extension_install_browsertest.cc",
"../browser/extensions/external_install_error_browsertest.cc",
"../browser/extensions/feature_provider_browsertest.cc",
"../browser/extensions/fetch_apitest.cc",
Original Bug Report
Third party installed extensions can silently increase permissions on update
Vulnerability Details
Extensions on Chrome are very powerful. They can control many aspects of the browser including (but not limited to) accessing user data, downloading files on user’s machine, modifying the webpages visited by the user. Whenever a user installs an extension from Chrome Web Store, they see a list of permissions which the extension asks for. The extension can only be installed if the user grants those permissions.
When a new version of the extension asks for more permissions than what the user originally accepted, Chrome disables the extension and notifies the user. The updated permissions are shown to the user and the extension is only enabled when the user accepts the new permissions.
In addition to the user themselves installing an extension from Chrome Web Store, third party apps can also install extensions on Chrome. This is done by adding a registry entry on Windows or via a preferences JSON file on macOS. Extensions added by this mechanism are disabled initially. User must accept their permissions to enable them. However, once they are enabled, Chrome doesn’t disable them if a new extension update increases its permissions. The new update can have significantly different permissions than what the user allowed. The permissions are silently granted and the user doesn’t even know that the extension can do more things now (possibly malicious).
Version
Chrome Version: 138.0.7204.184 - Stable.
Operating System: Tested on macOS, but should repro on all platforms.
Reproduction Case
- Publish an extension on Chrome Web Store.
- Install it via a preferences file on macOS.
- Extension will be disabled initially. Enable it by accepting the permissions.
- Publish an update to the extension on Chrome Web Store. The update should have more permissions.
- Restart Chrome and wait for extension updates to finish.
Observe that the extension is still enabled but has increased permissions.
Since this reproduction is difficult (because it requires publishing to store), I am attaching videos of the repro case.
- The first video shows the case where the extension is installed normally. It gets disabled on update due to permissions increase.
- The second video shows the same extension installed via preferences file (macOS). It can be observed that it silently increases permissions on update and remains enabled.
Code references
- Permissions increase check is only done for extensions with
kInternalmanifest location. - This wasn’t always the case. When the check was originally added, it also checked for kExternal manifest locations. A later commit changed to code to just check for kInternal location, probably unintentionally.
Original commit (see ExtensionsService::OnExtensionsLoaded method): https://codereview.chromium.org/165414/diff/3007/chrome/browser/extensions/extensions_service.cc
The commit which probably introduced the regression (see ExtensionsService::OnExtensionsLoaded method): https://codereview.chromium.org/4687005/diff/73001/chrome/browser/extensions/extensions_service.cc
Potential fix
ChromeExtensionRegistrarDelegate::CheckPermissionsIncrease only checks for kInternal manifest location. It should also check for external manifest locations (but still exclude policy installs and component installs).
if (extension->location() == ManifestLocation::kInternal &&
!auto_grant_permission) {
// ...
The above code should be changed to something like this
bool is_trusted_location = Manifest::IsComponentLocation(extension->location()) ||
Manifest::IsPolicyLocation(extension->location())
Manifest::IsUnpackedLocation(extension->location());
if (!is_trusted_location && !auto_grant_permission) {
// ...
- https://codereview.chromium.org/165414/diff/3007/chrome/browser/extensions/extensions_service.cc
- https://codereview.chromium.org/4687005/diff/73001/chrome/browser/extensions/extensions_service.cc
- https://developer.chrome.com/docs/extensions/how-to/distribute/install-extensions
- https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/extensions/chrome_extension_registrar_delegate.cc;l=517;drc=877c4eff05eee91a64ab498b2f02928fe718278b