Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in StorageAccessAPI
DescriptionInsufficient validation of untrusted input in StorageAccessAPI
ComponentStorageAccessAPI
Bug ClassLogic Error
Tracker517731924
Fix commit2cf6a168efd9 (chromium/src) +94/-21
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Changed Functions

FunctionChangeNotes
if
chrome/browser/storage_access_api/storage_access_grant_permission_context_unittest.cc
modified
TEST_F
chrome/browser/storage_access_api/storage_access_grant_permission_context_unittest.cc
modified

Files Changed

  • chrome/browser/bad_message.h
  • chrome/browser/storage_access_api/BUILD.gn
  • chrome/browser/storage_access_api/storage_access_grant_permission_context.cc
  • chrome/browser/storage_access_api/storage_access_grant_permission_context_unittest.cc
From 2cf6a168efd92e5ad8f4711efeb18ba990b55673 Mon Sep 17 00:00:00 2001
From: Chris Fredrickson <cfredric@chromium.org>
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 <anushamuley@google.com>
Commit-Queue: Chris Fredrickson <cfredric@chromium.org>
Auto-Submit: Chris Fredrickson <cfredric@chromium.org>
Reviewed-by: Alexei Svitkine <asvitkine@chromium.org>
Reviewed-by: Lei Zhang <thestig@chromium.org>
Commit-Queue: Alexei Svitkine <asvitkine@chromium.org>
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) {
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 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,
Loading diff…

Original Bug Report

reported by vm...@google.com

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.cc
  • components/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.

  1. 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).
  2. Load https://top.com which embeds a sandboxed same-origin iframe lacking SAA permission: <iframe sandbox="allow-scripts allow-same-origin" src="https://embed.com">.
  3. Compromise the renderer process hosting the sandboxed iframe.
  4. From the compromised renderer process, bypass Blink’s document checks and call the browser’s blink.mojom.PermissionService::RequestPermission with PermissionType::STORAGE_ACCESS.
  5. Observe that the request resolves to GRANTED via the pre-existing grant, and rfh->SetStorageAccessApiStatus is executed to apply the kStorageAccessGrantEligible override without terminating the renderer process via mojo::ReportBadMessage.
  6. Verify that the sandboxed frame is now allowed to read/write unpartitioned cross-site cookies of https://embed.com in RestrictedCookieManager.

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.

View on issue tracker