fix(security): trust file: URLs only inside the app renderer directory

This commit is contained in:
Yun Chan 2026-09-28 02:16:18 +09:00
parent b306034bfc
commit 5322d981cc
5 changed files with 299 additions and 17 deletions

View file

@ -1,11 +1,22 @@
// src/main/windows/web-contents-hardening.ts
// 외부 URL·내비게이션 정책(@d3ro/core/url-policy)의 Electron 어댑터.
// - 앱 창은 앱 오리진 밖으로 이동하지 않는다(원격 페이지가 preload electronAPI를 얻지 못하게).
// file: 은 앱 렌더러 디렉터리(out/renderer) 아래 HTML만 앱 페이지로 본다 — 사용자가 창에 드롭한
// 임의 파일(file:///C:/Users/.../x.html, UNC file://host/share/x.html)이 메인 창을 대체하거나
// electronAPI·신뢰 IPC를 얻지 못하게.
// - 새 창 요청은 허용 scheme일 때만 OS 브라우저로 넘긴다.
// - 모든 shell.openExternal 호출은 openExternalSafe 를 거친다.
import { join } from 'path'
import { pathToFileURL } from 'url'
import { shell, type BrowserWindow, type IpcMainInvokeEvent } from 'electron'
import { isAllowedExternalUrl, isAppOrigin, type ExternalUrlPolicyOptions } from '@d3ro/core/url-policy'
import {
isAllowedExternalUrl,
isAppNavigationTarget,
isAppOrigin,
type AppOriginOptions,
type ExternalUrlPolicyOptions,
} from '@d3ro/core/url-policy'
import { getLogger } from '../services/LoggerService'
const logger = getLogger('WebContentsHardening')
@ -24,6 +35,25 @@ export function appOrigins(): string[] {
return origins
}
/**
* 앱 렌더러 디렉터리의 file URL(끝에 '/'). WindowManager 가 loadFile(join(__dirname, '../renderer/...'))
* 로 여는 바로 그 디렉터리다(main 번들은 out/main, 렌더러는 out/renderer).
*/
export function appRendererFileRoot(): string {
const href = pathToFileURL(join(__dirname, '../renderer')).href
return href.endsWith('/') ? href : `${href}/`
}
/** 앱 페이지 판정에 쓰는 정책 입력(오리진 목록 + file: 루트). */
interface AppUrlPolicy {
origins: readonly string[]
options: AppOriginOptions
}
function appUrlPolicy(): AppUrlPolicy {
return { origins: appOrigins(), options: { fileRoot: appRendererFileRoot() } }
}
/** 로그에 토큰·쿼리가 남지 않도록 scheme + host 만 남긴다. */
function redact(url: string): string {
try {
@ -44,29 +74,39 @@ export async function openExternalSafe(url: string, options: ExternalUrlPolicyOp
return true
}
/** IPC 호출자가 앱 자신의 페이지인지(원격 페이지가 특권 IPC를 쓰지 못하게). */
/** 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())
const policy = appUrlPolicy()
return isAppOrigin(url, policy.origins, policy.options)
}
/** 앱 창이 이 URL로 이동해도 되는지(앱 번들 HTML 또는 개발 서버). */
export function isAllowedAppNavigation(url: string): boolean {
const policy = appUrlPolicy()
return isAppNavigationTarget(url, policy.origins, policy.options)
}
function openExternalInBackground(url: string): void {
void openExternalSafe(url).catch((err: unknown) => {
logger.warn(`openExternal failed: ${err instanceof Error ? err.message : String(err)}`)
})
}
/** 창의 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
if (isAllowedAppNavigation(url)) return
event.preventDefault()
void openExternalSafe(url).catch((err: unknown) => {
logger.warn(`openExternal failed: ${err instanceof Error ? err.message : String(err)}`)
})
// file: 등 허용되지 않은 scheme 은 openExternalSafe 가 거부하고 로그만 남긴다(드롭한 파일은 무시됨).
openExternalInBackground(url)
}
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)}`)
})
openExternalInBackground(details.url)
return { action: 'deny' }
})
contents.on('will-attach-webview', (event) => {