CVE-2026-14021
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/storage_access_api/storage_access_grant_permission_context_unittest.cc |
modified | |
TEST_Fchrome/browser/storage_access_api/storage_access_grant_permission_context_unittest.cc |
modified |
Files Changed
chrome/browser/bad_message.hchrome/browser/storage_access_api/BUILD.gnchrome/browser/storage_access_api/storage_access_grant_permission_context.ccchrome/browser/storage_access_api/storage_access_grant_permission_context_unittest.cc
Patch
From 2cf6a168efd92e5ad8f4711efeb18ba990b55673 Mon Sep 17 00:00:00 2001 From: Chris Fredrickson <[email protected]> Date: Fri, 29 May 2026 16:35:34 -0700 Subject: [PATCH] [SAA] Fix permissions logic bypass via compromised renderer This fix ensures that a compromised renderer cannot bypass browser-process checks of conditions that should prevent storage access (e.g. fenced frame, credentialless iframe) even if permission has already been granted to the given sites. Previously, such a bypass was possible, because the StorageAccessGrantPermissionContext class called into PermissionContextBase::RequestPermission, which may resolve the permission request, before validating that the requesting context is actually allowed to request permission. Fixed: 517731924 Change-Id: I8ff62e8443d1aa4413f0e116f29d86237025b47e Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7883924 Reviewed-by: Anusha Muley <[email protected]> Commit-Queue: Chris Fredrickson <[email protected]> Auto-Submit: Chris Fredrickson <[email protected]> Reviewed-by: Alexei Svitkine <[email protected]> Reviewed-by: Lei Zhang <[email protected]> Commit-Queue: Alexei Svitkine <[email protected]> Cr-Commit-Position: refs/heads/main@{#1638814} --- diff --git a/chrome/browser/bad_message.h b/chrome/browser/bad_message.h index 449cc96..220ecd6 100644 --- a/chrome/browser/bad_message.h +++ b/chrome/browser/bad_message.h @@ -33,6 +33,7 @@ CCU_SUPERFLUOUS_BIND = 10, RFH_INVALID_WEB_FRAME_URL = 11, PVM_PRINT_FENCED_FRAME = 12, + SAGPC_INVALID_PERMISSION_REQUEST_CONTEXT = 13, // Please add new elements here. The naming convention is abbreviated class // name (e.g. RenderFrameHost becomes RFH) plus a unique description of the diff --git a/chrome/browser/storage_access_api/BUILD.gn b/chrome/browser/storage_access_api/BUILD.gn index 2b48195..026b9b4 100644 --- a/chrome/browser/storage_access_api/BUILD.gn +++ b/chrome/browser/storage_access_api/BUILD.gn @@ -26,6 +26,7 @@ ] deps = [ + "//chrome/browser:bad_message", "//chrome/browser/content_settings:content_settings_factory", "//chrome/browser/first_party_sets", "//chrome/browser/webid", 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 3f31f0d..6de3a6b 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 @@ -15,6 +15,9 @@ #include "base/metrics/histogram_functions.h" #include "base/notreached.h" #include "base/time/time.h" +#include "base/types/expected.h" +#include "base/types/expected_macros.h" +#include "chrome/browser/bad_message.h" #include "chrome/browser/content_settings/cookie_settings_factory.h" #include "chrome/browser/content_settings/host_content_settings_map_factory.h" #include "chrome/browser/first_party_sets/first_party_sets_policy_service.h" @@ -221,6 +224,32 @@ return fedcm_context; } +// 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()) { + // 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. + RecordOutcomeSample(RequestOutcome::kDeniedByPrerequisites, + requesting_site); + bad_message::ReceivedBadMessage( + rfh->GetProcess(), bad_message::BadMessageReason:: + SAGPC_INVALID_PERMISSION_REQUEST_CONTEXT); + return base::unexpected(rfh->IsNestedWithinFencedFrame() + ? content::PermissionStatusSource::FENCED_FRAME + : content::PermissionStatusSource::UNSPECIFIED); + } + return base::ok(); +} + } // namespace // static @@ -259,6 +288,15 @@ // This callback (synchronously) handles the browser side of that. content::GlobalRenderFrameHostId frame_host_id = request_data->id.global_render_frame_host_id(); + + RETURN_IF_ERROR(ValidatePermissionEligibility( + content::RenderFrameHost::FromID(frame_host_id), + net::SchemefulSite(request_data->requesting_origin)), + [&](content::PermissionStatusSource source) { + std::move(callback).Run(content::PermissionResult( + blink::mojom::PermissionStatus::DENIED, source)); + }); + ContentSettingPermissionContextBase::RequestPermission( std::move(request_data), base::BindOnce( @@ -301,24 +339,11 @@ const url::Origin embedding_origin = url::Origin::Create(request_data->embedding_origin); - if (rfh->GetLastCommittedOrigin().opaque() || rfh->IsCredentialless() || - rfh->IsNestedWithinFencedFrame() || - rfh->IsSandboxed( - network::mojom::WebSandboxFlags::kStorageAccessByUserActivation) || - rfh->GetStorageKey().ForbidsUnpartitionedStorageAccess()) { - // 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. - RecordOutcomeSample(RequestOutcome::kDeniedByPrerequisites, - requesting_site); - mojo::ReportBadMessage( - "requestStorageAccess: Must not be called by a fenced frame, iframe " - "with an opaque origin, credentialless iframe, or sandboxed iframe"); - std::move(callback).Run(content::PermissionResult( - blink::mojom::PermissionStatus::DENIED, - content::PermissionStatusSource::FENCED_FRAME)); - return; - } + RETURN_IF_ERROR(ValidatePermissionEligibility(rfh, requesting_site), + [&](content::PermissionStatusSource source) { + std::move(callback).Run(content::PermissionResult( + blink::mojom::PermissionStatus::DENIED, source)); + }); // Return early without letting SAA override any explicit user settings to // block 3p cookies. 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 730c979..6c0734b0 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 @@ -5,6 +5,7 @@ #include "chrome/browser/storage_access_api/storage_access_grant_permission_context.h" #include <memory> +#include <utility> #include "base/check_deref.h" #include "base/metrics/metrics_hashes.h" @@ -47,6 +48,7 @@ #include "content/public/browser/web_contents.h" #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/test_renderer_host.h" #include "content/public/test/web_contents_tester.h" #include "net/base/schemeful_site.h" @@ -261,10 +263,13 @@ EXPECT_EQ(setting, expected_setting); } - permissions::PermissionRequestID CreateFakeID() { + permissions::PermissionRequestID CreateFakeID( + content::RenderFrameHost* rfh = nullptr) { + if (!rfh) { + rfh = web_contents()->GetPrimaryMainFrame(); + } return permissions::PermissionRequestID( - web_contents()->GetPrimaryMainFrame(), - request_id_generator_.GenerateNextId()); + rfh, request_id_generator_.GenerateNextId()); } void WaitUntilPrompt() { @@ -318,6 +323,46 @@ IsEmpty()); } +TEST_F(StorageAccessGrantPermissionContextTest, OpaqueOriginDisallowed) { + NavigateAndCommit(GURL("data:text/html,foo")); + + content::PermissionResult result = + RequestPermission(MakePermissionRequestData(/*user_gesture=*/true)) + .Take(); + + EXPECT_EQ(PermissionStatus::DENIED, result.status); + EXPECT_EQ(content::PermissionStatusSource::UNSPECIFIED, result.source); + + EXPECT_EQ(1, static_cast<content::MockRenderProcessHost*>( + web_contents()->GetPrimaryMainFrame()->GetProcess()) + ->bad_msg_count()); +} + +TEST_F(StorageAccessGrantPermissionContextTest, FencedFrameDisallowed) {
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 730c979..6c0734b0 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
@@ -5,6 +5,7 @@
#include "chrome/browser/storage_access_api/storage_access_grant_permission_context.h"
#include <memory>
+#include <utility>
#include "base/check_deref.h"
#include "base/metrics/metrics_hashes.h"
@@ -47,6 +48,7 @@
#include "content/public/browser/web_contents.h"
#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/test_renderer_host.h"
#include "content/public/test/web_contents_tester.h"
#include "net/base/schemeful_site.h"
@@ -261,10 +263,13 @@
EXPECT_EQ(setting, expected_setting);
}
- permissions::PermissionRequestID CreateFakeID() {
+ permissions::PermissionRequestID CreateFakeID(
+ content::RenderFrameHost* rfh = nullptr) {
+ if (!rfh) {
+ rfh = web_contents()->GetPrimaryMainFrame();
+ }
return permissions::PermissionRequestID(
- web_contents()->GetPrimaryMainFrame(),
- request_id_generator_.GenerateNextId());
+ rfh, request_id_generator_.GenerateNextId());
}
void WaitUntilPrompt() {
@@ -318,6 +323,46 @@
IsEmpty());
}
+TEST_F(StorageAccessGrantPermissionContextTest, OpaqueOriginDisallowed) {
+ NavigateAndCommit(GURL("data:text/html,foo"));
+
+ content::PermissionResult result =
+ RequestPermission(MakePermissionRequestData(/*user_gesture=*/true))
+ .Take();
+
+ EXPECT_EQ(PermissionStatus::DENIED, result.status);
+ EXPECT_EQ(content::PermissionStatusSource::UNSPECIFIED, result.source);
+
+ EXPECT_EQ(1, static_cast<content::MockRenderProcessHost*>(
+ web_contents()->GetPrimaryMainFrame()->GetProcess())
+ ->bad_msg_count());
+}
+
+TEST_F(StorageAccessGrantPermissionContextTest, FencedFrameDisallowed) {
+ NavigateAndCommit(GetTopLevelURL());
+
+ content::RenderFrameHost* fenced_frame_rfh =
+ content::RenderFrameHostTester::For(main_rfh())->AppendFencedFrame();
+
+ auto request_data = std::make_unique<permissions::PermissionRequestData>(
+ content::PermissionDescriptorUtil::
+ CreatePermissionDescriptorForPermissionType(
+ permissions::PermissionUtil::ContentSettingsTypeToPermissionType(
+ ContentSettingsType::STORAGE_ACCESS)),
+ CreateFakeID(fenced_frame_rfh), /*user_gesture=*/true, GetRequesterURL(),
+ GetTopLevelURL());
+
+ content::PermissionResult result =
+ RequestPermission(std::move(request_data)).Take();
+
+ EXPECT_EQ(PermissionStatus::DENIED, result.status);
+ EXPECT_EQ(content::PermissionStatusSource::FENCED_FRAME, result.source);
+
+ EXPECT_EQ(1, static_cast<content::MockRenderProcessHost*>(
+ fenced_frame_rfh->GetProcess())
+ ->bad_msg_count());
+}
+
// 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
Bypass of StorageAccessGrantPermissionContext validation via pre-existing grant
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: A potential vulnerability in StorageAccessGrantPermissionContext allows a compromised renderer to bypass security checks designed to prevent sandboxed or credentialless frames from acquiring storage access. When a pre-existing storage access grant is active, the permission flow short-circuits early to a GRANTED status without executing DecidePermission. This allows the compromised renderer to incorrectly obtain the authoritative kStorageAccessGrantEligible override on its RenderFrameHost without being terminated.
Affected files:
chrome/browser/storage_access_api/storage_access_grant_permission_context.cccomponents/permissions/permission_context_base.cc
Estimated timestamp from git blame: 2025-05-09
Potential Root Cause Analysis
In StorageAccessGrantPermissionContext::RequestPermission (chrome/browser/storage_access_api/storage_access_grant_permission_context.cc), the request is directly passed to the base class implementation:
void StorageAccessGrantPermissionContext::RequestPermission(
std::unique_ptr<permissions::PermissionRequestData> request_data,
permissions::BrowserPermissionCallback callback) {
content::GlobalRenderFrameHostId frame_host_id =
request_data->id.global_render_frame_host_id();
ContentSettingPermissionContextBase::RequestPermission(
std::move(request_data),
base::BindOnce(
[](content::GlobalRenderFrameHostId frame_host_id,
content::PermissionResult permission_result) {
if (permission_result.status ==
blink::mojom::PermissionStatus::GRANTED) {
content::RenderFrameHost* rfh =
content::RenderFrameHost::FromID(frame_host_id);
if (rfh) {
rfh->SetStorageAccessApiStatus(
net::StorageAccessApiStatus::kAccessViaAPI);
}
}
return permission_result;
}, frame_host_id).Then(std::move(callback)));
}
ContentSettingPermissionContextBase::RequestPermission resolves to PermissionContextBase::RequestPermission (components/permissions/permission_context_base.cc), which checks for pre-existing permission settings:
content::PermissionResult result = GetPermissionStatus(*request_data, rfh);
bool status_ignorable = PermissionUtil::CanPermissionRequestIgnoreStatus(...);
if (!status_ignorable && (result.status == PermissionStatus::GRANTED || ...)) {
// ...
NotifyPermissionSet(*request_data, std::move(callback), persist, &result, ...);
return; // DecidePermission() is never executed
}
Because of this early-return block, the browser-side compromised-renderer validation block located in StorageAccessGrantPermissionContext::DecidePermission is never reached when a pre-existing storage access grant is active.
The validation block in DecidePermission is meant to catch compromised renderers attempting to request storage access from credentialless, opaque, or sandboxed frames:
if (rfh->GetLastCommittedOrigin().opaque() || rfh->IsCredentialless() ||
rfh->IsNestedWithinFencedFrame() ||
rfh->IsSandboxed(
network::mojom::WebSandboxFlags::kStorageAccessByUserActivation) ||
rfh->GetStorageKey().ForbidsUnpartitionedStorageAccess()) {
RecordOutcomeSample(RequestOutcome::kDeniedByPrerequisites, ...);
mojo::ReportBadMessage(
"requestStorageAccess: Must not be called by a fenced frame, iframe "
"with an opaque origin, credentialless iframe, or sandboxed iframe");
// ...
return;
}
Since this safety block is bypassed, a compromised renderer can successfully trigger rfh->SetStorageAccessApiStatus(net::StorageAccessApiStatus::kAccessViaAPI) on a restricted RenderFrameHost, which installs the authoritative net::CookieSettingOverride::kStorageAccessGrantEligible override on the document.
Potential Impact
For credentialless frames, downstream validation in the network service prevents actual unpartitioned cookie access because the CookiePartitionKey contains a nonce.
However, for a sandboxed iframe with allow-same-origin (but lacking allow-storage-access-by-user-activation), its CookiePartitionKey does not have a nonce. Because RestrictedCookieManager does not have access to the RenderFrameHost and does not check sandbox flags directly, it relies entirely on the document overrides and the partition key. Thus, once the document override is installed via the bypass, the sandboxed frame successfully gains unpartitioned cross-site cookie access, bypassing the sandbox restriction.
Suggested / Potential Steps to Trigger
Note: The following steps are theoretical/potential sequence steps, as our testing tools do not currently have the capability to run code.
- Establish a standard, persistent storage-access grant for origin pair
(https://embed.com, https://top.com)(e.g., via a standard iframe interaction and accepted prompt). - Load
https://top.comwhich embeds a sandboxed same-origin iframe lacking SAA permission:<iframe sandbox="allow-scripts allow-same-origin" src="https://embed.com">. - Compromise the renderer process hosting the sandboxed iframe.
- From the compromised renderer process, bypass Blink’s document checks and call the browser’s
blink.mojom.PermissionService::RequestPermissionwithPermissionType::STORAGE_ACCESS. - Observe that the request resolves to
GRANTEDvia the pre-existing grant, andrfh->SetStorageAccessApiStatusis executed to apply thekStorageAccessGrantEligibleoverride without terminating the renderer process viamojo::ReportBadMessage. - Verify that the sandboxed frame is now allowed to read/write unpartitioned cross-site cookies of
https://embed.cominRestrictedCookieManager.
Suggested Fix
Perform the compromised-renderer safety check in StorageAccessGrantPermissionContext::RequestPermission prior to calling the base class RequestPermission implementation, ensuring that restricted frame contexts are validated and terminated regardless of whether a pre-existing grant exists:
void StorageAccessGrantPermissionContext::RequestPermission(
std::unique_ptr<permissions::PermissionRequestData> request_data,
permissions::BrowserPermissionCallback callback) {
content::RenderFrameHost* rfh = content::RenderFrameHost::FromID(
request_data->id.global_render_frame_host_id());
if (rfh && (rfh->GetLastCommittedOrigin().opaque() || rfh->IsCredentialless() ||
rfh->IsNestedWithinFencedFrame() ||
rfh->IsSandboxed(
network::mojom::WebSandboxFlags::kStorageAccessByUserActivation) ||
rfh->GetStorageKey().ForbidsUnpartitionedStorageAccess())) {
mojo::ReportBadMessage(
"requestStorageAccess: Must not be called by a fenced frame, iframe "
"with an opaque origin, credentialless iframe, or sandboxed iframe");
std::move(callback).Run(content::PermissionResult(
blink::mojom::PermissionStatus::DENIED,
content::PermissionStatusSource::UNSPECIFIED));
return;
}
content::GlobalRenderFrameHostId frame_host_id =
request_data->id.global_render_frame_host_id();
ContentSettingPermissionContextBase::RequestPermission(
std::move(request_data),
// ...
);
}
Evaluated with Chrome root at commit: 5133b93d189b383c37805b1cf3a9d2dbfe8d7379
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.