CVE-2026-87577
Overview
Background
- Isolated Web App (IWA)
- a highly privileged web app served from a dedicated origin whose frames must be navigationally isolated so that only the app itself can create or navigate into app-origin documents.
- `IsolatedWebAppThrottle`
- a
NavigationThrottle(incontent/browser/renderer_host/isolated_web_app_throttle.cc) that inspects navigations touching an IWA and returnsThrottleAction::PROCEEDor a block action to enforce the isolation invariant. - Opaque origin / precursor tuple
- a
data:(or error/web-bundle) document commits with an opaqueurl::Originthat records the origin that created it as its precursor;url::Origin::GetTupleOrPrecursorTupleIfOpaque()collapses such an opaque origin down to that precursorSchemeHostPort. - Initiator / previous / parent origin
- the throttle distinguishes the origin that started a navigation, the frame’s prior origin, and the embedding parent frame’s origin to decide whether a navigation into the app truly comes from the app.
Root Cause Analysis
The vulnerable path is the origin-comparison logic in IsolatedWebAppThrottle::MaybeThrottleNavigationTransition (function named in the commit subject; the @@ hunk headers do not name it). Before the patch the throttle computed a single web_contents_isolation_tuple via GetTupleOrPrecursorTupleIfOpaque() and then compared the previous, initiator, and parent origins against it also after collapsing them to their precursor tuples. A data: iframe that the app creates commits with an opaque origin whose precursor is the app origin, so its collapsed tuple equals the app’s tuple — meaning the throttle treated an opaque, non-app frame as if it were the app itself and allowed it to initiate and host navigations into the privileged app origin, violating the invariant that only genuine app-origin frames may navigate into the app.
The fix introduces app_origin (the un-collapsed web_contents_isolation_info->origin()) and switches the previous (*prev_origin_ != app_origin), initiator (GetInitiatorOrigin() == app_origin), and parent (GetLastCommittedOrigin() != app_origin) checks to full-origin comparisons, so an opaque data: origin can never compare equal to the app origin. The destination check intentionally keeps precursor-tuple collapsing, which remains correct because being permissive about where an app-initiated navigation goes does not grant a non-app frame authority.
GetTupleOrPrecursorTupleIfOpaque() to the initiator/previous/parent origins, which collapsed an opaque data: frame down to its app precursor and thereby authorized it as the app; the fix compares those origins as full origins so an opaque frame is never mistaken for the app itself.Attack Path
- Load the IWA and spawn a data: subframe
With an Isolated Web App loaded, the app (or content within it) creates a
data:URL iframe, which commits with an opaque origin whose precursor tuple is the app origin. - Attempt a renderer-initiated navigation into the app from the opaque frame
The
data:iframe issues a renderer-initiated navigation of itself (or a child) into the app URL, which is not a genuine app-origin frame. - Precursor collapse bypasses the initiator/parent checks
Pre-patch,
prev_origin_->GetTupleOrPrecursorTupleIfOpaque(), the initiator tuple, and the parent tuple all collapse to the app precursor, so they equalweb_contents_isolation_tuple; the throttle returnsThrottleAction::PROCEEDinstead ofblock_action. - Non-app frame gains app-origin navigation authority The opaque frame is treated as the app, letting it initiate navigations into, or host frames of, the privileged app origin (inferred: this yields access to app-origin document context that isolation is meant to withhold).
Impact Assessment
data: (or similarly opaque error/web-bundle) frame inside an Isolated Web App could bypass the IWA navigation-isolation check and initiate or host navigations into the app’s privileged origin, an incorrect-authorization flaw in the browser process’s navigation-throttle logic. The precondition is an IWA context in which an opaque-origin subframe with the app as its precursor exists. The advisory classifies this as incorrect authorization in Isolated Web Apps; any further escalation to app-origin data access or privileged-API abuse is (inferred) and there is no indication in the patch or advisory of exploitation in the wild.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/renderer_host/isolated_web_app_throttle.cc |
modified | |
TEST_Fcontent/browser/renderer_host/isolated_web_app_throttle_unittest.cc |
modified |
Files Changed
content/browser/renderer_host/isolated_web_app_throttle.cccontent/browser/renderer_host/isolated_web_app_throttle_unittest.cccontent/browser/renderer_host/isolated_web_app_throttle_browsertest.cc
Audit Directions
- Precursor-tuple collapsing on trust decisionsAudit any authorization or isolation check that calls
GetTupleOrPrecursorTupleIfOpaque()on an initiator, previous, or parent origin, since collapsing opaque origins to their precursor grants opaque frames the trust of their creator. - Opaque-origin frames as security principalsReview code that compares origins for equality where one side may be an opaque
data:/error/web-bundle origin; ensure the security-sensitive side uses full-origin comparison rather than tuple/precursor comparison. - Asymmetry between source and destination checksLook for throttles or policy gates that intentionally relax destination checks but must keep source/initiator checks strict, and verify a single shared “tuple” value is not reused across both permissive and strict comparisons.
Patch
From f407661caed4a60715e559f3a734679ba5fa02bb Mon Sep 17 00:00:00 2001
From: greengrape <greengrape@google.com>
Date: Wed, 29 Jul 2026 01:14:44 -0700
Subject: [PATCH] Fix data: URL iframe origin checks in IsolatedWebAppThrottle
This patch updates IsolatedWebAppThrottle::MaybeThrottleNavigationTransition
to differentiate between the true app origin and opaque origins with an
app precursor. It ensures opaque data: iframes cannot bypass apps isolation
by collapsing their origin to their trusted precursor for the initiator check.
Bug: 501700023
TAG=agy
Change-Id: If8ec88b963c5a282a8aaa31b6d0a376b6a6a6964
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8159004
Reviewed-by: Alex Moshchuk <alexmos@chromium.org>
Commit-Queue: Andrew Rayskiy <greengrape@google.com>
Cr-Commit-Position: refs/heads/main@{#1670056}
---
diff --git a/content/browser/renderer_host/isolated_web_app_throttle.cc b/content/browser/renderer_host/isolated_web_app_throttle.cc
index 0cf236b..734bfdd 100644
--- a/content/browser/renderer_host/isolated_web_app_throttle.cc
+++ b/content/browser/renderer_host/isolated_web_app_throttle.cc
@@ -193,15 +193,18 @@
return dest_needs_apps_isolation ? block_action : ThrottleAction::PROCEED;
}
- // We want the following origin checks to be a bit more permissive than
- // usual. In particular, if the isolation, previous, or destination origins
- // are opaque, we want to use their precursor tuple for "origin" comparisons.
- // This lets us allow navigations to/from data, error, or web bundle URLs
- // that originate from the same precursor URLs. Other rules may block these
- // navigations, but for the purpose of this throttle, these navigations are
- // valid.
+ // For the destination origin we want to be a bit more permissive than usual
+ // and use the precursor tuple if the origin is opaque. This lets us allow
+ // navigations to data, error, or web bundle URLs that originate from the
+ // app. Other rules may block these navigations, but for the purpose of this
+ // throttle, these navigations are valid. Note that the previous, initiator,
+ // and parent origins below are intentionally compared as full origins, since
+ // an opaque-origin frame (e.g. a data: URL iframe created by the app) must
+ // not be treated as the app itself when initiating or hosting navigations
+ // into the app.
+ const url::Origin& app_origin = web_contents_isolation_info->origin();
const url::SchemeHostPort& web_contents_isolation_tuple =
- web_contents_isolation_info->origin().GetTupleOrPrecursorTupleIfOpaque();
+ app_origin.GetTupleOrPrecursorTupleIfOpaque();
const url::SchemeHostPort& dest_tuple =
dest_origin_.GetTupleOrPrecursorTupleIfOpaque();
@@ -245,21 +248,13 @@
// initiated by a non-app frame. This ensures that all iframe navigations
// into the app come from the app itself.
if (navigation_handle()->IsRendererInitiated() && prev_origin_ &&
- prev_origin_->GetTupleOrPrecursorTupleIfOpaque() !=
- web_contents_isolation_tuple) {
+ *prev_origin_ != app_origin) {
// Allow the navigation if it was initiated by the app, meaning it has a
// trusted destination URL. This only applies to the initial request, as
// redirect locations come from outside the app.
if (navigation_handle()->GetRedirectChain().size() == 1 &&
- navigation_handle()->GetInitiatorOrigin().has_value()) {
- const url::SchemeHostPort& initiator_tuple =
- navigation_handle()
- ->GetInitiatorOrigin()
- .value()
- .GetTupleOrPrecursorTupleIfOpaque();
- if (initiator_tuple == web_contents_isolation_tuple) {
- return ThrottleAction::PROCEED;
- }
+ navigation_handle()->GetInitiatorOrigin() == app_origin) {
+ return ThrottleAction::PROCEED;
}
return block_action;
}
@@ -267,12 +262,8 @@
// Block iframe navigations to the app's origin if the parent frame
// doesn't belong to the app. This prevents non-app frames from having
// access to an app frame.
- const url::SchemeHostPort& parent_tuple =
- navigation_handle()
- ->GetParentFrame()
- ->GetLastCommittedOrigin()
- .GetTupleOrPrecursorTupleIfOpaque();
- if (parent_tuple != web_contents_isolation_tuple) {
+ if (navigation_handle()->GetParentFrame()->GetLastCommittedOrigin() !=
+ app_origin) {
return block_action;
}
diff --git a/content/browser/renderer_host/isolated_web_app_throttle_browsertest.cc b/content/browser/renderer_host/isolated_web_app_throttle_browsertest.cc
index 68ac43c..05efacd 100644
--- a/content/browser/renderer_host/isolated_web_app_throttle_browsertest.cc
+++ b/content/browser/renderer_host/isolated_web_app_throttle_browsertest.cc
@@ -233,6 +233,45 @@
}
IN_PROC_BROWSER_TEST_F(IsolatedWebAppThrottleBrowserTest,
+ DataIframeInitiatedNavigationIntoAppBlocked) {
+ GURL app_url = GetAppURL();
+ EXPECT_TRUE(NavigateToURL(web_contents(), app_url));
+ EXPECT_EQ(kIsolatedApplication, main_rfh()->GetWebExposedIsolationLevel());
+
+ // A data: URL iframe created by the app commits with an opaque origin whose
+ // precursor is the app origin.
+ RenderFrameHost* iframe =
+ CreateChildIframe(main_rfh(), GURL("data:text/html,body"));
+ EXPECT_TRUE(iframe->GetLastCommittedOrigin().opaque());
+
+ // The data: iframe must not be allowed to navigate itself back into the app.
+ std::unique_ptr<TestNavigationObserver> navigation_observer =
+ SelfNavigateIframeToURL(iframe, app_url);
+ EXPECT_FALSE(navigation_observer->last_navigation_succeeded());
+ EXPECT_EQ(net::ERR_BLOCKED_BY_CLIENT,
+ navigation_observer->last_net_error_code());
+}
+
+IN_PROC_BROWSER_TEST_F(IsolatedWebAppThrottleBrowserTest,
+ AppInitiatedDataIframeNavigationIntoAppAllowed) {
+ GURL app_url = GetAppURL();
+ EXPECT_TRUE(NavigateToURL(web_contents(), app_url));
+ EXPECT_EQ(kIsolatedApplication, main_rfh()->GetWebExposedIsolationLevel());
+
+ RenderFrameHost* iframe =
+ CreateChildIframe(main_rfh(), GURL("data:text/html,body"));
+ EXPECT_TRUE(iframe->GetLastCommittedOrigin().opaque());
+
+ // The app's main frame is allowed to navigate the data: iframe into the app.
+ std::unique_ptr<TestNavigationObserver> navigation_observer =
+ NavigateIframeToUrlFromParent(iframe, app_url);
+ EXPECT_EQ(main_rfh()->GetFrameToken(),
+ navigation_observer->last_initiator_frame_token().value());
+ EXPECT_TRUE(navigation_observer->last_navigation_succeeded());
+ EXPECT_EQ(net::OK, navigation_observer->last_net_error_code());
+}
+
+IN_PROC_BROWSER_TEST_F(IsolatedWebAppThrottleBrowserTest,
AppInitiatedIframeNavigationIntoAppAllowed) {
GURL app_url = GetAppURL();
EXPECT_TRUE(NavigateToURL(web_contents(), app_url));
diff --git a/content/browser/renderer_host/isolated_web_app_throttle_unittest.cc b/content/browser/renderer_host/isolated_web_app_throttle_unittest.cc
index c50eb0e4..ff8ca65 100644
--- a/content/browser/renderer_host/isolated_web_app_throttle_unittest.cc
+++ b/content/browser/renderer_host/isolated_web_app_throttle_unittest.cc
@@ -380,6 +380,53 @@
}
TEST_F(IsolatedWebAppThrottleTest,
+ BlockDataIframeRendererInitiatedNavigationIntoIsolatedWebApp) {
+ CommitBrowserInitiatedNavigation(kAppUrl, coop_coep_headers());
+ EXPECT_EQ(kIsolatedApplication, GetWebExposedIsolationLevel(main_frame_id()));
+ FrameTreeNodeId iframe_id = CreateIframe(main_frame_id(), "test_frame");
+
+ // Navigate the iframe to a data: URL, which commits with an opaque origin
+ // whose precursor is the app origin.
+ const char kDataUrl[] = "data:text/html,body";
+ CommitRendererInitiatedNavigation(iframe_id, kDataUrl);
+ url::Origin iframe_origin =
+ FrameTreeNode::GloballyFindByID(iframe_id)->current_origin();
+ EXPECT_TRUE(iframe_origin.opaque());
+ EXPECT_EQ(
+ url::Origin::Create(GURL(kAppUrl)).GetTupleOrPrecursorTupleIfOpaque(),
+ iframe_origin.GetTupleOrPrecursorTupleIfOpaque());
+
+ // The data: iframe must not be allowed to navigate itself back into the app.
+ auto simulator = StartRendererInitiatedNavigation(iframe_id, kAppUrl2);
+
+ auto start_result = simulator->GetLastThrottleCheckResult();
+ EXPECT_EQ(NavigationThrottle::BLOCK_REQUEST, start_result.action());
+}
+
+TEST_F(IsolatedWebAppThrottleTest, BlockIsolatedIframeInDataIframe) {
+ CommitBrowserInitiatedNavigation(kAppUrl, coop_coep_headers());
+ EXPECT_EQ(kIsolatedApplication, GetWebExposedIsolationLevel(main_frame_id()));
+
+ // Create a data: URL iframe, which commits with an opaque origin whose
+ // precursor is the app origin.
+ FrameTreeNodeId child_iframe_id =
+ CreateIframe(main_frame_id(), "test_frame1");
+ CommitRendererInitiatedNavigation(child_iframe_id, "data:text/html,body");
+ EXPECT_TRUE(FrameTreeNode::GloballyFindByID(child_iframe_id)
+ ->current_origin()
+ .opaque());
+
+ // Try to create an app iframe within the data: iframe.
+ FrameTreeNodeId grandchild_iframe_id =
+ CreateIframe(child_iframe_id, "test_frame2");
+ auto simulator =
+ StartRendererInitiatedNavigation(grandchild_iframe_id, kAppUrl);
+
+ auto start_result = simulator->GetLastThrottleCheckResult();
+ EXPECT_EQ(NavigationThrottle::BLOCK_REQUEST, start_result.action());
+}
+
+TEST_F(IsolatedWebAppThrottleTest,
AllowIframeBrowserInitiatedNavigationIntoIsolatedWebApp) {
CommitBrowserInitiatedNavigation(kAppUrl, coop_coep_headers());
EXPECT_EQ(kIsolatedApplication, GetWebExposedIsolationLevel(main_frame_id()));
Regression Test / PoC
diff --git a/content/browser/renderer_host/isolated_web_app_throttle_browsertest.cc b/content/browser/renderer_host/isolated_web_app_throttle_browsertest.cc
index 68ac43c..05efacd 100644
--- a/content/browser/renderer_host/isolated_web_app_throttle_browsertest.cc
+++ b/content/browser/renderer_host/isolated_web_app_throttle_browsertest.cc
@@ -233,6 +233,45 @@
}
IN_PROC_BROWSER_TEST_F(IsolatedWebAppThrottleBrowserTest,
+ DataIframeInitiatedNavigationIntoAppBlocked) {
+ GURL app_url = GetAppURL();
+ EXPECT_TRUE(NavigateToURL(web_contents(), app_url));
+ EXPECT_EQ(kIsolatedApplication, main_rfh()->GetWebExposedIsolationLevel());
+
+ // A data: URL iframe created by the app commits with an opaque origin whose
+ // precursor is the app origin.
+ RenderFrameHost* iframe =
+ CreateChildIframe(main_rfh(), GURL("data:text/html,body"));
+ EXPECT_TRUE(iframe->GetLastCommittedOrigin().opaque());
+
+ // The data: iframe must not be allowed to navigate itself back into the app.
+ std::unique_ptr<TestNavigationObserver> navigation_observer =
+ SelfNavigateIframeToURL(iframe, app_url);
+ EXPECT_FALSE(navigation_observer->last_navigation_succeeded());
+ EXPECT_EQ(net::ERR_BLOCKED_BY_CLIENT,
+ navigation_observer->last_net_error_code());
+}
+
+IN_PROC_BROWSER_TEST_F(IsolatedWebAppThrottleBrowserTest,
+ AppInitiatedDataIframeNavigationIntoAppAllowed) {
+ GURL app_url = GetAppURL();
+ EXPECT_TRUE(NavigateToURL(web_contents(), app_url));
+ EXPECT_EQ(kIsolatedApplication, main_rfh()->GetWebExposedIsolationLevel());
+
+ RenderFrameHost* iframe =
+ CreateChildIframe(main_rfh(), GURL("data:text/html,body"));
+ EXPECT_TRUE(iframe->GetLastCommittedOrigin().opaque());
+
+ // The app's main frame is allowed to navigate the data: iframe into the app.
+ std::unique_ptr<TestNavigationObserver> navigation_observer =
+ NavigateIframeToUrlFromParent(iframe, app_url);
+ EXPECT_EQ(main_rfh()->GetFrameToken(),
+ navigation_observer->last_initiator_frame_token().value());
+ EXPECT_TRUE(navigation_observer->last_navigation_succeeded());
+ EXPECT_EQ(net::OK, navigation_observer->last_net_error_code());
+}
+
+IN_PROC_BROWSER_TEST_F(IsolatedWebAppThrottleBrowserTest,
AppInitiatedIframeNavigationIntoAppAllowed) {
GURL app_url = GetAppURL();
EXPECT_TRUE(NavigateToURL(web_contents(), app_url));
diff --git a/content/browser/renderer_host/isolated_web_app_throttle_unittest.cc b/content/browser/renderer_host/isolated_web_app_throttle_unittest.cc
index c50eb0e4..ff8ca65 100644
--- a/content/browser/renderer_host/isolated_web_app_throttle_unittest.cc
+++ b/content/browser/renderer_host/isolated_web_app_throttle_unittest.cc
@@ -380,6 +380,53 @@
}
TEST_F(IsolatedWebAppThrottleTest,
+ BlockDataIframeRendererInitiatedNavigationIntoIsolatedWebApp) {
+ CommitBrowserInitiatedNavigation(kAppUrl, coop_coep_headers());
+ EXPECT_EQ(kIsolatedApplication, GetWebExposedIsolationLevel(main_frame_id()));
+ FrameTreeNodeId iframe_id = CreateIframe(main_frame_id(), "test_frame");
+
+ // Navigate the iframe to a data: URL, which commits with an opaque origin
+ // whose precursor is the app origin.
+ const char kDataUrl[] = "data:text/html,body";
+ CommitRendererInitiatedNavigation(iframe_id, kDataUrl);
+ url::Origin iframe_origin =
+ FrameTreeNode::GloballyFindByID(iframe_id)->current_origin();
+ EXPECT_TRUE(iframe_origin.opaque());
+ EXPECT_EQ(
+ url::Origin::Create(GURL(kAppUrl)).GetTupleOrPrecursorTupleIfOpaque(),
+ iframe_origin.GetTupleOrPrecursorTupleIfOpaque());
+
+ // The data: iframe must not be allowed to navigate itself back into the app.
+ auto simulator = StartRendererInitiatedNavigation(iframe_id, kAppUrl2);
+
+ auto start_result = simulator->GetLastThrottleCheckResult();
+ EXPECT_EQ(NavigationThrottle::BLOCK_REQUEST, start_result.action());
+}
+
+TEST_F(IsolatedWebAppThrottleTest, BlockIsolatedIframeInDataIframe) {
+ CommitBrowserInitiatedNavigation(kAppUrl, coop_coep_headers());
+ EXPECT_EQ(kIsolatedApplication, GetWebExposedIsolationLevel(main_frame_id()));
+
+ // Create a data: URL iframe, which commits with an opaque origin whose
+ // precursor is the app origin.
+ FrameTreeNodeId child_iframe_id =
+ CreateIframe(main_frame_id(), "test_frame1");
+ CommitRendererInitiatedNavigation(child_iframe_id, "data:text/html,body");
+ EXPECT_TRUE(FrameTreeNode::GloballyFindByID(child_iframe_id)
+ ->current_origin()
+ .opaque());
+
+ // Try to create an app iframe within the data: iframe.
+ FrameTreeNodeId grandchild_iframe_id =
+ CreateIframe(child_iframe_id, "test_frame2");
+ auto simulator =
+ StartRendererInitiatedNavigation(grandchild_iframe_id, kAppUrl);
+
+ auto start_result = simulator->GetLastThrottleCheckResult();
+ EXPECT_EQ(NavigationThrottle::BLOCK_REQUEST, start_result.action());
+}
+
+TEST_F(IsolatedWebAppThrottleTest,
AllowIframeBrowserInitiatedNavigationIntoIsolatedWebApp) {
CommitBrowserInitiatedNavigation(kAppUrl, coop_coep_headers());
EXPECT_EQ(kIsolatedApplication, GetWebExposedIsolationLevel(main_frame_id()));
Original Bug Report
IWA sandbox bypass via data: URL iframe precursor-collapse
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.
Overview: A logic error in IsolatedWebAppThrottle allows navigations from data: URL iframes into protected Isolated Web App (IWA) routes. The throttle incorrectly treats data: iframes as trusted app origins by collapsing their opaque origin to the IWA precursor.
Affected files:
content/browser/renderer_host/isolated_web_app_throttle.cc
Estimated timestamp from git blame: 2025-05-15
Description
IsolatedWebAppThrottle is responsible for enforcing security boundaries for Isolated Web Apps (IWAs). It ensures that renderer-initiated navigations into an IWA only originate from trusted app frames.
A logic error exists in MaybeThrottleNavigationTransition. The throttle uses GetTupleOrPrecursorTupleIfOpaque() to compare origins for security checks. If an IWA creates a data: URL iframe, the browser assigns the iframe an opaque origin, but sets its precursor to the IWA’s origin.
When a navigation is initiated from this data: iframe to an internal IWA route (isolated-app://...), the throttle evaluates the iframe’s origin (prev_origin_) using GetTupleOrPrecursorTupleIfOpaque(). This collapses the untrusted opaque origin down to the trusted IWA precursor. Consequently, the throttle believes the navigation originated from a trusted same-origin app frame and allows it to proceed.
Impact
If an IWA renders user-influenced content within a data: URL iframe, an attacker can use this bypass to navigate the iframe to internal IWA routes. This circumvents the intended security boundary separating the IWA from untrusted subframes.
Because the data: iframe is not restricted by the parent’s script-src CSP (it is an opaque origin), an attacker can execute script within the iframe and trigger a navigation. This enables GET-based CSRF against internal IWA routes, and potentially exposes DOM-XSS sinks within the protected IWA environment, bypassing the IWA’s isolation guarantees.
Potential Reproduction Steps
- Install an IWA that renders user-influenced HTML in an unsandboxed
data:URL iframe (e.g., a file preview feature). - Provide content to the iframe that executes:
window.location.href = "isolated-app://<app-id>/sensitive-route". - The navigation request is evaluated by
IsolatedWebAppThrottle. - The
prev_origin_check collapses thedata:URL’s opaque origin to the<app-id>, matching the IWA origin. - The navigation is incorrectly permitted, committing the protected internal route within the previously untrusted iframe.
Suggested Fix
Modify IsolatedWebAppThrottle::MaybeThrottleNavigationTransition to differentiate between the true app origin and opaque origins with an app precursor when evaluating the initiator of a navigation into the app.
For prev_origin_ and initiator_origin, the throttle should likely require a strict origin match (or explicitly handle opaque origins) rather than unconditionally collapsing them via GetTupleOrPrecursorTupleIfOpaque(), ensuring that only legitimate same-origin app frames can navigate to isolated-app:// URLs.
Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b
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.