fix(stage-tamagotchi): permission issue (#2002)
--------- Authored-by-agent: Codex <codex@openai.com>
This commit is contained in:
@@ -13,7 +13,7 @@ import { electronApp, optimizer } from '@electron-toolkit/utils'
|
|||||||
import { Format, LogLevel, setGlobalFormat, setGlobalHookPostLog, setGlobalLogLevel, useLogg } from '@guiiai/logg'
|
import { Format, LogLevel, setGlobalFormat, setGlobalHookPostLog, setGlobalLogLevel, useLogg } from '@guiiai/logg'
|
||||||
import { createContext } from '@moeru/eventa/adapters/electron/main'
|
import { createContext } from '@moeru/eventa/adapters/electron/main'
|
||||||
import { initScreenCaptureForMain } from '@proj-airi/electron-screen-capture/main'
|
import { initScreenCaptureForMain } from '@proj-airi/electron-screen-capture/main'
|
||||||
import { app, ipcMain } from 'electron'
|
import { app, ipcMain, session } from 'electron'
|
||||||
import { noop } from 'es-toolkit'
|
import { noop } from 'es-toolkit'
|
||||||
import { createLoggLogger, injeca, lifecycle } from 'injeca'
|
import { createLoggLogger, injeca, lifecycle } from 'injeca'
|
||||||
import { isLinux } from 'std-env'
|
import { isLinux } from 'std-env'
|
||||||
@@ -37,6 +37,7 @@ import { setupExtensionHost } from './services/airi/plugins'
|
|||||||
import { setupArtistryBridge } from './services/airi/widgets/artistry-bridge'
|
import { setupArtistryBridge } from './services/airi/widgets/artistry-bridge'
|
||||||
import { setupAutoUpdater } from './services/electron/auto-updater'
|
import { setupAutoUpdater } from './services/electron/auto-updater'
|
||||||
import { setupGlobalShortcutService } from './services/electron/global-shortcut'
|
import { setupGlobalShortcutService } from './services/electron/global-shortcut'
|
||||||
|
import { setupMediaPermissionHandlers } from './services/electron/media-permissions'
|
||||||
import { setupTray } from './tray'
|
import { setupTray } from './tray'
|
||||||
import { setupAboutWindowReusable } from './windows/about'
|
import { setupAboutWindowReusable } from './windows/about'
|
||||||
import { setupBeatSync } from './windows/beat-sync'
|
import { setupBeatSync } from './windows/beat-sync'
|
||||||
@@ -114,6 +115,8 @@ app.whenReady().then(async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setupMediaPermissionHandlers(session.defaultSession)
|
||||||
|
|
||||||
// Initialize file logger and register the hook
|
// Initialize file logger and register the hook
|
||||||
fileLogger = await setupFileLogger()
|
fileLogger = await setupFileLogger()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { env } from 'node:process'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a URL belongs to an AIRI-owned local renderer page.
|
||||||
|
*
|
||||||
|
* Use when:
|
||||||
|
* - Electron main-process policies need to distinguish AIRI pages from remote content
|
||||||
|
* - Packaged and development renderer URLs must share the same trust decision
|
||||||
|
*
|
||||||
|
* Expects:
|
||||||
|
* - Packaged pages use file URLs
|
||||||
|
* - Development pages share the exact origin configured by Electron Vite
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - Whether the URL uses the packaged file scheme or the configured renderer origin
|
||||||
|
*/
|
||||||
|
export function isLocalAppURL(rawURL: string | undefined): boolean {
|
||||||
|
if (!rawURL)
|
||||||
|
return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(rawURL)
|
||||||
|
if (url.protocol === 'file:')
|
||||||
|
return true
|
||||||
|
|
||||||
|
if ((url.protocol !== 'http:' && url.protocol !== 'https:') || !env.ELECTRON_RENDERER_URL)
|
||||||
|
return false
|
||||||
|
|
||||||
|
const rendererURL = new URL(env.ELECTRON_RENDERER_URL)
|
||||||
|
return url.origin === rendererURL.origin
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,4 +3,5 @@ export * from './auto-updater'
|
|||||||
export * from './global-shortcut'
|
export * from './global-shortcut'
|
||||||
export * from './powerMonitor'
|
export * from './powerMonitor'
|
||||||
export * from './screen'
|
export * from './screen'
|
||||||
|
export * from './system-preferences'
|
||||||
export * from './window'
|
export * from './window'
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import type { MediaAccessPermissionRequest, PermissionCheckHandlerHandlerDetails, WebContents } from 'electron'
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { shouldGrantAudioCapturePermission, shouldGrantElectronPermission } from './media-permissions'
|
||||||
|
|
||||||
|
const localWebContents = {
|
||||||
|
getURL: () => 'file:///app/index.html',
|
||||||
|
} satisfies Pick<WebContents, 'getURL'>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates official Electron request details for media permission tests.
|
||||||
|
*/
|
||||||
|
function createMediaRequestDetails(overrides: Partial<MediaAccessPermissionRequest> = {}): MediaAccessPermissionRequest {
|
||||||
|
return {
|
||||||
|
isMainFrame: true,
|
||||||
|
requestingUrl: 'file:///app/index.html',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates official Electron check details for media permission tests.
|
||||||
|
*/
|
||||||
|
function createPermissionCheckDetails(overrides: Partial<PermissionCheckHandlerHandlerDetails> = {}): PermissionCheckHandlerHandlerDetails {
|
||||||
|
return {
|
||||||
|
isMainFrame: true,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @example
|
||||||
|
* shouldGrantElectronPermission(localWebContents, 'media', origin, details)
|
||||||
|
*/
|
||||||
|
describe('media permissions', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubEnv('ELECTRON_RENDERER_URL', 'http://localhost:5173')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs()
|
||||||
|
})
|
||||||
|
|
||||||
|
/** @example Local packaged pages may request audio-only media. */
|
||||||
|
it('grants local audio media permission requests', () => {
|
||||||
|
expect(shouldGrantAudioCapturePermission(
|
||||||
|
localWebContents,
|
||||||
|
'media',
|
||||||
|
undefined,
|
||||||
|
createMediaRequestDetails({ mediaTypes: ['audio'] }),
|
||||||
|
)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** @example Camera-only requests remain denied. */
|
||||||
|
it('rejects video-only media permission requests', () => {
|
||||||
|
expect(shouldGrantAudioCapturePermission(
|
||||||
|
localWebContents,
|
||||||
|
'media',
|
||||||
|
undefined,
|
||||||
|
createMediaRequestDetails({ mediaTypes: ['video'] }),
|
||||||
|
)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** @example Combined microphone and camera requests remain denied. */
|
||||||
|
it('rejects media permission requests that include video', () => {
|
||||||
|
expect(shouldGrantAudioCapturePermission(
|
||||||
|
localWebContents,
|
||||||
|
'media',
|
||||||
|
undefined,
|
||||||
|
createMediaRequestDetails({ mediaTypes: ['audio', 'video'] }),
|
||||||
|
)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** @example A generic media request without a declared audio type is not inferred as safe. */
|
||||||
|
it('does not treat missing media details as audio', () => {
|
||||||
|
expect(shouldGrantAudioCapturePermission(
|
||||||
|
localWebContents,
|
||||||
|
'media',
|
||||||
|
undefined,
|
||||||
|
createMediaRequestDetails(),
|
||||||
|
)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** @example Electron permission checks report audio through mediaType. */
|
||||||
|
it('grants local audio permission checks', () => {
|
||||||
|
expect(shouldGrantAudioCapturePermission(
|
||||||
|
null,
|
||||||
|
'media',
|
||||||
|
'file:///app/index.html',
|
||||||
|
createPermissionCheckDetails({ mediaType: 'audio' }),
|
||||||
|
)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** @example A remote top-level origin cannot request the microphone. */
|
||||||
|
it('rejects audio requests from non-local origins', () => {
|
||||||
|
expect(shouldGrantAudioCapturePermission(
|
||||||
|
null,
|
||||||
|
'media',
|
||||||
|
'https://example.com',
|
||||||
|
createPermissionCheckDetails({ mediaType: 'audio' }),
|
||||||
|
)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** @example A remote requesting frame is rejected even inside a local BrowserWindow. */
|
||||||
|
it('rejects remote frame requests even when the host window is local', () => {
|
||||||
|
expect(shouldGrantAudioCapturePermission(
|
||||||
|
localWebContents,
|
||||||
|
'media',
|
||||||
|
undefined,
|
||||||
|
createMediaRequestDetails({ mediaTypes: ['audio'], requestingUrl: 'https://example.com/frame.html' }),
|
||||||
|
)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** @example A local child frame embedded by a remote page is not AIRI-owned. */
|
||||||
|
it('rejects local frames embedded by a remote origin', () => {
|
||||||
|
expect(shouldGrantAudioCapturePermission(
|
||||||
|
null,
|
||||||
|
'media',
|
||||||
|
'http://localhost:5173',
|
||||||
|
createPermissionCheckDetails({
|
||||||
|
embeddingOrigin: 'https://example.com',
|
||||||
|
mediaType: 'audio',
|
||||||
|
securityOrigin: 'http://localhost:5173',
|
||||||
|
}),
|
||||||
|
)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** @example All explicit requester identities are accepted when they remain local. */
|
||||||
|
it('grants audio requests with explicit local requester URLs', () => {
|
||||||
|
expect(shouldGrantAudioCapturePermission(
|
||||||
|
null,
|
||||||
|
'media',
|
||||||
|
'http://localhost:5173',
|
||||||
|
createPermissionCheckDetails({
|
||||||
|
mediaType: 'audio',
|
||||||
|
requestingUrl: 'http://localhost:5173',
|
||||||
|
securityOrigin: 'http://localhost:5173',
|
||||||
|
}),
|
||||||
|
)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** @example Extension assets served from AIRI's loopback server remain untrusted. */
|
||||||
|
it('rejects plugin asset frames served from a loopback origin', () => {
|
||||||
|
// ROOT CAUSE:
|
||||||
|
//
|
||||||
|
// Treating every loopback HTTP origin as AIRI-owned also trusts extension UI frames.
|
||||||
|
// Those frames use the same loopback transport but do not share the renderer origin.
|
||||||
|
// We fixed this by matching HTTP origins against ELECTRON_RENDERER_URL exactly.
|
||||||
|
expect(shouldGrantAudioCapturePermission(
|
||||||
|
null,
|
||||||
|
'media',
|
||||||
|
'http://127.0.0.1:48123',
|
||||||
|
createPermissionCheckDetails({
|
||||||
|
mediaType: 'audio',
|
||||||
|
requestingUrl: 'http://127.0.0.1:48123/_airi/extensions/example/sessions/session/ui/index.html',
|
||||||
|
securityOrigin: 'http://127.0.0.1:48123',
|
||||||
|
}),
|
||||||
|
)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** @example A plugin development server cannot inherit AIRI renderer permissions. */
|
||||||
|
it('rejects plugin frames served from another localhost port', () => {
|
||||||
|
expect(shouldGrantAudioCapturePermission(
|
||||||
|
null,
|
||||||
|
'media',
|
||||||
|
'http://localhost:4173',
|
||||||
|
createPermissionCheckDetails({
|
||||||
|
mediaType: 'audio',
|
||||||
|
requestingUrl: 'http://localhost:4173/index.html',
|
||||||
|
securityOrigin: 'http://localhost:4173',
|
||||||
|
}),
|
||||||
|
)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** @example Chromium's opaque origin does not override an explicit packaged file URL. */
|
||||||
|
it('ignores opaque file origins when packaged local pages request audio', () => {
|
||||||
|
expect(shouldGrantAudioCapturePermission(
|
||||||
|
localWebContents,
|
||||||
|
'media',
|
||||||
|
'null',
|
||||||
|
createMediaRequestDetails({ mediaTypes: ['audio'] }),
|
||||||
|
)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** @example Local AIRI pages retain screen-capture access. */
|
||||||
|
it('grants display capture requests from local app pages', () => {
|
||||||
|
expect(shouldGrantElectronPermission(
|
||||||
|
localWebContents,
|
||||||
|
'display-capture',
|
||||||
|
undefined,
|
||||||
|
createMediaRequestDetails(),
|
||||||
|
)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** @example Remote frames cannot invoke screen capture through the global session handler. */
|
||||||
|
it('rejects display capture requests from remote pages', () => {
|
||||||
|
expect(shouldGrantElectronPermission(
|
||||||
|
localWebContents,
|
||||||
|
'display-capture',
|
||||||
|
undefined,
|
||||||
|
createMediaRequestDetails({ requestingUrl: 'https://example.com/capture.html' }),
|
||||||
|
)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** @example Local AIRI pages retain sanitized clipboard writes used by chat copy actions. */
|
||||||
|
it('grants sanitized clipboard writes from local app pages', () => {
|
||||||
|
expect(shouldGrantElectronPermission(
|
||||||
|
localWebContents,
|
||||||
|
'clipboard-sanitized-write',
|
||||||
|
'file:///app/index.html',
|
||||||
|
createPermissionCheckDetails(),
|
||||||
|
)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** @example Unreviewed permission categories are denied by default. */
|
||||||
|
it('rejects unrelated permissions instead of granting all local requests', () => {
|
||||||
|
expect(shouldGrantElectronPermission(
|
||||||
|
localWebContents,
|
||||||
|
'notifications',
|
||||||
|
'file:///app/index.html',
|
||||||
|
createPermissionCheckDetails(),
|
||||||
|
)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import type { Session, WebContents } from 'electron'
|
||||||
|
|
||||||
|
import { isLocalAppURL } from '../../libs/electron/url'
|
||||||
|
|
||||||
|
type PermissionCheckHandler = Exclude<Parameters<Session['setPermissionCheckHandler']>[0], null>
|
||||||
|
type PermissionRequestHandler = Exclude<Parameters<Session['setPermissionRequestHandler']>[0], null>
|
||||||
|
type ElectronPermission = Parameters<PermissionCheckHandler>[1] | Parameters<PermissionRequestHandler>[1]
|
||||||
|
type ElectronPermissionDetails = Parameters<PermissionCheckHandler>[3] | Parameters<PermissionRequestHandler>[3]
|
||||||
|
type LocalAppWebContents = Pick<WebContents, 'getURL'>
|
||||||
|
|
||||||
|
const LOCAL_APP_PERMISSION_NAMES = new Set<ElectronPermission>([
|
||||||
|
'display-capture',
|
||||||
|
'clipboard-sanitized-write',
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters out Chromium's opaque origin marker before evaluating explicit frame URLs.
|
||||||
|
*/
|
||||||
|
function isUsableRequesterURL(rawURL: string | undefined): rawURL is string {
|
||||||
|
return !!rawURL && rawURL !== 'null'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether Electron described an audio-only media permission operation.
|
||||||
|
*/
|
||||||
|
function isAudioMediaPermission(permission: ElectronPermission, details?: ElectronPermissionDetails): boolean {
|
||||||
|
if (permission !== 'media' || !details)
|
||||||
|
return false
|
||||||
|
|
||||||
|
if ('mediaTypes' in details && details.mediaTypes?.length) {
|
||||||
|
return details.mediaTypes.includes('audio') && !details.mediaTypes.includes('video')
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'mediaType' in details && details.mediaType === 'audio'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether every requester identity supplied by Electron is local to AIRI.
|
||||||
|
*/
|
||||||
|
function shouldGrantLocalAppPermission(
|
||||||
|
webContents: LocalAppWebContents | null,
|
||||||
|
requestingOrigin?: string,
|
||||||
|
details?: ElectronPermissionDetails,
|
||||||
|
): boolean {
|
||||||
|
const requesterURLs = [
|
||||||
|
requestingOrigin,
|
||||||
|
details?.requestingUrl,
|
||||||
|
details && 'securityOrigin' in details ? details.securityOrigin : undefined,
|
||||||
|
details && 'embeddingOrigin' in details ? details.embeddingOrigin : undefined,
|
||||||
|
].filter(isUsableRequesterURL)
|
||||||
|
|
||||||
|
if (requesterURLs.length)
|
||||||
|
return requesterURLs.every(isLocalAppURL)
|
||||||
|
|
||||||
|
return isLocalAppURL(webContents?.getURL())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides whether an Electron media operation is an AIRI-owned audio-only request.
|
||||||
|
*
|
||||||
|
* Use when:
|
||||||
|
* - Chromium asks the default session to check or request microphone access
|
||||||
|
* - A caller needs the same local-frame policy outside the session callbacks
|
||||||
|
*
|
||||||
|
* Expects:
|
||||||
|
* - Permission details come from Electron's official request or check handler contracts
|
||||||
|
* - Packaged pages use file URLs and development pages use loopback HTTP URLs
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - Whether the operation is audio-only and every supplied requester identity is local
|
||||||
|
*/
|
||||||
|
export function shouldGrantAudioCapturePermission(
|
||||||
|
webContents: LocalAppWebContents | null,
|
||||||
|
permission: ElectronPermission,
|
||||||
|
requestingOrigin?: string,
|
||||||
|
details?: ElectronPermissionDetails,
|
||||||
|
): boolean {
|
||||||
|
return isAudioMediaPermission(permission, details)
|
||||||
|
&& shouldGrantLocalAppPermission(webContents, requestingOrigin, details)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies AIRI's allowlist to an Electron session permission operation.
|
||||||
|
*
|
||||||
|
* Use when:
|
||||||
|
* - Wiring both Electron permission check and request handlers
|
||||||
|
* - Preserving reviewed local display-capture and clipboard behavior
|
||||||
|
*
|
||||||
|
* Expects:
|
||||||
|
* - Unknown or unreviewed permission categories must remain denied
|
||||||
|
* - All explicit frame, security, and embedding origins must identify local AIRI pages
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - Whether the requested permission is both allowlisted and locally owned
|
||||||
|
*/
|
||||||
|
export function shouldGrantElectronPermission(
|
||||||
|
webContents: LocalAppWebContents | null,
|
||||||
|
permission: ElectronPermission,
|
||||||
|
requestingOrigin?: string,
|
||||||
|
details?: ElectronPermissionDetails,
|
||||||
|
): boolean {
|
||||||
|
if (permission === 'media')
|
||||||
|
return shouldGrantAudioCapturePermission(webContents, permission, requestingOrigin, details)
|
||||||
|
|
||||||
|
return LOCAL_APP_PERMISSION_NAMES.has(permission)
|
||||||
|
&& shouldGrantLocalAppPermission(webContents, requestingOrigin, details)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers the paired Electron session handlers required for complete permission policy.
|
||||||
|
*
|
||||||
|
* Use when:
|
||||||
|
* - Initializing Electron's default session after app readiness
|
||||||
|
*
|
||||||
|
* Expects:
|
||||||
|
* - The session is the one used by AIRI renderer windows
|
||||||
|
* - macOS systemPreferences remains responsible for OS-level consent prompts and status
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - Nothing; both handlers are installed on the supplied session
|
||||||
|
*/
|
||||||
|
export function setupMediaPermissionHandlers(
|
||||||
|
targetSession: Pick<Session, 'setPermissionCheckHandler' | 'setPermissionRequestHandler'>,
|
||||||
|
): void {
|
||||||
|
targetSession.setPermissionRequestHandler((webContents, permission, callback, details) => {
|
||||||
|
callback(shouldGrantElectronPermission(webContents, permission, undefined, details))
|
||||||
|
})
|
||||||
|
|
||||||
|
targetSession.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => {
|
||||||
|
return shouldGrantElectronPermission(webContents, permission, requestingOrigin, details)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import { isMacOS } from 'std-env'
|
|||||||
|
|
||||||
import { createServerChannelService } from '../../services/airi/channel-server'
|
import { createServerChannelService } from '../../services/airi/channel-server'
|
||||||
import { createI18nService } from '../../services/airi/i18n'
|
import { createI18nService } from '../../services/airi/i18n'
|
||||||
import { createAppService, createPowerMonitorService, createScreenService, createWindowService } from '../../services/electron'
|
import { createAppService, createPowerMonitorService, createScreenService, createSystemPreferencesService, createWindowService } from '../../services/electron'
|
||||||
|
|
||||||
export function toggleWindowShow(window?: BrowserWindow | null): void {
|
export function toggleWindowShow(window?: BrowserWindow | null): void {
|
||||||
if (!window) {
|
if (!window) {
|
||||||
@@ -100,6 +100,7 @@ export async function setupBaseWindowElectronInvokes(params: {
|
|||||||
createWindowService({ context: params.context, window: params.window })
|
createWindowService({ context: params.context, window: params.window })
|
||||||
createAppService({ context: params.context, window: params.window })
|
createAppService({ context: params.context, window: params.window })
|
||||||
createPowerMonitorService({ context: params.context, window: params.window })
|
createPowerMonitorService({ context: params.context, window: params.window })
|
||||||
|
createSystemPreferencesService({ context: params.context, window: params.window })
|
||||||
|
|
||||||
await createI18nService({ context: params.context, window: params.window, i18n: params.i18n })
|
await createI18nService({ context: params.context, window: params.window, i18n: params.i18n })
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user