Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOpen redirect in AppManifest
DescriptionOpen redirect in AppManifest
ComponentAppManifest
Sub-componentWebapps
Bug ClassLogic Error
Tracker517213220
Fix commitb68c9392b069 (chromium/src) +326/-20
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
switch
chrome/browser/web_applications/commands/fetch_manifest_and_update_command.cc
modified
WebAppUrlLoaderNavigationHandleData
components/webapps/browser/web_contents/web_app_url_loader.cc
modified
WebAppUrlLoaderNavigationThrottle
components/webapps/browser/web_contents/web_app_url_loader.cc
modified
if
components/webapps/browser/web_contents/web_app_url_loader.cc
modified
GURL
components/webapps/browser/web_contents/web_app_url_loader.h
modified
NavigationThrottleRegistry
components/webapps/browser/web_contents/web_app_url_loader.h
modified
WebContents
components/webapps/browser/web_contents/web_app_url_loader.h
modified
for
content/browser/manifest/manifest_manager_host.cc
modified
if
content/browser/manifest/manifest_manager_host.cc
modified

Files Changed

  • chrome/browser/webapps/web_contents/web_app_url_loader_browsertest.cc
  • chrome/browser/web_applications/commands/fetch_manifest_and_update_command.cc
  • content/browser/manifest/manifest_browsertest.cc
  • components/webapps/browser/web_contents/web_app_url_loader.cc
  • chrome/browser/web_applications/commands/fetch_manifest_and_update_command_unittest.cc
  • content/browser/manifest/manifest_manager_host.cc
  • components/webapps/browser/web_contents/web_app_url_loader.h
  • chrome/browser/web_applications/commands/install_migrate_to_app_command_browsertest.cc
  • chrome/browser/chrome_content_browser_client_navigation_throttles.cc
From b68c9392b0696880e82fc10c4574208aed1ab800 Mon Sep 17 00:00:00 2001
From: Marijn Kruisselbrink <mek@chromium.org>
Date: Thu, 10 Sep 2026 14:00:02 -0700
Subject: [PATCH] [webapps] Enforce URL matching and block redirects in WebAppUrlLoader

Add a NavigationThrottle for WebAppUrlLoader to validate URL comparison
criteria during the redirect phase of background navigations. This
prevents background loads from navigating to destinations that do not
satisfy the required origin or URL matching constraints.

Navigation constraints are tracked via NavigationHandleUserData attached
during DidStartNavigation, ensuring each navigation attempt is verified
without storing mutable loader state on WebContents.

Additionally, WebAppUrlLoader::LoadUrlInternal sets the initiator origin
from the target URL if not already provided, and
FetchManifestAndUpdateCommand treats redirected URL loads as failures.

Also enforce same-origin between migration id and install_url in
ManifestManagerHost to match parser constraints.

Bug: 517213220
Change-Id: I71af2e54ee510fe64c08129da46b1f143a69ae34
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8383476
Reviewed-by: Adriana Ixba <aixba@chromium.org>
Reviewed-by: Dibyajyoti Pal <dibyapal@chromium.org>
Reviewed-by: Daniel Murphy <dmurph@chromium.org>
Commit-Queue: Marijn Kruisselbrink <mek@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1695649}
---
diff --git a/chrome/browser/chrome_content_browser_client_navigation_throttles.cc b/chrome/browser/chrome_content_browser_client_navigation_throttles.cc
index f99fb171..cba0d32 100644
--- a/chrome/browser/chrome_content_browser_client_navigation_throttles.cc
+++ b/chrome/browser/chrome_content_browser_client_navigation_throttles.cc
@@ -71,6 +71,7 @@
 #include "components/subresource_filter/content/browser/content_subresource_filter_throttle_manager.h"
 #include "components/tabs/public/tab_interface.h"
 #include "components/user_prefs/user_prefs.h"
+#include "components/webapps/browser/web_contents/web_app_url_loader.h"
 #include "content/public/browser/navigation_handle.h"
 #include "content/public/browser/navigation_throttle_registry.h"
 #include "content/public/browser/web_contents.h"
@@ -627,6 +628,8 @@
 
   dom_distiller::DistillerReferrerThrottle::MaybeCreateAndAdd(registry);
 
+  webapps::WebAppUrlLoader::MaybeCreateAndAddNavigationThrottle(registry);
+
   glic::GlicNavigationThrottle::MaybeCreateAndAdd(registry);
   glic::GlicGuestNavigationThrottle::MaybeCreateAndAdd(registry);
 
diff --git a/chrome/browser/web_applications/commands/fetch_manifest_and_update_command.cc b/chrome/browser/web_applications/commands/fetch_manifest_and_update_command.cc
index baaaa8b..197bff8 100644
--- a/chrome/browser/web_applications/commands/fetch_manifest_and_update_command.cc
+++ b/chrome/browser/web_applications/commands/fetch_manifest_and_update_command.cc
@@ -82,8 +82,8 @@
     webapps::WebAppUrlLoaderResult result) {
   switch (result) {
     case webapps::WebAppUrlLoaderResult::kUrlLoaded:
-    case webapps::WebAppUrlLoaderResult::kRedirectedUrlLoaded:
       break;
+    case webapps::WebAppUrlLoaderResult::kRedirectedUrlLoaded:
     case webapps::WebAppUrlLoaderResult::kFailedUnknownReason:
     case webapps::WebAppUrlLoaderResult::kFailedPageTookTooLong:
     case webapps::WebAppUrlLoaderResult::kFailedWebContentsDestroyed:
diff --git a/components/webapps/browser/web_contents/web_app_url_loader.cc b/components/webapps/browser/web_contents/web_app_url_loader.cc
index 2e5f05854..bd11da54 100644
--- a/components/webapps/browser/web_contents/web_app_url_loader.cc
+++ b/components/webapps/browser/web_contents/web_app_url_loader.cc
@@ -11,12 +11,17 @@
 #include "base/functional/bind.h"
 #include "base/functional/callback.h"
 #include "base/logging.h"
+#include "base/memory/raw_ref.h"
 #include "base/memory/weak_ptr.h"
 #include "base/metrics/histogram_functions.h"
 #include "base/task/sequenced_task_runner.h"
 #include "base/timer/timer.h"
 #include "content/public/browser/browser_context.h"
 #include "content/public/browser/navigation_controller.h"
+#include "content/public/browser/navigation_handle.h"
+#include "content/public/browser/navigation_handle_user_data.h"
+#include "content/public/browser/navigation_throttle.h"
+#include "content/public/browser/navigation_throttle_registry.h"
 #include "content/public/browser/render_frame_host.h"
 #include "content/public/browser/web_contents.h"
 #include "content/public/browser/web_contents_observer.h"
@@ -25,6 +30,7 @@
 #include "net/http/http_status_code.h"
 #include "services/network/public/mojom/url_response_head.mojom.h"
 #include "url/gurl.h"
+#include "url/origin.h"
 #include "url/url_constants.h"
 
 namespace webapps {
@@ -54,6 +60,75 @@
   return a.ReplaceComponents(replace) == b.ReplaceComponents(replace);
 }
 
+// User data attached to a NavigationHandle during WebAppUrlLoader operations to
+// enforce URL constraints during navigation and redirect handling.
+class WebAppUrlLoaderNavigationHandleData
+    : public content::NavigationHandleUserData<
+          WebAppUrlLoaderNavigationHandleData> {
+ public:
+  ~WebAppUrlLoaderNavigationHandleData() override = default;
+
+  const GURL& desired_url() const { return desired_url_; }
+  UrlComparison url_comparison() const { return url_comparison_; }
+  bool is_redirect_blocked() const { return is_redirect_blocked_; }
+  void set_redirect_blocked(bool blocked) { is_redirect_blocked_ = blocked; }
+
+ private:
+  friend class content::NavigationHandleUserData<
+      WebAppUrlLoaderNavigationHandleData>;
+  NAVIGATION_HANDLE_USER_DATA_KEY_DECL();
+
+  WebAppUrlLoaderNavigationHandleData(
+      content::NavigationHandle& navigation_handle,
+      const GURL& desired_url,
+      UrlComparison url_comparison)
+      : desired_url_(desired_url), url_comparison_(url_comparison) {}
+
+  const GURL desired_url_;
+  const UrlComparison url_comparison_;
+  bool is_redirect_blocked_ = false;
+};
+
+NAVIGATION_HANDLE_USER_DATA_KEY_IMPL(WebAppUrlLoaderNavigationHandleData);
+
+// Navigation throttle that enforces UrlComparison rules before following
+// redirects, preventing out-of-scope background navigations.
+class WebAppUrlLoaderNavigationThrottle : public content::NavigationThrottle {
+ public:
+  explicit WebAppUrlLoaderNavigationThrottle(
+      content::NavigationThrottleRegistry& registry,
+      WebAppUrlLoaderNavigationHandleData& data)
+      : content::NavigationThrottle(registry), data_(data) {}
+  ~WebAppUrlLoaderNavigationThrottle() override = default;
+
+  ThrottleCheckResult WillStartRequest() override {
+    return WillStartOrRedirectRequest();
+  }
+
+  ThrottleCheckResult WillRedirectRequest() override {
+    return WillStartOrRedirectRequest();
+  }
+
+  const char* GetNameForLogging() override {
+    return "WebAppUrlLoaderNavigationThrottle";
+  }
+
+ private:
+  ThrottleCheckResult WillStartOrRedirectRequest() {
+    const GURL& target_url = navigation_handle()->GetURL();
+    if (!target_url.is_valid() || !data_->desired_url().is_valid() ||
+        !EqualsWithComparison(target_url, data_->desired_url(),
+                              data_->url_comparison())) {
+      data_->set_redirect_blocked(true);
+      return content::NavigationThrottle::CANCEL;
+    }
+
+    return content::NavigationThrottle::PROCEED;
+  }
+
+  const raw_ref<WebAppUrlLoaderNavigationHandleData> data_;
+};
+
 // TODO(b/302531937): Make this a utility that can be used through out the
 // web_applications/ system.
 bool WebContentsShuttingDown(content::WebContents* web_contents) {
@@ -70,7 +145,7 @@
   LoaderTask& operator=(LoaderTask&&) = delete;
   ~LoaderTask() override = default;
 
-  void LoadUrl(const content::NavigationController::LoadURLParams& load_params,
+  void LoadUrl(content::NavigationController::LoadURLParams load_params,
                content::WebContents* web_contents,
                UrlComparison url_comparison,
                WebAppUrlLoader::ResultCallback callback) {
@@ -84,7 +159,7 @@
       return;
     }
 
-    web_contents->GetController().LoadURLWithParams(load_params);
+    web_contents->GetController().LoadURLWithParams(std::move(load_params));
 
     timer_.Start(FROM_HERE, WebAppUrlLoader::kSecondsToWaitForWebContentsLoad,
                  base::BindOnce(&LoaderTask::OnLoadUrlTimeout,
@@ -95,6 +170,41 @@
   }
 
   // WebContentsObserver
+  void DidStartNavigation(
+      content::NavigationHandle* navigation_handle) override {
+    if (WebContentsShuttingDown(web_contents())) {
+      PostResultTask(WebAppUrlLoader::Result::kFailedWebContentsDestroyed);
+      return;
+    }
+
+    if (navigation_handle->IsInPrimaryMainFrame()) {
+      WebAppUrlLoaderNavigationHandleData::CreateForNavigationHandle(
+          *navigation_handle, url_, url_comparison_);
+    }
+  }
+
+  void DidFinishNavigation(
+      content::NavigationHandle* navigation_handle) override {
+    if (WebContentsShuttingDown(web_contents())) {
+      PostResultTask(WebAppUrlLoader::Result::kFailedWebContentsDestroyed);
+      return;
+    }
+
+    if (!navigation_handle->IsInPrimaryMainFrame()) {
+      return;
+    }
+
+    if (!navigation_handle->HasCommitted()) {
+      auto* data = WebAppUrlLoaderNavigationHandleData::GetForNavigationHandle(
+          *navigation_handle);
+      if (data && data->is_redirect_blocked()) {
+        LOG(ERROR) << "Error loading " << url_
+                   << "  page redirected to unexpected destination.";
+        PostResultTask(WebAppUrlLoader::Result::kRedirectedUrlLoaded);
+        return;
+      }
+    }
+  }
   // DidFinishLoad doesn't always get called after the page has fully loaded.
   // TODO(ortuno): Use DidStopLoading instead.
   void DidFinishLoad(content::RenderFrameHost* render_frame_host,
@@ -114,8 +224,6 @@
       return;
     }
 
-    timer_.Stop();
-
     if (validated_url == content::kUnreachableWebDataURL) {
       // Navigation ends up in an error page. For example, network errors and
       // policy blocked URLs.
@@ -163,14 +271,11 @@
       return;
     }
 
-    timer_.Stop();
-
     LOG(ERROR) << "Error loading " << url_ << "  page failed to load.";
     PostResultTask(WebAppUrlLoader::Result::kFailedUnknownReason);
   }
 
   void WebContentsDestroyed() override {
-    timer_.Stop();
     PostResultTask(WebAppUrlLoader::Result::kFailedWebContentsDestroyed);
   }
 
@@ -182,6 +287,7 @@
   }
 
   void PostResultTask(WebAppUrlLoader::Result result) {
+    timer_.Stop();
     Observe(nullptr);
     // Post a task to avoid reentrancy issues e.g. adding a WebContentsObserver
     // while a previous observer call is being executed.
@@ -221,6 +327,20 @@
 
 WebAppUrlLoader::~WebAppUrlLoader() = default;
 
+// static
+void WebAppUrlLoader::MaybeCreateAndAddNavigationThrottle(
+    content::NavigationThrottleRegistry& registry) {
+  content::NavigationHandle& handle = registry.GetNavigationHandle();
+  if (!handle.IsInPrimaryMainFrame()) {
+    return;
+  }
+  if (auto* data =
+          WebAppUrlLoaderNavigationHandleData::GetForNavigationHandle(handle)) {
+    registry.AddThrottle(
+        std::make_unique<WebAppUrlLoaderNavigationThrottle>(registry, *data));
+  }
+}
+
 void WebAppUrlLoader::LoadUrl(
     content::NavigationController::LoadURLParams load_url_params,
     content::WebContents* web_contents,
@@ -261,14 +381,14 @@
       GURL(url::kAboutBlankURL)};
   load_params.transition_type = ui::PAGE_TRANSITION_GENERATED;
   LoadUrlInternal(
-      load_params, web_contents->GetWeakPtr(), UrlComparison::kExact,
+      std::move(load_params), web_contents->GetWeakPtr(), UrlComparison::kExact,
       base::BindOnce(&WebAppUrlLoader::OnUrlLoaded, weak_factory_.GetWeakPtr(),
                      "Webapp.WebAppUrlLoaderPrepareForLoadResult",
                      base::IgnoreArgs<Result>(std::move(complete))));
 }
 
 void WebAppUrlLoader::LoadUrlInternal(
-    const content::NavigationController::LoadURLParams& load_url_params,
+    content::NavigationController::LoadURLParams load_url_params,
     base::WeakPtr<content::WebContents> web_contents,
     UrlComparison url_comparison,
     ResultCallback callback) {
@@ -279,10 +399,14 @@
                        WebAppUrlLoader::Result::kFailedWebContentsDestroyed));
     return;
   }
+  if (!load_url_params.initiator_origin.has_value() &&
+      load_url_params.url.is_valid() && !load_url_params.url.IsAboutBlank()) {
+    load_url_params.initiator_origin = url::Origin::Create(load_url_params.url);
+  }
   auto loader_task = std::make_unique<LoaderTask>();
   auto* loader_task_ptr = loader_task.get();
   loader_task_ptr->LoadUrl(
-      load_url_params, web_contents.get(), url_comparison,
+      std::move(load_url_params), web_contents.get(), url_comparison,
       base::BindOnce(
           [](std::unique_ptr<LoaderTask> task, Result result) {
             task.reset();
diff --git a/components/webapps/browser/web_contents/web_app_url_loader.h b/components/webapps/browser/web_contents/web_app_url_loader.h
index 9e0c2d92..249d041 100644
--- a/components/webapps/browser/web_contents/web_app_url_loader.h
+++ b/components/webapps/browser/web_contents/web_app_url_loader.h
@@ -15,6 +15,7 @@
 class GURL;
 
 namespace content {
+class NavigationThrottleRegistry;
 class WebContents;
 }  // namespace content
 
@@ -80,6 +81,13 @@
       UrlComparison url_comparison,
       ResultCallback callback);
 
+  // Registers a NavigationThrottle on `registry` if `registry`'s
+  // NavigationHandle is currently undergoing a WebAppUrlLoader load. This
+  // ensures redirects are constrained according to `UrlComparison` before
+  // network requests are sent.
+  static void MaybeCreateAndAddNavigationThrottle(
+      content::NavigationThrottleRegistry& registry);
+
   // Used by LoadUrl() to put `web_contents` into a clean state, will noop if
   // called redundantly. Useful for other uses of `web_contents` e.g.
   // downloading icons.
@@ -88,7 +96,7 @@
 
  private:
   void LoadUrlInternal(
-      const content::NavigationController::LoadURLParams& load_url_params,
+      content::NavigationController::LoadURLParams load_url_params,
       base::WeakPtr<content::WebContents> web_contents,
       UrlComparison url_comparison,
       ResultCallback callback);
diff --git a/content/browser/manifest/manifest_manager_host.cc b/content/browser/manifest/manifest_manager_host.cc
index 8d99484d..b03a30e 100644
--- a/content/browser/manifest/manifest_manager_host.cc
+++ b/content/browser/manifest/manifest_manager_host.cc
@@ -146,26 +146,44 @@
 
     net::SchemefulSite document_site(document_origin);
     for (const auto& migrate_from : manifest.migrate_from) {
-      if (!document_site.IsSameSiteWith(migrate_from->id)) {
+      if (!migrate_from->id.is_valid() ||
+          !document_site.IsSameSiteWith(migrate_from->id)) {
         return "Manifest migrate_from id must be the same site as the "
                "document.";
       }
-      if (migrate_from->install_url && migrate_from->install_url->is_valid() &&
-          !document_site.IsSameSiteWith(*migrate_from->install_url)) {
-        return "Manifest migrate_from install_url must be the same site as the "
-               "document.";
+      if (migrate_from->install_url) {
+        if (!migrate_from->install_url->is_valid()) {
+          return "Manifest migrate_from install_url must be valid.";
+        }
+        if (!document_site.IsSameSiteWith(*migrate_from->install_url)) {
+          return "Manifest migrate_from install_url must be the same site as "
+                 "the document.";
+        }
+        if (!url::IsSameOriginWith(migrate_from->id,
+                                   *migrate_from->install_url)) {
+          return "Manifest migrate_from install_url must be the same origin as "
+                 "the id.";
+        }
       }
     }
 
     if (manifest.migrate_to) {
-      if (!document_site.IsSameSiteWith(manifest.migrate_to->id)) {
+      if (!manifest.migrate_to->id.is_valid() ||
+          !document_site.IsSameSiteWith(manifest.migrate_to->id)) {
         return "Manifest migrate_to id must be the same site as the document.";
       }
-      if (manifest.migrate_to->install_url.is_valid() &&
-          !document_site.IsSameSiteWith(manifest.migrate_to->install_url)) {
+      if (!manifest.migrate_to->install_url.is_valid()) {
+        return "Manifest migrate_to install_url must be valid.";
+      }
+      if (!document_site.IsSameSiteWith(manifest.migrate_to->install_url)) {
         return "Manifest migrate_to install_url must be the same site as the "
                "document.";
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/web_applications/commands/fetch_manifest_and_update_command_unittest.cc b/chrome/browser/web_applications/commands/fetch_manifest_and_update_command_unittest.cc
index 478310c1..2ab1301b 100644
--- a/chrome/browser/web_applications/commands/fetch_manifest_and_update_command_unittest.cc
+++ b/chrome/browser/web_applications/commands/fetch_manifest_and_update_command_unittest.cc
@@ -204,6 +204,20 @@
       FetchManifestAndUpdateResult::kUrlLoadingError, 1);
 }
 
+TEST_F(FetchManifestAndUpdateTest, UrlRedirectFailure) {
+  ASSERT_OK_AND_ASSIGN(webapps::AppId app_id, InstallApp());
+
+  web_contents_manager()
+      .GetOrCreatePageState(GURL(kInstallUrl))
+      .url_load_result = webapps::WebAppUrlLoaderResult::kRedirectedUrlLoaded;
+
+  ASSERT_OK_AND_ASSIGN(FetchManifestAndUpdateResult result, RunUpdate());
+  EXPECT_EQ(result, FetchManifestAndUpdateResult::kUrlLoadingError);
+  histogram_tester_.ExpectUniqueSample(
+      "WebApp.FetchManifestAndUpdate.Result",
+      FetchManifestAndUpdateResult::kUrlLoadingError, 1);
+}
+
 TEST_F(FetchManifestAndUpdateTest, PrimaryPageChangedDuringIconFetch) {
   ASSERT_OK_AND_ASSIGN(webapps::AppId app_id, InstallApp());
diff --git a/chrome/browser/web_applications/commands/install_migrate_to_app_command_browsertest.cc b/chrome/browser/web_applications/commands/install_migrate_to_app_command_browsertest.cc
index c4fac0eb..d71d8ec3 100644
--- a/chrome/browser/web_applications/commands/install_migrate_to_app_command_browsertest.cc
+++ b/chrome/browser/web_applications/commands/install_migrate_to_app_command_browsertest.cc
@@ -5,11 +5,13 @@
 #include "base/run_loop.h"
 #include "base/test/bind.h"
 #include "base/test/scoped_feature_list.h"
+#include "base/test/test_future.h"
 #include "chrome/browser/ui/tabs/tab_strip_model.h"
 #include "chrome/browser/ui/web_applications/test/web_app_browsertest_util.h"
 #include "chrome/browser/ui/web_applications/web_app_browsertest_base.h"
 #include "chrome/browser/web_applications/test/web_app_test_observers.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_helpers.h"
 #include "chrome/browser/web_applications/web_app_install_manager.h"
 #include "chrome/browser/web_applications/web_app_provider.h"
@@ -85,5 +87,36 @@
                   .has_value());
 }
 
+IN_PROC_BROWSER_TEST_F(InstallMigrateToAppCommandBrowserTest,
+                       TargetNotInstalledWhenInstallUrlRedirects) {
+  // 1. Install the source app.
+  InstallWebAppFromPage(
+      browser(), embedded_https_test_server().GetURL(kMigrateFromSuggestUrl));
+
+  // 2. Schedule migration with an install_url that redirects.
+  GURL target_url = embedded_https_test_server().GetURL(
+      "/web_apps/migration/migrate_to/suggest.html");
+  GURL redirecting_install_url = embedded_https_test_server().GetURL(
+      "/server-redirect?" + target_url.spec());
+
+  webapps::ManifestId source_manifest_id(embedded_https_test_server().GetURL(
+      "/web_apps/migration/migrate_from/manifest_id"));
+  webapps::ManifestId target_manifest_id(
+      embedded_https_test_server().GetURL(kMigrateToManifestId));
+
+  base::test::TestFuture<InstallMigrateToAppResult> future;
+  provider().scheduler().ScheduleInstallMigrateToApp(
+      source_manifest_id, target_manifest_id, redirecting_install_url,
+      future.GetCallback());
+
+  EXPECT_EQ(future.Get(), InstallMigrateToAppResult::kUrlLoadFailed);
+
+  // 3. Verify target app is NOT installed.
+  EXPECT_FALSE(provider()
+                   .registrar_unsafe()
+                   .GetInstallState(GetTargetAppId())
+                   .has_value());
+}
+
 }  // namespace
 }  // namespace web_app
diff --git a/chrome/browser/webapps/web_contents/web_app_url_loader_browsertest.cc b/chrome/browser/webapps/web_contents/web_app_url_loader_browsertest.cc
index b1a9dfe..7a1dab6 100644
--- a/chrome/browser/webapps/web_contents/web_app_url_loader_browsertest.cc
+++ b/chrome/browser/webapps/web_contents/web_app_url_loader_browsertest.cc
@@ -208,6 +208,52 @@
                            "/server-redirect-302?" + final_url.spec()));
 }
 
+IN_PROC_BROWSER_TEST_F(WebAppUrlLoaderTest,
+                       RedirectDisallowedDestinationBlockedBeforeFetch) {
+  bool destination_reached = false;
+  embedded_test_server()->RegisterRequestHandler(base::BindLambdaForTesting(
+      [&](const net::test_server::HttpRequest& request)
+          -> std::unique_ptr<net::test_server::HttpResponse> {
+        if (request.relative_url == "/destination") {
+          destination_reached = true;
+          auto resp = std::make_unique<net::test_server::BasicHttpResponse>();
+          resp->set_code(net::HTTP_OK);
+          return resp;
+        }
+        return nullptr;
+      }));
+  SetupRedirect("/test-redirect-blocked", "/destination");
+  ASSERT_TRUE(embedded_test_server()->Start());
+
+  EXPECT_EQ(UrlResult::kRedirectedUrlLoaded,
+            LoadUrlAndWait(UrlComparison::kExact, "/test-redirect-blocked"));
+  EXPECT_FALSE(destination_reached);
+}
+
+IN_PROC_BROWSER_TEST_F(WebAppUrlLoaderTest,
+                       CrossOriginRedirectBlockedBeforeFetch) {
+  bool destination_reached = false;
+  embedded_test_server()->RegisterRequestHandler(base::BindLambdaForTesting(
+      [&](const net::test_server::HttpRequest& request)
+          -> std::unique_ptr<net::test_server::HttpResponse> {
+        if (request.relative_url == "/destination") {
+          destination_reached = true;
+          auto resp = std::make_unique<net::test_server::BasicHttpResponse>();
+          resp->set_code(net::HTTP_OK);
+          return resp;
+        }
+        return nullptr;
+      }));
+  ASSERT_TRUE(embedded_test_server()->Start());
+
+  const GURL destination_url =
+      embedded_test_server()->GetURL("foo.com", "/destination");
+  EXPECT_EQ(UrlResult::kRedirectedUrlLoaded,
+            LoadUrlAndWait(UrlComparison::kSameOrigin,
+                           "/server-redirect-302?" + destination_url.spec()));
+  EXPECT_FALSE(destination_reached);
+}
+
 IN_PROC_BROWSER_TEST_F(WebAppUrlLoaderTest, Http404ErrorWithContent) {
   SetupHttpResponseWithContent(net::HTTP_NOT_FOUND, kGenericPageContent);
   ASSERT_TRUE(embedded_test_server()->Start());
diff --git a/content/browser/manifest/manifest_browsertest.cc b/content/browser/manifest/manifest_browsertest.cc
index a4485c1..e4bf73a 100644
--- a/content/browser/manifest/manifest_browsertest.cc
+++ b/content/browser/manifest/manifest_browsertest.cc
@@ -1530,6 +1530,66 @@
 }
 
 IN_PROC_BROWSER_TEST_F(ManifestBrowserTest,
+                       MigrateCrossOriginBadMessage_MigrateToInstallUrl) {
+  const GURL test_url =
+      embedded_test_server()->GetURL("/manifest/empty-manifest.html");
+  ASSERT_TRUE(NavigateToURL(shell(), test_url));
+
+  ManifestManagerHost* host = ManifestManagerHost::GetOrCreateForPage(
+      shell()->web_contents()->GetPrimaryPage());
+
+  mojo::FakeMessageDispatchContext fake_dispatch_context;
+  auto bad_manifest = blink::mojom::Manifest::New();
+  bad_manifest->start_url = test_url;
+  bad_manifest->id = test_url;
+  bad_manifest->scope = embedded_test_server()->GetURL("/manifest/");
+
+  // Same site (127.0.0.1) but different port => cross-origin with id.
+  auto migrate_to = blink::mojom::ManifestMigrateTo::New();
+  migrate_to->id = embedded_test_server()->GetURL("/manifest/new");
+  migrate_to->install_url = GURL("http://127.0.0.1:9999/install");
+  bad_manifest->migrate_to = std::move(migrate_to);
+
+  mojo::test::BadMessageObserver bad_message_observer;
+  host->ValidateAndMaybeOverrideManifestForTesting(
+      blink::mojom::ManifestRequestResult::kSuccess, std::move(bad_manifest));
+  EXPECT_THAT(bad_message_observer.WaitForBadMessage(),
+              ::testing::StartsWith(
+                  "Manifest migrate_to install_url must be the same origin "
+                  "as the id."));
+}
+
+IN_PROC_BROWSER_TEST_F(ManifestBrowserTest,
+                       MigrateCrossOriginBadMessage_MigrateFromInstallUrl) {
+  const GURL test_url =
+      embedded_test_server()->GetURL("/manifest/empty-manifest.html");
+  ASSERT_TRUE(NavigateToURL(shell(), test_url));
+
+  ManifestManagerHost* host = ManifestManagerHost::GetOrCreateForPage(
+      shell()->web_contents()->GetPrimaryPage());
+
+  mojo::FakeMessageDispatchContext fake_dispatch_context;
+  auto bad_manifest = blink::mojom::Manifest::New();
+  bad_manifest->start_url = test_url;
+  bad_manifest->id = test_url;
+  bad_manifest->scope = embedded_test_server()->GetURL("/manifest/");
+
+  // Same site (127.0.0.1) but different port => cross-origin with id.
+  auto migrate_from = blink::mojom::ManifestMigrateFrom::New();
+  migrate_from->id = embedded_test_server()->GetURL("/manifest/old");
+  migrate_from->install_url = GURL("http://127.0.0.1:9999/install");
+  bad_manifest->migrate_from.push_back(std::move(migrate_from));
+
+  mojo::test::BadMessageObserver bad_message_observer;
+  host->ValidateAndMaybeOverrideManifestForTesting(
+      blink::mojom::ManifestRequestResult::kSuccess, std::move(bad_manifest));
+  EXPECT_THAT(bad_message_observer.WaitForBadMessage(),
+              ::testing::StartsWith(
+                  "Manifest migrate_from install_url must be the same origin "
+                  "as the id."));
+}
+
+IN_PROC_BROWSER_TEST_F(ManifestBrowserTest,
                        ManifestShortcutUrlOutsideScopeBadMessage) {
   const GURL test_url =
       embedded_test_server()->GetURL("/manifest/empty-manifest.html");
Loading diff…

Original Bug Report

reported by vm...@google.com

Bypass of same-site restriction in WebApp Migration API via HTTP redirects

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: The browser-side same-site check for the WebApp Migration API only validates the pre-redirect install URL. Since the subsequent background load behaves as a top-level browser-initiated navigation, it follows HTTP redirects to arbitrary cross-site or private network destinations with credentials, enabling potential CSRF and SSRF.

Affected files:

  • content/browser/manifest/manifest_manager_host.cc
  • components/webapps/browser/web_contents/web_app_url_loader.cc
  • chrome/browser/web_applications/commands/install_migrate_to_app_command.cc
  • chrome/browser/web_applications/commands/fetch_manifest_and_update_command.cc

Estimated timestamp from git blame: 2026-03-30

Summary

There is a potential security issue in the WebApp Migration API (active in desktop field trials under the DesktopAppOriginMigration config) where same-site checks are applied only to the initial, pre-redirect install_url of migrate_to/migrate_from manifests. Because the browser loads this URL using a browser-initiated, top-level main frame navigation in a background WebContents, the navigation automatically follows HTTP redirects without re-checking if the redirect destination is same-site. This can be abused to make credentialed GET requests to arbitrary cross-site or private network addresses, bypassing SameSite cookie restrictions and Local Network Access (LNA) checks.

Note: These findings are based on a static analysis of the codebase. Our tooling does not currently have the capability to execute code or run a live proof of concept, so this issue should be treated as potential/suggested until verified.

Root Cause Analysis

  1. Pre-Redirect Verification Only: In content/browser/manifest/manifest_manager_host.cc, the browser-side manifest parser validates the install_url of migrate_to and migrate_from against the document origin’s site using SchemefulSite::IsSameSiteWith():

    if (manifest.migrate_to) {
      if (manifest.migrate_to->install_url.is_valid() &&
          !document_site.IsSameSiteWith(manifest.migrate_to->install_url)) {
        return "Manifest migrate_to install_url must be the same site...";
      }
    }
    

    This validation is performed only on the parsed URL value, before any network connection is made.

  2. Browser-Initiated Top-Level Navigation: When installing or updating the app, the URL is navigated using WebAppUrlLoader::LoadUrl (defined in components/webapps/browser/web_contents/web_app_url_loader.cc), which initiates a top-level, main-frame navigation where is_renderer_initiated = false and initiator_origin = nullopt.

  3. Bypassing Protections on Redirects: Because the navigation is treated as browser-initiated and runs on a main frame:

    • Local Network Access (LNA) checks are completely bypassed since top-level main-frame navigations default to Policy::kAllow (see content/browser/renderer_host/local_network_access_util.cc). This enables reachability to private local IP addresses (RFC1918 / link-local addresses).
    • Fetch Metadata is set to Sec-Fetch-Site: none, which bypasses server-side checks that verify the source of the navigation.
    • SameSite Cookies (SameSite=Lax and potentially Strict depending on kCookieSameSiteConsidersRedirectChain) are attached to the redirected request since there is no cross-site initiator origin.
  4. Post-hoc Detection is Too Late: While WebAppUrlLoader monitors the load and detects redirects by comparing the final URL with the requested URL in DidFinishLoad (returning kRedirectedUrlLoaded to abort the command), the HTTP request to the redirect destination has already been fully processed by the target server before this check occurs.

Potential Steps to Reproduce

  1. An attacker serves a PWA from https://attacker.example/ with a manifest specifying:
    "migrate_to": {
      "id": "https://attacker.example/v2",
      "install_url": "https://attacker.example/r"
    }
    
  2. When the PWA is installed and subsequently updated, ManifestUpdateManager triggers InstallMigrateToAppCommand which loads https://attacker.example/r in a background WebContents.
  3. The server at https://attacker.example/r responds with an HTTP 302 Found redirecting to https://victim-bank.example/transfer?to=evil&amt=1000 or a local address like http://192.168.1.1/admin?cmd=reboot.
  4. The browser follows the redirect, attaching the user’s session cookies for victim-bank.example or successfully routing the request into the local router, executing the action.

Suggested Fix

Implement navigation throttling or strict redirection checks within WebAppUrlLoader or the migration commands. Specifically:

  • Introduce a policy or parameter in WebAppUrlLoader::LoadUrl to explicitly disallow cross-site redirects during background manifest-related navigations.
  • Implement a custom NavigationThrottle for the migration commands that monitors redirects (WillRedirectRequest) and cancels the navigation immediately if a redirect attempts to cross same-site/same-origin boundaries.

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