Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactPolicy bypass in Isolated Web Apps
DescriptionPolicy bypass in Isolated Web Apps
ComponentIsolated Web Apps
Bug ClassLogic Error
Tracker500475136
Fix commitb91bc19194d8 (chromium/src) +706/-35
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Changed Functions

FunctionChangeNotes
if
chrome/browser/web_applications/isolated_web_apps/chrome_content_browser_client_isolated_web_apps_part.cc
modified

Files Changed

  • chrome/browser/chrome_content_browser_client.cc
  • chrome/browser/chrome_content_browser_client.h
  • chrome/browser/web_applications/isolated_web_apps/chrome_content_browser_client_isolated_web_apps_part.cc
From b91bc19194d8d74a1e18600c2f67537e7c3d2bcf Mon Sep 17 00:00:00 2001
From: Vlad Krot <[email protected]>
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 <[email protected]>
Commit-Queue: Vlad Krot <[email protected]>
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
Loading diff…

Regression Test / PoC

shipped with the fix
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)
Loading diff…

Original Bug Report

reported by [email protected]

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.cc
  • content/browser/loader/navigation_url_loader_impl.cc
  • content/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:

  1. During navigation, NavigationURLLoaderImpl::MaybeStartLoader (content/browser/loader/navigation_url_loader_impl.cc) checks for interceptors.
  2. If an active Service Worker elects to handle the navigation, it provides a single_request_factory (backed by ServiceWorkerMainResourceLoader).
  3. NavigationURLLoaderImpl routes the request to StartInterceptedRequest, which uses the Service Worker’s factory. This completely bypasses the scheme-specific IsolatedWebAppURLLoaderFactory and, consequently, the HeaderInjectionURLLoaderClient.
  4. Within the Service Worker, event.respondWith() can be used to synthesize a response. ServiceWorkerMainResourceLoader::StartResponse passes the Service Worker’s synthesized headers directly to the browser via blink::ServiceWorkerLoaderHelpers::SaveResponseInfo without enforcing the mandatory IWA CSP.
  5. The Service Worker can intentionally omit the CSP header but include Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp.
  6. When the navigation commits, NavigationRequest::ComputeWebExposedIsolationInfo sees the valid COOP/COEP headers on an isolated-app:// scheme and grants the document WebExposedIsolationInfo::CreateIsolatedApplication() status.
  7. Since the Service Worker’s own CSP (applied during its installation) allows connect-src https:, the Service Worker can fetch() 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.

  1. An attacker authors a malicious IWA and convinces an enterprise administrator or user to install it.
  2. The IWA registers a Service Worker on its first load.
  3. The user navigates to a new page within the app, triggering a navigation FetchEvent in the Service Worker.
  4. 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).
  5. The Service Worker calls event.respondWith(), synthesizing an HTML response that embeds the remote payload in an inline <script> tag.
  6. The Service Worker sets the Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers to satisfy IWA isolation checks, but deliberately omits the Content-Security-Policy header.
  7. 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.

View on issue tracker