diff --git a/src/main/browser/browser-manager-downloads.test.ts b/src/main/browser/browser-manager-downloads.test.ts index 981002c727b..e79d510ff62 100644 --- a/src/main/browser/browser-manager-downloads.test.ts +++ b/src/main/browser/browser-manager-downloads.test.ts @@ -123,6 +123,16 @@ describe('browserManager', () => { permission: 'media', origin: 'https://example.com' }) + browserManager.notifyPermissionDenied({ + guestWebContentsId: guest.id, + permission: 'geolocation', + rawUrl: '' + }) + expect(rendererSendMock).toHaveBeenCalledWith('browser:permission-denied', { + browserPageId: 'browser-1', + permission: 'geolocation', + origin: 'unknown' + }) expect(rendererSendMock).toHaveBeenCalledWith( 'browser:download-requested', expect.objectContaining({ diff --git a/src/main/browser/browser-session-partition-policies.ts b/src/main/browser/browser-session-partition-policies.ts index e31691b9646..2118c62ed68 100644 --- a/src/main/browser/browser-session-partition-policies.ts +++ b/src/main/browser/browser-session-partition-policies.ts @@ -22,6 +22,21 @@ const handleWillDownload = ( browserManager.handleGuestWillDownload({ guestWebContentsId: webContents.id, item }) } +function resolvePermissionNoticeUrl( + webContents: Electron.WebContents, + details: Electron.PermissionRequest | undefined +): string { + const requestingUrl = details?.requestingUrl + if (!requestingUrl) { + return webContents.getURL() + } + try { + return new URL(requestingUrl).origin === 'null' ? '' : requestingUrl + } catch { + return '' + } +} + export function installBrowserSessionPartitionPolicies(profile: BrowserSessionProfile): void { const { partition } = profile const sess = session.fromPartition(partition) @@ -39,6 +54,8 @@ export function installBrowserSessionPartitionPolicies(profile: BrowserSessionPr sess.setPermissionRequestHandler((webContents, permission, callback, details) => { // 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. + const rawUrl = resolvePermissionNoticeUrl(webContents, details) void requestSystemMediaAccess( details as Electron.MediaAccessPermissionRequest | undefined ).then( @@ -47,7 +64,7 @@ export function installBrowserSessionPartitionPolicies(profile: BrowserSessionPr browserManager.notifyPermissionDenied({ guestWebContentsId: webContents.id, permission, - rawUrl: webContents.getURL() + rawUrl }) } callback(granted) @@ -57,7 +74,7 @@ export function installBrowserSessionPartitionPolicies(profile: BrowserSessionPr browserManager.notifyPermissionDenied({ guestWebContentsId: webContents.id, permission, - rawUrl: webContents.getURL() + rawUrl }) callback(false) } @@ -66,10 +83,11 @@ export function installBrowserSessionPartitionPolicies(profile: BrowserSessionPr } const allowed = isAutoGrantedBrowserSessionPermission(permission) if (!allowed) { + const rawUrl = resolvePermissionNoticeUrl(webContents, details) browserManager.notifyPermissionDenied({ guestWebContentsId: webContents.id, permission, - rawUrl: webContents.getURL() + rawUrl }) } callback(allowed) diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts index cb9229174ed..a3caa67b19e 100644 --- a/src/main/browser/browser-session-registry.persistence.test.ts +++ b/src/main/browser/browser-session-registry.persistence.test.ts @@ -564,6 +564,58 @@ describe('BrowserSessionRegistry persistence', () => { permission: 'geolocation', rawUrl: 'https://example.com/account' }) + + // A subframe denial must name the requester, not its top-level embedder. + browserManagerNotifyPermissionDeniedMock.mockClear() + requestHandler(guestWc, 'geolocation', permissionCallback, { + requestingUrl: 'https://widget.example.net/embed', + isMainFrame: false + }) + await vi.waitFor(() => + expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledWith({ + guestWebContentsId: 401, + permission: 'geolocation', + rawUrl: 'https://widget.example.net/embed' + }) + ) + + // Missing or empty frame URLs fall back to the visible top-level page. + browserManagerNotifyPermissionDeniedMock.mockClear() + requestHandler(guestWc, 'geolocation', permissionCallback, { isMainFrame: true }) + await vi.waitFor(() => + expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledWith({ + guestWebContentsId: 401, + permission: 'geolocation', + rawUrl: 'https://example.com/account' + }) + ) + + browserManagerNotifyPermissionDeniedMock.mockClear() + requestHandler(guestWc, 'geolocation', permissionCallback, { + requestingUrl: '', + isMainFrame: false + }) + await vi.waitFor(() => + expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledWith({ + guestWebContentsId: 401, + permission: 'geolocation', + rawUrl: 'https://example.com/account' + }) + ) + + // Opaque frame URLs have no site Orca can name accurately. + browserManagerNotifyPermissionDeniedMock.mockClear() + requestHandler(guestWc, 'geolocation', permissionCallback, { + requestingUrl: 'about:blank', + isMainFrame: false + }) + await vi.waitFor(() => + expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledWith({ + guestWebContentsId: 401, + permission: 'geolocation', + rawUrl: '' + }) + ) expect( browserManagerNotifyPermissionDeniedMock.mock.calls.map(([args]) => args.permission) ).toEqual(['geolocation']) @@ -735,6 +787,7 @@ describe('BrowserSessionRegistry persistence', () => { const callback = vi.fn() requestHandler(guestWc, 'media', callback, { mediaTypes: ['video'] }) + guestWc.getURL.mockReturnValue('https://example.com/after-navigation') await vi.waitFor(() => expect(callback).toHaveBeenCalledWith(false)) expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledWith({ diff --git a/src/renderer/src/components/browser-pane/navigate/browser-notices.test.ts b/src/renderer/src/components/browser-pane/navigate/browser-notices.test.ts index 509dc8a08a2..f812d5fe02f 100644 --- a/src/renderer/src/components/browser-pane/navigate/browser-notices.test.ts +++ b/src/renderer/src/components/browser-pane/navigate/browser-notices.test.ts @@ -19,6 +19,61 @@ describe('browser notice formatting', () => { origin: 'https://example.com' }) ).toBe('https://example.com asked for camera or microphone access, and Orca denied it.') + expect( + formatPermissionNotice({ + browserPageId: 'browser-1', + permission: 'geolocation', + origin: 'unknown' + }) + ).toBe('this page asked for your location, and Orca denied it.') + }) + + it('names the storage permission in words rather than its raw token', () => { + const notice = formatPermissionNotice({ + browserPageId: 'browser-1', + permission: 'top-level-storage-access', + origin: 'https://example.com' + }) + expect(notice).not.toContain('top-level-storage-access') + expect(notice).toBe( + 'https://example.com asked for cookie access on behalf of an embedded site, and Orca denied it.' + ) + }) + + it.each([ + ['storage-access', 'access to its own cookies and storage while embedded on this page'], + ['idle-detection', 'permission to detect when you are idle'], + ['display-capture', 'permission to capture your screen'], + ['window-management', 'screen information and multi-screen window placement'], + ['keyboardLock', 'permission to capture keyboard input'], + ['openExternal', 'permission to open a link outside Orca'], + ['fileSystem', 'access to your files or folders'], + ['hid', 'access to a connected human interface device'], + ['usb', 'access to a USB device'], + ['serial', 'access to a serial device'], + ['midi', 'access to your MIDI devices'], + ['midiSysex', 'access to system-exclusive MIDI messages'], + ['mediaKeySystem', 'access to protected media playback'], + ['speaker-selection', 'permission to choose an audio output device'] + ])('formats the %s permission as readable copy', (permission, description) => { + expect( + formatPermissionNotice({ + browserPageId: 'browser-1', + permission, + origin: 'https://example.com' + }) + ).toBe(`https://example.com asked for ${description}, and Orca denied it.`) + }) + + // Pin the raw-token fallback for permissions Chromium adds later. + it('falls back to the raw permission name for anything unmapped', () => { + expect( + formatPermissionNotice({ + browserPageId: 'browser-1', + permission: 'some-future-permission', + origin: 'https://example.com' + }) + ).toBe('https://example.com asked for some-future-permission, and Orca denied it.') }) it('formats popup outcomes', () => { diff --git a/src/renderer/src/components/browser-pane/navigate/browser-notices.ts b/src/renderer/src/components/browser-pane/navigate/browser-notices.ts index 76888fc7b94..ca5e6aa209a 100644 --- a/src/renderer/src/components/browser-pane/navigate/browser-notices.ts +++ b/src/renderer/src/components/browser-pane/navigate/browser-notices.ts @@ -15,12 +15,45 @@ export type LoadFailureMeta = { type BrowserLoadErrorLike = BrowserLoadError | null +// Unknown Chromium permissions keep their raw name instead of disappearing behind invented copy. function humanizePermission(permission: string): string { switch (permission) { case 'media': return 'camera or microphone access' case 'pointerLock': return 'pointer lock' + case 'storage-access': + return 'access to its own cookies and storage while embedded on this page' + case 'top-level-storage-access': + return 'cookie access on behalf of an embedded site' + case 'geolocation': + return 'your location' + case 'idle-detection': + return 'permission to detect when you are idle' + case 'display-capture': + return 'permission to capture your screen' + case 'window-management': + return 'screen information and multi-screen window placement' + case 'keyboardLock': + return 'permission to capture keyboard input' + case 'openExternal': + return 'permission to open a link outside Orca' + case 'fileSystem': + return 'access to your files or folders' + case 'hid': + return 'access to a connected human interface device' + case 'usb': + return 'access to a USB device' + case 'serial': + return 'access to a serial device' + case 'midi': + return 'access to your MIDI devices' + case 'midiSysex': + return 'access to system-exclusive MIDI messages' + case 'mediaKeySystem': + return 'access to protected media playback' + case 'speaker-selection': + return 'permission to choose an audio output device' default: return permission }