d3ro-voice/apps/desktop/src/main/windows/web-contents-hardening.ts

75 lines
2.9 KiB
TypeScript

// src/main/windows/web-contents-hardening.ts
// 외부 URL·내비게이션 정책(@d3ro/core/url-policy)의 Electron 어댑터.
// - 앱 창은 앱 오리진 밖으로 이동하지 않는다(원격 페이지가 preload electronAPI를 얻지 못하게).
// - 새 창 요청은 허용 scheme일 때만 OS 브라우저로 넘긴다.
// - 모든 shell.openExternal 호출은 openExternalSafe 를 거친다.
import { shell, type BrowserWindow, type IpcMainInvokeEvent } from 'electron'
import { isAllowedExternalUrl, isAppOrigin, type ExternalUrlPolicyOptions } from '@d3ro/core/url-policy'
import { getLogger } from '../services/LoggerService'
const logger = getLogger('WebContentsHardening')
/** 앱 렌더러가 올라오는 오리진: 배포본 file://, 개발 서버(ELECTRON_RENDERER_URL). */
export function appOrigins(): string[] {
const origins = ['file://']
const devUrl = process.env['ELECTRON_RENDERER_URL']
if (devUrl) {
try {
origins.push(new URL(devUrl).origin)
} catch {
// 잘못된 개발 URL은 무시 — file:// 만 허용한다.
}
}
return origins
}
/** 로그에 토큰·쿼리가 남지 않도록 scheme + host 만 남긴다. */
function redact(url: string): string {
try {
const parsed = new URL(url)
return `${parsed.protocol}//${parsed.host}`
} catch {
return '(unparseable)'
}
}
/** 허용된 URL만 OS 기본 핸들러로 연다. 열었으면 true. */
export async function openExternalSafe(url: string, options: ExternalUrlPolicyOptions = {}): Promise<boolean> {
if (!isAllowedExternalUrl(url, options)) {
logger.warn(`Blocked external URL: ${redact(url)}`)
return false
}
await shell.openExternal(url)
return true
}
/** IPC 호출자가 앱 자신의 페이지인지(원격 페이지가 특권 IPC를 쓰지 못하게). */
export function isTrustedIpcSender(event: Pick<IpcMainInvokeEvent, 'senderFrame'>): boolean {
const url = event.senderFrame?.url
if (typeof url !== 'string') return false
return isAppOrigin(url, appOrigins())
}
/** 창의 webContents에 내비게이션·새 창·webview 가드를 건다. */
export function hardenWebContents(win: BrowserWindow): void {
const contents = win.webContents
const guardNavigation = (event: { preventDefault: () => void }, url: string): void => {
if (isAppOrigin(url, appOrigins())) return
event.preventDefault()
void openExternalSafe(url).catch((err: unknown) => {
logger.warn(`openExternal failed: ${err instanceof Error ? err.message : String(err)}`)
})
}
contents.on('will-navigate', guardNavigation)
contents.on('will-redirect', guardNavigation)
contents.setWindowOpenHandler((details) => {
void openExternalSafe(details.url).catch((err: unknown) => {
logger.warn(`openExternal failed: ${err instanceof Error ? err.message : String(err)}`)
})
return { action: 'deny' }
})
contents.on('will-attach-webview', (event) => {
event.preventDefault()
})
}