Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactPolicy bypass in StorageAccessAPI
DescriptionPolicy bypass in StorageAccessAPI
ComponentStorageAccessAPI
Bug ClassLogic Error
Tracker518247789
Fix commitb6e0c9d8ddad (chromium/src) +78/-12
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Changed Functions

FunctionChangeNotes
MockStorageAccessAPIService
chrome/browser/storage_access_api/storage_access_api_tab_helper_unittest.cc
modified
TEST_F
chrome/browser/storage_access_api/storage_access_api_tab_helper_unittest.cc
modified
RenderFrameHost
chrome/browser/storage_access_api/storage_access_api_utils.h
modified

Files Changed

  • chrome/browser/storage_access_api/BUILD.gn
  • chrome/browser/storage_access_api/storage_access_api_tab_helper.cc
  • chrome/browser/storage_access_api/storage_access_api_tab_helper_unittest.cc
  • chrome/browser/storage_access_api/storage_access_api_utils.cc
  • chrome/browser/storage_access_api/storage_access_api_utils.h
  • chrome/browser/storage_access_api/storage_access_grant_permission_context.cc
From b6e0c9d8ddadcd747787171207202a7e743392d5 Mon Sep 17 00:00:00 2001
From: Chris Fredrickson <[email protected]>
Date: Mon, 01 Jun 2026 14:14:17 -0700
Subject: [PATCH] [SAA] Add missing checks to storage-access permission renewals

User interactions in credentialless, fenced frames, sandboxed frames,
etc., should not count as a signal for renewing a storage-access grant
(since those contexts do not have access to those permission grants
anyway).

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

diff --git a/chrome/browser/storage_access_api/BUILD.gn b/chrome/browser/storage_access_api/BUILD.gn
index 026b9b4..13559ac8 100644
--- a/chrome/browser/storage_access_api/BUILD.gn
+++ b/chrome/browser/storage_access_api/BUILD.gn
@@ -15,6 +15,8 @@
     "storage_access_api_service_impl.h",
     "storage_access_api_tab_helper.cc",
     "storage_access_api_tab_helper.h",
+    "storage_access_api_utils.cc",
+    "storage_access_api_utils.h",
     "storage_access_grant_permission_context.cc",
     "storage_access_grant_permission_context.h",
   ]
diff --git a/chrome/browser/storage_access_api/storage_access_api_tab_helper.cc b/chrome/browser/storage_access_api/storage_access_api_tab_helper.cc
index d2fdedaf..c6270a88 100644
--- a/chrome/browser/storage_access_api/storage_access_api_tab_helper.cc
+++ b/chrome/browser/storage_access_api/storage_access_api_tab_helper.cc
@@ -6,6 +6,7 @@
 
 #include "base/metrics/histogram_functions.h"
 #include "chrome/browser/storage_access_api/storage_access_api_service.h"
+#include "chrome/browser/storage_access_api/storage_access_api_utils.h"
 #include "components/guest_view/buildflags/buildflags.h"
 #include "content/public/browser/render_frame_host.h"
 #include "content/public/browser/web_contents_observer.h"
@@ -33,8 +34,8 @@
   }
 #endif
 
-  if (rfh->GetLastCommittedOrigin().opaque() ||
-      rfh->GetParentOrOuterDocument()->GetLastCommittedOrigin().opaque()) {
+  if (rfh->GetParentOrOuterDocument()->GetLastCommittedOrigin().opaque() ||
+      IsAccessRestrictedInFrame(rfh)) {
     return;
   }
 
diff --git a/chrome/browser/storage_access_api/storage_access_api_tab_helper_unittest.cc b/chrome/browser/storage_access_api/storage_access_api_tab_helper_unittest.cc
index 7c630791..ba5b07e 100644
--- a/chrome/browser/storage_access_api/storage_access_api_tab_helper_unittest.cc
+++ b/chrome/browser/storage_access_api/storage_access_api_tab_helper_unittest.cc
@@ -13,6 +13,8 @@
 #include "testing/gtest/include/gtest/gtest.h"
 #include "url/origin.h"
 
+using testing::_;
+
 class MockStorageAccessAPIService : public StorageAccessAPIService {
  public:
   MOCK_METHOD(std::optional<base::TimeDelta>,
@@ -53,7 +55,7 @@
 
 TEST_F(StorageAccessAPITabHelperTest, OnFrameReceivedUserActivation_MainFrame) {
   // The service should not be invoked.
-  EXPECT_CALL(service(), RenewPermissionGrant(testing::_, testing::_)).Times(0);
+  EXPECT_CALL(service(), RenewPermissionGrant(_, _)).Times(0);
 
   NavigateAndCommit(GURL("https://example.test/"));
 
@@ -85,7 +87,7 @@
 
 TEST_F(StorageAccessAPITabHelperTest,
        OnFrameReceivedUserActivation_SubframeOpaque) {
-  EXPECT_CALL(service(), RenewPermissionGrant(testing::_, testing::_)).Times(0);
+  EXPECT_CALL(service(), RenewPermissionGrant(_, _)).Times(0);
 
   NavigateAndCommit(GURL("https://example.test/"));
 
@@ -99,3 +101,37 @@
   // User activations in iframes with opaque origins are ignored.
   tab_helper()->FrameReceivedUserActivation(subframe);
 }
+
+TEST_F(StorageAccessAPITabHelperTest,
+       OnFrameReceivedUserActivation_SubframeCredentialless) {
+  EXPECT_CALL(service(), RenewPermissionGrant(_, _)).Times(0);
+
+  NavigateAndCommit(GURL("https://example.test/"));
+
+  content::RenderFrameHost* subframe =
+      content::RenderFrameHostTester::For(main_rfh())
+          ->AppendCredentiallessChild("subframe");
+
+  subframe = SimulateNavigateAndCommit(GURL("https://bar.test/foo"), subframe);
+  ASSERT_NE(nullptr, subframe);
+
+  // User activations in credentialless iframes are ignored.
+  tab_helper()->FrameReceivedUserActivation(subframe);
+}
+
+TEST_F(StorageAccessAPITabHelperTest,
+       OnFrameReceivedUserActivation_FencedFrame) {
+  EXPECT_CALL(service(), RenewPermissionGrant(_, _)).Times(0);
+
+  NavigateAndCommit(GURL("https://example.test/"));
+
+  content::RenderFrameHost* fenced_frame =
+      content::RenderFrameHostTester::For(main_rfh())->AppendFencedFrame();
+
+  fenced_frame =
+      SimulateNavigateAndCommit(GURL("https://bar.test/foo"), fenced_frame);
+  ASSERT_NE(nullptr, fenced_frame);
+
+  // User activations in fenced frames are ignored.
+  tab_helper()->FrameReceivedUserActivation(fenced_frame);
+}
diff --git a/chrome/browser/storage_access_api/storage_access_api_utils.cc b/chrome/browser/storage_access_api/storage_access_api_utils.cc
new file mode 100644
index 0000000..f9a3c43
--- /dev/null
+++ b/chrome/browser/storage_access_api/storage_access_api_utils.cc
@@ -0,0 +1,17 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "chrome/browser/storage_access_api/storage_access_api_utils.h"
+
+#include "content/public/browser/render_frame_host.h"
+#include "services/network/public/mojom/web_sandbox_flags.mojom.h"
+#include "third_party/blink/public/common/storage_key/storage_key.h"
+
+bool IsAccessRestrictedInFrame(content::RenderFrameHost* rfh) {
+  return rfh->GetLastCommittedOrigin().opaque() || rfh->IsCredentialless() ||
+         rfh->IsNestedWithinFencedFrame() ||
+         rfh->IsSandboxed(
+             network::mojom::WebSandboxFlags::kStorageAccessByUserActivation) ||
+         rfh->GetStorageKey().ForbidsUnpartitionedStorageAccess();
+}
diff --git a/chrome/browser/storage_access_api/storage_access_api_utils.h b/chrome/browser/storage_access_api/storage_access_api_utils.h
new file mode 100644
index 0000000..8ccff3af
--- /dev/null
+++ b/chrome/browser/storage_access_api/storage_access_api_utils.h
@@ -0,0 +1,17 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#ifndef CHROME_BROWSER_STORAGE_ACCESS_API_STORAGE_ACCESS_API_UTILS_H_
+#define CHROME_BROWSER_STORAGE_ACCESS_API_STORAGE_ACCESS_API_UTILS_H_
+
+namespace content {
+class RenderFrameHost;
+}
+
+// Returns true if the given frame should be disallowed from interacting with
+// unpartitioned storage and the `storage-access` permission (including querying
+// the permission status).
+bool IsAccessRestrictedInFrame(content::RenderFrameHost* rfh);
+
+#endif  // CHROME_BROWSER_STORAGE_ACCESS_API_STORAGE_ACCESS_API_UTILS_H_
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 5b111ba..881a364 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
@@ -23,6 +23,7 @@
 #include "chrome/browser/first_party_sets/first_party_sets_policy_service.h"
 #include "chrome/browser/first_party_sets/first_party_sets_policy_service_factory.h"
 #include "chrome/browser/profiles/profile.h"
+#include "chrome/browser/storage_access_api/storage_access_api_utils.h"
 #include "chrome/browser/webid/federated_identity_auto_reauthn_permission_context.h"
 #include "chrome/browser/webid/federated_identity_auto_reauthn_permission_context_factory.h"
 #include "chrome/browser/webid/federated_identity_permission_context.h"
@@ -224,14 +225,6 @@
   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.
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/storage_access_api/storage_access_api_tab_helper_unittest.cc b/chrome/browser/storage_access_api/storage_access_api_tab_helper_unittest.cc
index 7c630791..ba5b07e 100644
--- a/chrome/browser/storage_access_api/storage_access_api_tab_helper_unittest.cc
+++ b/chrome/browser/storage_access_api/storage_access_api_tab_helper_unittest.cc
@@ -13,6 +13,8 @@
 #include "testing/gtest/include/gtest/gtest.h"
 #include "url/origin.h"
 
+using testing::_;
+
 class MockStorageAccessAPIService : public StorageAccessAPIService {
  public:
   MOCK_METHOD(std::optional<base::TimeDelta>,
@@ -53,7 +55,7 @@
 
 TEST_F(StorageAccessAPITabHelperTest, OnFrameReceivedUserActivation_MainFrame) {
   // The service should not be invoked.
-  EXPECT_CALL(service(), RenewPermissionGrant(testing::_, testing::_)).Times(0);
+  EXPECT_CALL(service(), RenewPermissionGrant(_, _)).Times(0);
 
   NavigateAndCommit(GURL("https://example.test/"));
 
@@ -85,7 +87,7 @@
 
 TEST_F(StorageAccessAPITabHelperTest,
        OnFrameReceivedUserActivation_SubframeOpaque) {
-  EXPECT_CALL(service(), RenewPermissionGrant(testing::_, testing::_)).Times(0);
+  EXPECT_CALL(service(), RenewPermissionGrant(_, _)).Times(0);
 
   NavigateAndCommit(GURL("https://example.test/"));
 
@@ -99,3 +101,37 @@
   // User activations in iframes with opaque origins are ignored.
   tab_helper()->FrameReceivedUserActivation(subframe);
 }
+
+TEST_F(StorageAccessAPITabHelperTest,
+       OnFrameReceivedUserActivation_SubframeCredentialless) {
+  EXPECT_CALL(service(), RenewPermissionGrant(_, _)).Times(0);
+
+  NavigateAndCommit(GURL("https://example.test/"));
+
+  content::RenderFrameHost* subframe =
+      content::RenderFrameHostTester::For(main_rfh())
+          ->AppendCredentiallessChild("subframe");
+
+  subframe = SimulateNavigateAndCommit(GURL("https://bar.test/foo"), subframe);
+  ASSERT_NE(nullptr, subframe);
+
+  // User activations in credentialless iframes are ignored.
+  tab_helper()->FrameReceivedUserActivation(subframe);
+}
+
+TEST_F(StorageAccessAPITabHelperTest,
+       OnFrameReceivedUserActivation_FencedFrame) {
+  EXPECT_CALL(service(), RenewPermissionGrant(_, _)).Times(0);
+
+  NavigateAndCommit(GURL("https://example.test/"));
+
+  content::RenderFrameHost* fenced_frame =
+      content::RenderFrameHostTester::For(main_rfh())->AppendFencedFrame();
+
+  fenced_frame =
+      SimulateNavigateAndCommit(GURL("https://bar.test/foo"), fenced_frame);
+  ASSERT_NE(nullptr, fenced_frame);
+
+  // User activations in fenced frames are ignored.
+  tab_helper()->FrameReceivedUserActivation(fenced_frame);
+}
Loading diff…

Original Bug Report

reported by [email protected]

Missing security checks in Storage Access API FrameReceivedUserActivation

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: StorageAccessAPITabHelper::FrameReceivedUserActivation is missing critical validation gates present in other Storage Access API write paths. Consequently, credentialless, sandboxed, or partitioned contexts can inappropriately renew unpartitioned STORAGE_ACCESS content settings, potentially extending their lifespan indefinitely. In addition, compromised renderers can trigger this renewal without any actual user activation.

Affected files:

  • chrome/browser/storage_access_api/storage_access_api_tab_helper.cc

Estimated timestamp from git blame: 2023-06-27

Root Cause

In chrome/browser/storage_access_api/storage_access_api_tab_helper.cc, StorageAccessAPITabHelper::FrameReceivedUserActivation is invoked when a subframe receives user activation to renew the corresponding profile-wide STORAGE_ACCESS content setting:

void StorageAccessAPITabHelper::FrameReceivedUserActivation(
    content::RenderFrameHost* rfh) {
  if (rfh->IsInPrimaryMainFrame()) return;
#if BUILDFLAG(ENABLE_GUEST_VIEW)
  if (guest_view::GuestViewBase::IsGuest(rfh)) return;
#endif
  if (rfh->GetLastCommittedOrigin().opaque() ||
      rfh->GetParentOrOuterDocument()->GetLastCommittedOrigin().opaque()) {
    return;
  }
  service_->RenewPermissionGrant(
      rfh->GetLastCommittedOrigin(),
      rfh->GetParentOrOuterDocument()->GetLastCommittedOrigin());
}

Unlike the sibling permission request path (ValidatePermissionEligibility in chrome/browser/storage_access_api/storage_access_grant_permission_context.cc), FrameReceivedUserActivation only checks for opaque origins. It lacks several critical security gates:

  • rfh->IsCredentialless()
  • rfh->IsSandboxed(network::mojom::WebSandboxFlags::kStorageAccessByUserActivation)
  • rfh->GetStorageKey().ForbidsUnpartitionedStorageAccess()

Because a credentialless or sandboxed (with allow-same-origin) subframe commits with its non-opaque tuple origin (e.g., https://embed.com), it passes the .opaque() origin check. Consequently, user activation in these partitioned contexts falls through to service_->RenewPermissionGrant, which resets the 30-day expiry of the unpartitioned STORAGE_ACCESS rule.

Furthermore, because the browser process trusts the renderer’s user activation state updates via Mojo without secondary verification, a compromised renderer can repeatedly send LocalFrameHost::UpdateUserActivationState to renew the unpartitioned grant without any physical user gesture.

Potential Trigger Sequence

The following are suggested steps that could be used to trigger the issue (please note that our tooling does not currently have the capability to run code or dynamically verify this PoC):

  1. A browser profile has a pre-existing unpartitioned STORAGE_ACCESS grant allowed for the origin pair (https://embed.com, https://top.com) with a standard 30-day lifespan (created via a previous valid document.requestStorageAccess() flow).
  2. The user navigates to https://top.com, which embeds a partitioned context pointing to https://embed.com/page (using either <iframe credentialless> or <iframe sandbox="allow-same-origin allow-scripts">).
  3. Inside the subframe, a user gesture (such as a click) occurs, or a compromised renderer sends a Mojo message representing user activation:
    • Interface: blink::mojom::LocalFrameHost
    • Method: UpdateUserActivationState(kNotifyActivation, kInteraction)
  4. The browser receives the notification, propagates it up the frame ancestors via FrameTreeNode::NotifyUserActivation, and fires the FrameReceivedUserActivation observer.
  5. StorageAccessAPITabHelper::FrameReceivedUserActivation executes, passes the basic opaque() checks, and triggers StorageAccessAPIServiceImpl::RenewPermissionGrant(https://embed.com, https://top.com).
  6. The unpartitioned STORAGE_ACCESS content setting is updated via HostContentSettingsMap::RenewContentSetting, extending its lifetime by another 30 days.

Suggested Fix

To resolve this potential issue, StorageAccessAPITabHelper::FrameReceivedUserActivation should incorporate validation checks to verify frame eligibility before calling RenewPermissionGrant. For example:

  if (rfh->IsCredentialless() ||
      rfh->IsSandboxed(
          network::mojom::WebSandboxFlags::kStorageAccessByUserActivation) ||
      rfh->GetStorageKey().ForbidsUnpartitionedStorageAccess()) {
    return;
  }

This aligns the renewal path’s security constraints with those of the permission context path, ensuring partitioned or restricted contexts cannot interact with or renew unpartitioned permission state.

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