CVE-2026-106354
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/chrome_content_browser_client.cc |
modified | |
IN_PROC_BROWSER_TEST_Pchrome/browser/webapps/web_app_offline_interactive_uitest.cc |
modified |
Files Changed
chrome/browser/web_applications/alternative_error_page_override_info_browsertest.ccchrome/browser/web_applications/web_app_utils.ccchrome/test/data/banners/iframe_with_icon.htmlchrome/browser/webapps/web_app_offline_interactive_uitest.ccchrome/browser/chrome_content_browser_client.cc
Patch
From 6da65c175b55df661c995df69c001896dd077c42 Mon Sep 17 00:00:00 2001 From: Marijn Kruisselbrink <[email protected]> Date: Thu, 10 Sep 2026 11:59:13 -0700 Subject: [PATCH] Restrict web app alternative error pages to primary main frames AppIconFetcherTask injects a script into the primary main frame to populate the icon on the default PWA offline error page using ISOLATED_WORLD_ID_EXTENSIONS. That constant is the base of the extension content script isolated world ID range, which can collide with the first extension content script and allow cross-origin icon leaks or extension DoS. This CL addresses this by: 1. Restricting web app and isolated web app alternative error page generation in ChromeContentBrowserClient to primary main frame navigations only. 2. In AppIconFetcherTask, switching the injection world to ISOLATED_WORLD_ID_CHROME_INTERNAL instead of ISOLATED_WORLD_ID_EXTENSIONS. Bug: 511796554 Change-Id: Ifea886a24e7453437304226ee39cbdb746dfcc51 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8373542 Reviewed-by: Daniel Murphy <[email protected]> Commit-Queue: Marijn Kruisselbrink <[email protected]> Cr-Commit-Position: refs/heads/main@{#1695546} --- diff --git a/chrome/browser/chrome_content_browser_client.cc b/chrome/browser/chrome_content_browser_client.cc index 288d7c1..bbdb121 100644 --- a/chrome/browser/chrome_content_browser_client.cc +++ b/chrome/browser/chrome_content_browser_client.cc @@ -8605,7 +8605,8 @@ #if BUILDFLAG(IS_WIN) || BUILDFLAG(IS_MAC) || BUILDFLAG(IS_LINUX) || \ BUILDFLAG(IS_CHROMEOS) - if (content::AreIsolatedWebAppsEnabled(browser_context) && + if (navigation_handle.IsInPrimaryMainFrame() && + content::AreIsolatedWebAppsEnabled(browser_context) && IsIsolatedWebAppUrl(url)) { content::mojom::AlternativeErrorPageOverrideInfoPtr alternative_error_page_override_info = @@ -8620,7 +8621,8 @@ #endif // BUILDFLAG(IS_WIN) || BUILDFLAG(IS_MAC) || BUILDFLAG(IS_LINUX) || // BUILDFLAG(IS_CHROMEOS) - if (error_code == net::ERR_INTERNET_DISCONNECTED) { + if (navigation_handle.IsInPrimaryMainFrame() && + error_code == net::ERR_INTERNET_DISCONNECTED) { content::mojom::AlternativeErrorPageOverrideInfoPtr alternative_error_page_override_info = web_app::GetOfflinePageInfo( url, render_frame_host, browser_context); diff --git a/chrome/browser/web_applications/web_app_utils.cc b/chrome/browser/web_applications/web_app_utils.cc index 74898e1b..90bf5ef 100644 --- a/chrome/browser/web_applications/web_app_utils.cc +++ b/chrome/browser/web_applications/web_app_utils.cc @@ -159,12 +159,12 @@ } void OnIconFetched(int fetched_size, OrderedSizeToBitmap icon_bitmaps) { - DCHECK_EQ(icon_bitmaps.size(), 1ul); - DCHECK_EQ(icon_bitmaps.begin()->first, fetched_size); if (icon_bitmaps.empty()) { delete this; return; } + DCHECK_EQ(icon_bitmaps.size(), 1ul); + DCHECK_EQ(icon_bitmaps.begin()->first, fetched_size); icon_url_ = EncodeIconAsUrl(icon_bitmaps.begin()->second); MaybeSendImageAndSelfDestruct(); } @@ -188,7 +188,7 @@ content::RenderFrameHost* host = web_contents()->GetPrimaryMainFrame(); host->ExecuteJavaScriptInIsolatedWorld(app_icon_inline, base::DoNothing(), - ISOLATED_WORLD_ID_EXTENSIONS); + ISOLATED_WORLD_ID_CHROME_INTERNAL); delete this; } @@ -598,7 +598,6 @@ return scope.is_valid() && scope.has_scheme() && scope.SchemeIsHTTPOrHTTPS(); } - // TODO(crbug.com/331208955): Remove after migration. bool WillBeSystemWebApp(const webapps::AppId& app_id, WebAppManagementTypes sources) { diff --git a/chrome/browser/web_applications/alternative_error_page_override_info_browsertest.cc b/chrome/browser/web_applications/alternative_error_page_override_info_browsertest.cc index c82af845..d6d7d90 100644 --- a/chrome/browser/web_applications/alternative_error_page_override_info_browsertest.cc +++ b/chrome/browser/web_applications/alternative_error_page_override_info_browsertest.cc @@ -207,5 +207,30 @@ "offlineIcon"); } +// Testing subframe navigation inside an installed app scope. +IN_PROC_BROWSER_TEST_P(AlternativeErrorPageOverrideInfoBrowserTest, + SubframeNavigation) { + EXPECT_TRUE(embedded_test_server()->Start()); + const GURL app_url = embedded_test_server()->GetURL( + "/banners/" + "manifest_test_page.html?manifest=manifest_short_name_only.json"); + web_app::NavigateViaLinkClickToURLAndWait(browser(), app_url); + web_app::test::InstallPwaForCurrentUrl(browser()); + content::BrowserContext* context = browser()->GetProfile(); + + content::MockNavigationHandle navigation_handle( + app_url, /*render_frame_host=*/nullptr); + navigation_handle.set_is_in_primary_main_frame(false); + content::mojom::AlternativeErrorPageOverrideInfoPtr info = + content::GetContentClientForTesting() + ->browser() + ->GetAlternativeErrorPageOverrideInfo( + navigation_handle, /*render_frame_host=*/nullptr, context, + net::ERR_INTERNET_DISCONNECTED); + + // Subframes should not use web app alternative error pages. + EXPECT_FALSE(info); +} + INSTANTIATE_FEATURE_OVERRIDE_TEST_SUITE( AlternativeErrorPageOverrideInfoBrowserTest); diff --git a/chrome/browser/webapps/web_app_offline_interactive_uitest.cc b/chrome/browser/webapps/web_app_offline_interactive_uitest.cc index 92a3235..13d7e6a 100644 --- a/chrome/browser/webapps/web_app_offline_interactive_uitest.cc +++ b/chrome/browser/webapps/web_app_offline_interactive_uitest.cc @@ -8,6 +8,7 @@ #include "base/compiler_specific.h" #include "base/containers/span.h" #include "base/metrics/crc32.h" +#include "base/strings/string_util.h" #include "base/test/metrics/histogram_tester.h" #include "base/test/scoped_feature_list.h" #include "base/test/with_feature_override.h" @@ -24,6 +25,7 @@ #include "chrome/browser/web_applications/test/web_app_install_test_utils.h" #include "chrome/browser/web_applications/test/web_app_test_utils.h" #include "chrome/common/chrome_features.h" +#include "chrome/common/chrome_isolated_world_ids.h" #include "chrome/test/base/in_process_browser_test.h" #include "chrome/test/base/ui_test_utils.h" #include "components/metrics/content/subprocess_metrics_provider.h" @@ -286,6 +288,58 @@ base::span(static_cast<const uint8_t*>(bitmap.pixmap().addr()), bitmap.computeByteSize())); EXPECT_EQ(1504857296u, base::Crc32(0, image_bytes)); + + EXPECT_TRUE( + EvalJs( + web_contents, + "Object.getOwnPropertyDescriptor(globalThis, 'icon') !== undefined", + content::EXECUTE_SCRIPT_DEFAULT_OPTIONS, + ISOLATED_WORLD_ID_CHROME_INTERNAL) + .ExtractBool()); + EXPECT_FALSE( + EvalJs( + web_contents, + "Object.getOwnPropertyDescriptor(globalThis, 'icon') !== undefined", + content::EXECUTE_SCRIPT_DEFAULT_OPTIONS, ISOLATED_WORLD_ID_EXTENSIONS) + .ExtractBool()); +} + +IN_PROC_BROWSER_TEST_P(WebAppOfflinePageTest, + SubframeErrorDoesNotAffectMainFrame) { + EXPECT_TRUE(embedded_test_server()->Start()); + + const GURL pwa_url = + embedded_test_server()->GetURL("/banners/no-sw-with-colors.html"); + web_app::NavigateViaLinkClickToURLAndWait(browser(), pwa_url); + webapps::AppId app_id = web_app::test::InstallPwaForCurrentUrl(browser()); + WebAppIconWaiter(browser()->GetProfile(), app_id).Wait(); + + std::unique_ptr<content::URLLoaderInterceptor> interceptor = + content::URLLoaderInterceptor::SetupRequestFailForURL( + pwa_url, net::ERR_INTERNET_DISCONNECTED); + + const GURL outer_url = + embedded_test_server()->GetURL("/banners/iframe_with_icon.html"); + content::WebContents* web_contents = + browser()->GetTabStripModel()->GetActiveWebContents(); + EXPECT_TRUE(ui_test_utils::NavigateToURL(browser(), outer_url)); + content::WaitForLoadStop(web_contents); + EXPECT_EQ(web_contents->GetPrimaryMainFrame()->GetLastCommittedURL(), + outer_url); + + std::string img_src = content::EvalJs(web_contents->GetPrimaryMainFrame(), + "document.getElementById('icon').src") + .ExtractString(); + EXPECT_FALSE(base::StartsWith(img_src, "data:image/png;base64,")); + + const bool icon_own_prop = + content::EvalJs(web_contents->GetPrimaryMainFrame(), + "Object.getOwnPropertyDescriptor(globalThis, 'icon') " + "!== undefined", + content::EXECUTE_SCRIPT_DEFAULT_OPTIONS, + ISOLATED_WORLD_ID_EXTENSIONS) + .ExtractBool(); + EXPECT_FALSE(icon_own_prop); } IN_PROC_BROWSER_TEST_P(WebAppOfflinePageTest, WebAppOfflineMetricsNavigation) { diff --git a/chrome/test/data/banners/iframe_with_icon.html b/chrome/test/data/banners/iframe_with_icon.html new file mode 100644 index 0000000..aa65c4a4 --- /dev/null +++ b/chrome/test/data/banners/iframe_with_icon.html @@ -0,0 +1,8 @@ +<!DOCTYPE html> +<html> + <head><title>Subframe test page with icon</title></head> + <body> + <img id="icon" alt="icon"> + <iframe src="no-sw-with-colors.html"></iframe> + </body> +</html>
Regression Test / PoC
diff --git a/chrome/browser/web_applications/alternative_error_page_override_info_browsertest.cc b/chrome/browser/web_applications/alternative_error_page_override_info_browsertest.cc
index c82af845..d6d7d90 100644
--- a/chrome/browser/web_applications/alternative_error_page_override_info_browsertest.cc
+++ b/chrome/browser/web_applications/alternative_error_page_override_info_browsertest.cc
@@ -207,5 +207,30 @@
"offlineIcon");
}
+// Testing subframe navigation inside an installed app scope.
+IN_PROC_BROWSER_TEST_P(AlternativeErrorPageOverrideInfoBrowserTest,
+ SubframeNavigation) {
+ EXPECT_TRUE(embedded_test_server()->Start());
+ const GURL app_url = embedded_test_server()->GetURL(
+ "/banners/"
+ "manifest_test_page.html?manifest=manifest_short_name_only.json");
+ web_app::NavigateViaLinkClickToURLAndWait(browser(), app_url);
+ web_app::test::InstallPwaForCurrentUrl(browser());
+ content::BrowserContext* context = browser()->GetProfile();
+
+ content::MockNavigationHandle navigation_handle(
+ app_url, /*render_frame_host=*/nullptr);
+ navigation_handle.set_is_in_primary_main_frame(false);
+ content::mojom::AlternativeErrorPageOverrideInfoPtr info =
+ content::GetContentClientForTesting()
+ ->browser()
+ ->GetAlternativeErrorPageOverrideInfo(
+ navigation_handle, /*render_frame_host=*/nullptr, context,
+ net::ERR_INTERNET_DISCONNECTED);
+
+ // Subframes should not use web app alternative error pages.
+ EXPECT_FALSE(info);
+}
+
INSTANTIATE_FEATURE_OVERRIDE_TEST_SUITE(
AlternativeErrorPageOverrideInfoBrowserTest);
diff --git a/chrome/browser/webapps/web_app_offline_interactive_uitest.cc b/chrome/browser/webapps/web_app_offline_interactive_uitest.cc
index 92a3235..13d7e6a 100644
--- a/chrome/browser/webapps/web_app_offline_interactive_uitest.cc
+++ b/chrome/browser/webapps/web_app_offline_interactive_uitest.cc
@@ -8,6 +8,7 @@
#include "base/compiler_specific.h"
#include "base/containers/span.h"
#include "base/metrics/crc32.h"
+#include "base/strings/string_util.h"
#include "base/test/metrics/histogram_tester.h"
#include "base/test/scoped_feature_list.h"
#include "base/test/with_feature_override.h"
@@ -24,6 +25,7 @@
#include "chrome/browser/web_applications/test/web_app_install_test_utils.h"
#include "chrome/browser/web_applications/test/web_app_test_utils.h"
#include "chrome/common/chrome_features.h"
+#include "chrome/common/chrome_isolated_world_ids.h"
#include "chrome/test/base/in_process_browser_test.h"
#include "chrome/test/base/ui_test_utils.h"
#include "components/metrics/content/subprocess_metrics_provider.h"
@@ -286,6 +288,58 @@
base::span(static_cast<const uint8_t*>(bitmap.pixmap().addr()),
bitmap.computeByteSize()));
EXPECT_EQ(1504857296u, base::Crc32(0, image_bytes));
+
+ EXPECT_TRUE(
+ EvalJs(
+ web_contents,
+ "Object.getOwnPropertyDescriptor(globalThis, 'icon') !== undefined",
+ content::EXECUTE_SCRIPT_DEFAULT_OPTIONS,
+ ISOLATED_WORLD_ID_CHROME_INTERNAL)
+ .ExtractBool());
+ EXPECT_FALSE(
+ EvalJs(
+ web_contents,
+ "Object.getOwnPropertyDescriptor(globalThis, 'icon') !== undefined",
+ content::EXECUTE_SCRIPT_DEFAULT_OPTIONS, ISOLATED_WORLD_ID_EXTENSIONS)
+ .ExtractBool());
+}
+
+IN_PROC_BROWSER_TEST_P(WebAppOfflinePageTest,
+ SubframeErrorDoesNotAffectMainFrame) {
+ EXPECT_TRUE(embedded_test_server()->Start());
+
+ const GURL pwa_url =
+ embedded_test_server()->GetURL("/banners/no-sw-with-colors.html");
+ web_app::NavigateViaLinkClickToURLAndWait(browser(), pwa_url);
+ webapps::AppId app_id = web_app::test::InstallPwaForCurrentUrl(browser());
+ WebAppIconWaiter(browser()->GetProfile(), app_id).Wait();
+
+ std::unique_ptr<content::URLLoaderInterceptor> interceptor =
+ content::URLLoaderInterceptor::SetupRequestFailForURL(
+ pwa_url, net::ERR_INTERNET_DISCONNECTED);
+
+ const GURL outer_url =
+ embedded_test_server()->GetURL("/banners/iframe_with_icon.html");
+ content::WebContents* web_contents =
+ browser()->GetTabStripModel()->GetActiveWebContents();
+ EXPECT_TRUE(ui_test_utils::NavigateToURL(browser(), outer_url));
+ content::WaitForLoadStop(web_contents);
+ EXPECT_EQ(web_contents->GetPrimaryMainFrame()->GetLastCommittedURL(),
+ outer_url);
+
+ std::string img_src = content::EvalJs(web_contents->GetPrimaryMainFrame(),
+ "document.getElementById('icon').src")
+ .ExtractString();
+ EXPECT_FALSE(base::StartsWith(img_src, "data:image/png;base64,"));
+
+ const bool icon_own_prop =
+ content::EvalJs(web_contents->GetPrimaryMainFrame(),
+ "Object.getOwnPropertyDescriptor(globalThis, 'icon') "
+ "!== undefined",
+ content::EXECUTE_SCRIPT_DEFAULT_OPTIONS,
+ ISOLATED_WORLD_ID_EXTENSIONS)
+ .ExtractBool();
+ EXPECT_FALSE(icon_own_prop);
}
IN_PROC_BROWSER_TEST_P(WebAppOfflinePageTest, WebAppOfflineMetricsNavigation) {
diff --git a/chrome/test/data/banners/iframe_with_icon.html b/chrome/test/data/banners/iframe_with_icon.html
new file mode 100644
index 0000000..aa65c4a4
--- /dev/null
+++ b/chrome/test/data/banners/iframe_with_icon.html
@@ -0,0 +1,8 @@
+<!DOCTYPE html>
+<html>
+ <head><title>Subframe test page with icon</title></head>
+ <body>
+ <img id="icon" alt="icon">
+ <iframe src="no-sw-with-colors.html"></iframe>
+ </body>
+</html>
Original Bug Report
Info Leak and Extension DoS via AppIconFetcherTask World ID collision
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 without the Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: When a subframe navigation to an installed Web App fails, AppIconFetcherTask incorrectly injects an icon-update script into the primary main frame using ISOLATED_WORLD_ID_EXTENSIONS. This can be exploited to leak the PWA’s installation status and custom icon cross-origin, and permanently corrupt the V8 context for the first extension attempting to inject into the page.
Affected files:
chrome/browser/web_applications/web_app_utils.ccchrome/common/chrome_isolated_world_ids.h
Estimated timestamp from git blame: 2023-04-25
Summary
A logic vulnerability exists in AppIconFetcherTask used for generating offline error pages for Web Apps. When a navigation fails, the task fetches the app’s icon and injects a script to update the error page. However, the task incorrectly targets the primary main frame (web_contents()->GetPrimaryMainFrame()) instead of the specific subframe where the error occurred, and executes the script using ISOLATED_WORLD_ID_EXTENSIONS.
Potential Exploit Steps
Note: These are suggested steps based on static analysis, as our tooling agent does not run code.
- An attacker creates a page with an
<img id="icon">and an<iframe>pointing to a known PWA or Isolated Web App URL (e.g.,isolated-app://[id]/). - The attacker intentionally delays the main frame’s
loadevent (e.g., using a slow-loading subresource) and induces a network error in the iframe (e.g., relying on an offline state or blocked IWA access). - The subframe failure triggers
ConstructWebAppErrorPage(chrome/browser/web_applications/web_app_utils.cc:519), which identifies the PWA based on the subframe’s URL and creates anAppIconFetcherTask. - The task fetches the icon asynchronously and waits for the main frame to finish loading (
DocumentOnLoadCompletedInPrimaryMainFrame). - Once the attacker allows the main frame to finish loading, the task executes the script
var icon = document.getElementById('icon'); icon.src =...inside the primary main frame usingISOLATED_WORLD_ID_EXTENSIONS. - The attacker’s page observes the
srcchange on their<img id="icon">element, successfully leaking the PWA’s custom icon and confirming its installation status cross-origin.
Extension Capability Downgrade (DoS)
Furthermore, because the browser directly injects into ISOLATED_WORLD_ID_EXTENSIONS (ID 5), V8 creates a script context for this world.
ScriptContextSet::Registerattempts to classify this context, but sinceIsolatedWorldManagerhas no mapping for world ID 5 (because an actual extension didn’t initiate it), it is classified asmojom::ContextType::kUnspecifiedinstead ofkContentScript.- The context is initialized without the
chrome.*Extension APIs. - When an actual Chrome Extension later attempts to inject a content script into the attacker’s page,
IsolatedWorldManagerassigns it the first available ID, which is 5. - V8 reuses the already-created, broken script context. The extension script executes but lacks its required APIs, causing a Denial of Service for that extension on the attacker’s page.
Recommended Fix
AppIconFetcherTaskshould store aWeakPtr<content::RenderFrameHost>or use aRenderFrameHostIdto target the exact subframe where the navigation failed, rather than always usingweb_contents()->GetPrimaryMainFrame().- The injection should use
ISOLATED_WORLD_ID_CHROME_INTERNALinstead ofISOLATED_WORLD_ID_EXTENSIONSto prevent overlapping with extension contexts and corrupting their V8 environments.
Evaluated with Chrome root at commit: eca8648a4e1cdfdda68c495a6003059fed641955
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.