CVE-2026-13886
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/web_applications/isolated_web_apps/chrome_content_browser_client_isolated_web_apps_part.cc |
modified |
Files Changed
chrome/browser/chrome_content_browser_client.ccchrome/browser/chrome_content_browser_client.hchrome/browser/web_applications/isolated_web_apps/chrome_content_browser_client_isolated_web_apps_part.cc
Patch
From b91bc19194d8d74a1e18600c2f67537e7c3d2bcf Mon Sep 17 00:00:00 2001
From: Vlad Krot <vkrot@google.com>
Date: Fri, 22 May 2026 11:08:23 -0700
Subject: [PATCH] [IWA] enforce headers on navigations and in workers.
This CL makes sure that in Isolated Web Apps CSP, COEP, COOP, CORP
headers are injected on navigation / shared worker main script load /
dedicated worker main script load.
Previously, the headers were only injected via
components/webapps/isolated_web_apps/url_loading/url_loader_factory.h,
however, that is not enough because ServiceWorker interception bypasses
url_loader_factory and correct headers were not injected.
Change-Id: Iad801ac9e59f225521de44bc3bd9aa9c025c2f93
Fixed: 500475136
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7859854
Reviewed-by: Andrew Rayskiy <greengrape@google.com>
Commit-Queue: Vlad Krot <vkrot@google.com>
Cr-Commit-Position: refs/heads/main@{#1635085}
---
diff --git a/chrome/browser/chrome_content_browser_client.cc b/chrome/browser/chrome_content_browser_client.cc
index a3014f4..959f8ff3 100644
--- a/chrome/browser/chrome_content_browser_client.cc
+++ b/chrome/browser/chrome_content_browser_client.cc
@@ -2456,6 +2456,18 @@
#endif
}
+void ChromeContentBrowserClient::EnsureRequiredHeadersForIsolatedApp(
+ content::BrowserContext* browser_context,
+ const GURL& url,
+ network::mojom::URLResponseHead* response_head,
+ const std::optional<content::FrameTreeNodeId>& frame_tree_node) {
+#if !BUILDFLAG(IS_ANDROID)
+ ChromeContentBrowserClientIsolatedWebAppsPart::
+ EnsureRequiredHeadersForIsolatedApp(browser_context, url, response_head,
+ frame_tree_node);
+#endif
+}
+
bool ChromeContentBrowserClient::ShouldTryToUseExistingProcessHost(
content::BrowserContext* browser_context,
const GURL& url) {
diff --git a/chrome/browser/chrome_content_browser_client.h b/chrome/browser/chrome_content_browser_client.h
index 9e3afb46..6108a30 100644
--- a/chrome/browser/chrome_content_browser_client.h
+++ b/chrome/browser/chrome_content_browser_client.h
@@ -34,6 +34,7 @@
#include "content/public/browser/child_process_security_policy.h"
#include "content/public/browser/content_browser_client.h"
#include "content/public/browser/digital_identity_provider.h"
+#include "content/public/browser/frame_tree_node_id.h"
#include "content/public/browser/legacy_tech_cookie_issue_details.h"
#include "content/public/browser/web_contents.h"
#include "content/public/common/alternative_error_page_override_info.mojom-forward.h"
@@ -268,6 +269,11 @@
GetBaselinePermissionsPolicyForIsolatedApp(
content::BrowserContext* browser_context,
const url::Origin& app_origin) override;
+ void EnsureRequiredHeadersForIsolatedApp(
+ content::BrowserContext* browser_context,
+ const GURL& url,
+ network::mojom::URLResponseHead* response_head,
+ const std::optional<content::FrameTreeNodeId>& frame_tree_node) override;
bool ShouldTryToUseExistingProcessHost(
content::BrowserContext* browser_context,
const GURL& url) override;
diff --git a/chrome/browser/web_applications/isolated_web_apps/chrome_content_browser_client_isolated_web_apps_part.cc b/chrome/browser/web_applications/isolated_web_apps/chrome_content_browser_client_isolated_web_apps_part.cc
index 9d4d16b5..f52e3044 100644
--- a/chrome/browser/web_applications/isolated_web_apps/chrome_content_browser_client_isolated_web_apps_part.cc
+++ b/chrome/browser/web_applications/isolated_web_apps/chrome_content_browser_client_isolated_web_apps_part.cc
@@ -6,16 +6,33 @@
#include "base/command_line.h"
#include "base/containers/to_vector.h"
+#include "base/feature_list.h"
#include "base/types/expected_macros.h"
#include "build/build_config.h"
#include "chrome/browser/profiles/profile.h"
+#include "chrome/browser/web_applications/isolated_web_apps/chrome_iwa_client.h"
+#include "chrome/browser/web_applications/isolated_web_apps/install/non_installed_bundle_inspection_context.h"
+#include "chrome/browser/web_applications/isolated_web_apps/isolated_web_app_url_info.h"
#include "chrome/browser/web_applications/isolated_web_apps/iwa_permissions_policy_cache.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_utils.h"
+#include "components/webapps/isolated_web_apps/client.h"
+#include "components/webapps/isolated_web_apps/public/header_utils.h"
+#include "components/webapps/isolated_web_apps/scheme.h"
#include "components/webapps/isolated_web_apps/types/iwa_origin.h"
+#include "components/webapps/isolated_web_apps/types/source.h"
#include "content/public/browser/isolated_web_apps_policy.h"
#include "content/public/browser/render_process_host.h"
+#include "content/public/browser/web_contents.h"
#include "content/public/common/content_features.h"
#include "content/public/common/content_switches.h"
+#include "net/http/http_response_headers.h"
+#include "services/network/public/cpp/content_security_policy/content_security_policy.h"
+#include "services/network/public/mojom/content_security_policy.mojom.h"
+#include "services/network/public/mojom/cross_origin_embedder_policy.mojom-shared.h"
+#include "services/network/public/mojom/cross_origin_opener_policy.mojom-shared.h"
+#include "services/network/public/mojom/url_response_head.mojom.h"
#if BUILDFLAG(IS_CHROMEOS)
#include "chromeos/ash/components/browser_context_helper/browser_context_types.h"
@@ -25,6 +42,30 @@
namespace {
+BASE_FEATURE(kIsolatedWebAppEnsureRequiredHeadersOnNavigationAndWorkers,
+ base::FEATURE_ENABLED_BY_DEFAULT);
+
+std::optional<IwaSourceWithMode> GetIwaSource(
+ content::BrowserContext* browser_context,
+ const GURL& url) {
+ Profile* profile = Profile::FromBrowserContext(browser_context);
+ auto info = IsolatedWebAppUrlInfo::Create(url);
+ if (!info.has_value()) {
+ return std::nullopt;
+ }
+
+ WebAppRegistrar& registrar =
+ WebAppProvider::GetForWebApps(profile)->registrar_unsafe();
+ const WebApp* iwa =
+ registrar.GetAppById(info->app_id(), WebAppFilter::IsIsolatedApp());
+ if (!iwa) {
+ return std::nullopt;
+ }
+
+ return IwaSourceWithMode::FromStorageLocation(
+ profile->GetPath(), iwa->isolation_data()->location());
+}
+
using PermissionsPolicyCacheEntry = IwaPermissionsPolicyCache::CacheEntry;
using PermissionPolicyEntryPtr =
blink::mojom::IsolatedAppPermissionPolicyEntryPtr;
@@ -66,6 +107,59 @@
}
// static
+void ChromeContentBrowserClientIsolatedWebAppsPart::
+ EnsureRequiredHeadersForIsolatedApp(
+ content::BrowserContext* browser_context,
+ const GURL& url,
+ network::mojom::URLResponseHead* response_head,
+ const std::optional<content::FrameTreeNodeId>& frame_tree_node) {
+ if (!content::AreIsolatedWebAppsEnabled(browser_context)) {
+ return;
+ }
+ if (!base::FeatureList::IsEnabled(
+ kIsolatedWebAppEnsureRequiredHeadersOnNavigationAndWorkers)) {
+ return;
+ }
+
+ std::optional<web_app::IwaSourceWithMode> iwa_source;
+
+ if (frame_tree_node) {
+ if (auto* web_contents =
+ content::WebContents::FromFrameTreeNodeId(*frame_tree_node)) {
+ if (auto* inspection_context =
+ NonInstalledBundleInspectionContext::FromWebContents(
+ web_contents)) {
+ if (url.GetPath() == kInstallPagePath) {
+ // IWA installation page does not need headers.
+ return;
+ }
+ // Take IWA source from NonInstalledBundleInspectionContext,
+ // since look up via WebAppRegistrar would fail for non yet installed
+ // IWA.
+ iwa_source = inspection_context->source();
+ }
+ }
+ }
+
+ if (!iwa_source) {
+ iwa_source = GetIwaSource(browser_context, url);
+ }
+
+ // Modify raw headers.
+ // Mostly values are taken from parsed headers, but sometimes
+ // headers also accessed, thus they are modified too for consistency.
+ if (auto* headers = response_head->headers.get()) {
+ web_app::iwa::SetRequiredHeadersForIsolatedApp(iwa_source, *headers);
+ }
+
+ // Modify parsed headers.
+ if (auto* parsed_headers = response_head->parsed_headers.get()) {
+ web_app::iwa::SetRequiredParsedHeadersForIsolatedApp(iwa_source,
+ *parsed_headers, url);
+ }
+}
+
+// static
bool ChromeContentBrowserClientIsolatedWebAppsPart::AreIsolatedWebAppsEnabled(
content::BrowserContext* browser_context) {
Profile* profile = Profile::FromBrowserContext(browser_context);
diff --git a/chrome/browser/web_applications/isolated_web_apps/chrome_content_browser_client_isolated_web_apps_part.h b/chrome/browser/web_applications/isolated_web_apps/chrome_content_browser_client_isolated_web_apps_part.h
index aa2cc4e..e88d790 100644
Regression Test / PoC
diff --git a/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_csp_browsertest.cc b/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_csp_browsertest.cc
index 9669d28..b43e6aa 100644
--- a/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_csp_browsertest.cc
+++ b/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_csp_browsertest.cc
@@ -11,9 +11,13 @@
#include "chrome/browser/ui/web_applications/test/isolated_web_app_test_utils.h"
#include "chrome/browser/web_applications/isolated_web_apps/isolated_web_app_url_info.h"
#include "chrome/browser/web_applications/isolated_web_apps/test/isolated_web_app_builder.h"
+#include "chrome/test/base/ui_test_utils.h"
+#include "components/webapps/isolated_web_apps/test_support/signing_keys.h"
+#include "content/public/browser/navigation_handle.h"
#include "content/public/browser/render_frame_host.h"
#include "content/public/test/browser_test.h"
#include "content/public/test/browser_test_utils.h"
+#include "net/http/http_response_headers.h"
#include "net/test/embedded_test_server/embedded_test_server.h"
#include "net/test/embedded_test_server/install_default_websocket_handlers.h"
#include "net/test/test_data_directory.h"
@@ -31,6 +35,19 @@
}
protected:
+ void NavigateAndWaitForTitle(content::RenderFrameHost*& iwa_frame,
+ const GURL& url,
+ const std::u16string& page_title) {
+ ASSERT_TRUE(iwa_frame);
+ content::TitleWatcher title_watcher(
+ content::WebContents::FromRenderFrameHost(iwa_frame), page_title);
+
+ iwa_frame =
+ ui_test_utils::NavigateToURL(GetBrowserFromFrame(iwa_frame), url);
+
+ EXPECT_EQ(page_title, title_watcher.WaitAndGetTitle());
+ }
+
base::FilePath resource_path() {
base::FilePath base_path;
CHECK(base::PathService::Get(base::DIR_SRC_TEST_DATA_ROOT, &base_path));
@@ -272,6 +289,340 @@
EXPECT_EQ("violation", EvalJs(app_frame, test_js));
}
+constexpr char kServiceWorkerRegistrationScript[] = R"js(
+ const policy = trustedTypes.createPolicy('default', {
+ createScriptURL(url) { return new URL(url, document.baseURI); },
+ });
+ const register_service_worker = async () => {
+ const registration = await navigator.serviceWorker.register(
+ policy.createScriptURL('service_worker.js'), { scope: '/' }
+ );
+ const worker = registration.installing ||
+ registration.waiting ||
+ registration.active;
+ await new Promise(resolve => {
+ if (worker.state === 'activated') resolve();
+ worker.addEventListener('statechange', () => {
+ if (worker.state === 'activated') resolve();
+ });
+ });
+ };
+ window.addEventListener('load', (async () => {
+ await register_service_worker();
+ document.title = 'SW Registered';
+ }));
+)js";
+
+constexpr char kIndexHtml[] = R"html(
+ <html>
+ <head>
+ <script type="text/javascript" src="/script.js"></script>
+ </head>
+ </html>
+ )html";
+
+constexpr char kTargetHtml[] = R"html(
+ <html>
+ <head>
+ <script type="text/javascript" src="/target_script.js">
+ </script>
+ <title>Intercepted Target</title>
+ </head>
+ <body>
+ </body>
+ </html>
+ )html";
+
+constexpr char kWaitForWorkerMessageScript[] = R"(
+ new Promise(resolve => {
+ if (window.workerMessage) {
+ resolve(window.workerMessage.success);
+ } else {
+ window.onWorkerMessage = () => resolve(window.workerMessage.success);
+ }
+ })
+)";
+
+// Test that service worker intercepting navigations cannot bypass CSP
+// rules and the page stays in Isolated Context.
+IN_PROC_BROWSER_TEST_F(IsolatedWebAppCspBrowserTest, ServiceWorkerRespondWith) {
+ std::unique_ptr<ScopedBundledIsolatedWebApp> app =
+ IsolatedWebAppBuilder(ManifestBuilder())
+ .AddHtml("/", kIndexHtml)
+ .AddHtml("/target.html", kTargetHtml)
+ .AddJs("/target_script.js", R"(
+ window.evilExecuted = false;
+ )")
+ .AddJs("/script.js", kServiceWorkerRegistrationScript)
+ .AddJs("/service_worker.js", content::JsReplace(R"js(
+ addEventListener('fetch', (event) => {
+ if (event.request.url.endsWith('/target.html')) {
+ const html = `<html>
+ <head>
+ <title>Intercepted Target</title>
+ <script>
+ // Should be blocked by CSP.
+ window.dynamicJsExecuted = true;
+ </script>
+ </head>
+ <body>Intercepted Target page</body>
+ </html>`;
+ event.respondWith(new Response(html, {
+ // No CSP header is provided.
+ headers: {
+ 'Content-Type': 'text/html'
+ }
+ }));
+ }
+ });
+
+ self.addEventListener('activate', (event) => {
+ event.waitUntil(clients.claim());
+ });
+ )js"))
+ .BuildBundle(test::GetDefaultEd25519KeyPair());
+
+ IsolatedWebAppUrlInfo url_info = app->InstallChecked(profile());
+ content::RenderFrameHost* iwa_frame =
+ OpenIsolatedWebApp(profile(), url_info.app_id());
+
+ content::TitleWatcher sw_registered_watcher(
+ content::WebContents::FromRenderFrameHost(iwa_frame), u"SW Registered");
+ EXPECT_EQ(u"SW Registered", sw_registered_watcher.WaitAndGetTitle());
+
+ NavigateAndWaitForTitle(iwa_frame,
+ url_info.origin().GetURL().Resolve("/target.html"),
+ u"Intercepted Target");
+
+ EXPECT_FALSE(EvalJs(iwa_frame, "!!window.dynamicJsExecuted").ExtractBool());
+
+ // Check that page still has isolated context which
+ // means that COEP and COOP headers are injected.
+ EXPECT_TRUE(EvalJs(iwa_frame, "window.crossOriginIsolated").ExtractBool());
+}
+
+// Test that service worker intercepting dedicated worker scripts
+// cannot bypass CSP rules and the page stays in Isolated Context.
+IN_PROC_BROWSER_TEST_F(IsolatedWebAppCspBrowserTest,
+ ServiceWorkerRespondWithDedicatedWorker) {
+ std::unique_ptr<ScopedBundledIsolatedWebApp> app =
+ IsolatedWebAppBuilder(ManifestBuilder())
+ .AddHtml("/", kIndexHtml)
+ .AddHtml("/target.html", kTargetHtml)
+ .AddJs("/target_script.js", R"(
+ const workerPolicy = window.trustedTypes
+ .createPolicy('workerPolicy', {
+ createScriptURL: url => url,
+ });
+ const worker = new Worker(workerPolicy
+ .createScriptURL('worker.js'));
+ worker.addEventListener('message', e => {
+ window.workerMessage = e.data;
+ if (window.onWorkerMessage) window.onWorkerMessage();
+ });
+ )")
+ .AddJs("/script.js", kServiceWorkerRegistrationScript)
+ .AddJs("/service_worker.js", content::JsReplace(R"js(
+ addEventListener('fetch', (event) => {
+ if (event.request.url.endsWith('/worker.js')) {
+ const js = `
+ try {
+ eval("self.evilExecuted = true");
+ postMessage({success: true, evilExecuted: self.evilExecuted,
+ crossOriginIsolated: self.crossOriginIsolated});
+ } catch (e) {
+ postMessage({success: true, evilExecuted: false,
+ crossOriginIsolated: self.crossOriginIsolated,
+ error: e.message});
+ }
+ `;
+ event.respondWith(new Response(js, {
+ headers: {
+ 'Content-Type': 'text/javascript'
+ }
+ }));
+ }
+ });
+
+ self.addEventListener('activate', (event) => {
+ event.waitUntil(clients.claim());
+ });
+ )js"))
+ .BuildBundle(test::GetDefaultEd25519KeyPair());
+
+ IsolatedWebAppUrlInfo url_info = app->InstallChecked(profile());
+ content::RenderFrameHost* iwa_frame =
+ OpenIsolatedWebApp(profile(), url_info.app_id());
+
+ content::TitleWatcher sw_registered_watcher(
+ content::WebContents::FromRenderFrameHost(iwa_frame), u"SW Registered");
+ EXPECT_EQ(u"SW Registered", sw_registered_watcher.WaitAndGetTitle());
+
+ NavigateAndWaitForTitle(iwa_frame,
+ url_info.origin().GetURL().Resolve("/target.html"),
+ u"Intercepted Target");
+
+ // Wait for message from worker.
+ EXPECT_EQ(true, EvalJs(iwa_frame, kWaitForWorkerMessageScript));
+
+ EXPECT_FALSE(
+ EvalJs(iwa_frame, "window.workerMessage.evilExecuted").ExtractBool());
+
+ EXPECT_TRUE(EvalJs(iwa_frame, "window.workerMessage.crossOriginIsolated")
+ .ExtractBool());
+}
+
+// Test that service worker intercepting shared worker scripts
+// cannot bypass CSP rules and the page stays in Isolated Context.
+IN_PROC_BROWSER_TEST_F(IsolatedWebAppCspBrowserTest,
+ ServiceWorkerRespondWithSharedWorker) {
+ std::unique_ptr<ScopedBundledIsolatedWebApp> app =
+ IsolatedWebAppBuilder(ManifestBuilder())
+ .AddHtml("/", kIndexHtml)
+ .AddHtml("/target.html", kTargetHtml)
+ .AddJs("/target_script.js", R"(
+ const workerPolicy = window.trustedTypes
+ .createPolicy('workerPolicy', {
+ createScriptURL: url => url,
+ });
+ const worker = new SharedWorker(workerPolicy.
+ createScriptURL('worker.js'));
+ worker.port.start();
+ worker.port.addEventListener('message', e => {
+ window.workerMessage = e.data;
+ if (window.onWorkerMessage) window.onWorkerMessage();
+ });
+ )")
+ .AddJs("/script.js", kServiceWorkerRegistrationScript)
+ .AddJs("/service_worker.js", content::JsReplace(R"js(
+ addEventListener('fetch', (event) => {
+ if (event.request.url.endsWith('/worker.js')) {
+ const js = `
+ addEventListener('connect', event => {
+ const port = event.ports[0];
+ try {
+ eval("self.evilExecuted = true");
+ port.postMessage({success: true, evilExecuted: self.evilExecuted,
+ crossOriginIsolated: self.crossOriginIsolated});
+ } catch (e) {
+ port.postMessage({success: true, evilExecuted: false,
+ crossOriginIsolated: self.crossOriginIsolated, error: e.message});
+ }
+ });
+ `;
+ event.respondWith(new Response(js, {
+ headers: {
+ 'Content-Type': 'text/javascript'
+ }
+ }));
+ }
+ });
+
+ self.addEventListener('activate', (event) => {
+ event.waitUntil(clients.claim());
+ });
+ )js"))
+ .BuildBundle(test::GetDefaultEd25519KeyPair());
+
+ IsolatedWebAppUrlInfo url_info = app->InstallChecked(profile());
+ content::RenderFrameHost* iwa_frame =
+ OpenIsolatedWebApp(profile(), url_info.app_id());
+
+ content::TitleWatcher sw_registered_watcher(
+ content::WebContents::FromRenderFrameHost(iwa_frame), u"SW Registered");
+ EXPECT_EQ(u"SW Registered", sw_registered_watcher.WaitAndGetTitle());
+
+ NavigateAndWaitForTitle(iwa_frame,
+ url_info.origin().GetURL().Resolve("/target.html"),
+ u"Intercepted Target");
+
+ // Wait for message from worker.
+ EXPECT_EQ(true, EvalJs(iwa_frame, kWaitForWorkerMessageScript));
+
+ EXPECT_FALSE(
+ EvalJs(iwa_frame, "window.workerMessage.evilExecuted").ExtractBool());
+
+ EXPECT_TRUE(EvalJs(iwa_frame, "window.workerMessage.crossOriginIsolated")
+ .ExtractBool());
+}
+
+class RedirectHeaderObserver : public content::WebContentsObserver {
... (truncated)
Original Bug Report
Bypass of mandatory IWA CSP via Service Worker navigation interception
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 security team.
Overview: A potential vulnerability exists where an Isolated Web App (IWA) Service Worker can bypass the mandatory browser-injected Content Security Policy (CSP). By intercepting a navigation request and providing a synthesized response, the Service Worker evades the IWA URL loader factory, allowing unsigned remote code to execute with full IWA privileges.
Affected files:
components/webapps/isolated_web_apps/url_loading/url_loader_factory.cccontent/browser/loader/navigation_url_loader_impl.cccontent/browser/service_worker/service_worker_main_resource_loader.cc
Estimated timestamp from git blame: 2024-07-28
Summary
Isolated Web Apps (IWAs) are granted high-privilege capabilities (such as Direct Sockets, Smart Card access, and unrestricted USB access) based on the guarantee that all executable code is contained within a signed .swbn bundle. This guarantee is enforced by a mandatory, non-overridable, browser-injected Content Security Policy (CSP).
A potential vulnerability exists where an IWA Service Worker can bypass this mandatory CSP injection by intercepting navigation requests via event.respondWith(). This allows a malicious IWA author to serve a synthesized document with no CSP, enabling the execution of unsigned, remotely-fetched scripts while retaining full IWA privileges.
Technical Details
The mandatory CSP for IWAs is injected by the HeaderInjectionURLLoaderClient inside IsolatedWebAppURLLoaderFactoryImpl (components/webapps/isolated_web_apps/url_loading/url_loader_factory.cc). This guarantees that all resources loaded through the isolated-app:// scheme are strictly sandboxed.
However, when a Service Worker intercepts a navigation request, this loader is bypassed:
- During navigation,
NavigationURLLoaderImpl::MaybeStartLoader(content/browser/loader/navigation_url_loader_impl.cc) checks for interceptors. - If an active Service Worker elects to handle the navigation, it provides a
single_request_factory(backed byServiceWorkerMainResourceLoader). NavigationURLLoaderImplroutes the request toStartInterceptedRequest, which uses the Service Worker’s factory. This completely bypasses the scheme-specificIsolatedWebAppURLLoaderFactoryand, consequently, theHeaderInjectionURLLoaderClient.- Within the Service Worker,
event.respondWith()can be used to synthesize a response.ServiceWorkerMainResourceLoader::StartResponsepasses the Service Worker’s synthesized headers directly to the browser viablink::ServiceWorkerLoaderHelpers::SaveResponseInfowithout enforcing the mandatory IWA CSP. - The Service Worker can intentionally omit the CSP header but include
Cross-Origin-Opener-Policy: same-originandCross-Origin-Embedder-Policy: require-corp. - When the navigation commits,
NavigationRequest::ComputeWebExposedIsolationInfosees the valid COOP/COEP headers on anisolated-app://scheme and grants the documentWebExposedIsolationInfo::CreateIsolatedApplication()status. - Since the Service Worker’s own CSP (applied during its installation) allows
connect-src https:, the Service Worker canfetch()a malicious remote script and inline it into the HTML of the synthesized response. The resulting document commits with full IWA privileges and no CSP, executing the remote code.
Potential Attack Scenario
Note: These are potential steps based on code analysis; our tooling agent does not have the ability to run live code.
- An attacker authors a malicious IWA and convinces an enterprise administrator or user to install it.
- The IWA registers a Service Worker on its first load.
- The user navigates to a new page within the app, triggering a navigation
FetchEventin the Service Worker. - The Service Worker intercepts the request (
request.mode === 'navigate') and fetches an unsigned payload from the attacker’s remote server (https://attacker.example/payload.js). - The Service Worker calls
event.respondWith(), synthesizing an HTML response that embeds the remote payload in an inline<script>tag. - The Service Worker sets the
Cross-Origin-Opener-PolicyandCross-Origin-Embedder-Policyheaders to satisfy IWA isolation checks, but deliberately omits theContent-Security-Policyheader. - The browser commits the navigation. Because the IWA URL Loader Factory was bypassed, no default CSP is injected. The document is granted full IWA privileges, and the inline unsigned remote script executes, defeating the signed bundle’s integrity guarantees.
Suggested Fix
The mandatory IWA CSP enforcement relies too heavily on the URL Loader layer, which is inherently bypassable via Service Worker interception.
To fix this, the default IWA CSP should be enforced during the navigation commit phase, independently of the URL Loader that provided the response. Specifically, in NavigationRequest::ComputePoliciesToCommit (content/browser/renderer_host/navigation_request.cc), if the navigation is committing to an isolated-app:// origin, the browser should forcefully append the kIsolatedAppCspTemplate to the document’s PolicyContainer, overriding or merging with any headers provided by the response.
Evaluated with Chrome root at commit: 137d451a126685dd5010e6609db9f6d4a78d8234
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.