From 531b620a44bcda42cffadbae59808933aa8a1e11 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 27 Aug 2026 18:52:21 -0400 Subject: [PATCH] fix(browser-preview): deny runtime permissions in document previews --- ...browser-session-partition-policies.test.ts | 36 ++++++++++++++++++- .../browser-session-partition-policies.ts | 22 ++++++++++-- src/main/browser/doc-preview-protocol.test.ts | 12 +++---- src/main/browser/doc-preview-protocol.ts | 5 ++- 4 files changed, 62 insertions(+), 13 deletions(-) diff --git a/src/main/browser/browser-session-partition-policies.test.ts b/src/main/browser/browser-session-partition-policies.test.ts index a3667154c9b..aa33fc076a3 100644 --- a/src/main/browser/browser-session-partition-policies.test.ts +++ b/src/main/browser/browser-session-partition-policies.test.ts @@ -20,6 +20,7 @@ type FakeSession = { setUserAgent: ReturnType setPermissionRequestHandler: ReturnType setPermissionCheckHandler: ReturnType + setDevicePermissionHandler: ReturnType setDisplayMediaRequestHandler: ReturnType } @@ -47,6 +48,7 @@ function fakeSession(): FakeSession { setUserAgent: vi.fn(), setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(), + setDevicePermissionHandler: vi.fn(), setDisplayMediaRequestHandler: vi.fn() } } @@ -94,7 +96,7 @@ vi.mock('./browser-webauthn-access', () => ({ type PartitionPolicyInstaller = ( profile: BrowserSessionProfile, - options?: { downloads?: 'route' | 'deny' } + options?: { downloads?: 'route' | 'deny'; permissions?: 'browser' | 'deny' } ) => void // Why imported per test rather than at the top: the installer remembers which partitions it has @@ -183,3 +185,35 @@ describe('partition download policy', () => { expect(mocks.handleGuestWillDownload).toHaveBeenCalledTimes(1) }) }) + +describe('partition permission policy', () => { + it('denies browser and device permissions on a partition that asks for the deny', async () => { + const install = await loadInstaller() + install(profileFor('orca-doc-preview'), { permissions: 'deny' }) + + const sess = sessionsByPartition.get('orca-doc-preview') + const requestHandler = sess?.setPermissionRequestHandler.mock.calls[0]?.[0] as ( + webContents: { id: number }, + permission: string, + callback: (allowed: boolean) => void, + details?: object + ) => void + const checkHandler = sess?.setPermissionCheckHandler.mock.calls[0]?.[0] as ( + webContents: { id: number }, + permission: string, + origin: string, + details?: object + ) => boolean + const deviceHandler = sess?.setDevicePermissionHandler.mock.calls[0]?.[0] as ( + details: object + ) => boolean + + for (const permission of ['media', 'clipboard-read', 'notifications']) { + let allowed: boolean | null = null + requestHandler({ id: 42 }, permission, (decision) => (allowed = decision), {}) + expect(allowed).toBe(false) + expect(checkHandler({ id: 42 }, permission, 'orca-preview://grant', {})).toBe(false) + } + expect(deviceHandler({})).toBe(false) + }) +}) diff --git a/src/main/browser/browser-session-partition-policies.ts b/src/main/browser/browser-session-partition-policies.ts index cf542665ae4..9d9a18bfc9b 100644 --- a/src/main/browser/browser-session-partition-policies.ts +++ b/src/main/browser/browser-session-partition-policies.ts @@ -57,10 +57,16 @@ function resolvePermissionNoticeUrl( /** `route` hands the item to the owning page's download flow; `deny` cancels it before it starts. */ export type BrowserPartitionDownloadPolicy = 'route' | 'deny' +export type BrowserPartitionPermissionPolicy = 'browser' | 'deny' + +type BrowserPartitionPolicyOptions = { + downloads?: BrowserPartitionDownloadPolicy + permissions?: BrowserPartitionPermissionPolicy +} export function installBrowserSessionPartitionPolicies( profile: BrowserSessionProfile, - options?: { downloads?: BrowserPartitionDownloadPolicy } + options?: BrowserPartitionPolicyOptions ): void { const { partition } = profile const sess = session.fromPartition(partition) @@ -75,7 +81,12 @@ export function installBrowserSessionPartitionPolicies( sess.setUserAgent(cleanUA) setupClientHintsOverride(sess, cleanUA) } + const denyPermissions = options?.permissions === 'deny' sess.setPermissionRequestHandler((webContents, permission, callback, details) => { + if (denyPermissions) { + callback(false) + return + } // Why: defer media to macOS TCC; denying at the session layer throws NotAllowedError even after the user granted Camera/Mic to the OS. if (permission === 'media') { // Capture before async handling; opaque frames cannot be attributed to a named site. @@ -117,6 +128,9 @@ export function installBrowserSessionPartitionPolicies( callback(allowed) }) sess.setPermissionCheckHandler((_webContents, permission, _origin, details) => { + if (denyPermissions) { + return false + } if (permission === 'media') { return hasSystemMediaAccess(details?.mediaType) } @@ -125,7 +139,11 @@ export function installBrowserSessionPartitionPolicies( } return isAutoGrantedBrowserSessionPermission(permission) }) - installBrowserWebAuthnAccessHandlers(sess) + if (denyPermissions) { + sess.setDevicePermissionHandler(() => false) + } else { + installBrowserWebAuthnAccessHandlers(sess) + } sess.setDisplayMediaRequestHandler((_request, callback) => { callback({ video: undefined, audio: undefined }) }) diff --git a/src/main/browser/doc-preview-protocol.test.ts b/src/main/browser/doc-preview-protocol.test.ts index b87e7f9e942..d8bd1f69068 100644 --- a/src/main/browser/doc-preview-protocol.test.ts +++ b/src/main/browser/doc-preview-protocol.test.ts @@ -261,8 +261,8 @@ describe('installDocPreviewProtocolHandler', () => { expect(cancelled(`orca-preview://${'a'.repeat(32)}/index.html`)).toBe(false) }) - // Why: preview guests are webviews like any other, so they must not skip the deny-by-default - // permission and display-media policy every browser partition gets. + // Why: preview guests need the shared session policy installer even though they deny more than + // user-opened browser pages. it('applies the shared browser partition policies to the preview session', () => { installDocPreviewProtocolHandler() @@ -272,14 +272,12 @@ describe('installDocPreviewProtocolHandler', () => { ) }) - // Why downloads and nothing else: the browser download flow attributes a file to the page that - // asked for it, and a previewed document is no page. Routed, it would write remote-authored bytes - // into this desktop's Downloads folder with no prompt and no tab to name as the source. - it('asks for downloads to be denied on the preview partition', () => { + it('asks for downloads and runtime permissions to be denied on the preview partition', () => { installDocPreviewProtocolHandler() expect(mocks.installBrowserSessionPartitionPolicies).toHaveBeenCalledWith(expect.anything(), { - downloads: 'deny' + downloads: 'deny', + permissions: 'deny' }) }) }) diff --git a/src/main/browser/doc-preview-protocol.ts b/src/main/browser/doc-preview-protocol.ts index 7ed1507c5b8..c209a9de3cf 100644 --- a/src/main/browser/doc-preview-protocol.ts +++ b/src/main/browser/doc-preview-protocol.ts @@ -124,8 +124,7 @@ export function installDocPreviewProtocolHandler(): void { previewSession.webRequest.onBeforeRequest((details, callback) => { callback({ cancel: !isAllowedDocPreviewRequestUrl(details.url) }) }) - // Why: preview guests are webviews like any other, so they inherit the same deny-by-default - // permission, display-media and user-agent policy every browser partition gets. + // Why: agent-authored previews must not inherit the grants needed by user-opened browser pages. installBrowserSessionPartitionPolicies( { id: DOC_PREVIEW_PARTITION, @@ -139,6 +138,6 @@ export function installDocPreviewProtocolHandler(): void { // page to attribute the file to, and a previewed document is not one. Routed here it would // reserve a name in this desktop's Downloads folder and write remote-authored bytes into it // with nothing in the UI naming the tab that asked, and no prompt in front of it. - { downloads: 'deny' } + { downloads: 'deny', permissions: 'deny' } ) }