CVE-2026-95363
Overview
Background
- File System Access (FSA) usage indicator
- A page action icon (
kActionShowFileSystemAccess) andFileSystemAccessUsageBubbleViewthat 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::WebContentsObservercallbacks so it recomputes state as the tab’s primary page changes. - `PrimaryPageChanged`
- A
WebContentsObserverhook 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/OriginHasReadAccessto 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.
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
- Grant file access on origin A
The user grants read or write access to origin A in a tab, so
FileSystemAccessPermissionContextrecords the grant and the usage indicator/bubble shows for A. - 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.
- Navigate the tab to another origin
The tab commits a new primary page to a different origin, but without a
PrimaryPageChangedobserver the controller does not callUpdateVisibility(), leaving the indicator computed for A displayed on the new page — a UI misrepresentation of which origin holds file access. - 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
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
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/ui/views/file_system_access/file_system_access_page_action_controller.cc |
modified | |
Pagechrome/browser/ui/views/file_system_access/file_system_access_page_action_controller.h |
modified | |
TabInterfacechrome/browser/ui/views/file_system_access/file_system_access_page_action_controller.h |
modified | |
FileSystemAccessPageActionControllerchrome/browser/ui/views/file_system_access/file_system_access_page_action_controller.h |
modified | |
BackForwardCacheFileSystemAccessBrowserTestchrome/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.hchrome/browser/ui/views/file_system_access/file_system_access_page_action_controller.ccchrome/browser/ui/views/file_system_access/file_system_access_browsertest.cc
Audit Directions
- On-demand-only UI stateLook for other page action or omnibox indicator controllers that expose an
UpdateVisibility-style method but do not observePrimaryPageChanged/WebContents, since they can render stale per-origin security state after navigation. - Unscoped singleton bubble teardownAudit callers of shared
CloseCurrentBubble/GetBubblesingletons that close or mutate the bubble without checking it belongs to the currentWebContents, as cross-window refresh loops can dismiss another window’s UI. - Origin kept alive across tabsReview 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.
Patch
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:
Regression Test / PoC
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:
Original Bug Report
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.ccchrome/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:
- Attacker hosts a site and tricks the victim into granting it File System Access. The FSA icon appears.
- The attacker’s script opens a second tab to the same origin in the background (to keep the origin active).
- In the first tab, the script triggers a cross-origin navigation:
window.location = "https://bank.com". - The navigation completes, but the
PageActionControlleris never updated. The FSA icon persists alongsidebank.com.
Cross-Window DoS:
- The victim opens Window A, visits a legitimate site (e.g., github.com), and grants it File System Access.
- The victim clicks the FSA icon in Window A to open the security audit bubble.
- 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()). DoUsageIconUpdate()runs, evaluates the active tab in Window B, finds it has no access, and callsFileSystemAccessUsageBubbleView::CloseCurrentBubble().- The global pointer
bubble_is used to close the bubble in Window A, interrupting the user’s security audit.
Suggested Fix
- Spoofing: Make
FileSystemAccessPageActionControllerimplementcontent::WebContentsObserverortabs::TabInterface::Observerand explicitly hide the icon upon cross-origin navigations, similar to how other page action controllers operate. - DoS: Remove the global static
bubble_pointer inFileSystemAccessUsageBubbleView. Bubble management should be scoped to the specificBrowser,WebContents, orPageActionControllerinstance 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.