From f01eaa0f62cd012651e9d4bbddb1dd85f76b3d53 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Sat, 24 Oct 2026 04:44:46 +0000 Subject: [PATCH] fix(browser): let pages use files picked, saved or dropped through File System Access (#32970) --- ...browser-session-partition-policies.test.ts | 82 +++++++++++++++++++ .../browser-session-partition-policies.ts | 50 ++++++++++- .../browser/browser-session-route-policies.ts | 2 +- 3 files changed, 131 insertions(+), 3 deletions(-) diff --git a/src/main/browser/browser-session-partition-policies.test.ts b/src/main/browser/browser-session-partition-policies.test.ts index 961e2cc5b69..67adbe675e6 100644 --- a/src/main/browser/browser-session-partition-policies.test.ts +++ b/src/main/browser/browser-session-partition-policies.test.ts @@ -368,6 +368,88 @@ describe('partition permission policy', () => { expect(check()).toBe(true) }) + it('gives browsing pages File System Access but keeps it from pages another machine can script', async () => { + const install = await loadInstaller() + const { installBrowserRoutePartitionPolicies } = + await import('./browser-session-route-policies') + const routed = `persist:orca-browser-v1-${'a'.repeat(64)}` + install(profileFor('persist:browsing-5')) + void installBrowserRoutePartitionPolicies(profileFor('persist:browsing-5'), routed) + const decide = (partition: string) => { + const sess = sessionsByPartition.get(partition) + if (!sess) { + throw new Error(`Expected the session for ${partition}`) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the installer registered exactly this handler on the fake session. + const requestHandler = sess.setPermissionRequestHandler.mock.calls[0]?.[0] as ( + webContents: { id: number; getURL: () => string }, + permission: string, + callback: (allowed: boolean) => void, + details: Partial + ) => void + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the installer registered exactly this handler on the fake session. + const checkHandler = sess.setPermissionCheckHandler.mock.calls[0]?.[0] as ( + webContents: null, + permission: string, + requestingOrigin: string, + details: Partial + ) => boolean + let requested: boolean | null = null + requestHandler( + { id: 11, getURL: () => 'https://app.test/' }, + 'fileSystem', + (allowed) => (requested = allowed), + { filePath: '/Users/me/project', isDirectory: true, fileAccessType: 'writable' } + ) + const checked = checkHandler(null, 'fileSystem', 'https://app.test', { + filePath: '/Users/me/notes.txt', + isDirectory: false, + fileAccessType: 'writable' + }) + return { requested, checked } + } + + expect(decide('persist:browsing-5')).toEqual({ requested: true, checked: true }) + expect(mocks.notifyPermissionDenied).not.toHaveBeenCalled() + expect(decide(routed)).toEqual({ requested: false, checked: false }) + expect(mocks.notifyPermissionDenied).toHaveBeenCalledWith( + expect.objectContaining({ guestWebContentsId: 11, permission: 'fileSystem' }) + ) + }) + + it('refuses a blocklisted system folder at once instead of leaving the page waiting', async () => { + const install = await loadInstaller() + install(profileFor('persist:browsing-6')) + install(profileFor('orca-doc-preview'), { permissions: 'deny' }) + const answer = (partition: string) => { + const sess = sessionsByPartition.get(partition) + const listener = sess?.on.mock.calls.find( + ([event]) => event === 'file-system-access-restricted' + )?.[1] + const actions: string[] = [] + listener?.( + {}, + { + origin: 'https://app.test', + isDirectory: true, + path: '/Users/me', + webContents: { id: 12 } + }, + (action: string) => actions.push(action) + ) + return actions + } + + expect(answer('orca-doc-preview')).toEqual(['deny']) + expect(mocks.notifyPermissionDenied).not.toHaveBeenCalled() + expect(answer('persist:browsing-6')).toEqual(['deny']) + expect(mocks.notifyPermissionDenied).toHaveBeenCalledWith({ + guestWebContentsId: 12, + permission: 'fileSystem', + rawUrl: 'https://app.test' + }) + }) + it('hands a link to another app to the open-app prompt instead of denying it', async () => { const install = await loadInstaller() install(profileFor('persist:browsing-4')) diff --git a/src/main/browser/browser-session-partition-policies.ts b/src/main/browser/browser-session-partition-policies.ts index 75f70d7bebe..bbc11663a77 100644 --- a/src/main/browser/browser-session-partition-policies.ts +++ b/src/main/browser/browser-session-partition-policies.ts @@ -89,6 +89,34 @@ const handleDeniedWillDownload = ( noticeDocPreviewDownloadBlocked(webContents) } +type RestrictedFileSystemAccessListener = ( + event: Electron.Event, + details: Electron.FileSystemAccessRestrictedDetails, + callback: (action: 'allow' | 'deny' | 'tryAgain') => void +) => void + +// Chromium waits for an answer on blocklisted system folders; without one the page hangs. +const denyRestrictedFileSystemAccess: RestrictedFileSystemAccessListener = ( + _event, + _details, + callback +) => callback('deny') + +const handleRestrictedFileSystemAccess: RestrictedFileSystemAccessListener = ( + _event, + details, + callback +) => { + callback('deny') + if (details.webContents) { + browserManager.notifyPermissionDenied({ + guestWebContentsId: details.webContents.id, + permission: 'fileSystem', + rawUrl: details.origin + }) + } +} + function resolvePermissionNoticeUrl( webContents: Electron.WebContents, details: Electron.PermissionRequest | undefined @@ -114,6 +142,8 @@ export async function installBrowserSessionPartitionPolicies( downloads?: BrowserPartitionDownloadPolicy permissions?: BrowserPartitionPermissionPolicy applyAppWideProxy?: boolean + /** Off where another machine can script pages and reuse their stored file handles. */ + fileSystemAccess?: boolean userAgentExceptions?: boolean /** Session-only answers are not persisted to a browser profile. */ sitePermissions?: 'saved' | 'session' @@ -139,6 +169,12 @@ export async function installBrowserSessionPartitionPolicies( sess.setPermissionCheckHandler(() => false) clearBrowserWebAuthnAccessHandlers(sess) } else { + // Electron answers every File System Access grant here, overriding Chromium's own; + // the user's pick or drop is the consent, and handles a site stores keep their access. + const grantsFileSystem = options.fileSystemAccess !== false + const isAutoGranted = (permission: string): boolean => + isAutoGrantedBrowserSessionPermission(permission) || + (grantsFileSystem && permission === 'fileSystem') const siteMemory = options.sitePermissions === 'session' ? sessionBrowserSitePermissions(partition) @@ -172,7 +208,7 @@ export async function installBrowserSessionPartitionPolicies( const mediaRequest = 'mediaTypes' in details ? details : undefined const sitePermissions = browserSitePermissionsFor(permission, mediaRequest?.mediaTypes) if (!sitePermissions) { - const allowed = isAutoGrantedBrowserSessionPermission(permission) + const allowed = isAutoGranted(permission) if (!allowed) { notifyDenied() } @@ -227,7 +263,7 @@ export async function installBrowserSessionPartitionPolicies( (permission !== 'media' || hasSystemMediaAccess(details?.mediaType)) ) } - return isAutoGrantedBrowserSessionPermission(permission) + return isAutoGranted(permission) }) installBrowserWebAuthnAccessHandlers(sess) } @@ -240,6 +276,14 @@ export async function installBrowserSessionPartitionPolicies( 'will-download', options?.downloads === 'deny' ? handleDeniedWillDownload : handleWillDownload ) + sess.removeListener('file-system-access-restricted', denyRestrictedFileSystemAccess) + sess.removeListener('file-system-access-restricted', handleRestrictedFileSystemAccess) + sess.on( + 'file-system-access-restricted', + options?.permissions === 'deny' + ? denyRestrictedFileSystemAccess + : handleRestrictedFileSystemAccess + ) configuredPartitions.add(partition) return proxyReady } @@ -252,6 +296,8 @@ export function clearBrowserSessionPartitionPolicies(partition: string, sess: Se browserManager.removeCertificateRequestGuard(sess) sess.removeListener('will-download', handleWillDownload) sess.removeListener('will-download', handleDeniedWillDownload) + sess.removeListener('file-system-access-restricted', denyRestrictedFileSystemAccess) + sess.removeListener('file-system-access-restricted', handleRestrictedFileSystemAccess) clearBrowserWebAuthnAccessHandlers(sess) sess.setPermissionRequestHandler(null) sess.setPermissionCheckHandler(null) diff --git a/src/main/browser/browser-session-route-policies.ts b/src/main/browser/browser-session-route-policies.ts index a77937020c2..a7f2d0364ed 100644 --- a/src/main/browser/browser-session-route-policies.ts +++ b/src/main/browser/browser-session-route-policies.ts @@ -16,7 +16,7 @@ export function installBrowserRoutePartitionPolicies( } return installBrowserSessionPartitionPolicies( { ...profile, partition }, - { applyAppWideProxy: false, sitePermissions } + { applyAppWideProxy: false, fileSystemAccess: false, sitePermissions } ) }