fix(browser): let pages use files picked, saved or dropped through File System Access (#32970)

This commit is contained in:
Kelvin Amoaba
2026-10-24 04:44:46 +00:00
committed by GitHub
parent cc929aaadb
commit f01eaa0f62
3 changed files with 131 additions and 3 deletions
@@ -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<Electron.FilesystemPermissionRequest>
) => 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<Electron.PermissionCheckHandlerHandlerDetails>
) => 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'))
@@ -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)
@@ -16,7 +16,7 @@ export function installBrowserRoutePartitionPolicies(
}
return installBrowserSessionPartitionPolicies(
{ ...profile, partition },
{ applyAppWideProxy: false, sitePermissions }
{ applyAppWideProxy: false, fileSystemAccess: false, sitePermissions }
)
}