Low watch CVSS 4.3 chrome Info Disclosure 🔧 Commit mapped

Overview

Low
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInformation leak in StorageAccessAPI
DescriptionInformation leak in StorageAccessAPI
ComponentStorageAccessAPI
Sub-componentSAA
Bug ClassInfo Disclosure
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker519229463
Fix commit1b717d86dbb4 (chromium/src) +226/-64
CWECWE-200 (Information exposure)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-06-02 (per vendor advisory)
Fix landed2026-07-01 (public git)
Disclosed2026-08-25
Patch gap55 days — fix public in source before the advisory

Background

Storage Access API (SAA)
lets an embedded cross-site frame call document.requestStorageAccess() to ask for unpartitioned cookies, and lets it check state with navigator.permissions.query({name: "storage-access"}).
Denial non-exposure rule
the SAA spec says a user’s rejection must not be visible to the embedded site (it should look like “prompt”, not “denied”), so sites cannot retaliate against users who refuse.
Permission embargo
after repeated dismissals (3 by default here), Chrome’s permission layer denies further requests from that origin automatically for a while, producing a DENIED result that is not backed by a stored CONTENT_SETTING_BLOCK.
`MaybeOverridePermissionResultToReturn`
a new virtual hook on PermissionContextBase, called by PermissionManager just before a request result or status-query result is returned to the caller.

Root Cause Analysis

Before the fix, StorageAccessGrantPermissionContext masked denials too early. Its GetContentSettingStatusInternal override (the name is inferred from the base-class call in the removed lines) rewrote a stored CONTENT_SETTING_BLOCK to CONTENT_SETTING_ASK, and also returned CONTENT_SETTING_ASK for frames where IsAccessRestrictedInFrame() is true (e.g. credentialless iframes). Denials produced later in the shared permission pipeline, most notably the automatic denial from embargo after repeated dismissals, were never passed through that mask. A cross-site frame could therefore see "denied" from a permission status query, learning that the user had repeatedly dismissed its prompt; the new browser test shows this from a credentialless iframe.

The fix makes the internal state truthful: IsAccessRestrictedInFrame() now yields CONTENT_SETTING_BLOCK, a persisted BLOCK is no longer rewritten, and the special re-check in the request-decision path (the removed block that called NotifyPermissionSetInternal(... kDeny, kReusedPreviousDecision)) is deleted because the base class now denies before that point. Masking moves to the exposure boundary: PermissionManager calls MaybeOverridePermissionResultToReturn() on both the request-response path and the status path, and the SAA override turns any PermissionStatus::DENIED into PermissionStatus::ASK, whatever its source.

Key insight
The “never reveal denial” rule was enforced at one internal source of denial (the stored content setting) instead of at the boundary where results leave the browser, so denials from other sources such as embargo got through. The fix applies the DENIED→ASK mask at the PermissionManager return point for both query and request results.

Attack Path

  1. Embed a cross-site frame A top-level site embeds a third-party frame that uses the Storage Access API with third-party cookies blocked (the test uses SetBlockThirdPartyCookies(true)).
  2. Get the user to dismiss prompts The frame calls document.requestStorageAccess() and the user dismisses the prompt three times, which puts that origin under embargo. This needs user interaction, which the attacker cannot force.
  3. Query from a frame where the mask is skipped The site queries the storage-access permission, for example from a credentialless iframe as in PermissionQueryEmbargoed_CredentiallessFrame, where the embargo-derived denial was not rewritten.
  4. Learn the user's choice Before the fix the query could return "denied" instead of "prompt", showing that the user refused. The site could then degrade service or otherwise act on that (inferred).

Impact Assessment

An embedded third-party site learns a privacy-sensitive bit of user state (that the user repeatedly dismissed its storage-access prompt), which the SAA spec says must stay hidden. The leak is in the browser-process permission logic, observed by ordinary web content through the Permissions API, and needs earlier user dismissals that trigger embargo. No memory-safety or cross-origin data read is involved; the advisory says only “Information leak in StorageAccessAPI” and gives no in-the-wild status.

Changed Functions

FunctionChangeNotes
if
chrome/browser/storage_access_api/storage_access_grant_permission_context.cc
modified
if
components/permissions/permission_manager.cc
modified
for
chrome/browser/storage_access_api/api_browsertest.cc
modified
IN_PROC_BROWSER_TEST_F
chrome/browser/storage_access_api/api_browsertest.cc
modified
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
for
chrome/browser/storage_access_api/storage_access_grant_permission_context_unittest.cc
modified

Files Changed

  • chrome/browser/storage_access_api/storage_access_grant_permission_context.h
  • components/permissions/permission_context_base.h
  • components/permissions/permission_manager_unittest.cc
  • chrome/browser/storage_access_api/storage_access_grant_permission_context_unittest.cc
  • components/permissions/permission_manager.cc
  • components/permissions/permission_context_base.cc
  • components/permissions/test/permission_test_util.cc
  • chrome/browser/storage_access_api/api_browsertest.cc
  • chrome/browser/storage_access_api/storage_access_grant_permission_context.cc

Audit Directions

  • Masking at a single source instead of at egress
    For any permission or feature that must hide DENIED (or other states) from the web, check that masking happens where results leave the browser, and that every source of denial (embargo, policy, kill switch, restricted frame, insecure origin) goes through it.
  • Alternate query surfaces and frame types
    Compare permissions.query, request-promise outcomes, permissionchange events and PermissionStatus.onchange across credentialless, fenced, sandboxed and prerendered frames for differences in what state is exposed.
  • Embargo as a side channel
    Check other permission types for whether an embargo transition can be seen by the requesting origin through status changes or timing, especially where the spec forbids revealing user rejection.
From 1b717d86dbb44d6227a6f693931c91157624460e Mon Sep 17 00:00:00 2001
From: Chris Fredrickson <[email protected]>
Date: Wed, 01 Jul 2026 09:20:01 -0700
Subject: [PATCH] [SAA] Fix embargoed permissions exposure

This prevents exposure of an automatic denial via permissions embargo,
per the SAA specification. (Note: this does not disable the embargo,
meaning the user will not be prompted >3 times if they continually
dismiss the prompt.)

Fixed: 519229463
Change-Id: I60985c89171297770a169041c7eea60258986b31
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7909441
Reviewed-by: Antonio Sartori <[email protected]>
Auto-Submit: Chris Fredrickson <[email protected]>
Reviewed-by: Chris Fredrickson <[email protected]>
Commit-Queue: Chris Fredrickson <[email protected]>
Reviewed-by: Johann Hofmann <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1655543}
---
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 881a364..18b4fdce 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
@@ -407,29 +407,6 @@
     return;
   }
 
-  {
-    // Normally a previous prompt rejection would already be filtered before
-    // reaching `StorageAccessGrantPermissionContext::DecidePermission`, but the
-    // requirement not to surface the user's denial back to the caller means
-    // this code is reachable even after permission has been blocked.
-    // Accordingly, check the default implementation, and if a denial has been
-    // persisted, respect that decision.
-    ContentSetting existing_setting = permissions::
-        ContentSettingPermissionContextBase::GetContentSettingStatusInternal(
-            rfh, request_data->requesting_origin,
-            request_data->embedding_origin);
-    // ALLOW grants are handled by ContentSettingPermissionContextBase so they
-    // never reach this point.
-    CHECK_NE(existing_setting, CONTENT_SETTING_ALLOW);
-    if (existing_setting == CONTENT_SETTING_BLOCK) {
-      NotifyPermissionSetInternal(*request_data, std::move(callback),
-                                  /*persist=*/false, PermissionDecision::kDeny,
-                                  RequestOutcome::kReusedPreviousDecision);
-      return;
-    }
-    CHECK_EQ(existing_setting, CONTENT_SETTING_ASK);
-  }
-
   // FedCM grants (and the appropriate permissions policy) may allow the call to
   // auto-resolve (without granting a new permission).
   if (FederatedIdentityPermissionContext* fedcm_context =
@@ -582,7 +559,7 @@
     const GURL& embedding_origin) const {
   if (render_frame_host) {
     if (IsAccessRestrictedInFrame(render_frame_host)) {
-      return CONTENT_SETTING_ASK;
+      return CONTENT_SETTING_BLOCK;
     }
 
     // Permission query from top-level frame should be "granted" by default.
@@ -591,16 +568,9 @@
     }
   }
 
-  ContentSetting setting = permissions::ContentSettingPermissionContextBase::
+  return permissions::ContentSettingPermissionContextBase::
       GetContentSettingStatusInternal(render_frame_host, requesting_origin,
                                       embedding_origin);
-
-  // The spec calls for avoiding exposure of rejections to prevent any attempt
-  // at retaliating against users who would reject a prompt.
-  if (setting == CONTENT_SETTING_BLOCK) {
-    return CONTENT_SETTING_ASK;
-  }
-  return setting;
 }
 
 void StorageAccessGrantPermissionContext::NotifyPermissionSet(
@@ -641,6 +611,15 @@
                               decision.overall_decision, outcome);
 }
 
+void StorageAccessGrantPermissionContext::MaybeOverridePermissionResultToReturn(
+    content::PermissionResult& result) const {
+  // The spec calls for avoiding exposure of rejections to prevent any attempt
+  // at retaliating against users who would reject a prompt.
+  if (result.status == content::PermissionStatus::DENIED) {
+    result.status = content::PermissionStatus::ASK;
+  }
+}
+
 void StorageAccessGrantPermissionContext::ReportRelatedWebsiteSetsDeprecation(
     content::RenderFrameHost* rfh) {
   auto deprecation_details = blink::mojom::DeprecationIssueDetails::New();
diff --git a/chrome/browser/storage_access_api/storage_access_grant_permission_context.h b/chrome/browser/storage_access_api/storage_access_grant_permission_context.h
index 1d2e4ed9..19254db 100644
--- a/chrome/browser/storage_access_api/storage_access_grant_permission_context.h
+++ b/chrome/browser/storage_access_api/storage_access_grant_permission_context.h
@@ -91,6 +91,10 @@
       std::unique_ptr<permissions::PermissionRequestData> request_data,
       permissions::BrowserPermissionCallback callback);
 
+  // PermissionContextBase:
+  void MaybeOverridePermissionResultToReturn(
+      content::PermissionResult& result) const override;
+
   static int GetImplicitGrantLimitForTesting();
   static void SetImplicitGrantLimitForTesting(int limit);
 
diff --git a/components/permissions/permission_context_base.cc b/components/permissions/permission_context_base.cc
index 171e53d..07c339d 100644
--- a/components/permissions/permission_context_base.cc
+++ b/components/permissions/permission_context_base.cc
@@ -501,6 +501,9 @@
                              embedding_origin);
 }
 
+void PermissionContextBase::MaybeOverridePermissionResultToReturn(
+    content::PermissionResult& result) const {}
+
 bool PermissionContextBase::IsPermissionAvailableToOrigins(
     const GURL& requesting_origin,
     const GURL& embedding_origin) const {
diff --git a/components/permissions/permission_context_base.h b/components/permissions/permission_context_base.h
index fa672a2..7bf04a9e 100644
--- a/components/permissions/permission_context_base.h
+++ b/components/permissions/permission_context_base.h
@@ -137,6 +137,13 @@
       const GURL& requesting_origin,
       const GURL& embedding_origin) const;
 
+  // Optionally overwrites the result before returning it to the caller (for a
+  // permissions request or status query). Default implementation does nothing.
+  // Useful (for example) to mask an internal DENIED state and expose it as
+  // PROMPT to the web.
+  virtual void MaybeOverridePermissionResultToReturn(
+      content::PermissionResult& result) const;
+
   // Returns whether the permission is usable by requesting/embedding origins.
   bool IsPermissionAvailableToOrigins(const GURL& requesting_origin,
                                       const GURL& embedding_origin) const;
diff --git a/components/permissions/permission_manager.cc b/components/permissions/permission_manager.cc
index cb3e9b2..2801c94 100644
--- a/components/permissions/permission_manager.cc
+++ b/components/permissions/permission_manager.cc
@@ -16,6 +16,7 @@
 #include "components/content_settings/core/browser/permission_settings_info.h"
 #include "components/content_settings/core/browser/permission_settings_registry.h"
 #include "components/content_settings/core/common/content_settings_pattern.h"
+#include "components/content_settings/core/common/content_settings_types.h"
 #include "components/permissions/content_setting_permission_context_base.h"
 #include "components/permissions/features.h"
 #include "components/permissions/permission_decision.h"
@@ -284,8 +285,23 @@
             request_id, request_description,
             canonical_requesting_origin.DeprecatedGetOriginAsURL(), GURL(), i),
         base::BindOnce(
-            &PermissionResponseCallback::OnPermissionsRequestResponse,
-            std::move(response_callback)));
+            [](base::WeakPtr<PermissionManager> manager,
+               ContentSettingsType permission,
+               base::OnceCallback<void(content::PermissionResult)> callback,
+               content::PermissionResult result) {
+              if (!manager) {
+                return;
+              }
+
+              if (auto* context = manager->GetPermissionContext(permission)) {
+                context->MaybeOverridePermissionResultToReturn(result);
+              }
+              std::move(callback).Run(std::move(result));
+            },
+            weak_factory_.GetWeakPtr(), permission,
+            base::BindOnce(
+                &PermissionResponseCallback::OnPermissionsRequestResponse,
+                std::move(response_callback))));
   }
 }
 
@@ -766,6 +782,7 @@
     // maybe notify observers.
     context->MaybeUpdateCachedHasDevicePermission(web_contents);
   }
+  context->MaybeOverridePermissionResultToReturn(result);
   DCHECK(result.status == PermissionStatus::GRANTED ||
          result.status == PermissionStatus::ASK ||
          result.status == PermissionStatus::DENIED)
diff --git a/chrome/browser/storage_access_api/api_browsertest.cc b/chrome/browser/storage_access_api/api_browsertest.cc
index 6f3da6a..9ed824a 100644
--- a/chrome/browser/storage_access_api/api_browsertest.cc
+++ b/chrome/browser/storage_access_api/api_browsertest.cc
@@ -918,6 +918,37 @@
       UnorderedElementsAre(Pair(net::SchemefulSite(GURL(kOriginB)), false)));
 }
 
+// Test that permissions.query inside a credentialless iframe does not expose
+// embargoed status when a storage access request has been repeatedly dismissed.
+IN_PROC_BROWSER_TEST_F(StorageAccessAPIBrowserTest,
+                       PermissionQueryEmbargoed_CredentiallessFrame) {
+  SetBlockThirdPartyCookies(true);
+  EnsureUserInteractionOn(kHostB);
+
+  NavigateToPageWithFrame(kHostA);
+  NavigateFrameTo(kHostB, "/echoheader?cookie");
+
+  prompt_factory()->set_response_type(
+      permissions::PermissionRequestManager::DISMISS);
+
+  // Dismissing the prompt 3 times places the origin under embargo.
+  for (int i = 0; i < 3; ++i) {
+    EXPECT_FALSE(
+        content::ExecJs(GetFrame(), "document.requestStorageAccess()"));
+  }
+  ASSERT_EQ(prompt_factory()->TotalRequestCount(), 3);
+  ASSERT_EQ(QueryPermission(GetFrame()), "prompt");
+
+  NavigateToPageWithFrame(kHostA, /*browser_ptr=*/nullptr,
+                          /*credentialless=*/true);
+  NavigateFrameTo(kHostB, "/echoheader?cookie");
+  EXPECT_FALSE(storage::test::HasStorageAccessForFrame(GetFrame()));
+
+  // Even when under embargo, permissions.query inside a credentialless iframe
+  // should return "prompt" rather than "denied".
+  EXPECT_EQ(QueryPermission(GetFrame()), "prompt");
+}
+
 IN_PROC_BROWSER_TEST_F(StorageAccessAPIBrowserTest, PermissionQueryCrossSite) {
   SetBlockThirdPartyCookies(true);
 
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 ff068b66..6c3e083a 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
@@ -43,6 +43,7 @@
 #include "components/prefs/pref_service.h"
 #include "components/privacy_sandbox/privacy_sandbox_prefs.h"
 #include "content/public/browser/btm_service.h"
+#include "content/public/browser/global_routing_id.h"
 #include "content/public/browser/permission_descriptor_util.h"
 #include "content/public/browser/permission_result.h"
 #include "content/public/browser/web_contents.h"
@@ -85,6 +86,7 @@
 constexpr char kPromptResultHistogram[] = "Permissions.Action.StorageAccess";
 constexpr char kRequestOutcomeHistogram[] = "API.StorageAccess.RequestOutcome";
 constexpr int kImplicitGrantLimit = 5;  // Implicit grant limit for testing.
+constexpr int kDefaultDismissalsBeforeEmbargo = 3;
 
 const uint64_t kDwaEventNameHash =
     base::HashMetricName("StorageAccess.RequestOutcome");
@@ -196,12 +198,20 @@
     ChromeRenderViewHostTestHarness::TearDown();
   }
 
+  content::RenderFrameHost* RenderFrameHostFromID(
+      content::GlobalRenderFrameHostId id) {
+    content::RenderFrameHost* target_rfh = content::RenderFrameHost::FromID(id);
+    return target_rfh ? target_rfh : main_rfh();
+  }
+
   base::test::TestFuture<content::PermissionResult> DecidePermission(
       std::unique_ptr<permissions::PermissionRequestData> request_data,
       bool simulate_user_gesture = true) {
     DCHECK(request_data);
     if (request_data->user_gesture && simulate_user_gesture) {
-      content::RenderFrameHostTester::For(main_rfh())->SimulateUserActivation();
+      content::RenderFrameHostTester::For(
+          RenderFrameHostFromID(request_data->id.global_render_frame_host_id()))
+          ->SimulateUserActivation();
     }
     base::test::TestFuture<content::PermissionResult> future;
     permission_context_->DecidePermissionForTesting(std::move(request_data),
@@ -213,7 +223,9 @@
       std::unique_ptr<permissions::PermissionRequestData> request_data) {
     DCHECK(request_data);
     if (request_data->user_gesture) {
-      content::RenderFrameHostTester::For(main_rfh())->SimulateUserActivation();
+      content::RenderFrameHostTester::For(
+          RenderFrameHostFromID(request_data->id.global_render_frame_host_id()))
+          ->SimulateUserActivation();
     }
     base::test::TestFuture<content::PermissionResult> future;
     permission_context_->RequestPermissionForTesting(std::move(request_data),
@@ -222,14 +234,15 @@
   }
 
   std::unique_ptr<permissions::PermissionRequestData> MakePermissionRequestData(
-      bool user_gesture) {
+      bool user_gesture,
+      content::RenderFrameHost* rfh = nullptr) {
     return std::make_unique<permissions::PermissionRequestData>(
         content::PermissionDescriptorUtil::
             CreatePermissionDescriptorForPermissionType(
                 permissions::PermissionUtil::
                     ContentSettingsTypeToPermissionType(
                         ContentSettingsType::STORAGE_ACCESS)),
-        CreateFakeID(), user_gesture, GetRequesterURL(), GetTopLevelURL());
+        CreateFakeID(rfh), user_gesture, GetRequesterURL(), GetTopLevelURL());
   }
 
   // Helper to ensure that a given content setting is consistently applied on a
@@ -417,17 +430,14 @@
 
   // 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);
+  content::PermissionResult result = permission_context()->GetPermissionStatus(
+      content::PermissionDescriptorUtil::
+          CreatePermissionDescriptorForPermissionType(
+              permissions::PermissionUtil::ContentSettingsTypeToPermissionType(
+                  permission_context()->content_settings_type())),
+      child_rfh, GetRequesterURL(), GetTopLevelURL());
+  permission_context()->MaybeOverridePermissionResultToReturn(result);
+  EXPECT_EQ(PermissionStatus::ASK, result.status);
 }
 
 // Test that after a successful explicit storage access grant, there's a content
@@ -897,6 +907,14 @@
 
 TEST_F(StorageAccessGrantPermissionContextTest,
        ExplicitGrantDenialNotExposedViaQuery) {
+  content::RenderFrameHost* child_rfh =
+      content::RenderFrameHostTester::For(main_rfh())->AppendChild("child");
+  std::unique_ptr<content::NavigationSimulator> navigation =
+      content::NavigationSimulator::CreateRendererInitiated(GetRequesterURL(),
+                                                            child_rfh);
+  navigation->Commit();
+  child_rfh = navigation->GetFinalRenderFrameHost();
+
   // Set the content setting to blocked, mimicking a prompt rejection by the
   // user.
   HostContentSettingsMap* settings_map =
@@ -908,26 +926,22 @@
   prompt_factory().set_response_type(
       permissions::PermissionRequestManager::AutoResponseType::NONE);
 
-  auto future =
-      DecidePermission(MakePermissionRequestData(/*user_gesture=*/true));
+  auto future = RequestPermission(
+      MakePermissionRequestData(/*user_gesture=*/true, child_rfh));
   // Ensure the prompt is not shown.
   ASSERT_FALSE(request_manager()->IsRequestInProgress());
   EXPECT_EQ(PermissionStatus::DENIED, future.Get().status);
 
   // However, ensure that the user's denial is not exposed when querying the
   // permission, per the spec.
-  EXPECT_EQ(
-      PermissionStatus::ASK,
-      permission_context()
-          ->GetPermissionStatus(
-              content::PermissionDescriptorUtil::
-                  CreatePermissionDescriptorForPermissionType(
-                      permissions::PermissionUtil::
-                          ContentSettingsTypeToPermissionType(
-                              permission_context()->content_settings_type())),
-              /*render_frame_host=*/nullptr, GetRequesterURL(),
-              GetTopLevelURL())
-          .status);
+  content::PermissionResult result = permission_context()->GetPermissionStatus(
+      content::PermissionDescriptorUtil::
+          CreatePermissionDescriptorForPermissionType(
+              permissions::PermissionUtil::ContentSettingsTypeToPermissionType(
+                  permission_context()->content_settings_type())),
+      child_rfh, GetRequesterURL(), GetTopLevelURL());
+  permission_context()->MaybeOverridePermissionResultToReturn(result);
+  EXPECT_EQ(PermissionStatus::ASK, result.status);
 
   EXPECT_THAT(page_specific_content_settings()->GetTwoSiteRequests(
                   ContentSettingsType::STORAGE_ACCESS),
@@ -1045,6 +1059,67 @@
               IsEmpty());
 }
 
+TEST_F(StorageAccessGrantPermissionContextTest, RepeatedDismissalsNotExposed) {
+  content::RenderFrameHost* child_rfh =
+      content::RenderFrameHostTester::For(main_rfh())->AppendChild("child");
+  std::unique_ptr<content::NavigationSimulator> navigation =
+      content::NavigationSimulator::CreateRendererInitiated(GetRequesterURL(),
+                                                            child_rfh);
+  navigation->Commit();
+  child_rfh = navigation->GetFinalRenderFrameHost();
+
+  prompt_factory().set_response_type(
+      permissions::PermissionRequestManager::AutoResponseType::DISMISS);
+
+  for (int i = 0; i < kDefaultDismissalsBeforeEmbargo; ++i) {
+    EXPECT_EQ(PermissionStatus::ASK,
+              RequestPermission(
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/storage_access_api/api_browsertest.cc b/chrome/browser/storage_access_api/api_browsertest.cc
index 6f3da6a..9ed824a 100644
--- a/chrome/browser/storage_access_api/api_browsertest.cc
+++ b/chrome/browser/storage_access_api/api_browsertest.cc
@@ -918,6 +918,37 @@
       UnorderedElementsAre(Pair(net::SchemefulSite(GURL(kOriginB)), false)));
 }
 
+// Test that permissions.query inside a credentialless iframe does not expose
+// embargoed status when a storage access request has been repeatedly dismissed.
+IN_PROC_BROWSER_TEST_F(StorageAccessAPIBrowserTest,
+                       PermissionQueryEmbargoed_CredentiallessFrame) {
+  SetBlockThirdPartyCookies(true);
+  EnsureUserInteractionOn(kHostB);
+
+  NavigateToPageWithFrame(kHostA);
+  NavigateFrameTo(kHostB, "/echoheader?cookie");
+
+  prompt_factory()->set_response_type(
+      permissions::PermissionRequestManager::DISMISS);
+
+  // Dismissing the prompt 3 times places the origin under embargo.
+  for (int i = 0; i < 3; ++i) {
+    EXPECT_FALSE(
+        content::ExecJs(GetFrame(), "document.requestStorageAccess()"));
+  }
+  ASSERT_EQ(prompt_factory()->TotalRequestCount(), 3);
+  ASSERT_EQ(QueryPermission(GetFrame()), "prompt");
+
+  NavigateToPageWithFrame(kHostA, /*browser_ptr=*/nullptr,
+                          /*credentialless=*/true);
+  NavigateFrameTo(kHostB, "/echoheader?cookie");
+  EXPECT_FALSE(storage::test::HasStorageAccessForFrame(GetFrame()));
+
+  // Even when under embargo, permissions.query inside a credentialless iframe
+  // should return "prompt" rather than "denied".
+  EXPECT_EQ(QueryPermission(GetFrame()), "prompt");
+}
+
 IN_PROC_BROWSER_TEST_F(StorageAccessAPIBrowserTest, PermissionQueryCrossSite) {
   SetBlockThirdPartyCookies(true);
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 ff068b66..6c3e083a 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
@@ -43,6 +43,7 @@
 #include "components/prefs/pref_service.h"
 #include "components/privacy_sandbox/privacy_sandbox_prefs.h"
 #include "content/public/browser/btm_service.h"
+#include "content/public/browser/global_routing_id.h"
 #include "content/public/browser/permission_descriptor_util.h"
 #include "content/public/browser/permission_result.h"
 #include "content/public/browser/web_contents.h"
@@ -85,6 +86,7 @@
 constexpr char kPromptResultHistogram[] = "Permissions.Action.StorageAccess";
 constexpr char kRequestOutcomeHistogram[] = "API.StorageAccess.RequestOutcome";
 constexpr int kImplicitGrantLimit = 5;  // Implicit grant limit for testing.
+constexpr int kDefaultDismissalsBeforeEmbargo = 3;
 
 const uint64_t kDwaEventNameHash =
     base::HashMetricName("StorageAccess.RequestOutcome");
@@ -196,12 +198,20 @@
     ChromeRenderViewHostTestHarness::TearDown();
   }
 
+  content::RenderFrameHost* RenderFrameHostFromID(
+      content::GlobalRenderFrameHostId id) {
+    content::RenderFrameHost* target_rfh = content::RenderFrameHost::FromID(id);
+    return target_rfh ? target_rfh : main_rfh();
+  }
+
   base::test::TestFuture<content::PermissionResult> DecidePermission(
       std::unique_ptr<permissions::PermissionRequestData> request_data,
       bool simulate_user_gesture = true) {
     DCHECK(request_data);
     if (request_data->user_gesture && simulate_user_gesture) {
-      content::RenderFrameHostTester::For(main_rfh())->SimulateUserActivation();
+      content::RenderFrameHostTester::For(
+          RenderFrameHostFromID(request_data->id.global_render_frame_host_id()))
+          ->SimulateUserActivation();
     }
     base::test::TestFuture<content::PermissionResult> future;
     permission_context_->DecidePermissionForTesting(std::move(request_data),
@@ -213,7 +223,9 @@
       std::unique_ptr<permissions::PermissionRequestData> request_data) {
     DCHECK(request_data);
     if (request_data->user_gesture) {
-      content::RenderFrameHostTester::For(main_rfh())->SimulateUserActivation();
+      content::RenderFrameHostTester::For(
+          RenderFrameHostFromID(request_data->id.global_render_frame_host_id()))
+          ->SimulateUserActivation();
     }
     base::test::TestFuture<content::PermissionResult> future;
     permission_context_->RequestPermissionForTesting(std::move(request_data),
@@ -222,14 +234,15 @@
   }
 
   std::unique_ptr<permissions::PermissionRequestData> MakePermissionRequestData(
-      bool user_gesture) {
+      bool user_gesture,
+      content::RenderFrameHost* rfh = nullptr) {
     return std::make_unique<permissions::PermissionRequestData>(
         content::PermissionDescriptorUtil::
             CreatePermissionDescriptorForPermissionType(
                 permissions::PermissionUtil::
                     ContentSettingsTypeToPermissionType(
                         ContentSettingsType::STORAGE_ACCESS)),
-        CreateFakeID(), user_gesture, GetRequesterURL(), GetTopLevelURL());
+        CreateFakeID(rfh), user_gesture, GetRequesterURL(), GetTopLevelURL());
   }
 
   // Helper to ensure that a given content setting is consistently applied on a
@@ -417,17 +430,14 @@
 
   // 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);
+  content::PermissionResult result = permission_context()->GetPermissionStatus(
+      content::PermissionDescriptorUtil::
+          CreatePermissionDescriptorForPermissionType(
+              permissions::PermissionUtil::ContentSettingsTypeToPermissionType(
+                  permission_context()->content_settings_type())),
+      child_rfh, GetRequesterURL(), GetTopLevelURL());
+  permission_context()->MaybeOverridePermissionResultToReturn(result);
+  EXPECT_EQ(PermissionStatus::ASK, result.status);
 }
 
 // Test that after a successful explicit storage access grant, there's a content
@@ -897,6 +907,14 @@
 
 TEST_F(StorageAccessGrantPermissionContextTest,
        ExplicitGrantDenialNotExposedViaQuery) {
+  content::RenderFrameHost* child_rfh =
+      content::RenderFrameHostTester::For(main_rfh())->AppendChild("child");
+  std::unique_ptr<content::NavigationSimulator> navigation =
+      content::NavigationSimulator::CreateRendererInitiated(GetRequesterURL(),
+                                                            child_rfh);
+  navigation->Commit();
+  child_rfh = navigation->GetFinalRenderFrameHost();
+
   // Set the content setting to blocked, mimicking a prompt rejection by the
   // user.
   HostContentSettingsMap* settings_map =
@@ -908,26 +926,22 @@
   prompt_factory().set_response_type(
       permissions::PermissionRequestManager::AutoResponseType::NONE);
 
-  auto future =
-      DecidePermission(MakePermissionRequestData(/*user_gesture=*/true));
+  auto future = RequestPermission(
+      MakePermissionRequestData(/*user_gesture=*/true, child_rfh));
   // Ensure the prompt is not shown.
   ASSERT_FALSE(request_manager()->IsRequestInProgress());
   EXPECT_EQ(PermissionStatus::DENIED, future.Get().status);
 
   // However, ensure that the user's denial is not exposed when querying the
   // permission, per the spec.
-  EXPECT_EQ(
-      PermissionStatus::ASK,
-      permission_context()
-          ->GetPermissionStatus(
-              content::PermissionDescriptorUtil::
-                  CreatePermissionDescriptorForPermissionType(
-                      permissions::PermissionUtil::
-                          ContentSettingsTypeToPermissionType(
-                              permission_context()->content_settings_type())),
-              /*render_frame_host=*/nullptr, GetRequesterURL(),
-              GetTopLevelURL())
-          .status);
+  content::PermissionResult result = permission_context()->GetPermissionStatus(
+      content::PermissionDescriptorUtil::
+          CreatePermissionDescriptorForPermissionType(
+              permissions::PermissionUtil::ContentSettingsTypeToPermissionType(
+                  permission_context()->content_settings_type())),
+      child_rfh, GetRequesterURL(), GetTopLevelURL());
+  permission_context()->MaybeOverridePermissionResultToReturn(result);
+  EXPECT_EQ(PermissionStatus::ASK, result.status);
 
   EXPECT_THAT(page_specific_content_settings()->GetTwoSiteRequests(
                   ContentSettingsType::STORAGE_ACCESS),
@@ -1045,6 +1059,67 @@
               IsEmpty());
 }
 
+TEST_F(StorageAccessGrantPermissionContextTest, RepeatedDismissalsNotExposed) {
+  content::RenderFrameHost* child_rfh =
+      content::RenderFrameHostTester::For(main_rfh())->AppendChild("child");
+  std::unique_ptr<content::NavigationSimulator> navigation =
+      content::NavigationSimulator::CreateRendererInitiated(GetRequesterURL(),
+                                                            child_rfh);
+  navigation->Commit();
+  child_rfh = navigation->GetFinalRenderFrameHost();
+
+  prompt_factory().set_response_type(
+      permissions::PermissionRequestManager::AutoResponseType::DISMISS);
+
+  for (int i = 0; i < kDefaultDismissalsBeforeEmbargo; ++i) {
+    EXPECT_EQ(PermissionStatus::ASK,
+              RequestPermission(
+                  MakePermissionRequestData(/*user_gesture=*/true, child_rfh))
+                  .Get()
+                  .status);
+  }
+
+  content::PermissionResult result = permission_context()->GetPermissionStatus(
+      content::PermissionDescriptorUtil::
+          CreatePermissionDescriptorForPermissionType(
+              permissions::PermissionUtil::ContentSettingsTypeToPermissionType(
+                  permission_context()->content_settings_type())),
+      child_rfh, GetRequesterURL(), GetTopLevelURL());
+  permission_context()->MaybeOverridePermissionResultToReturn(result);
+  EXPECT_EQ(PermissionStatus::ASK, result.status);
+}
+
+TEST_F(StorageAccessGrantPermissionContextTest,
+       EmbargoActivatesAfterRepeatedDismissals) {
+  content::RenderFrameHost* child_rfh =
+      content::RenderFrameHostTester::For(main_rfh())->AppendChild("child");
+  std::unique_ptr<content::NavigationSimulator> navigation =
+      content::NavigationSimulator::CreateRendererInitiated(GetRequesterURL(),
+                                                            child_rfh);
+  navigation->Commit();
+  child_rfh = navigation->GetFinalRenderFrameHost();
+
+  prompt_factory().set_response_type(
+      permissions::PermissionRequestManager::AutoResponseType::DISMISS);
+
+  for (int i = 0; i < kDefaultDismissalsBeforeEmbargo; ++i) {
+    EXPECT_EQ(PermissionStatus::ASK,
+              RequestPermission(
+                  MakePermissionRequestData(/*user_gesture=*/true, child_rfh))
+                  .Get()
+                  .status);
+  }
+
+  prompt_factory().set_response_type(
+      permissions::PermissionRequestManager::AutoResponseType::NONE);
+
+  EXPECT_EQ(PermissionStatus::DENIED,
+            RequestPermission(
+                MakePermissionRequestData(/*user_gesture=*/true, child_rfh))
+                .Get()
+                .status);
+}
+
 class StorageAccessGrantPermissionContextAPIWithFedCMConnectionTest
     : public StorageAccessGrantPermissionContextTest,
       public testing::WithParamInterface<bool> {
diff --git a/components/permissions/permission_manager_unittest.cc b/components/permissions/permission_manager_unittest.cc
index 96c3f65..b215d147 100644
--- a/components/permissions/permission_manager_unittest.cc
+++ b/components/permissions/permission_manager_unittest.cc
@@ -938,6 +938,34 @@
   EXPECT_TRUE(!context);
 }
 
+TEST_F(PermissionManagerTest, StorageAccessPermissionStatusMasksDenied) {
+  SetPermission(PermissionType::STORAGE_ACCESS_GRANT,
+                PermissionStatus::GRANTED);
+  CheckPermissionStatus(PermissionType::STORAGE_ACCESS_GRANT,
+                        PermissionStatus::GRANTED);
+
+  SetPermission(PermissionType::STORAGE_ACCESS_GRANT, PermissionStatus::ASK);
+  CheckPermissionStatus(PermissionType::STORAGE_ACCESS_GRANT,
+                        PermissionStatus::ASK);
+
+  SetPermission(PermissionType::STORAGE_ACCESS_GRANT, PermissionStatus::DENIED);
+  CheckPermissionStatus(PermissionType::STORAGE_ACCESS_GRANT,
+                        PermissionStatus::ASK);
+  CheckPermissionResult(PermissionType::STORAGE_ACCESS_GRANT,
+                        PermissionStatus::ASK,
+                        content::PermissionStatusSource::UNSPECIFIED);
+}
+
+TEST_F(PermissionManagerTest, StorageAccessPermissionRequestMasksDenied) {
+  NavigateAndCommit(url());
+  SetPermission(PermissionType::STORAGE_ACCESS_GRANT, PermissionStatus::DENIED);
+
+  RequestPermissionFromCurrentDocument(PermissionType::STORAGE_ACCESS_GRANT,
+                                       main_rfh());
+  EXPECT_TRUE(callback_called());
+  EXPECT_EQ(PermissionStatus::ASK, callback_result());
+}
+
 class PermissionManagerWithGeolocationTest : public PermissionManagerTest {
  public:
   PermissionManagerWithGeolocationTest() {
diff --git a/components/permissions/test/permission_test_util.cc b/components/permissions/test/permission_test_util.cc
index 3818979..4592373a 100644
--- a/components/permissions/test/permission_test_util.cc
... (truncated)
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.