CVE-2026-14155
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/storage_access_api/storage_access_grant_permission_context.cc |
modified |
Files Changed
chrome/browser/storage_access_api/storage_access_grant_permission_context.ccchrome/browser/storage_access_api/storage_access_grant_permission_context_unittest.cc
Patch
From 4ed9ae855d59587800f417c986e74d45af3753b2 Mon Sep 17 00:00:00 2001
From: Chris Fredrickson <cfredric@chromium.org>
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 <anushamuley@google.com>
Commit-Queue: Chris Fredrickson <cfredric@chromium.org>
Auto-Submit: Chris Fredrickson <cfredric@chromium.org>
Commit-Queue: Anusha Muley <anushamuley@google.com>
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,
Regression Test / PoC
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,
Original Bug Report
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.)
- In a standard, non-restricted context, visit
https://top.comwhich embeds a standard cross-site iframehttps://embed.com. Request storage access and obtain a grant, persistingSTORAGE_ACCESS(embed.com, top.com)inHostContentSettingsMapasALLOW. - Navigate to
https://top.comloaded with a credentialless iframe<iframe credentialless src="https://embed.com">. - Inside the credentialless iframe, execute:
navigator.permissions.query({name: 'storage-access'}).then(s => { console.log(s.state); }); - Observe if the restricted iframe successfully reads the unpartitioned, profile-persistent storage access grant (resolving to
'granted'instead of'prompt'). - Attach an
onchangeevent 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.