Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in WebAppInstalls
DescriptionInappropriate implementation in WebAppInstalls
ComponentWebAppInstalls
Bug ClassLogic Error
Tracker517487028
Fix commit00e32e52f56a (chromium/src) +960/-660
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-29

Changed Functions

FunctionChangeNotes
source_set
chrome/browser/web_applications/BUILD.gn
modified
BindOnce
chrome/browser/web_applications/commands/add_validated_origin_associations_command.cc
modified
app_id_
chrome/browser/web_applications/commands/add_validated_origin_associations_command.cc
modified
if
chrome/browser/web_applications/commands/add_validated_origin_associations_command.cc
modified

Files Changed

  • chrome/browser/web_applications/BUILD.gn
  • chrome/browser/web_applications/commands/add_validated_origin_associations_command.cc
From 00e32e52f56aeb0d33e577577207956e43068a15 Mon Sep 17 00:00:00 2001
From: Dibyajyoti Pal <[email protected]>
Date: Wed, 03 Jun 2026 15:22:25 -0700
Subject: [PATCH] [PWA] Revalidate PWA association metadata on launch to detect revocation

Once a cross-origin scope extension (or migration information) is validated at install time, it was permanently retained by regular PWAs even if the target domain deleted its association file to revoke consent.

This occurred because:
1. Silent manifest update checks early-exit
   without performing validation if the downloaded manifest is byte
   identical.
2. Daily throttled origin association revalidation on launch was
   restricted solely to Isolated Web Apps (IWAs).

This CL fixes the issue by extending launch-time revalidation to regular PWAs that declare scope extensions or migration sources. To make things clearer, this CL also:

1. Renames the command (and corresponding metrics and tests) to
   accurately portray what it's doing (updating instead of adding
   association data).
2. Ensures that the command doesn't run when Chrome is offline, to
   ensure associated origins are not removed if the user is offline
   for some reason.

OBSOLETE_HISTOGRAM[WebApp.AddValidatedOriginAssociations]=Replaced by WebApp.ValidatedOriginAssociations.Updated.

Bug: 517487028
Include-Ci-Only-Tests: chromium.mac:mac15-x64-rel-tests|browser_tests
Change-Id: I7a8e9efb4a512b9b9216bd4c289c3eeab42c1040
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7881954
Reviewed-by: Marijn Kruisselbrink <[email protected]>
Reviewed-by: Kunjan Patel <[email protected]>
Commit-Queue: Dibyajyoti Pal <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1641253}
---

diff --git a/chrome/browser/web_applications/BUILD.gn b/chrome/browser/web_applications/BUILD.gn
index 7ebf7c4e..6d17f77 100644
--- a/chrome/browser/web_applications/BUILD.gn
+++ b/chrome/browser/web_applications/BUILD.gn
@@ -14,8 +14,6 @@
 source_set("web_applications") {
   sources = [
     "callback_utils.h",
-    "commands/add_validated_origin_associations_command.cc",
-    "commands/add_validated_origin_associations_command.h",
     "commands/app_migration_data_read_command.cc",
     "commands/app_migration_data_read_command.h",
     "commands/app_update_data_read_command.cc",
@@ -83,6 +81,8 @@
     "commands/update_file_handler_command.h",
     "commands/update_protocol_handler_approval_command.cc",
     "commands/update_protocol_handler_approval_command.h",
+    "commands/update_validated_origin_associations_command.cc",
+    "commands/update_validated_origin_associations_command.h",
     "commands/web_app_command.h",
     "commands/web_app_icon_diagnostic_command.cc",
     "commands/web_app_icon_diagnostic_command.h",
@@ -325,7 +325,6 @@
     "preinstalled_web_apps/preinstalled_web_app_definition_utils.h",
     "preinstalled_web_apps/preinstalled_web_apps.cc",
     "preinstalled_web_apps/preinstalled_web_apps.h",
-    "scheduler/add_validated_origin_associations_result.h",
     "scheduler/apply_manifest_migration_result.cc",
     "scheduler/apply_manifest_migration_result.h",
     "scheduler/apply_pending_manifest_update_result.cc",
@@ -348,6 +347,7 @@
     "scheduler/navigate_and_trigger_install_dialog_result.h",
     "scheduler/rewrite_diy_icons_result.cc",
     "scheduler/rewrite_diy_icons_result.h",
+    "scheduler/update_validated_origin_associations_result.h",
     "scheduler/web_app_install_from_migrate_from_field_result.cc",
     "scheduler/web_app_install_from_migrate_from_field_result.h",
     "scope_extension_info.cc",
@@ -979,7 +979,6 @@
   testonly = true
 
   sources = [
-    "commands/add_validated_origin_associations_command_unittest.cc",
     "commands/app_migration_data_read_command_unittest.cc",
     "commands/app_update_data_read_command_unittest.cc",
     "commands/apply_manifest_migration_command_unittest.cc",
@@ -1003,6 +1002,7 @@
     "commands/set_user_display_mode_command_unittest.cc",
     "commands/uninstall_all_user_installed_web_apps_command_unittest.cc",
     "commands/update_file_handler_command_unittest.cc",
+    "commands/update_validated_origin_associations_command_unittest.cc",
     "commands/web_app_install_from_migrate_from_field_command_unittest.cc",
     "commands/web_app_uninstall_command_unittest.cc",
     "daily_metrics_helper_unittest.cc",
@@ -1174,6 +1174,7 @@
     "//content/public/browser",
     "//content/public/common",
     "//content/test:test_support",
+    "//net:test_support",
     "//services/metrics/public/cpp:ukm_builders",
     "//services/preferences/public/cpp",
     "//skia",
diff --git a/chrome/browser/web_applications/commands/add_validated_origin_associations_command.cc b/chrome/browser/web_applications/commands/add_validated_origin_associations_command.cc
deleted file mode 100644
index 1f65f4b..0000000
--- a/chrome/browser/web_applications/commands/add_validated_origin_associations_command.cc
+++ /dev/null
@@ -1,171 +0,0 @@
-// 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 "chrome/browser/web_applications/commands/add_validated_origin_associations_command.h"
-
-#include <algorithm>
-#include <ranges>
-#include <vector>
-
-#include "base/check_deref.h"
-#include "base/containers/flat_set.h"
-#include "base/functional/bind.h"
-#include "base/functional/callback_forward.h"
-#include "base/metrics/histogram_functions.h"
-#include "base/rand_util.h"
-#include "base/stl_util.h"
-#include "base/time/clock.h"
-#include "base/time/time.h"
-#include "chrome/browser/web_applications/commands/command_result.h"
-#include "chrome/browser/web_applications/model/migration_source.h"
-#include "chrome/browser/web_applications/scheduler/add_validated_origin_associations_result.h"
-#include "chrome/browser/web_applications/scope_extension_info.h"
-#include "chrome/browser/web_applications/web_app.h"
-#include "chrome/browser/web_applications/web_app_command_scheduler.h"
-#include "chrome/browser/web_applications/web_app_constants.h"
-#include "chrome/browser/web_applications/web_app_filter.h"
-#include "chrome/browser/web_applications/web_app_origin_association_manager.h"
-#include "chrome/browser/web_applications/web_app_registrar.h"
-#include "chrome/browser/web_applications/web_app_registry_update.h"
-#include "chrome/browser/web_applications/web_app_sync_bridge.h"
-#include "components/sync/base/time.h"
-#include "third_party/blink/public/common/features.h"
-
-namespace web_app {
-
-AddValidatedOriginAssociationsCommand::AddValidatedOriginAssociationsCommand(
-    const webapps::AppId& app_id,
-    base::OnceCallback<void(AddValidatedOriginAssociationsResult)> callback)
-    : WebAppCommand<AppLock, AddValidatedOriginAssociationsResult>(
-          "WebApp.AddValidatedOriginAssociations",
-          AppLockDescription(app_id),
-          base::BindOnce([](AddValidatedOriginAssociationsResult result) {
-            base::UmaHistogramEnumeration(
-                "WebApp.AddValidatedOriginAssociations", result);
-            return result;
-          }).Then(std::move(callback)),
-          /*args_for_shutdown=*/
-          AddValidatedOriginAssociationsResult::kShutdown),
-      app_id_(app_id) {}
-
-AddValidatedOriginAssociationsCommand::
-    ~AddValidatedOriginAssociationsCommand() = default;
-
-void AddValidatedOriginAssociationsCommand::StartWithLock(
-    std::unique_ptr<AppLock> lock) {
-  lock_ = std::move(lock);
-
-  const WebAppRegistrar& registrar = lock_->registrar();
-  const WebApp* app =
-      registrar.GetAppById(app_id_, WebAppFilter::InstalledInChrome());
-
-  if (!app) {
-    CompleteAndSelfDestruct(
-        CommandResult::kSuccess,
-        AddValidatedOriginAssociationsResult::kWebAppNotInstalled);
-    return;
-  }
-
-  // If the last validation time isn't set, randomize it in the past to ensure
-  // no network fetch spikes.
-  if (!app->origin_association_last_validation_check_time().has_value()) {
-    ScopedRegistryUpdate update = lock_->sync_bridge().BeginUpdate();
-    WebApp& app_to_update = CHECK_DEREF(update->UpdateApp(app_id_));
-    base::TimeDelta delta =
-        base::Seconds(base::RandIntInclusive(0, base::Days(1).InSeconds()));
-    app_to_update.SetOriginAssociationLastValidationCheckTime(
-        lock_->clock().Now() + delta + base::Days(1));
-    CompleteAndSelfDestruct(CommandResult::kSuccess,
-                            AddValidatedOriginAssociationsResult::kThrottled);
-    return;
-  }
-
-  if (*app->origin_association_last_validation_check_time() + base::Days(1) >
-      lock_->clock().Now()) {
-    CompleteAndSelfDestruct(CommandResult::kSuccess,
-                            AddValidatedOriginAssociationsResult::kThrottled);
-    return;
-  }
-
-  OriginAssociations origin_associations;
-  origin_associations.scope_extensions = app->scope_extensions();
-  origin_associations.migration_sources = app->unvalidated_migration_sources();
-
-  lock_->origin_association_manager().GetWebAppOriginAssociations(
-      app->manifest_id().value(), std::move(origin_associations),
-      base::BindOnce(
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/web_applications/commands/add_validated_origin_associations_command_unittest.cc b/chrome/browser/web_applications/commands/add_validated_origin_associations_command_unittest.cc
deleted file mode 100644
index 5debe34..0000000
--- a/chrome/browser/web_applications/commands/add_validated_origin_associations_command_unittest.cc
+++ /dev/null
@@ -1,535 +0,0 @@
-// 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 "chrome/browser/web_applications/commands/add_validated_origin_associations_command.h"
-
-#include <memory>
-
-#include "base/test/bind.h"
-#include "base/test/metrics/histogram_tester.h"
-#include "base/test/scoped_feature_list.h"
-#include "base/test/simple_test_clock.h"
-#include "base/test/test_future.h"
-#include "base/time/clock.h"
-#include "base/time/time.h"
-#include "chrome/browser/web_applications/scheduler/add_validated_origin_associations_result.h"
-#include "chrome/browser/web_applications/test/fake_web_app_origin_association_manager.h"
-#include "chrome/browser/web_applications/test/fake_web_app_provider.h"
-#include "chrome/browser/web_applications/test/web_app_install_test_utils.h"
-#include "chrome/browser/web_applications/test/web_app_test.h"
-#include "chrome/browser/web_applications/test/web_app_test_utils.h"
-#include "chrome/browser/web_applications/web_app_command_manager.h"
-#include "chrome/browser/web_applications/web_app_command_scheduler.h"
-#include "chrome/browser/web_applications/web_app_provider.h"
-#include "chrome/browser/web_applications/web_app_registrar.h"
-#include "chrome/browser/web_applications/web_app_registrar_observer.h"
-#include "chrome/browser/web_applications/web_app_registry_update.h"
-#include "chrome/browser/web_applications/web_app_sync_bridge.h"
-#include "components/webapps/common/web_app_id.h"
-#include "testing/gmock/include/gmock/gmock.h"
-#include "testing/gtest/include/gtest/gtest.h"
-#include "third_party/blink/public/common/features.h"
-
-namespace web_app {
-
-namespace {
-
-using testing::_;
-
-class MockWebAppCommandScheduler : public WebAppCommandScheduler {
- public:
-  using WebAppCommandScheduler::WebAppCommandScheduler;
-  MOCK_METHOD(void,
-              ScheduleResolveWebAppPendingMigrationInfo,
-              (base::OnceClosure callback, const base::Location& location),
-              (override));
-};
-
-class MockWebAppRegistrarObserver : public WebAppRegistrarObserver {
- public:
-  MOCK_METHOD(void, OnAppRegistrarDestroyed, (), (override));
-  MOCK_METHOD(void,
-              OnWebAppEffectiveScopeChanged,
-              (const webapps::AppId& app_id, const WebAppScope& new_scope),
-              (override));
-};
-
-}  // namespace
-
-class AddValidatedOriginAssociationsCommandTest : public WebAppTest {
- public:
-  AddValidatedOriginAssociationsCommandTest() = default;
-  ~AddValidatedOriginAssociationsCommandTest() override = default;
-
-  void SetUp() override {
-    WebAppTest::SetUp();
-
-    auto origin_association_manager =
-        std::make_unique<FakeWebAppOriginAssociationManager>();
-    fake_origin_association_manager_ = origin_association_manager.get();
-    fake_provider().SetOriginAssociationManager(
-        std::move(origin_association_manager));
-
-    auto scheduler =
-        std::make_unique<testing::NiceMock<MockWebAppCommandScheduler>>(
-            *profile());
-    mock_scheduler_ = scheduler.get();
-    fake_provider().SetScheduler(std::move(scheduler));
-
-    clock_ = std::make_unique<base::SimpleTestClock>();
-    clock_->SetNow(base::Time::Now());
-
-    fake_provider().SetClockForTesting(clock_.get());
-
-    test::AwaitStartWebAppProviderAndSubsystems(profile());
-  }
-
-  void TearDown() override {
-    fake_origin_association_manager_ = nullptr;
-    mock_scheduler_ = nullptr;
-    WebAppTest::TearDown();
-  }
-
-  webapps::AppId InstallApp(const GURL& start_url,
-                            const ScopeExtensions& scope_extensions) {
-    auto info = WebAppInstallInfo::CreateWithStartUrlForTesting(start_url);
-    info->title = u"Test App";
-    info->scope_extensions = scope_extensions;
-    return test::InstallWebApp(profile(), std::move(info));
-  }
-
-  FakeWebAppOriginAssociationManager* fake_origin_association_manager() {
-    return fake_origin_association_manager_;
-  }
-
-  base::SimpleTestClock& clock() { return *clock_.get(); }
-
-  MockWebAppCommandScheduler& mock_scheduler() { return *mock_scheduler_; }
-
- private:
-  raw_ptr<FakeWebAppOriginAssociationManager> fake_origin_association_manager_ =
-      nullptr;
-  raw_ptr<MockWebAppCommandScheduler> mock_scheduler_ = nullptr;
-  std::unique_ptr<base::SimpleTestClock> clock_;
-};
-
-TEST_F(AddValidatedOriginAssociationsCommandTest, Success) {
-  GURL start_url("https://example.com/");
-  ScopeExtensionInfo extension = ScopeExtensionInfo::CreateForScope(
-      GURL("https://example.org/scope"), /*has_origin_wildcard=*/false);
-
-  // Install will not validate scope extensions.
-  fake_origin_association_manager()->set_pass_through(false);
-  webapps::AppId app_id = InstallApp(start_url, {extension});
-  clock().Advance(base::Days(1));
-
-  base::HistogramTester tester;
-  fake_origin_association_manager()->set_pass_through(true);
-
-  MockWebAppRegistrarObserver observer;
-  base::ScopedObservation<WebAppRegistrar, WebAppRegistrarObserver> observation(
-      &observer);
-  observation.Observe(&provider().registrar_unsafe());
-
-  EXPECT_CALL(observer, OnWebAppEffectiveScopeChanged(app_id, _));
-
-  base::test::TestFuture<AddValidatedOriginAssociationsResult> future;
-
-  provider().scheduler().ScheduleAddValidatedOriginAssociations(
-      app_id, future.GetCallback());
-  ASSERT_EQ(AddValidatedOriginAssociationsResult::kSuccess, future.Get());
-
-  tester.ExpectUniqueSample("WebApp.AddValidatedOriginAssociations",
-                            AddValidatedOriginAssociationsResult::kSuccess, 1);
-
-  const WebApp* app = provider().registrar_unsafe().GetAppById(app_id);
-  EXPECT_FALSE(app->validated_scope_extensions().empty());
-  EXPECT_EQ(extension, *app->validated_scope_extensions().begin());
-  EXPECT_TRUE(app->origin_association_last_validation_check_time().has_value());
-}
-
-TEST_F(AddValidatedOriginAssociationsCommandTest, UnvalidatedItemsRemain) {
-  GURL start_url("https://example.com/");
-
-  // Fake manager defaults to returning empty associations, which means failure
-  // if we have unvalidated extensions.
-  fake_origin_association_manager()->set_pass_through(false);
-
-  ScopeExtensionInfo extension = ScopeExtensionInfo::CreateForScope(
-      GURL("https://example.org/scope"), /*has_origin_wildcard=*/false);
-
-  webapps::AppId app_id = InstallApp(start_url, {extension});
-  {
-    const WebApp* app = provider().registrar_unsafe().GetAppById(app_id);
-    EXPECT_TRUE(app->validated_scope_extensions().empty());
-    EXPECT_TRUE(
-        app->origin_association_last_validation_check_time().has_value());
-  }
-
-  base::HistogramTester tester;
-  clock().Advance(base::Days(1));
-  base::test::TestFuture<AddValidatedOriginAssociationsResult> future;
-  provider().scheduler().ScheduleAddValidatedOriginAssociations(
-      app_id, future.GetCallback());
-  ASSERT_EQ(AddValidatedOriginAssociationsResult::kUnvalidatedItemsRemain,
-            future.Get());
-
-  tester.ExpectUniqueSample(
-      "WebApp.AddValidatedOriginAssociations",
-      AddValidatedOriginAssociationsResult::kUnvalidatedItemsRemain, 1);
-
-  const WebApp* app = provider().registrar_unsafe().GetAppById(app_id);
-  EXPECT_TRUE(app->validated_scope_extensions().empty());
-  EXPECT_TRUE(app->origin_association_last_validation_check_time().has_value());
-}
-
-TEST_F(AddValidatedOriginAssociationsCommandTest, ThrottledAfterInstall) {
-  GURL start_url("https://example.com/");
-  ScopeExtensionInfo extension = ScopeExtensionInfo::CreateForScope(
-      GURL("https://example.org/scope"), /*has_origin_wildcard=*/false);
-
-  webapps::AppId app_id = InstallApp(start_url, {extension});
-
-  // Fake manager defaults to returning empty associations, which means failure
-  // if we have unvalidated extensions.
-  fake_origin_association_manager()->set_pass_through(false);
-
-  base::HistogramTester tester;
-  base::test::TestFuture<AddValidatedOriginAssociationsResult> future;
-  provider().scheduler().ScheduleAddValidatedOriginAssociations(
-      app_id, future.GetCallback());
-  ASSERT_EQ(AddValidatedOriginAssociationsResult::kThrottled, future.Get());
-
-  tester.ExpectUniqueSample("WebApp.AddValidatedOriginAssociations",
-                            AddValidatedOriginAssociationsResult::kThrottled,
-                            1);
-
-  const WebApp* app = provider().registrar_unsafe().GetAppById(app_id);
-  EXPECT_TRUE(app->validated_scope_extensions().empty());
-  EXPECT_TRUE(app->origin_association_last_validation_check_time().has_value());
-}
-
-TEST_F(AddValidatedOriginAssociationsCommandTest, ThrottledAfterRevalidate) {
-  GURL start_url("https://example.com/");
-  ScopeExtensionInfo extension = ScopeExtensionInfo::CreateForScope(
-      GURL("https://example.org/scope"), /*has_origin_wildcard=*/false);
-
-  fake_origin_association_manager()->set_pass_through(false);
-
-  webapps::AppId app_id = InstallApp(start_url, {extension});
-  clock().Advance(base::Days(1));
-
-  base::HistogramTester tester;
-  base::test::TestFuture<AddValidatedOriginAssociationsResult> future;
-  provider().scheduler().ScheduleAddValidatedOriginAssociations(
-      app_id, future.GetCallback());
-  ASSERT_EQ(AddValidatedOriginAssociationsResult::kUnvalidatedItemsRemain,
-            future.Get());
-  tester.ExpectUniqueSample(
-      "WebApp.AddValidatedOriginAssociations",
-      AddValidatedOriginAssociationsResult::kUnvalidatedItemsRemain, 1);
-
-  base::HistogramTester tester2;
-  base::test::TestFuture<AddValidatedOriginAssociationsResult> future2;
-  provider().scheduler().ScheduleAddValidatedOriginAssociations(
-      app_id, future2.GetCallback());
-  EXPECT_EQ(AddValidatedOriginAssociationsResult::kThrottled, future2.Get());
-  tester2.ExpectUniqueSample("WebApp.AddValidatedOriginAssociations",
-                             AddValidatedOriginAssociationsResult::kThrottled,
-                             1);
-
-  const WebApp* app = provider().registrar_unsafe().GetAppById(app_id);
-  EXPECT_TRUE(app->validated_scope_extensions().empty());
-  EXPECT_TRUE(app->origin_association_last_validation_check_time().has_value());
-}
-
-TEST_F(AddValidatedOriginAssociationsCommandTest, ThrottledWhenNoTimeValue) {
-  GURL start_url("https://example.com/");
-  ScopeExtensionInfo extension = ScopeExtensionInfo::CreateForScope(
-      GURL("https://example.org/scope"), /*has_origin_wildcard=*/false);
-
-  webapps::AppId app_id = InstallApp(start_url, {extension});
-
-  // Clear the time value.
-  {
-    ScopedRegistryUpdate update = provider().sync_bridge_unsafe().BeginUpdate();
-    WebApp* app_to_update = update->UpdateApp(app_id);
-    app_to_update->SetOriginAssociationLastValidationCheckTime(std::nullopt);
-  }
-
-  {
-    const WebApp* app = provider().registrar_unsafe().GetAppById(app_id);
-    EXPECT_FALSE(
-        app->origin_association_last_validation_check_time().has_value());
-  }
-
-  base::HistogramTester tester;
-  base::test::TestFuture<AddValidatedOriginAssociationsResult> future;
-  provider().scheduler().ScheduleAddValidatedOriginAssociations(
-      app_id, future.GetCallback());
-  ASSERT_EQ(AddValidatedOriginAssociationsResult::kThrottled, future.Get());
-
-  tester.ExpectUniqueSample("WebApp.AddValidatedOriginAssociations",
-                            AddValidatedOriginAssociationsResult::kThrottled,
-                            1);
-
-  const WebApp* app = provider().registrar_unsafe().GetAppById(app_id);
-  EXPECT_TRUE(app->origin_association_last_validation_check_time().has_value());
-  // The time should be within the last 24 hours.
-  EXPECT_GE(*app->origin_association_last_validation_check_time(),
-            clock().Now());
-}
-
-TEST_F(AddValidatedOriginAssociationsCommandTest, NotThrottleAfterDay) {
-  GURL start_url("https://example.com/");
-  ScopeExtensionInfo extension = ScopeExtensionInfo::CreateForScope(
-      GURL("https://example.org/scope"), /*has_origin_wildcard=*/false);
-
-  fake_origin_association_manager()->set_pass_through(false);
-  webapps::AppId app_id = InstallApp(start_url, {extension});
-  clock().Advance(base::Days(1));
-
-  base::HistogramTester tester;
-  base::test::TestFuture<AddValidatedOriginAssociationsResult> future;
... (truncated)
Loading diff…

Original Bug Report

reported by [email protected]

Permanent retention of validated PWA scope extensions due to revalidation bypass

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A potential logic vulnerability in Google Chrome’s Web App manifest update flow allows normal user-installed Progressive Web Apps (PWAs) to permanently retain validated scope extensions even after the target domain revokes consent. This occurs because background re-verification is restricted exclusively to Isolated Web Apps, while silent manifest updates early-exit without validation when the manifest is unchanged. Consequently, an attacker could potentially spoof target domains or hijack link intents indefinitely after a transient initial validation.

Affected files:

  • chrome/browser/web_applications/jobs/manifest_update_job.cc
  • chrome/browser/web_applications/commands/launch_web_app_command.cc
  • chrome/browser/web_applications/model/web_app_comparison.cc
  • chrome/browser/web_applications/jobs/finalize_update_job.cc
  • chrome/browser/web_applications/jobs/finalize_install_job.cc

Estimated timestamp from git blame: 2026-03-19

Technical Description

Google Chrome’s scope_extensions feature allows Progressive Web Apps (PWAs) to expand their application boundaries to other origins. To prevent unauthorized spoofing, a handshake is required: the PWA lists the target origin in its manifest, and the target origin must host a validation file at https://<target-domain>/.well-known/web-app-origin-association to opt in. Removing this association file is the designed mechanism for revoking a PWA’s scope extensions.

However, a potential logic flaw exists in Chrome’s background update and revalidation pipelines for regular (non-Isolated) PWAs. Once a cross-origin scope extension is initially validated and persisted, it can be permanently retained by the PWA even if the target origin deletes the association file.

This occurs due to two distinct gates in the web application lifecycle:

  1. IWA-Only Revalidation Trigger: In LaunchWebAppCommand::OnAppLaunched (chrome/browser/web_applications/commands/launch_web_app_command.cc), the daily revalidation task (ScheduleAddValidatedOriginAssociations) is restricted solely to Isolated Web Apps (IWAs):
if (base::FeatureList::IsEnabled(
        blink::features::kWebAppEnableScopeExtensionsForIsolatedWebApps) &&
    container == apps::LaunchContainer::kLaunchContainerWindow &&
    lock_->registrar().AppMatches(
        app_id_,
        WebAppFilter::IsIsolatedApp() | WebAppFilter::IsIsolatedSubApp())) {
  provider_->scheduler().ScheduleAddValidatedOriginAssociations(
      app_id_, base::DoNothing());
}

Regular HTTPS PWAs never trigger this revalidation task on launch.

  1. Manifest Update Early-Exit: During a background manifest update check, if the fetched manifest is identical to the existing one, ManifestUpdateJob short-circuits early (chrome/browser/web_applications/jobs/manifest_update_job.cc):
if (!can_fix_generated_icons) {
  if (web_app_comparison_.ExistingAppWithoutPendingEqualsNewUpdate()) {
    WritePendingUpdateInfoThenComplete(std::nullopt, Result::kNoUpdateNeeded);
    return;
  }
}

ExistingAppWithoutPendingEqualsNewUpdate() utilizes WebAppComparison::CompareWebApps (chrome/browser/web_applications/model/web_app_comparison.cc), which compares the unvalidated scope_extensions declared in the manifest rather than checking if the active validated_scope_extensions need re-verification. Because the manifest is byte-identical, the silent update returns kNoUpdateNeeded and never executes FinalizeUpdateJob::Start (where the actual association file is retrieved and verified).

As a result, there is no automatic code path that re-verifies or revokes the validated scope extensions of regular PWAs, causing them to be retained indefinitely.

Potential Attack Steps

(Note: These are suggested/potential steps based on code analysis as our tooling agent does not have the ability to run code/exploits)

  1. An attacker sets up a PWA on https://attacker.example/ and declares "scope_extensions": [{"type": "origin", "origin": "https://victim.com"}] in its manifest.
  2. The attacker transiently manages to upload or trigger a deployment of a valid association file listing the attacker’s PWA manifest ID at https://victim.com/.well-known/web-app-origin-association (e.g., via a temporary staging misconfiguration or subdomain takeover).
  3. A user visits https://attacker.example/ and installs the PWA. During installation, the browser verifies the association, successfully validates victim.com, and persists it in the local database.
  4. The victim cleans up the misconfiguration and deletes the .well-known association file from https://victim.com/.
  5. When the user subsequently launches the PWA, no revalidation is scheduled. Whenever a silent manifest update is run, it fetches the identical manifest, matches the unvalidated fields, and early-exits without re-verifying the association.
  6. Consequently, the validated scope extension is permanently retained. Navigating to https://victim.com/ inside the attacker PWA window suppresses the address bar/minimal UI, potentially spoofing the victim’s domain identity. On platforms like ChromeOS, links to https://victim.com/* will also be captured and opened by the attacker’s PWA.

Proposed Fix

Ensure that regular PWAs either periodically schedule the AddValidatedOriginAssociationsCommand or check the validation status during silent background manifest updates even if other manifest fields have not changed.

Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker