Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient policy enforcement in StorageAccessAPI
DescriptionInsufficient policy enforcement in StorageAccessAPI
ComponentStorageAccessAPI
Bug ClassLogic Error
Tracker518246925
Fix commit4ed9ae855d59 (chromium/src) +85/-8
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Changed Functions

FunctionChangeNotes
if
chrome/browser/storage_access_api/storage_access_grant_permission_context.cc
modified

Files Changed

  • chrome/browser/storage_access_api/storage_access_grant_permission_context.cc
  • chrome/browser/storage_access_api/storage_access_grant_permission_context_unittest.cc
From 4ed9ae855d59587800f417c986e74d45af3753b2 Mon Sep 17 00:00:00 2001
From: Chris Fredrickson <[email protected]>
Date: Mon, 01 Jun 2026 12:54:35 -0700
Subject: [PATCH] [SAA] Fix permission status leak for credentialless iframes

This fixes a 1-bit leak of the permission status into credentialless
iframes and sandboxed iframes. (Note that fenced frames did not have
this leak, since there is a permissions-framework-level override that
rewrites all permission statuses to DENIED within fenced frames.)

Fixed: 518246925
Change-Id: Id78e88a5ad70216fa723cc27b9cefaf254aa1f8c
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7886965
Reviewed-by: Anusha Muley <[email protected]>
Commit-Queue: Chris Fredrickson <[email protected]>
Auto-Submit: Chris Fredrickson <[email protected]>
Commit-Queue: Anusha Muley <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1639603}
---

diff --git a/chrome/browser/storage_access_api/storage_access_grant_permission_context.cc b/chrome/browser/storage_access_api/storage_access_grant_permission_context.cc
index 6de3a6b..5b111ba 100644
--- a/chrome/browser/storage_access_api/storage_access_grant_permission_context.cc
+++ b/chrome/browser/storage_access_api/storage_access_grant_permission_context.cc
@@ -224,17 +224,21 @@
   return fedcm_context;
 }
 
+bool IsAccessRestrictedInFrame(content::RenderFrameHost* rfh) {
+  return rfh->GetLastCommittedOrigin().opaque() || rfh->IsCredentialless() ||
+         rfh->IsNestedWithinFencedFrame() ||
+         rfh->IsSandboxed(
+             network::mojom::WebSandboxFlags::kStorageAccessByUserActivation) ||
+         rfh->GetStorageKey().ForbidsUnpartitionedStorageAccess();
+}
+
 // Verifies that the given RenderFrameHost is allowed to request this
 // permission. If the RenderFrameHost is not allowed to request permission, this
 // calls `bad_message::ReceivedBadMessage` to close the pipe.
 base::expected<void, content::PermissionStatusSource>
 ValidatePermissionEligibility(content::RenderFrameHost* rfh,
                               const net::SchemefulSite& requesting_site) {
-  if (rfh->GetLastCommittedOrigin().opaque() || rfh->IsCredentialless() ||
-      rfh->IsNestedWithinFencedFrame() ||
-      rfh->IsSandboxed(
-          network::mojom::WebSandboxFlags::kStorageAccessByUserActivation) ||
-      rfh->GetStorageKey().ForbidsUnpartitionedStorageAccess()) {
+  if (IsAccessRestrictedInFrame(rfh)) {
     // No need to log anything here, since well-behaved renderers have already
     // done these checks and have logged to the console. This block is to handle
     // compromised renderers.
@@ -583,9 +587,15 @@
     content::RenderFrameHost* render_frame_host,
     const GURL& requesting_origin,
     const GURL& embedding_origin) const {
-  // Permission query from top-level frame should be "granted" by default.
-  if (render_frame_host && render_frame_host->IsInPrimaryMainFrame()) {
-    return CONTENT_SETTING_ALLOW;
+  if (render_frame_host) {
+    if (IsAccessRestrictedInFrame(render_frame_host)) {
+      return CONTENT_SETTING_ASK;
+    }
+
+    // Permission query from top-level frame should be "granted" by default.
+    if (render_frame_host->IsInPrimaryMainFrame()) {
+      return CONTENT_SETTING_ALLOW;
+    }
   }
 
   ContentSetting setting = permissions::ContentSettingPermissionContextBase::
diff --git a/chrome/browser/storage_access_api/storage_access_grant_permission_context_unittest.cc b/chrome/browser/storage_access_api/storage_access_grant_permission_context_unittest.cc
index 6c0734b0..ff068b66 100644
--- a/chrome/browser/storage_access_api/storage_access_grant_permission_context_unittest.cc
+++ b/chrome/browser/storage_access_api/storage_access_grant_permission_context_unittest.cc
@@ -49,6 +49,7 @@
 #include "content/public/common/content_features.h"
 #include "content/public/test/browser_test_utils.h"
 #include "content/public/test/mock_render_process_host.h"
+#include "content/public/test/navigation_simulator.h"
 #include "content/public/test/test_renderer_host.h"
 #include "content/public/test/web_contents_tester.h"
 #include "net/base/schemeful_site.h"
@@ -363,6 +364,72 @@
                    ->bad_msg_count());
 }
 
+TEST_F(StorageAccessGrantPermissionContextTest,
+       FencedFrameQueryReturnsDeniedEvenWithGrant) {
+  NavigateAndCommit(GetTopLevelURL());
+
+  // Set an explicit grant.
+  HostContentSettingsMap* settings_map =
+      HostContentSettingsMapFactory::GetForProfile(profile());
+  settings_map->SetContentSettingDefaultScope(
+      GetRequesterURL(), GetTopLevelURL(), ContentSettingsType::STORAGE_ACCESS,
+      CONTENT_SETTING_ALLOW);
+
+  content::RenderFrameHost* fenced_frame_rfh =
+      content::RenderFrameHostTester::For(main_rfh())->AppendFencedFrame();
+
+  // The permissions framework transforms all permissions statuses to `DENIED`
+  // within fenced frames.
+  EXPECT_EQ(
+      PermissionStatus::DENIED,
+      permission_context()
+          ->GetPermissionStatus(
+              content::PermissionDescriptorUtil::
+                  CreatePermissionDescriptorForPermissionType(
+                      permissions::PermissionUtil::
+                          ContentSettingsTypeToPermissionType(
+                              permission_context()->content_settings_type())),
+              fenced_frame_rfh, GetRequesterURL(), GetTopLevelURL())
+          .status);
+}
+
+TEST_F(StorageAccessGrantPermissionContextTest,
+       CredentiallessFrameQueryReturnsAskEvenWithGrant) {
+  NavigateAndCommit(GetTopLevelURL());
+
+  // Set an explicit grant.
+  HostContentSettingsMap* settings_map =
+      HostContentSettingsMapFactory::GetForProfile(profile());
+  settings_map->SetContentSettingDefaultScope(
+      GetRequesterURL(), GetTopLevelURL(), ContentSettingsType::STORAGE_ACCESS,
+      CONTENT_SETTING_ALLOW);
+
+  // Create a credentialless child frame.
+  content::RenderFrameHost* child_rfh =
+      content::RenderFrameHostTester::For(main_rfh())
+          ->AppendCredentiallessChild("child");
+  std::unique_ptr<content::NavigationSimulator> navigation =
+      content::NavigationSimulator::CreateRendererInitiated(GetRequesterURL(),
+                                                            child_rfh);
+  navigation->Commit();
+  child_rfh = navigation->GetFinalRenderFrameHost();
+  ASSERT_TRUE(child_rfh->IsCredentialless());
+
+  // Querying permission from a credentialless frame should return ASK (prompt)
+  // even if there is a grant.
+  EXPECT_EQ(
+      PermissionStatus::ASK,
+      permission_context()
+          ->GetPermissionStatus(
+              content::PermissionDescriptorUtil::
+                  CreatePermissionDescriptorForPermissionType(
+                      permissions::PermissionUtil::
+                          ContentSettingsTypeToPermissionType(
+                              permission_context()->content_settings_type())),
+              child_rfh, GetRequesterURL(), GetTopLevelURL())
+          .status);
+}
+
 // Test that after a successful explicit storage access grant, there's a content
 // setting that applies on an (embedded site, top-level site) scope.
 TEST_F(StorageAccessGrantPermissionContextTest,
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/storage_access_api/storage_access_grant_permission_context_unittest.cc b/chrome/browser/storage_access_api/storage_access_grant_permission_context_unittest.cc
index 6c0734b0..ff068b66 100644
--- a/chrome/browser/storage_access_api/storage_access_grant_permission_context_unittest.cc
+++ b/chrome/browser/storage_access_api/storage_access_grant_permission_context_unittest.cc
@@ -49,6 +49,7 @@
 #include "content/public/common/content_features.h"
 #include "content/public/test/browser_test_utils.h"
 #include "content/public/test/mock_render_process_host.h"
+#include "content/public/test/navigation_simulator.h"
 #include "content/public/test/test_renderer_host.h"
 #include "content/public/test/web_contents_tester.h"
 #include "net/base/schemeful_site.h"
@@ -363,6 +364,72 @@
                    ->bad_msg_count());
 }
 
+TEST_F(StorageAccessGrantPermissionContextTest,
+       FencedFrameQueryReturnsDeniedEvenWithGrant) {
+  NavigateAndCommit(GetTopLevelURL());
+
+  // Set an explicit grant.
+  HostContentSettingsMap* settings_map =
+      HostContentSettingsMapFactory::GetForProfile(profile());
+  settings_map->SetContentSettingDefaultScope(
+      GetRequesterURL(), GetTopLevelURL(), ContentSettingsType::STORAGE_ACCESS,
+      CONTENT_SETTING_ALLOW);
+
+  content::RenderFrameHost* fenced_frame_rfh =
+      content::RenderFrameHostTester::For(main_rfh())->AppendFencedFrame();
+
+  // The permissions framework transforms all permissions statuses to `DENIED`
+  // within fenced frames.
+  EXPECT_EQ(
+      PermissionStatus::DENIED,
+      permission_context()
+          ->GetPermissionStatus(
+              content::PermissionDescriptorUtil::
+                  CreatePermissionDescriptorForPermissionType(
+                      permissions::PermissionUtil::
+                          ContentSettingsTypeToPermissionType(
+                              permission_context()->content_settings_type())),
+              fenced_frame_rfh, GetRequesterURL(), GetTopLevelURL())
+          .status);
+}
+
+TEST_F(StorageAccessGrantPermissionContextTest,
+       CredentiallessFrameQueryReturnsAskEvenWithGrant) {
+  NavigateAndCommit(GetTopLevelURL());
+
+  // Set an explicit grant.
+  HostContentSettingsMap* settings_map =
+      HostContentSettingsMapFactory::GetForProfile(profile());
+  settings_map->SetContentSettingDefaultScope(
+      GetRequesterURL(), GetTopLevelURL(), ContentSettingsType::STORAGE_ACCESS,
+      CONTENT_SETTING_ALLOW);
+
+  // Create a credentialless child frame.
+  content::RenderFrameHost* child_rfh =
+      content::RenderFrameHostTester::For(main_rfh())
+          ->AppendCredentiallessChild("child");
+  std::unique_ptr<content::NavigationSimulator> navigation =
+      content::NavigationSimulator::CreateRendererInitiated(GetRequesterURL(),
+                                                            child_rfh);
+  navigation->Commit();
+  child_rfh = navigation->GetFinalRenderFrameHost();
+  ASSERT_TRUE(child_rfh->IsCredentialless());
+
+  // Querying permission from a credentialless frame should return ASK (prompt)
+  // even if there is a grant.
+  EXPECT_EQ(
+      PermissionStatus::ASK,
+      permission_context()
+          ->GetPermissionStatus(
+              content::PermissionDescriptorUtil::
+                  CreatePermissionDescriptorForPermissionType(
+                      permissions::PermissionUtil::
+                          ContentSettingsTypeToPermissionType(
+                              permission_context()->content_settings_type())),
+              child_rfh, GetRequesterURL(), GetTopLevelURL())
+          .status);
+}
+
 // Test that after a successful explicit storage access grant, there's a content
 // setting that applies on an (embedded site, top-level site) scope.
 TEST_F(StorageAccessGrantPermissionContextTest,
Loading diff…

Original Bug Report

reported by [email protected]

Unpartitioned storage access grant leak in credentialless and sandboxed frames

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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: StorageAccessGrantPermissionContext does not validate permission eligibility on the status-read path. This allows a credentialless, sandboxed, or nonced frame to query and observe profile-wide storage access grants via navigator.permissions.query(). This potentially leaks user profile state and enables a cross-partition side-channel via permission change listeners.

Affected files:

  • chrome/browser/storage_access_api/storage_access_grant_permission_context.cc

Estimated timestamp from git blame: 2023-09-21

Potential Unpartitioned Storage Access Grant Leak in Credentialless and Sandboxed Frames

Root Cause Analysis In chrome/browser/storage_access_api/storage_access_grant_permission_context.cc, the write-paths (RequestPermission and DecidePermission) call ValidatePermissionEligibility() to filter out frames that are credentialless, sandboxed without user activation, or have a storage key forbidding unpartitioned storage access.

However, the status-read path, which is invoked when a frame runs navigator.permissions.query({name: 'storage-access'}), resolves to:

ContentSetting
StorageAccessGrantPermissionContext::GetContentSettingStatusInternal(
    content::RenderFrameHost* render_frame_host,
    const GURL& requesting_origin,
    const GURL& embedding_origin) const {
  if (render_frame_host && render_frame_host->IsInPrimaryMainFrame()) {
    return CONTENT_SETTING_ALLOW;
  }

  ContentSetting setting = permissions::ContentSettingPermissionContextBase::
      GetContentSettingStatusInternal(render_frame_host, requesting_origin,
                                      embedding_origin);
  if (setting == CONTENT_SETTING_BLOCK) {
    return CONTENT_SETTING_ASK;
  }
  return setting;
}

This status-read path does not validate frame eligibility. It directly queries the profile-wide HostContentSettingsMap using the requesting and embedding origin URLs. Since credentialless frames commit standard tuple origins (and are only isolated via nonced StorageKeys), the lookup returns the profile-wide CONTENT_SETTING_ALLOW grant if one was previously authorized in a standard context.

Furthermore, registration of the PermissionStatus.onchange listener on the query result registers an observer via the browser-side PermissionControllerImpl::GetSubscriptionCurrentResult(). When a user subsequently grants storage access in a standard context, the restricted frame receives a live state change notification, enabling a cross-partition/cross-sandbox side-channel.

Potential Steps to Reproduce (Note: These are suggested/potential steps, as our tooling agent does not yet have the ability to execute code and run a live proof of concept.)

  1. In a standard, non-restricted context, visit https://top.com which embeds a standard cross-site iframe https://embed.com. Request storage access and obtain a grant, persisting STORAGE_ACCESS(embed.com, top.com) in HostContentSettingsMap as ALLOW.
  2. Navigate to https://top.com loaded with a credentialless iframe <iframe credentialless src="https://embed.com">.
  3. Inside the credentialless iframe, execute:
    navigator.permissions.query({name: 'storage-access'}).then(s => {
      console.log(s.state);
    });
    
  4. Observe if the restricted iframe successfully reads the unpartitioned, profile-persistent storage access grant (resolving to 'granted' instead of 'prompt').
  5. Attach an onchange event listener to the permission status inside the credentialless frame, grant storage access inside a standard sibling frame of the same origin, and observe if the event fires inside the credentialless frame.

Suggested Fix In StorageAccessGrantPermissionContext::GetContentSettingStatusInternal, mirror the check criteria from ValidatePermissionEligibility(). When render_frame_host is present, check if the frame is credentialless, sandboxed without allow-storage-access-by-user-activation, has an opaque origin, or if its StorageKey forbids unpartitioned storage access. If any of these conditions hold, return CONTENT_SETTING_ASK instead of querying the profile-wide HostContentSettingsMap (do not call ReceivedBadMessage, as queries from these frames are technically allowed by Web API designs).

Evaluated with Chrome root at commit: 208ca3371d87589335b108c431b95a36d768dc47


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.

View on issue tracker