CVE-2026-79213
Overview
Background
- Trusted Web Activity (TWA)
- an Android app that hosts web content for an associated origin; the web content’s permissions can be delegated from the wrapper app’s OS-level Android permissions.
- `InstalledWebappPermissionManager` / `InstalledWebappBridge`
- the Java side that reads the wrapper app’s Android permissions and hands
Permissionrecords over JNI to native (installed_webapp_bridge.cc) so Chrome can compute the webContentSettingfor the TWA origin. - `GeolocationSetting`
- a native structure with two tiers, an approximate (coarse) permission option and a precise (fine) permission option, mirroring
ACCESS_COARSE_LOCATIONandACCESS_FINE_LOCATION. - `hasAndroidPermissions` (any-of) vs. `hasAndroidFineLocationPermission`
- the former returns true if the app holds any of the listed permissions; the latter (new) checks specifically for
ACCESS_FINE_LOCATION.
Root Cause Analysis
When resolving geolocation for a TWA, InstalledWebappBridge::GetInstalledWebappPermissions (in installed_webapp_bridge.cc) constructed a GeolocationSetting by mapping a single incoming setting value into both the approximate and precise fields via ToPermissionOption(setting). That single setting was derived from an any-of ACCESS_COARSE_LOCATION/ACCESS_FINE_LOCATION grant, so a wrapper app that held only ACCESS_COARSE_LOCATION still produced precise = Allowed, granting the web content precise location it was never authorized for.
The fix threads a separate preciseSetting through the InstalledWebappBridge.Permission POD: InstalledWebappPermissionManager.getPermissions now, for GEOLOCATION_WITH_OPTIONS with an ALLOW approximate setting, calls the new hasAndroidFineLocationPermission(packageName) and sets preciseSetting to ALLOW only when fine is actually granted, otherwise BLOCK. Native then reads that value via the new Java_InstalledWebappBridge_getPreciseSettingFromPermission and uses it for the precise member of GeolocationSetting, while the approximate member keeps the existing any-of semantics. This works because the precise tier is now derived from the fine-specific OS grant rather than being a duplicate of the coarse-or-fine grant.
ACCESS_FINE_LOCATION grant; the fix computes preciseSetting from a dedicated fine-location check so coarse-only wrapper apps no longer report precise=Allowed.Attack Path
- Wrapper app holds only coarse location
A TWA’s Android wrapper app requests/holds
ACCESS_COARSE_LOCATION(but notACCESS_FINE_LOCATION) and the user allows location for the TWA origin (ContentSetting.ALLOW). - Chrome reads the delegated permission
getPermissions(GEOLOCATION_WITH_OPTIONS)reads the any-of grant and, pre-patch, packages a singlesettinginto thePermissionrecord. - Precise tier is inflated
Native
GetInstalledWebappPermissionsmaps that single value into bothGeolocationSettingfields, so the precise tier is reported asAlloweddespite no fine grant. - Web content obtains precise location The TWA’s web content requesting high-accuracy geolocation is served precise location it was not authorized for, an over-broad grant / incorrect authorization (privacy exposure).
Impact Assessment
GEOLOCATION_WITH_OPTIONS grant where the wrapper app holds coarse but not fine location and the origin’s content setting is ALLOW. The advisory classifies it as incorrect authorization in WebAppInstalls; no exploited-in-the-wild status is stated in the provided material.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/android/java/src/org/chromium/chrome/browser/browserservices/permissiondelegation/InstalledWebappPermissionManager.java |
modified | |
ifchrome/android/junit/src/org/chromium/chrome/browser/browserservices/permissiondelegation/InstalledWebappPermissionManagerTest.java |
modified | |
ifchrome/browser/webapps/installable/installed_webapp_bridge.cc |
modified |
Files Changed
chrome/android/java/src/org/chromium/chrome/browser/browserservices/permissiondelegation/InstalledWebappBridge.javachrome/android/junit/src/org/chromium/chrome/browser/browserservices/permissiondelegation/InstalledWebappPermissionManagerTest.javachrome/android/java/src/org/chromium/chrome/browser/browserservices/permissiondelegation/InstalledWebappPermissionManager.javachrome/browser/webapps/installable/installed_webapp_bridge.cc
Audit Directions
- Any-of permission checks feeding tiered settingsAudit places where a coarse “any of these permissions” query result is reused for a finer-grained capability; each tier should be derived from its own specific grant.
- Values duplicated across structure fieldsSearch for constructions like
Setting{ToOption(x), ToOption(x)}where the same input populates two distinct fields (approximate/precise, read/write, etc.); this pattern often masks a missing distinct source. - Permission delegation to embedded web contentReview other delegated permissions in TWA/installed-webapp bridges (camera, microphone, etc.) to confirm the web-exposed setting is not broader than the wrapper app’s actual OS grant.
Patch
From ab9ee6d092bd527ba910c995376f3fc1f64063a7 Mon Sep 17 00:00:00 2001 From: Nate Chapin <[email protected]> Date: Mon, 20 Jul 2026 13:03:13 -0700 Subject: [PATCH] [TWA] Derive precise geolocation tier from FINE OS grant InstalledWebappBridge mapped a single any-of ACCESS_COARSE_LOCATION / ACCESS_FINE_LOCATION grant to both fields of GeolocationSetting, so a TWA whose wrapper app held only ACCESS_COARSE_LOCATION was reported as precise=Allowed. Thread a separate preciseSetting through the InstalledWebappBridge.Permission POD, populated from a new hasAndroidFineLocationPermission() check, and use it for the precise member of GeolocationSetting in GetInstalledWebappPermissions(). The approximate tier keeps the existing any-of semantics. TAG=agy CONV=b71eacbb-9620-4275-a780-1697b5d00cfd Fixed: 513119757 Change-Id: I6186412fe145d115309637db0961febe842d088e Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8112120 Reviewed-by: Dibyajyoti Pal <[email protected]> Reviewed-by: Peter Conn <[email protected]> Commit-Queue: Nate Chapin <[email protected]> Cr-Commit-Position: refs/heads/main@{#1664876} --- diff --git a/chrome/android/java/src/org/chromium/chrome/browser/browserservices/permissiondelegation/InstalledWebappBridge.java b/chrome/android/java/src/org/chromium/chrome/browser/browserservices/permissiondelegation/InstalledWebappBridge.java index 40b838d..4f9475e 100644 --- a/chrome/android/java/src/org/chromium/chrome/browser/browserservices/permissiondelegation/InstalledWebappBridge.java +++ b/chrome/android/java/src/org/chromium/chrome/browser/browserservices/permissiondelegation/InstalledWebappBridge.java @@ -31,19 +31,24 @@ * A POD class to store the combination of a permission setting and the origin the permission is * relevant for. * - * It would make more sense for this to be a subclass of - * {@link InstalledWebappPermissionManager} or a top level class. Unfortunately for the JNI - * tool to be able to handle passing a class over the JNI boundary the class either needs to be - * in this file or imported explicitly. Our presubmits don't like explicitly importing classes - * that we don't need to, so it's easier to just let the class live here. + * <p>It would make more sense for this to be a subclass of {@link + * InstalledWebappPermissionManager} or a top level class. Unfortunately for the JNI tool to be + * able to handle passing a class over the JNI boundary the class either needs to be in this + * file or imported explicitly. Our presubmits don't like explicitly importing classes that we + * don't need to, so it's easier to just let the class live here. */ static class Permission { public final Origin origin; + // The primary permission setting. For Geolocation, this represents the approximate setting. public final @ContentSetting int setting; + // The precise permission setting. Only used for Geolocation. + public final @ContentSetting int preciseSetting; - public Permission(Origin origin, @ContentSetting int setting) { + public Permission( + Origin origin, @ContentSetting int setting, @ContentSetting int preciseSetting) { this.origin = origin; this.setting = setting; + this.preciseSetting = preciseSetting; } } @@ -81,6 +86,11 @@ } @CalledByNative + private static int getPreciseSettingFromPermission(Permission permission) { + return permission.preciseSetting; + } + + @CalledByNative private static void decidePermission( @ContentSettingsType.EnumType int type, @JniType("std::string") String originUrl, diff --git a/chrome/android/java/src/org/chromium/chrome/browser/browserservices/permissiondelegation/InstalledWebappPermissionManager.java b/chrome/android/java/src/org/chromium/chrome/browser/browserservices/permissiondelegation/InstalledWebappPermissionManager.java index 41d82165..7ff0084 100644 --- a/chrome/android/java/src/org/chromium/chrome/browser/browserservices/permissiondelegation/InstalledWebappPermissionManager.java +++ b/chrome/android/java/src/org/chromium/chrome/browser/browserservices/permissiondelegation/InstalledWebappPermissionManager.java @@ -77,7 +77,19 @@ @ContentSetting int setting = getPermission(type, origin); if (setting != ContentSetting.DEFAULT) { - permissions.add(new InstalledWebappBridge.Permission(origin, setting)); + @ContentSetting int preciseSetting = setting; + if (type == ContentSettingsType.GEOLOCATION_WITH_OPTIONS) { + String packageName = getDelegatePackageName(origin); + Boolean fineEnabled = hasAndroidFineLocationPermission(packageName); + if (setting == ContentSetting.ALLOW) { + preciseSetting = + (fineEnabled != null && fineEnabled) + ? ContentSetting.ALLOW + : ContentSetting.BLOCK; + } + } + permissions.add( + new InstalledWebappBridge.Permission(origin, setting, preciseSetting)); } } @@ -245,6 +257,14 @@ } /** + * Returns whether the delegate application for the origin has Android fine location permission, + * or {@code null} if it does not exist or did not request fine location permission. + */ + public static @Nullable Boolean hasAndroidFineLocationPermission(@Nullable String packageName) { + return hasAndroidPermissions(packageName, new String[] {ACCESS_FINE_LOCATION}); + } + + /** * Returns whether the delegate application for the origin has any of specific Android * permissions, or {@code null} if it does not exist or did not request those permissions. */ diff --git a/chrome/android/junit/src/org/chromium/chrome/browser/browserservices/permissiondelegation/InstalledWebappPermissionManagerTest.java b/chrome/android/junit/src/org/chromium/chrome/browser/browserservices/permissiondelegation/InstalledWebappPermissionManagerTest.java index de210a1..b605d70 100644 --- a/chrome/android/junit/src/org/chromium/chrome/browser/browserservices/permissiondelegation/InstalledWebappPermissionManagerTest.java +++ b/chrome/android/junit/src/org/chromium/chrome/browser/browserservices/permissiondelegation/InstalledWebappPermissionManagerTest.java @@ -12,6 +12,7 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -34,9 +35,12 @@ import org.robolectric.shadows.ShadowPackageManager; import org.robolectric.util.ReflectionHelpers; +import org.chromium.base.ActivityState; +import org.chromium.base.ApplicationStatus; import org.chromium.base.FeatureOverrides; import org.chromium.base.test.BaseRobolectricTestRule; import org.chromium.base.test.util.Feature; +import org.chromium.chrome.browser.customtabs.CustomTabActivity; import org.chromium.chrome.browser.webapps.WebappRegistry; import org.chromium.components.content_settings.ContentSetting; import org.chromium.components.content_settings.ContentSettingsType; @@ -45,6 +49,7 @@ import java.util.Arrays; import java.util.Collection; +import java.util.Collections; /** Tests for {@link InstalledWebappPermissionManager}. */ @RunWith(ParameterizedRobolectricTestRunner.class) @@ -237,4 +242,89 @@ .setStateForOrigin( eq(mOrigin), eq(PACKAGE_NAME), anyString(), eq(mType), eq(settingValue)); } + + @Test + @Feature("TrustedWebActivities") + public void getPermissions_distinguishesPreciseFromApproximate() { + if (mType != ContentSettingsType.GEOLOCATION_WITH_OPTIONS) { + return; + } + + // Mock store to return our origin. + when(mStore.getStoredOrigins()).thenReturn(Collections.singleton(mOrigin.toString())); + + // Mock CustomTabActivity. + CustomTabActivity activity = mock(CustomTabActivity.class); + when(activity.isInTwaMode()).thenReturn(true); + + // Register activity with ApplicationStatus. + // We need to transition it to CREATED then RESUMED to mimic lifecycle. + ApplicationStatus.onStateChangeForTesting(activity, ActivityState.CREATED); + ApplicationStatus.onStateChangeForTesting(activity, ActivityState.RESUMED); + + try { + // Scenario 1: Coarse only granted. + { + PackageInfo packageInfo = new PackageInfo(); + packageInfo.packageName = PACKAGE_NAME; + packageInfo.requestedPermissions = new String[] {ACCESS_COARSE_LOCATION}; + packageInfo.requestedPermissionsFlags = + new int[] {PackageInfo.REQUESTED_PERMISSION_GRANTED}; + mShadowPackageManager.installPackage(packageInfo); + + setStoredLocationPermission(ContentSetting.ALLOW); + + InstalledWebappBridge.Permission[] permissions = + InstalledWebappPermissionManager.getPermissions(mType); + assertEquals(1, permissions.length); + assertEquals(mOrigin, permissions[0].origin); + assertEquals(ContentSetting.ALLOW, permissions[0].setting); // approximate + assertEquals(ContentSetting.BLOCK, permissions[0].preciseSetting); // precise + } + + // Scenario 2: Both granted. + { + PackageInfo packageInfo = new PackageInfo(); + packageInfo.packageName = PACKAGE_NAME; + packageInfo.requestedPermissions = + new String[] {ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION}; + packageInfo.requestedPermissionsFlags = + new int[] { + PackageInfo.REQUESTED_PERMISSION_GRANTED, + PackageInfo.REQUESTED_PERMISSION_GRANTED + }; + mShadowPackageManager.installPackage(packageInfo); + + setStoredLocationPermission(ContentSetting.ALLOW); + + InstalledWebappBridge.Permission[] permissions = + InstalledWebappPermissionManager.getPermissions(mType); + assertEquals(1, permissions.length); + assertEquals(ContentSetting.ALLOW, permissions[0].setting); // approximate + assertEquals(ContentSetting.ALLOW, permissions[0].preciseSetting); // precise + } + + // Scenario 3: Both requested, but only coarse granted. + { + PackageInfo packageInfo = new PackageInfo(); + packageInfo.packageName = PACKAGE_NAME; + packageInfo.requestedPermissions = + new String[] {ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION}; + packageInfo.requestedPermissionsFlags = + new int[] {PackageInfo.REQUESTED_PERMISSION_GRANTED, 0}; + mShadowPackageManager.installPackage(packageInfo); + + setStoredLocationPermission(ContentSetting.ALLOW); + + InstalledWebappBridge.Permission[] permissions = + InstalledWebappPermissionManager.getPermissions(mType); + assertEquals(1, permissions.length); + assertEquals(ContentSetting.ALLOW, permissions[0].setting); // approximate + assertEquals(ContentSetting.BLOCK, permissions[0].preciseSetting); // precise + } + } finally { + // Clean up activity. + ApplicationStatus.onStateChangeForTesting(activity, ActivityState.DESTROYED); + } + } } diff --git a/chrome/browser/webapps/installable/installed_webapp_bridge.cc b/chrome/browser/webapps/installable/installed_webapp_bridge.cc index 0ae847a6..42d9990c 100644 --- a/chrome/browser/webapps/installable/installed_webapp_bridge.cc +++ b/chrome/browser/webapps/installable/installed_webapp_bridge.cc @@ -92,9 +92,12 @@ Java_InstalledWebappBridge_getSettingFromPermission(env, j_permission)); PermissionSetting permission_setting = setting; if (type == ContentSettingsType::GEOLOCATION_WITH_OPTIONS) { - permission_setting = - GeolocationSetting{content_settings::ToPermissionOption(setting), - content_settings::ToPermissionOption(setting)}; + ContentSetting precise_setting = IntToContentSetting( + Java_InstalledWebappBridge_getPreciseSettingFromPermission( + env, j_permission)); + permission_setting = GeolocationSetting{ + content_settings::ToPermissionOption(setting), + content_settings::ToPermissionOption(precise_setting)}; } rules.emplace_back(origin, permission_setting); }
Original Bug Report
Potential privacy bypass in Android TWA location delegation flattens precision controls
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: Chrome’s Trusted Web Activity (TWA) permission delegation flattens Android 12+ location precision controls, incorrectly promoting approximate grants to precise access. This potentially allows web origins to bypass permission prompts and access high-accuracy GPS data in regular tabs despite the user selecting approximate-only access for the corresponding TWA app.
Affected files:
chrome/browser/webapps/installable/installed_webapp_bridge.ccchrome/android/java/src/org/chromium/chrome/browser/browserservices/permissiondelegation/InstalledWebappPermissionManager.javachrome/browser/webapps/installable/installed_webapp_provider.cc
Estimated timestamp from git blame: 2025-09-09
Summary
A logic flaw in the Trusted Web Activity (TWA) permission delegation system on Android causes ‘Approximate’ location permissions to be incorrectly promoted to ‘Precise’ location permissions. When a user grants only approximate location access to a TWA wrapper app, the delegation bridge in Chrome treats this as a full grant for both approximate and precise location. This allows the web origin to obtain high-accuracy GPS data via Chrome’s internal geolocation service in regular tabs, bypassing the user’s explicit choice.
Root Cause Analysis
The issue exists in how location permissions are processed as they flow from the Android OS through the Java layer and into the C++ bridge:
-
Java Layer Flattening: In
InstalledWebappPermissionManager.java, the methodhasAndroidLocationPermissionchecks for eitherACCESS_COARSE_LOCATIONorACCESS_FINE_LOCATION. If either is granted, it returnstrue(chrome/android/java/src/org/chromium/chrome/browser/browserservices/permissiondelegation/InstalledWebappPermissionManager.java:244). This boolean state is converted to a singleContentSetting.ALLOWvalue, losing the distinction between coarse and fine access. -
C++ Bridge Inflation: In
installed_webapp_bridge.cc, theGetInstalledWebappPermissionsmethod receives this setting. ForGEOLOCATION_WITH_OPTIONS, it unconditionally sets both theapproximateandprecisefields of theGeolocationSettingtokAllowedif the incoming setting isALLOW(chrome/browser/webapps/installable/installed_webapp_bridge.cc:94-98). -
Provider Leakage: The
InstalledWebappProvideris registered with high precedence inHostContentSettingsMap. The delegation logic is active ifisRunningTwa()is true, which currently relies onApplicationStatus.getLastTrackedFocusedActivity()(chrome/android/java/src/org/chromium/chrome/browser/browserservices/permissiondelegation/InstalledWebappPermissionManager.java:57). In multi-window or split-screen scenarios, if a TWA was the last focused activity, a regular Chrome tab on the same origin can inherit these inflated permissions.
Since the permission check passes for ‘precise’ access, Chrome proceeds to use its own system-level privileged location access to fulfill the request, providing high-accuracy coordinates to the origin.
Potential Steps to Reproduce
- On an Android 12+ device, install a TWA wrapper app for origin
https://example.comthat requests location permissions. - Launch the TWA. When the OS location prompt appears, select ‘Approximate’ (ensure ‘Precise’ is toggled off) and ‘While using the app’.
- Ensure Chrome itself has system-level ‘Precise’ location access.
- Open
https://example.comin a regular Chrome tab. - Put the TWA and the Chrome tab in split-screen mode. Focus the TWA pane so it becomes the last focused activity.
- In the Chrome tab, execute JavaScript to request high-accuracy location:
navigator.geolocation.getCurrentPosition( (pos) => console.log(pos), (err) => console.error(err), {enableHighAccuracy: true} ); - Observe that the Chrome tab potentially receives high-accuracy coordinates without a permission prompt, and
navigator.permissions.query({name:'geolocation'})reports that precise access is allowed.
Suggested Fix
- Modify
InstalledWebappPermissionManager.javato distinguish between coarse and fine location grants (e.g., by returning an integer or a pair of booleans instead of a single boolean). - Update
InstalledWebappBridge::GetInstalledWebappPermissionsin C++ to correctly populate theapproximateandprecisefields of theGeolocationSettingstruct based on the actual Android permission state. - Tighten the
isRunningTwa()check or theInstalledWebappProviderlogic to ensure that delegated permissions do not inappropriately leak into standard Chrome tabs.
Evaluated with Chrome root at commit: b3153093eb3c78c3e88ccf562bcbc20437a04b0e
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.