CVE-2026-106322
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
switchchrome/browser/web_applications/commands/fetch_manifest_and_update_command.cc |
modified | |
WebAppUrlLoaderNavigationHandleDatacomponents/webapps/browser/web_contents/web_app_url_loader.cc |
modified | |
WebAppUrlLoaderNavigationThrottlecomponents/webapps/browser/web_contents/web_app_url_loader.cc |
modified | |
ifcomponents/webapps/browser/web_contents/web_app_url_loader.cc |
modified | |
GURLcomponents/webapps/browser/web_contents/web_app_url_loader.h |
modified | |
NavigationThrottleRegistrycomponents/webapps/browser/web_contents/web_app_url_loader.h |
modified | |
WebContentscomponents/webapps/browser/web_contents/web_app_url_loader.h |
modified | |
forcontent/browser/manifest/manifest_manager_host.cc |
modified | |
ifcontent/browser/manifest/manifest_manager_host.cc |
modified |
Files Changed
chrome/browser/webapps/web_contents/web_app_url_loader_browsertest.ccchrome/browser/web_applications/commands/fetch_manifest_and_update_command.cccontent/browser/manifest/manifest_browsertest.cccomponents/webapps/browser/web_contents/web_app_url_loader.ccchrome/browser/web_applications/commands/fetch_manifest_and_update_command_unittest.cccontent/browser/manifest/manifest_manager_host.cccomponents/webapps/browser/web_contents/web_app_url_loader.hchrome/browser/web_applications/commands/install_migrate_to_app_command_browsertest.ccchrome/browser/chrome_content_browser_client_navigation_throttles.cc
Patch
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)
Regression Test / PoC
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");
Original Bug Report
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.cccomponents/webapps/browser/web_contents/web_app_url_loader.ccchrome/browser/web_applications/commands/install_migrate_to_app_command.ccchrome/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
-
Pre-Redirect Verification Only: In
content/browser/manifest/manifest_manager_host.cc, the browser-side manifest parser validates theinstall_urlofmigrate_toandmigrate_fromagainst the document origin’s site usingSchemefulSite::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.
-
Browser-Initiated Top-Level Navigation: When installing or updating the app, the URL is navigated using
WebAppUrlLoader::LoadUrl(defined incomponents/webapps/browser/web_contents/web_app_url_loader.cc), which initiates a top-level, main-frame navigation whereis_renderer_initiated = falseandinitiator_origin = nullopt. -
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(seecontent/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=Laxand potentiallyStrictdepending onkCookieSameSiteConsidersRedirectChain) are attached to the redirected request since there is no cross-site initiator origin.
- Local Network Access (LNA) checks are completely bypassed since top-level main-frame navigations default to
-
Post-hoc Detection is Too Late: While
WebAppUrlLoadermonitors the load and detects redirects by comparing the final URL with the requested URL inDidFinishLoad(returningkRedirectedUrlLoadedto 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
- 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" } - When the PWA is installed and subsequently updated,
ManifestUpdateManagertriggersInstallMigrateToAppCommandwhich loadshttps://attacker.example/rin a backgroundWebContents. - The server at
https://attacker.example/rresponds with an HTTP302 Foundredirecting tohttps://victim-bank.example/transfer?to=evil&amt=1000or a local address likehttp://192.168.1.1/admin?cmd=reboot. - The browser follows the redirect, attaching the user’s session cookies for
victim-bank.exampleor 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::LoadUrlto explicitly disallow cross-site redirects during background manifest-related navigations. - Implement a custom
NavigationThrottlefor 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.