diff --git a/docs/api/session.md b/docs/api/session.md index 1d13094426a..96f0ab6e9a1 100644 --- a/docs/api/session.md +++ b/docs/api/session.md @@ -939,14 +939,18 @@ session.fromPartition('some-partition').setPermissionRequestHandler((webContents * `top-level-storage-access` - Allow top-level sites to request third-party cookie access on behalf of embedded content originating from another site in the same related website set using the [Storage Access API](https://developer.mozilla.org/en-US/docs/Web/API/Storage_Access_API). * `usb` - Expose non-standard Universal Serial Bus (USB) compatible devices services to the web with the [WebUSB API](https://developer.mozilla.org/en-US/docs/Web/API/WebUSB_API). * `deprecated-sync-clipboard-read` _Deprecated_ - Request access to run `document.execCommand("paste")` + * `fileSystem` - Access to read, write, and file management capabilities using the [File System API](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API). * `requestingOrigin` string - The origin URL of the permission check * `details` Object - Some properties are only available on certain permission types. * `embeddingOrigin` string (optional) - The origin of the frame embedding the frame that made the permission check. Only set for cross-origin sub frames making permission checks. * `securityOrigin` string (optional) - The security origin of the `media` check. * `mediaType` string (optional) - The type of media access being requested, can be `video`, - `audio` or `unknown` + `audio` or `unknown`. * `requestingUrl` string (optional) - The last URL the requesting frame loaded. This is not provided for cross-origin sub frames making permission checks. - * `isMainFrame` boolean - Whether the frame making the request is the main frame + * `isMainFrame` boolean - Whether the frame making the request is the main frame. + * `filePath` string (optional) - The path of a `fileSystem` request. + * `isDirectory` boolean (optional) - Whether a `fileSystem` request is a directory. + * `fileAccessType` string (optional) - The access type of a `fileSystem` request. Can be `writable` or `readable`. Sets the handler which can be used to respond to permission checks for the `session`. Returning `true` will allow the permission and `false` will reject it. Please note that @@ -968,6 +972,9 @@ session.fromPartition('some-partition').setPermissionCheckHandler((webContents, }) ``` +> [!NOTE] +> `isMainFrame` will always be `false` for a `fileSystem` request as a result of Chromium limitations. + #### `ses.setDisplayMediaRequestHandler(handler[, opts])` * `handler` Function | null diff --git a/shell/browser/electron_permission_manager.cc b/shell/browser/electron_permission_manager.cc index d947c5246b2..b32dcd24328 100644 --- a/shell/browser/electron_permission_manager.cc +++ b/shell/browser/electron_permission_manager.cc @@ -142,6 +142,14 @@ void ElectronPermissionManager::SetBluetoothPairingHandler( bluetooth_pairing_handler_ = handler; } +bool ElectronPermissionManager::HasPermissionRequestHandler() const { + return !request_handler_.is_null(); +} + +bool ElectronPermissionManager::HasPermissionCheckHandler() const { + return !check_handler_.is_null(); +} + void ElectronPermissionManager::RequestPermissionWithDetails( blink::mojom::PermissionDescriptorPtr permission, content::RenderFrameHost* render_frame_host, diff --git a/shell/browser/electron_permission_manager.h b/shell/browser/electron_permission_manager.h index a0e720d9320..5cdeb324780 100644 --- a/shell/browser/electron_permission_manager.h +++ b/shell/browser/electron_permission_manager.h @@ -82,6 +82,9 @@ class ElectronPermissionManager : public content::PermissionControllerDelegate { void SetProtectedUSBHandler(const ProtectedUSBHandler& handler); void SetBluetoothPairingHandler(const BluetoothPairingHandler& handler); + bool HasPermissionRequestHandler() const; + bool HasPermissionCheckHandler() const; + void CheckBluetoothDevicePair(gin_helper::Dictionary details, PairCallback pair_callback) const; diff --git a/shell/browser/file_system_access/file_system_access_permission_context.cc b/shell/browser/file_system_access/file_system_access_permission_context.cc index 08f1cf937a2..a714b6b49ee 100644 --- a/shell/browser/file_system_access/file_system_access_permission_context.cc +++ b/shell/browser/file_system_access/file_system_access_permission_context.cc @@ -257,6 +257,28 @@ class FileSystemAccessPermissionContext::PermissionGrantImpl // FileSystemAccessPermissionGrant: PermissionStatus GetStatus() override { DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); + + auto* permission_manager = + static_cast( + context_->browser_context()->GetPermissionControllerDelegate()); + if (permission_manager && permission_manager->HasPermissionCheckHandler()) { + base::Value::Dict details; + details.Set("filePath", base::FilePathToValue(path_info_.path)); + details.Set("isDirectory", handle_type_ == HandleType::kDirectory); + details.Set("fileAccessType", + type_ == GrantType::kWrite ? "writable" : "readable"); + + bool granted = permission_manager->CheckPermissionWithDetails( + blink::PermissionType::FILE_SYSTEM, nullptr, origin_.GetURL(), + std::move(details)); + return granted ? PermissionStatus::GRANTED : PermissionStatus::DENIED; + } + + return status_; + } + + PermissionStatus GetActivePermissionStatus() { + DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); return status_; } @@ -279,8 +301,8 @@ class FileSystemAccessPermissionContext::PermissionGrantImpl // Check if a permission request has already been processed previously. This // check is done first because we don't want to reset the status of a // permission if it has already been granted. - if (GetStatus() != PermissionStatus::ASK || !context_) { - if (GetStatus() == PermissionStatus::GRANTED) { + if (GetActivePermissionStatus() != PermissionStatus::ASK || !context_) { + if (GetActivePermissionStatus() == PermissionStatus::GRANTED) { SetStatus(PermissionStatus::GRANTED); } std::move(callback).Run(PermissionRequestOutcome::kRequestAborted); @@ -294,7 +316,7 @@ class FileSystemAccessPermissionContext::PermissionGrantImpl return; } - // Don't request permission for an inactive RenderFrameHost as the + // Don't request permission for an inactive RenderFrameHost as the // page might not distinguish properly between user denying the permission // and automatic rejection. if (rfh->IsInactiveAndDisallowActivation( @@ -347,7 +369,7 @@ class FileSystemAccessPermissionContext::PermissionGrantImpl permission_manager->RequestPermissionWithDetails( content::PermissionDescriptorUtil:: CreatePermissionDescriptorForPermissionType(type), - rfh, origin, false, std::move(details), + rfh, origin, rfh->HasTransientUserActivation(), std::move(details), base::BindOnce(&PermissionGrantImpl::OnPermissionRequestResult, this, std::move(callback))); } @@ -394,7 +416,8 @@ class FileSystemAccessPermissionContext::PermissionGrantImpl return; } - DCHECK_EQ(entry_it->second->GetStatus(), PermissionStatus::GRANTED); + DCHECK_EQ(entry_it->second->GetActivePermissionStatus(), + PermissionStatus::GRANTED); auto* const grant_impl = entry_it->second; grant_impl->SetPath(new_path); @@ -963,7 +986,8 @@ bool FileSystemAccessPermissionContext::OriginHasReadAccess( auto it = active_permissions_map_.find(origin); if (it != active_permissions_map_.end()) { return std::ranges::any_of(it->second.read_grants, [&](const auto& grant) { - return grant.second->GetStatus() == PermissionStatus::GRANTED; + return grant.second->GetActivePermissionStatus() == + PermissionStatus::GRANTED; }); } @@ -977,7 +1001,8 @@ bool FileSystemAccessPermissionContext::OriginHasWriteAccess( auto it = active_permissions_map_.find(origin); if (it != active_permissions_map_.end()) { return std::ranges::any_of(it->second.write_grants, [&](const auto& grant) { - return grant.second->GetStatus() == PermissionStatus::GRANTED; + return grant.second->GetActivePermissionStatus() == + PermissionStatus::GRANTED; }); } @@ -1031,7 +1056,7 @@ bool FileSystemAccessPermissionContext::AncestorHasActivePermission( parent = parent.DirName()) { auto i = relevant_grants.find(parent); if (i != relevant_grants.end() && i->second && - i->second->GetStatus() == PermissionStatus::GRANTED) { + i->second->GetActivePermissionStatus() == PermissionStatus::GRANTED) { return true; } } @@ -1054,7 +1079,7 @@ void FileSystemAccessPermissionContext::PermissionGrantDestroyed( // be granted but won't be visible in any UI because the permission context // isn't tracking them anymore. if (grant_it == grants.end()) { - DCHECK_EQ(PermissionStatus::DENIED, grant->GetStatus()); + DCHECK_EQ(PermissionStatus::DENIED, grant->GetActivePermissionStatus()); return; } diff --git a/spec/chromium-spec.ts b/spec/chromium-spec.ts index 55a9e09a201..7858556ae04 100644 --- a/spec/chromium-spec.ts +++ b/spec/chromium-spec.ts @@ -901,13 +901,15 @@ describe('chromium features', () => { }); describe('File System API,', () => { - afterEach(closeAllWindows); + let w: BrowserWindow | null = null; + afterEach(() => { session.defaultSession.setPermissionRequestHandler(null); + closeAllWindows(); }); it('allows access by default to reading an OPFS file', async () => { - const w = new BrowserWindow({ + w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, @@ -929,7 +931,7 @@ describe('chromium features', () => { }); it('fileHandle.queryPermission by default has permission to read and write to OPFS files', async () => { - const w = new BrowserWindow({ + w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, @@ -951,7 +953,8 @@ describe('chromium features', () => { }); it('fileHandle.requestPermission automatically grants permission to read and write to OPFS files', async () => { - const w = new BrowserWindow({ + w = new BrowserWindow({ + show: false, webPreferences: { nodeIntegration: true, partition: 'file-system-spec', @@ -971,8 +974,8 @@ describe('chromium features', () => { expect(status).to.equal('granted'); }); - it('requests permission when trying to create a writable file handle', (done) => { - const writablePath = path.join(fixturesPath, 'file-system', 'test-writable.html'); + it('allows permission when trying to create a writable file handle', (done) => { + const writablePath = path.join(fixturesPath, 'file-system', 'test-perms.html'); const testFile = path.join(fixturesPath, 'file-system', 'test.txt'); const w = new BrowserWindow({ @@ -1000,9 +1003,9 @@ describe('chromium features', () => { ipcMain.once('did-create-file-handle', async () => { const result = await w.webContents.executeJavaScript(` - new Promise((resolve, reject) => { + new Promise(async (resolve, reject) => { try { - const writable = fileHandle.createWritable(); + const writable = await handle.createWritable(); resolve(true); } catch { resolve(false); @@ -1021,6 +1024,258 @@ describe('chromium features', () => { w.webContents.paste(); }); }); + + it('denies permission when trying to create a writable file handle', (done) => { + const writablePath = path.join(fixturesPath, 'file-system', 'test-perms.html'); + const testFile = path.join(fixturesPath, 'file-system', 'test.txt'); + + const w = new BrowserWindow({ + webPreferences: { + nodeIntegration: true, + contextIsolation: false, + sandbox: false + } + }); + + w.webContents.session.setPermissionRequestHandler((wc, permission, callback, details) => { + expect(permission).to.equal('fileSystem'); + + const { href } = url.pathToFileURL(writablePath); + expect(details).to.deep.equal({ + fileAccessType: 'writable', + isDirectory: false, + isMainFrame: true, + filePath: testFile, + requestingUrl: href + }); + + callback(false); + }); + + ipcMain.once('did-create-file-handle', async () => { + const result = await w.webContents.executeJavaScript(` + new Promise(async (resolve, reject) => { + try { + const writable = await handle.createWritable(); + resolve(true); + } catch { + resolve(false); + } + }) + `, true); + expect(result).to.be.false(); + done(); + }); + + w.loadFile(writablePath); + + w.webContents.once('did-finish-load', () => { + // @ts-expect-error Undocumented testing method. + clipboard._writeFilesForTesting([testFile]); + w.webContents.paste(); + }); + }); + + it('calls twice when trying to query a read/write file handle permissions', (done) => { + const writablePath = path.join(fixturesPath, 'file-system', 'test-perms.html'); + const testFile = path.join(fixturesPath, 'file-system', 'test.txt'); + + const w = new BrowserWindow({ + webPreferences: { + nodeIntegration: true, + contextIsolation: false, + sandbox: false + } + }); + + let calls = 0; + w.webContents.session.setPermissionCheckHandler((wc, permission, origin, details) => { + if (permission === 'fileSystem') { + const { fileAccessType, isDirectory, filePath } = details; + expect(['writable', 'readable']).to.contain(fileAccessType); + expect(isDirectory).to.be.false(); + expect(filePath).to.equal(testFile); + calls++; + return true; + } + + return false; + }); + + ipcMain.once('did-create-file-handle', async () => { + const permission = await w.webContents.executeJavaScript(` + new Promise(async (resolve, reject) => { + try { + const permission = await handle.queryPermission({ mode: 'readwrite' }); + resolve(permission); + } catch { + resolve('denied'); + } + }) + `, true); + expect(permission).to.equal('granted'); + expect(calls).to.equal(2); + done(); + }); + + w.loadFile(writablePath); + + w.webContents.once('did-finish-load', () => { + // @ts-expect-error Undocumented testing method. + clipboard._writeFilesForTesting([testFile]); + w.webContents.paste(); + }); + }); + + it('correctly denies permissions after creating a readable directory handle', (done) => { + const permPath = path.join(fixturesPath, 'file-system', 'test-perms.html'); + const testDir = path.join(fixturesPath, 'file-system'); + + const w = new BrowserWindow({ + webPreferences: { + nodeIntegration: true, + contextIsolation: false, + sandbox: false + } + }); + + w.webContents.session.setPermissionCheckHandler((wc, permission, origin, details) => { + expect(permission).to.equal('fileSystem'); + + const { fileAccessType, isDirectory, filePath } = details; + expect(fileAccessType).to.equal('readable'); + expect(isDirectory).to.be.true(); + expect(filePath).to.equal(testDir); + return false; + }); + + ipcMain.once('did-create-directory-handle', async () => { + const permission = await w.webContents.executeJavaScript(` + new Promise(async (resolve, reject) => { + try { + const permission = await handle.queryPermission({ mode: 'read' }); + resolve(permission); + } catch { + resolve('denied'); + } + }) + `, true); + expect(permission).to.equal('denied'); + done(); + }); + + w.loadFile(permPath); + + w.webContents.once('did-finish-load', () => { + // @ts-expect-error Undocumented testing method. + clipboard._writeFilesForTesting([testDir]); + w.webContents.paste(); + }); + }); + + it('correctly allows permissions after creating a readable directory handle', (done) => { + const permPath = path.join(fixturesPath, 'file-system', 'test-perms.html'); + const testDir = path.join(fixturesPath, 'file-system'); + + const w = new BrowserWindow({ + webPreferences: { + nodeIntegration: true, + contextIsolation: false, + sandbox: false + } + }); + + w.webContents.session.setPermissionCheckHandler((wc, permission, origin, details) => { + if (permission === 'fileSystem') { + const { fileAccessType, isDirectory, filePath } = details; + expect(fileAccessType).to.equal('readable'); + expect(isDirectory).to.be.true(); + expect(filePath).to.equal(testDir); + return true; + } + return false; + }); + + ipcMain.once('did-create-directory-handle', async () => { + const permission = await w.webContents.executeJavaScript(` + new Promise(async (resolve, reject) => { + try { + const permission = await handle.queryPermission({ mode: 'read' }); + resolve(permission); + } catch { + resolve('denied'); + } + }) + `, true); + expect(permission).to.equal('granted'); + done(); + }); + + w.loadFile(permPath); + + w.webContents.once('did-finish-load', () => { + // @ts-expect-error Undocumented testing method. + clipboard._writeFilesForTesting([testDir]); + w.webContents.paste(); + }); + }); + + it('allows in-session persistence of granted file permissions', (done) => { + const writablePath = path.join(fixturesPath, 'file-system', 'test-perms.html'); + const testFile = path.join(fixturesPath, 'file-system', 'persist.txt'); + + const w = new BrowserWindow({ + webPreferences: { + nodeIntegration: true, + contextIsolation: false, + sandbox: false + } + }); + + w.webContents.session.setPermissionRequestHandler((_wc, _permission, callback) => { + callback(true); + }); + + w.webContents.session.setPermissionCheckHandler((_wc, permission, _origin, details) => { + if (permission === 'fileSystem') { + const { fileAccessType, isDirectory, filePath } = details; + expect(fileAccessType).to.deep.equal('readable'); + expect(isDirectory).to.be.false(); + expect(filePath).to.equal(testFile); + return true; + } + return false; + }); + + let reload = true; + ipcMain.on('did-create-file-handle', async () => { + if (reload) { + w.webContents.reload(); + reload = false; + } else { + const permission = await w.webContents.executeJavaScript(` + new Promise(async (resolve, reject) => { + try { + const permission = await handle.queryPermission({ mode: 'read' }); + resolve(permission); + } catch { + resolve('denied'); + } + }) + `, true); + expect(permission).to.equal('granted'); + done(); + } + }); + + w.loadFile(writablePath); + + w.webContents.on('did-finish-load', () => { + // @ts-expect-error Undocumented testing method. + clipboard._writeFilesForTesting([testFile]); + w.webContents.paste(); + }); + }); }); describe('web workers', () => { diff --git a/spec/fixtures/file-system/persist.txt b/spec/fixtures/file-system/persist.txt new file mode 100644 index 00000000000..4750e51cc1a --- /dev/null +++ b/spec/fixtures/file-system/persist.txt @@ -0,0 +1 @@ +hello persist \ No newline at end of file diff --git a/spec/fixtures/file-system/test-writable.html b/spec/fixtures/file-system/test-perms.html similarity index 59% rename from spec/fixtures/file-system/test-writable.html rename to spec/fixtures/file-system/test-perms.html index 6d7012192b1..24a4787d257 100644 --- a/spec/fixtures/file-system/test-writable.html +++ b/spec/fixtures/file-system/test-perms.html @@ -10,13 +10,17 @@