Overview

Medium
Severity
5.4
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUI misrepresentation in FileSystem
DescriptionUI misrepresentation in FileSystem
ComponentFileSystem
Sub-componentFSA
Bug ClassSpoofing
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.
Tracker522344883
Fix commit6763160b88f9 (chromium/src) +115/-12
CWECWE-451
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:L
CISA KEVNot listed
CreditedGoogle
Reported2026-06-10 (per vendor advisory)
Fix landed2026-08-31 (public git)
Disclosed2026-09-22
Patch gap22 days — fix public in source before the advisory

Background

File System Access (FSA) usage indicator
A page action icon (kActionShowFileSystemAccess) and FileSystemAccessUsageBubbleView that tell the user which committed origin currently holds read/write grants to local files.
`ContentsObservingTabFeature`
A tab-scoped base class that wires a feature into content::WebContentsObserver callbacks so it recomputes state as the tab’s primary page changes.
`PrimaryPageChanged`
A WebContentsObserver hook fired when a tab commits a new primary page, i.e. when the visible origin actually changes.
`FileSystemAccessPermissionContext`
The per-profile store queried via OriginHasWriteAccess / OriginHasReadAccess to decide whether the indicator should be shown for the tab’s origin.

Root Cause Analysis

Before the fix, FileSystemAccessPageActionController held only a raw_ref<tabs::TabInterface> and exposed UpdateVisibility(), but it did not observe the tab’s WebContents, so a navigation that committed a new primary page did not automatically recompute the indicator. The invariant that the FSA usage icon and bubble must reflect the tab’s currently committed origin could therefore be violated: the indicator computed for a prior origin (kept alive because other tabs still referenced it) could linger after the tab navigated elsewhere, misrepresenting which site has file access. Additionally, UpdateVisibility() unconditionally called FileSystemAccessUsageBubbleView::CloseCurrentBubble() in the no-grant branch, and because the permission context refreshes the active tab of every browser window, a tab without grants in one window could dismiss the bubble legitimately shown in another window.

The fix makes the controller derive from tabs::ContentsObservingTabFeature and overrides PrimaryPageChanged() to call UpdateVisibility() on every primary-page commit, keeping the indicator synchronized with the committed origin. It also gates the bubble close on bubble->web_contents() == contents, so only the bubble anchored to the tab being updated is closed. Together these ensure the security-relevant indicator tracks the real origin and is not spuriously torn down for an unrelated tab.

Key insight
The controller recomputed the FSA usage indicator only on demand and never in response to page navigation, so a stale indicator could keep attributing file access to the wrong origin; deriving from ContentsObservingTabFeature and refreshing in PrimaryPageChanged() (plus scoping the bubble close to the matching WebContents) binds the indicator to the tab’s committed origin.

Attack Path

  1. Grant file access on origin A The user grants read or write access to origin A in a tab, so FileSystemAccessPermissionContext records the grant and the usage indicator/bubble shows for A.
  2. Keep the origin alive Another tab or window continues to reference origin A’s grant, preventing the permission state from being cleared when the first tab moves on.
  3. Navigate the tab to another origin The tab commits a new primary page to a different origin, but without a PrimaryPageChanged observer the controller does not call UpdateVisibility(), leaving the indicator computed for A displayed on the new page — a UI misrepresentation of which origin holds file access.
  4. Suppress another window's bubble (inferred) Because the permission context refreshes the active tab of every window and the pre-fix code closed the bubble unconditionally, a grant-less tab’s UpdateVisibility() could dismiss the bubble that a different window is legitimately showing, further confusing the user about active access.

Impact Assessment

The advisory classifies this as UI misrepresentation in the FileSystem (File System Access) component, a LogicError: an attacker page can cause the FSA usage indicator to display state inconsistent with the tab’s actually committed origin, so the user may misjudge which site holds read/write access to their local files. The gain is confined to the browser UI in the renderer/browser presentation layer with no memory-safety primitive shown by the patch; the precondition is an existing FSA grant that another tab or window keeps alive across a navigation. No exploited-in-the-wild status or severity beyond the stated UI-misrepresentation impact is asserted by the advisory, and any escalation past user deception is not supported by the diff (inferred).

Changed Functions

FunctionChangeNotes
if
chrome/browser/ui/views/file_system_access/file_system_access_page_action_controller.cc
modified
Page
chrome/browser/ui/views/file_system_access/file_system_access_page_action_controller.h
modified
TabInterface
chrome/browser/ui/views/file_system_access/file_system_access_page_action_controller.h
modified
FileSystemAccessPageActionController
chrome/browser/ui/views/file_system_access/file_system_access_page_action_controller.h
modified
BackForwardCacheFileSystemAccessBrowserTest
chrome/browser/ui/views/file_system_access/file_system_access_browsertest.cc
modified

Files Changed

  • chrome/browser/ui/views/file_system_access/file_system_access_page_action_controller.h
  • chrome/browser/ui/views/file_system_access/file_system_access_page_action_controller.cc
  • chrome/browser/ui/views/file_system_access/file_system_access_browsertest.cc

Audit Directions

  • On-demand-only UI state
    Look for other page action or omnibox indicator controllers that expose an UpdateVisibility-style method but do not observe PrimaryPageChanged/WebContents, since they can render stale per-origin security state after navigation.
  • Unscoped singleton bubble teardown
    Audit callers of shared CloseCurrentBubble/GetBubble singletons that close or mutate the bubble without checking it belongs to the current WebContents, as cross-window refresh loops can dismiss another window’s UI.
  • Origin kept alive across tabs
    Review permission-indicator logic that assumes grant state clears on navigation; grants referenced by other tabs/windows can keep an origin’s state alive and desynchronize the indicator from the committed origin.
From 6763160b88f9d613a1e669f190bf192bebb657e4 Mon Sep 17 00:00:00 2001
From: Muhammad Salmaan <musalmaan@chromium.org>
Date: Mon, 31 Aug 2026 14:15:29 -0700
Subject: [PATCH] [FSA] Refresh page action on navigation and scope bubble close

Make FileSystemAccessPageActionController derive from
ContentsObservingTabFeature so the usage indicator is recomputed when
the primary page changes, matching other page action controllers. This
keeps the indicator in sync with the committed origin even when other
tabs keep the previous origin alive.

Also restrict UpdateVisibility() to only close the usage bubble when it
is anchored to the tab being updated, since the permission context
refreshes the active tab of every browser window and a tab without
grants in one window could otherwise dismiss the bubble showing in
another.

Bug: 522344883
Change-Id: If5424b166abd29597574a14019401a2c9eb10ace
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8327107
Reviewed-by: Foromo Daniel Soromou <koretadaniel@chromium.org>
Commit-Queue: Muhammad Salmaan <musalmaan@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1689261}
---
diff --git a/chrome/browser/ui/views/file_system_access/file_system_access_page_action_controller.cc b/chrome/browser/ui/views/file_system_access/file_system_access_page_action_controller.cc
index a0724a72..c8bc0cc 100644
--- a/chrome/browser/ui/views/file_system_access/file_system_access_page_action_controller.cc
+++ b/chrome/browser/ui/views/file_system_access/file_system_access_page_action_controller.cc
@@ -13,28 +13,32 @@
 #include "chrome/grit/generated_resources.h"
 #include "components/tabs/public/tab_interface.h"
 #include "components/vector_icons/vector_icons.h"
+#include "content/public/browser/page.h"
 #include "ui/base/l10n/l10n_util.h"
 #include "ui/base/ui_base_features.h"
 
 FileSystemAccessPageActionController::FileSystemAccessPageActionController(
     tabs::TabInterface& tab_interface)
-    : tab_interface_(tab_interface) {}
+    : tabs::ContentsObservingTabFeature(tab_interface) {}
+
+FileSystemAccessPageActionController::~FileSystemAccessPageActionController() =
+    default;
 
 void FileSystemAccessPageActionController::UpdateVisibility() {
   bool has_write_access = false;
   bool show_read_indicator = false;
 
-  content::WebContents* web_contents = tab_interface_->GetContents();
+  content::WebContents* contents = tab().GetContents();
   url::Origin origin =
-      web_contents->GetPrimaryMainFrame()->GetLastCommittedOrigin();
+      contents->GetPrimaryMainFrame()->GetLastCommittedOrigin();
   auto* context =
       FileSystemAccessPermissionContextFactory::GetForProfileIfExists(
-          web_contents->GetBrowserContext());
+          contents->GetBrowserContext());
   if (context) {
     has_write_access = context->OriginHasWriteAccess(origin);
     show_read_indicator = context->OriginHasReadAccess(origin);
   }
-  tabs::TabFeatures* tab_features = tab_interface_->GetTabFeatures();
+  tabs::TabFeatures* tab_features = tab().GetTabFeatures();
   CHECK(tab_features);
   page_actions::PageActionController* page_action_controller =
       tab_features->page_action_controller();
@@ -62,14 +66,23 @@
       page_action_controller->Show(kActionShowFileSystemAccess);
     }
   } else {
-    FileSystemAccessUsageBubbleView::CloseCurrentBubble();
+    if (FileSystemAccessUsageBubbleView* bubble =
+            FileSystemAccessUsageBubbleView::GetBubble();
+        bubble && bubble->web_contents() == contents) {
+      FileSystemAccessUsageBubbleView::CloseCurrentBubble();
+    }
     HideIcon();
   }
 }
 
+void FileSystemAccessPageActionController::PrimaryPageChanged(
+    content::Page& page) {
+  UpdateVisibility();
+}
+
 void FileSystemAccessPageActionController::HideIcon() {
   page_actions::PageActionController* page_action_controller =
-      tab_interface_->GetTabFeatures()->page_action_controller();
+      tab().GetTabFeatures()->page_action_controller();
   CHECK(page_action_controller);
   page_action_controller->Hide(kActionShowFileSystemAccess);
 }
diff --git a/chrome/browser/ui/views/file_system_access/file_system_access_page_action_controller.h b/chrome/browser/ui/views/file_system_access/file_system_access_page_action_controller.h
index 30131449..27d517e6 100644
--- a/chrome/browser/ui/views/file_system_access/file_system_access_page_action_controller.h
+++ b/chrome/browser/ui/views/file_system_access/file_system_access_page_action_controller.h
@@ -5,17 +5,22 @@
 #ifndef CHROME_BROWSER_UI_VIEWS_FILE_SYSTEM_ACCESS_FILE_SYSTEM_ACCESS_PAGE_ACTION_CONTROLLER_H_
 #define CHROME_BROWSER_UI_VIEWS_FILE_SYSTEM_ACCESS_FILE_SYSTEM_ACCESS_PAGE_ACTION_CONTROLLER_H_
 
-#include "base/memory/raw_ref.h"
+#include "chrome/browser/ui/tabs/contents_observing_tab_feature.h"
+
+namespace content {
+class Page;
+}
 
 namespace tabs {
 class TabInterface;
 }
 
-class FileSystemAccessPageActionController {
+class FileSystemAccessPageActionController
+    : public tabs::ContentsObservingTabFeature {
  public:
   explicit FileSystemAccessPageActionController(
       tabs::TabInterface& tab_interface);
-  ~FileSystemAccessPageActionController() = default;
+  ~FileSystemAccessPageActionController() override;
 
   FileSystemAccessPageActionController(
       const FileSystemAccessPageActionController&) = delete;
@@ -26,10 +31,11 @@
   void UpdateVisibility();
 
  private:
+  // content::WebContentsObserver:
+  void PrimaryPageChanged(content::Page& page) override;
+
   // Hides the File System Access page action icon.
   void HideIcon();
-
-  const raw_ref<tabs::TabInterface> tab_interface_;
 };
 
 #endif  // CHROME_BROWSER_UI_VIEWS_FILE_SYSTEM_ACCESS_FILE_SYSTEM_ACCESS_PAGE_ACTION_CONTROLLER_H_
diff --git a/chrome/browser/ui/views/file_system_access/file_system_access_browsertest.cc b/chrome/browser/ui/views/file_system_access/file_system_access_browsertest.cc
index db7bcc9..ab34e72f 100644
--- a/chrome/browser/ui/views/file_system_access/file_system_access_browsertest.cc
+++ b/chrome/browser/ui/views/file_system_access/file_system_access_browsertest.cc
@@ -2,6 +2,8 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
+#include <vector>
+
 #include "base/files/file_path.h"
 #include "base/files/file_util.h"
 #include "base/files/scoped_temp_dir.h"
@@ -24,9 +26,12 @@
 #include "chrome/browser/ui/browser_actions.h"
 #include "chrome/browser/ui/browser_commands.h"
 #include "chrome/browser/ui/browser_window/public/browser_window_interface.h"
+#include "chrome/browser/ui/tabs/public/tab_features.h"
 #include "chrome/browser/ui/tabs/split_tab_metrics.h"
 #include "chrome/browser/ui/ui_features.h"
+#include "chrome/browser/ui/views/file_system_access/file_system_access_page_action_controller.h"
 #include "chrome/browser/ui/views/file_system_access/file_system_access_test_utils.h"
+#include "chrome/browser/ui/views/file_system_access/file_system_access_usage_bubble_view.h"
 #include "chrome/browser/ui/views/frame/browser_view.h"
 #include "chrome/browser/ui/views/frame/toolbar_button_provider.h"
 #include "chrome/browser/ui/views/location_bar/icon_label_bubble_view.h"
@@ -39,6 +44,7 @@
 #include "components/permissions/permission_util.h"
 #include "components/safe_browsing/buildflags.h"
 #include "components/safe_browsing/content/common/file_type_policies_test_util.h"
+#include "components/tabs/public/tab_interface.h"
 #include "content/public/browser/file_system_access_permission_context.h"
 #include "content/public/common/content_switches.h"
 #include "content/public/test/back_forward_cache_util.h"
@@ -980,6 +986,84 @@
   // indicator is visible when the original page is visited again.
 }
 
+// Tests that the usage indicator is hidden after navigating to another origin
+// even when another tab keeps the original origin open.
+IN_PROC_BROWSER_TEST_F(PersistedPermissionsFileSystemAccessBrowserTest,
+                       UsageIndicatorHiddenAfterNavigationWithBackgroundTab) {
+  const base::FilePath test_file = CreateTestFile("");
+  ui::SelectFileDialog::SetFactory(
+      std::make_unique<SelectPredeterminedFileDialogFactory>(
+          std::vector<base::FilePath>{test_file}));
+
+  const GURL test_url = embedded_test_server()->GetURL("/title1.html");
+  ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), test_url));
+  content::WebContents* web_contents =
+      browser()->tab_strip_model()->GetActiveWebContents();
+
+  EXPECT_FALSE(IsUsageIndicatorVisible(browser()));
+
+  EXPECT_EQ(test_file.BaseName().AsUTF8Unsafe(),
+            content::EvalJs(web_contents,
+                            "(async () => {"
+                            "  let e = await self.showSaveFilePicker();"
+                            "  self.entry = e;"
+                            "  return e.name; })()"));
+
+  EXPECT_TRUE(IsUsageIndicatorVisible(browser()));
+
+  // Open a second tab on the same origin in the background so that the origin
+  // remains active after the first tab navigates away.
+  ui_test_utils::NavigateToURLWithDisposition(
+      browser(), test_url, WindowOpenDisposition::NEW_BACKGROUND_TAB,
+      ui_test_utils::BROWSER_TEST_WAIT_FOR_LOAD_STOP);
+  ASSERT_EQ(web_contents, browser()->tab_strip_model()->GetActiveWebContents());
+
+  // Navigate the first tab to another origin.
+  ASSERT_TRUE(ui_test_utils::NavigateToURL(
+      browser(), embedded_test_server()->GetURL("a.com", "/title2.html")));
+
+  // The usage indicator should not be shown for the new origin.
+  EXPECT_FALSE(IsUsageIndicatorVisible(browser()));
+}
+
+// Tests that updating the page action visibility for one tab does not close
+// the usage bubble showing for a different tab.
+IN_PROC_BROWSER_TEST_F(PersistedPermissionsFileSystemAccessBrowserTest,
+                       UsageBubbleNotClosedByOtherTabUpdate) {
+  ASSERT_TRUE(ui_test_utils::NavigateToURL(
+      browser(), embedded_test_server()->GetURL("/title1.html")));
+
+  // Open a second tab on a different origin in the background.
+  ui_test_utils::NavigateToURLWithDisposition(
+      browser(), embedded_test_server()->GetURL("a.com", "/title1.html"),
+      WindowOpenDisposition::NEW_BACKGROUND_TAB,
+      ui_test_utils::BROWSER_TEST_WAIT_FOR_LOAD_STOP);
+  ASSERT_EQ(2, browser()->tab_strip_model()->count());
+
+  // Show the usage bubble for the active tab.
+  FileSystemAccessUsageBubbleView::Usage usage;
+  usage.writable_files.emplace_back(FILE_PATH_LITERAL("/foo/bar/file.txt"));
+  FileSystemAccessUsageBubbleView::ShowBubble(
+      browser()->tab_strip_model()->GetActiveWebContents(),
+      url::Origin::Create(GURL("https://example.com")), std::move(usage));
+  ASSERT_NE(FileSystemAccessUsageBubbleView::GetBubble(), nullptr);
+
+  // Updating the page action visibility for the background tab, which has no
+  // grants, should not close the bubble showing for the active tab.
+  tabs::TabInterface* background_tab =
+      browser()->tab_strip_model()->GetTabAtIndex(1);
+  ASSERT_TRUE(background_tab);
+  FileSystemAccessPageActionController* controller =
+      background_tab->GetTabFeatures()
+          ->file_system_access_page_action_controller();
+  ASSERT_TRUE(controller);
+  controller->UpdateVisibility();
+
+  EXPECT_NE(FileSystemAccessUsageBubbleView::GetBubble(), nullptr);
+
+  FileSystemAccessUsageBubbleView::CloseCurrentBubble();
+}
+
 class BackForwardCacheFileSystemAccessBrowserTest
     : public FileSystemAccessBrowserTest {
  public:
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/ui/views/file_system_access/file_system_access_browsertest.cc b/chrome/browser/ui/views/file_system_access/file_system_access_browsertest.cc
index db7bcc9..ab34e72f 100644
--- a/chrome/browser/ui/views/file_system_access/file_system_access_browsertest.cc
+++ b/chrome/browser/ui/views/file_system_access/file_system_access_browsertest.cc
@@ -2,6 +2,8 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
+#include <vector>
+
 #include "base/files/file_path.h"
 #include "base/files/file_util.h"
 #include "base/files/scoped_temp_dir.h"
@@ -24,9 +26,12 @@
 #include "chrome/browser/ui/browser_actions.h"
 #include "chrome/browser/ui/browser_commands.h"
 #include "chrome/browser/ui/browser_window/public/browser_window_interface.h"
+#include "chrome/browser/ui/tabs/public/tab_features.h"
 #include "chrome/browser/ui/tabs/split_tab_metrics.h"
 #include "chrome/browser/ui/ui_features.h"
+#include "chrome/browser/ui/views/file_system_access/file_system_access_page_action_controller.h"
 #include "chrome/browser/ui/views/file_system_access/file_system_access_test_utils.h"
+#include "chrome/browser/ui/views/file_system_access/file_system_access_usage_bubble_view.h"
 #include "chrome/browser/ui/views/frame/browser_view.h"
 #include "chrome/browser/ui/views/frame/toolbar_button_provider.h"
 #include "chrome/browser/ui/views/location_bar/icon_label_bubble_view.h"
@@ -39,6 +44,7 @@
 #include "components/permissions/permission_util.h"
 #include "components/safe_browsing/buildflags.h"
 #include "components/safe_browsing/content/common/file_type_policies_test_util.h"
+#include "components/tabs/public/tab_interface.h"
 #include "content/public/browser/file_system_access_permission_context.h"
 #include "content/public/common/content_switches.h"
 #include "content/public/test/back_forward_cache_util.h"
@@ -980,6 +986,84 @@
   // indicator is visible when the original page is visited again.
 }
 
+// Tests that the usage indicator is hidden after navigating to another origin
+// even when another tab keeps the original origin open.
+IN_PROC_BROWSER_TEST_F(PersistedPermissionsFileSystemAccessBrowserTest,
+                       UsageIndicatorHiddenAfterNavigationWithBackgroundTab) {
+  const base::FilePath test_file = CreateTestFile("");
+  ui::SelectFileDialog::SetFactory(
+      std::make_unique<SelectPredeterminedFileDialogFactory>(
+          std::vector<base::FilePath>{test_file}));
+
+  const GURL test_url = embedded_test_server()->GetURL("/title1.html");
+  ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), test_url));
+  content::WebContents* web_contents =
+      browser()->tab_strip_model()->GetActiveWebContents();
+
+  EXPECT_FALSE(IsUsageIndicatorVisible(browser()));
+
+  EXPECT_EQ(test_file.BaseName().AsUTF8Unsafe(),
+            content::EvalJs(web_contents,
+                            "(async () => {"
+                            "  let e = await self.showSaveFilePicker();"
+                            "  self.entry = e;"
+                            "  return e.name; })()"));
+
+  EXPECT_TRUE(IsUsageIndicatorVisible(browser()));
+
+  // Open a second tab on the same origin in the background so that the origin
+  // remains active after the first tab navigates away.
+  ui_test_utils::NavigateToURLWithDisposition(
+      browser(), test_url, WindowOpenDisposition::NEW_BACKGROUND_TAB,
+      ui_test_utils::BROWSER_TEST_WAIT_FOR_LOAD_STOP);
+  ASSERT_EQ(web_contents, browser()->tab_strip_model()->GetActiveWebContents());
+
+  // Navigate the first tab to another origin.
+  ASSERT_TRUE(ui_test_utils::NavigateToURL(
+      browser(), embedded_test_server()->GetURL("a.com", "/title2.html")));
+
+  // The usage indicator should not be shown for the new origin.
+  EXPECT_FALSE(IsUsageIndicatorVisible(browser()));
+}
+
+// Tests that updating the page action visibility for one tab does not close
+// the usage bubble showing for a different tab.
+IN_PROC_BROWSER_TEST_F(PersistedPermissionsFileSystemAccessBrowserTest,
+                       UsageBubbleNotClosedByOtherTabUpdate) {
+  ASSERT_TRUE(ui_test_utils::NavigateToURL(
+      browser(), embedded_test_server()->GetURL("/title1.html")));
+
+  // Open a second tab on a different origin in the background.
+  ui_test_utils::NavigateToURLWithDisposition(
+      browser(), embedded_test_server()->GetURL("a.com", "/title1.html"),
+      WindowOpenDisposition::NEW_BACKGROUND_TAB,
+      ui_test_utils::BROWSER_TEST_WAIT_FOR_LOAD_STOP);
+  ASSERT_EQ(2, browser()->tab_strip_model()->count());
+
+  // Show the usage bubble for the active tab.
+  FileSystemAccessUsageBubbleView::Usage usage;
+  usage.writable_files.emplace_back(FILE_PATH_LITERAL("/foo/bar/file.txt"));
+  FileSystemAccessUsageBubbleView::ShowBubble(
+      browser()->tab_strip_model()->GetActiveWebContents(),
+      url::Origin::Create(GURL("https://example.com")), std::move(usage));
+  ASSERT_NE(FileSystemAccessUsageBubbleView::GetBubble(), nullptr);
+
+  // Updating the page action visibility for the background tab, which has no
+  // grants, should not close the bubble showing for the active tab.
+  tabs::TabInterface* background_tab =
+      browser()->tab_strip_model()->GetTabAtIndex(1);
+  ASSERT_TRUE(background_tab);
+  FileSystemAccessPageActionController* controller =
+      background_tab->GetTabFeatures()
+          ->file_system_access_page_action_controller();
+  ASSERT_TRUE(controller);
+  controller->UpdateVisibility();
+
+  EXPECT_NE(FileSystemAccessUsageBubbleView::GetBubble(), nullptr);
+
+  FileSystemAccessUsageBubbleView::CloseCurrentBubble();
+}
+
 class BackForwardCacheFileSystemAccessBrowserTest
     : public FileSystemAccessBrowserTest {
  public:
Loading diff…

Original Bug Report

reported by rj...@google.com

File System Access UI spoofing on navigation and cross-window DoS of security UI

Flapjack, 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: The File System Access (FSA) omnibox icon fails to update on cross-origin navigations, allowing a malicious site to spoof access permissions for new origins. Additionally, a global singleton management flaw allows any site to programmatically close the FSA usage bubble in other browser windows, preventing users from auditing permissions.

Affected files:

  • chrome/browser/ui/views/file_system_access/file_system_access_page_action_controller.cc
  • chrome/browser/ui/views/file_system_access/file_system_access_usage_bubble_view.cc

Estimated timestamp from git blame: 2025-04-11

Description

The FileSystemAccessPageActionController manages the visibility of the File System Access omnibox icon but suffers from two related logical flaws that affect the integrity and availability of its security UI.

1. UI Spoofing via Persistent Icon State The FileSystemAccessPageActionController does not implement content::WebContentsObserver or tabs::TabInterface::Observer to monitor navigations. It relies entirely on ChromeFileSystemAccessPermissionContext::DoUsageIconUpdate() to call its UpdateVisibility() method.

When a tab navigates cross-origin, FileSystemAccessTabHelper::DidFinishNavigation calls NavigatedAwayFromOrigin(). Because the kFileSystemAccessPersistentPermissions feature is enabled by default on desktop, this function does nothing and returns immediately. As a result, ScheduleUsageIconUpdate() is not called, and the FSA icon remains visible, falsely indicating that the newly navigated-to origin has local file access.

2. Cross-Window Denial of Service of Security UI When DoUsageIconUpdate() runs (e.g., when permissions are granted or revoked), it iterates over all open browser windows and calls UpdateVisibility() on the active tab of each window. If a tab lacks FSA access, the controller executes the following code:

} else {
  FileSystemAccessUsageBubbleView::CloseCurrentBubble();
  HideIcon();
}

CloseCurrentBubble() relies on a global static pointer (bubble_). This unconditionally closes the currently active FSA usage bubble globally, even if the user had opened it to audit permissions on a legitimate site in a completely different browser window.

Potential Steps to Reproduce

Note: These are potential steps based on source code analysis; a functional proof-of-concept has not been run.

UI Spoofing:

  1. Attacker hosts a site and tricks the victim into granting it File System Access. The FSA icon appears.
  2. The attacker’s script opens a second tab to the same origin in the background (to keep the origin active).
  3. In the first tab, the script triggers a cross-origin navigation: window.location = "https://bank.com".
  4. The navigation completes, but the PageActionController is never updated. The FSA icon persists alongside bank.com.

Cross-Window DoS:

  1. The victim opens Window A, visits a legitimate site (e.g., github.com), and grants it File System Access.
  2. The victim clicks the FSA icon in Window A to open the security audit bubble.
  3. In Window B, the attacker triggers a permission state change (e.g., by deleting a file they previously had write access to, triggering NotifyEntryRemoved -> ScheduleUsageIconUpdate()).
  4. DoUsageIconUpdate() runs, evaluates the active tab in Window B, finds it has no access, and calls FileSystemAccessUsageBubbleView::CloseCurrentBubble().
  5. The global pointer bubble_ is used to close the bubble in Window A, interrupting the user’s security audit.

Suggested Fix

  1. Spoofing: Make FileSystemAccessPageActionController implement content::WebContentsObserver or tabs::TabInterface::Observer and explicitly hide the icon upon cross-origin navigations, similar to how other page action controllers operate.
  2. DoS: Remove the global static bubble_ pointer in FileSystemAccessUsageBubbleView. Bubble management should be scoped to the specific Browser, WebContents, or PageActionController instance so that updating the icon in one window cannot interfere with the security UI of another.

Evaluated with Chrome root at commit: 2155cb00003ec35716a76ed3246eae995f87b7ff


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
Links in the report