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) => {

View file

@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ipcMain, shell } from 'electron'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import {
appRendererFileRoot,
hardenWebContents,
isTrustedIpcSender,
openExternalSafe,
@ -50,7 +51,7 @@ describe('hardenWebContents', () => {
it('앱 자신의 페이지(file://)로의 이동은 허용한다', () => {
const w = fakeWindow()
hardenWebContents(w.win)
expect(w.fire('will-navigate', 'file:///C:/app/out/renderer/index.html#/x')).not.toHaveBeenCalled()
expect(w.fire('will-navigate', `${appRendererFileRoot()}index.html#/x`)).not.toHaveBeenCalled()
})
it('새 창은 항상 거부하고 file:/UNC/사용자 정의 scheme 은 OS로 넘기지 않는다', async () => {
@ -73,7 +74,7 @@ describe('openExternalSafe / isTrustedIpcSender', () => {
})
it('원격 페이지가 보낸 IPC 는 신뢰하지 않는다', () => {
expect(isTrustedIpcSender({ senderFrame: { url: 'file:///C:/app/index.html' } } as never)).toBe(true)
expect(isTrustedIpcSender({ senderFrame: { url: `${appRendererFileRoot()}index.html` } } as never)).toBe(true)
expect(isTrustedIpcSender({ senderFrame: { url: 'https://evil.example/' } } as never)).toBe(false)
expect(isTrustedIpcSender({ senderFrame: null } as never)).toBe(false)
})
@ -88,7 +89,7 @@ describe('SYSTEM.OPEN_EXTERNAL IPC', () => {
const { registerWindowHandlers } = await import('../../../src/main/ipc/window-handlers')
registerWindowHandlers()
const handler = handlers.get(IPC_CHANNELS.SYSTEM.OPEN_EXTERNAL)!
const appEvent = { senderFrame: { url: 'file:///C:/app/index.html' } }
const appEvent = { senderFrame: { url: `${appRendererFileRoot()}index.html` } }
const remoteEvent = { senderFrame: { url: 'https://evil.example/' } }
await expect(handler(appEvent, { url: 'file:///C:/Windows/System32/calc.exe' })).resolves.toMatchObject({ success: false })

View file

@ -0,0 +1,91 @@
// 드롭한 로컬 파일(file:)이 앱 창을 대체하거나 신뢰 IPC 발신자로 인정되던 문제의 회귀 테스트.
// 앱 페이지는 렌더러 디렉터리(join(__dirname, '../renderer')) 아래 HTML만이다.
import { resolve } from 'path'
import { pathToFileURL } from 'url'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { shell } from 'electron'
import {
appRendererFileRoot,
hardenWebContents,
isAllowedAppNavigation,
isTrustedIpcSender,
} from '../../../src/main/windows/web-contents-hardening'
type Handler = (...args: unknown[]) => unknown
// 테스트에서 어댑터 모듈의 __dirname 은 src/main/windows 이므로 렌더러 루트는 src/main/renderer.
const RENDERER_ROOT = `${pathToFileURL(resolve(__dirname, '../../../src/main/renderer')).href}/`
function fakeWindow() {
const listeners = new Map<string, Handler>()
const webContents = {
on: vi.fn((event: string, listener: Handler) => {
listeners.set(event, listener)
}),
setWindowOpenHandler: vi.fn(),
}
return {
win: { webContents } as unknown as Parameters<typeof hardenWebContents>[0],
fire: (event: string, url: string) => {
const preventDefault = vi.fn()
listeners.get(event)?.({ preventDefault }, url)
return preventDefault
},
}
}
beforeEach(() => {
vi.mocked(shell.openExternal).mockClear()
})
describe('appRendererFileRoot', () => {
it('is the renderer directory next to the main bundle, as a file URL ending in /', () => {
expect(appRendererFileRoot()).toBe(RENDERER_ROOT)
})
})
describe('file: navigation guard', () => {
it('blocks a dropped HTML/audio file from replacing the app window and never opens it externally', async () => {
const w = fakeWindow()
hardenWebContents(w.win)
expect(w.fire('will-navigate', 'file:///C:/Users/me/Downloads/evil.html')).toHaveBeenCalled()
expect(w.fire('will-navigate', 'file:///C:/Users/me/Music/meeting.mp3')).toHaveBeenCalled()
expect(w.fire('will-navigate', 'file://host/share/evil.html')).toHaveBeenCalled()
expect(w.fire('will-redirect', 'file:///C:/Users/me/Downloads/evil.html')).toHaveBeenCalled()
await new Promise((r) => setTimeout(r, 10))
expect(shell.openExternal).not.toHaveBeenCalled()
})
it('still allows the app own renderer pages', () => {
const w = fakeWindow()
hardenWebContents(w.win)
expect(w.fire('will-navigate', `${RENDERER_ROOT}index.html#/meetings`)).not.toHaveBeenCalled()
expect(w.fire('will-navigate', `${RENDERER_ROOT}popups/result-popup/index.html`)).not.toHaveBeenCalled()
})
it('does not navigate to non-HTML files inside the bundle', () => {
expect(isAllowedAppNavigation(`${RENDERER_ROOT}assets/index.js`)).toBe(false)
})
})
describe('isTrustedIpcSender with file: senders', () => {
it('trusts the app renderer but not arbitrary local or UNC files', () => {
expect(isTrustedIpcSender({ senderFrame: { url: `${RENDERER_ROOT}index.html` } } as never)).toBe(true)
expect(isTrustedIpcSender({ senderFrame: { url: 'file:///C:/Users/me/Downloads/evil.html' } } as never)).toBe(false)
expect(isTrustedIpcSender({ senderFrame: { url: 'file://host/share/evil.html' } } as never)).toBe(false)
})
})
describe('dev server origin', () => {
it('keeps allowing ELECTRON_RENDERER_URL routes', () => {
vi.stubEnv('ELECTRON_RENDERER_URL', 'http://localhost:5173')
try {
expect(isAllowedAppNavigation('http://localhost:5173/popups/result-popup/index.html')).toBe(true)
expect(isTrustedIpcSender({ senderFrame: { url: 'http://localhost:5173/#/x' } } as never)).toBe(true)
expect(isAllowedAppNavigation('file:///C:/Users/me/Downloads/evil.html')).toBe(false)
} finally {
vi.unstubAllEnvs()
}
})
})

View file

@ -0,0 +1,69 @@
// file: 앱 오리진 판정을 렌더러 디렉터리로 제한하는 회귀 테스트(드롭한 파일이 앱 창을 대체하던 문제).
import { describe, expect, it } from 'vitest'
import { isAppNavigationTarget, isAppOrigin, isFileUrlWithinRoot } from '../src/url-policy'
const ROOT = 'file:///C:/Program%20Files/D3RO%20Voice/resources/app.asar/out/renderer/'
const ORIGINS = ['file://', 'http://localhost:5173']
const OPTIONS = { fileRoot: ROOT }
describe('isFileUrlWithinRoot', () => {
it('accepts files under the renderer root (encoding, drive case, hash/query ignored)', () => {
expect(isFileUrlWithinRoot(`${ROOT}index.html#/meetings`, ROOT)).toBe(true)
expect(isFileUrlWithinRoot(`${ROOT}popups/result-popup/index.html?x=1`, ROOT)).toBe(true)
expect(isFileUrlWithinRoot('file:///c:/program files/d3ro voice/resources/app.asar/out/renderer/index.html', ROOT)).toBe(true)
expect(isFileUrlWithinRoot(`${ROOT}index.html`, ROOT.slice(0, -1))).toBe(true)
})
it('accepts a non-ASCII install path encoded the way Chromium encodes it', () => {
const root = 'file:///C:/Users/%EC%9C%A4%EC%B0%AC/AppData/Local/Programs/d3ro-voice/resources/app.asar/out/renderer/'
expect(isFileUrlWithinRoot('file:///C:/Users/윤찬/AppData/Local/Programs/d3ro-voice/resources/app.asar/out/renderer/index.html', root)).toBe(true)
})
it.each([
'file:///C:/Users/me/Downloads/x.html',
'file:///C:/Program%20Files/D3RO%20Voice/resources/app.asar/out/renderer-evil/index.html',
'file:///C:/Program%20Files/D3RO%20Voice/resources/app.asar/out/index.html',
'file://evil-host/share/x.html',
'file://evil-host/C:/Program%20Files/D3RO%20Voice/resources/app.asar/out/renderer/index.html',
`${ROOT}..%2F..%2Fx.html`,
`${ROOT}..%5C..%5Cx.html`,
`${ROOT}%2e%2e/%2e%2e/x.html`,
'http://localhost:5173/index.html',
'not a url',
])('rejects %s', (url) => {
expect(isFileUrlWithinRoot(url, ROOT)).toBe(false)
})
it('rejects everything when the root itself is not a file URL', () => {
expect(isFileUrlWithinRoot(`${ROOT}index.html`, 'https://example.com/')).toBe(false)
})
})
describe('isAppOrigin with fileRoot', () => {
it('trusts only the renderer bundle and the dev server', () => {
expect(isAppOrigin(`${ROOT}index.html#/settings`, ORIGINS, OPTIONS)).toBe(true)
expect(isAppOrigin('http://localhost:5173/#/settings', ORIGINS, OPTIONS)).toBe(true)
expect(isAppOrigin('file:///C:/Users/me/Downloads/evil.html', ORIGINS, OPTIONS)).toBe(false)
expect(isAppOrigin('file://host/share/evil.html', ORIGINS, OPTIONS)).toBe(false)
expect(isAppOrigin('https://evil.example/', ORIGINS, OPTIONS)).toBe(false)
})
it('keeps the dev server origin exact even with a fileRoot', () => {
expect(isAppOrigin('http://localhost:5174/', ORIGINS, OPTIONS)).toBe(false)
})
})
describe('isAppNavigationTarget', () => {
it('allows app HTML pages and dev server routes', () => {
expect(isAppNavigationTarget(`${ROOT}index.html`, ORIGINS, OPTIONS)).toBe(true)
expect(isAppNavigationTarget(`${ROOT}popups/command-popup/index.html#x`, ORIGINS, OPTIONS)).toBe(true)
expect(isAppNavigationTarget('http://localhost:5173/popups/result-popup/index.html', ORIGINS, OPTIONS)).toBe(true)
})
it('blocks dropped files and non-HTML files inside the bundle', () => {
expect(isAppNavigationTarget('file:///C:/Users/me/Music/meeting.mp3', ORIGINS, OPTIONS)).toBe(false)
expect(isAppNavigationTarget('file:///C:/Users/me/Downloads/x.html', ORIGINS, OPTIONS)).toBe(false)
expect(isAppNavigationTarget(`${ROOT}assets/index-abc.js`, ORIGINS, OPTIONS)).toBe(false)
expect(isAppNavigationTarget(`${ROOT}`, ORIGINS, OPTIONS)).toBe(false)
})
})

View file

@ -4,6 +4,8 @@
// - 앱 밖으로 넘기는 URL(OS 기본 핸들러)은 허용 scheme만 연다. file:, UNC, ms-*, search-ms: 같은
// OS 핸들러는 원격 코드 실행 경로가 되므로 막는다.
// - 앱 창은 앱 자신의 오리진 밖으로 이동하지 않는다(원격 페이지가 preload API를 얻지 못하게).
// - file: 은 오리진이 'null'이라 오리진 비교로는 앱 번들과 사용자가 드롭한 임의 파일을 구분할 수 없다.
// 그래서 앱 렌더러 디렉터리(fileRoot) 아래의 경로만 앱 페이지로 본다.
export interface ExternalUrlPolicyOptions {
/** 허용할 scheme(콜론 포함, 소문자). 기본 https:·mailto: */
@ -12,8 +14,20 @@ export interface ExternalUrlPolicyOptions {
allowOrigins?: readonly string[]
}
export interface AppOriginOptions {
/**
* 앱 렌더러가 올라오는 디렉터리의 file URL(예: file:///C:/app/resources/app.asar/out/renderer/).
* 지정하면 appOrigins 의 'file://' 항목은 이 디렉터리 아래 경로만 허용한다(호스트까지 일치해야 하므로
* UNC file://host/share/... 도 거부). 지정하지 않으면 모든 file: URL을 허용하는 예전 동작이다 —
* 앱 창 가드에서는 반드시 지정한다.
*/
fileRoot?: string
}
export const DEFAULT_EXTERNAL_SCHEMES: readonly string[] = ['https:', 'mailto:']
const FILE_ORIGIN = 'file://'
function parse(url: string): URL | null {
if (typeof url !== 'string' || url.trim() === '' || url.length > 8192) return null
// 백슬래시로 시작하는 UNC(\\server\share)는 URL 파서가 상대 경로로 보지 않도록 먼저 거른다.
@ -25,6 +39,50 @@ function parse(url: string): URL | null {
}
}
/** 퍼센트 인코딩을 풀고 경로 세그먼트로 나눈다. 인코딩으로 숨긴 '..'·'\\'·NUL 이 있으면 null. */
function decodedSegments(pathname: string): string[] | null {
let decoded: string
try {
decoded = decodeURIComponent(pathname)
} catch {
return null
}
if (decoded.includes('\\') || decoded.includes('\0')) return null
const segments = decoded.split('/')
if (segments.some((segment) => segment === '.' || segment === '..')) return null
return segments
}
/** '/C:/...' 처럼 Windows 드라이브 경로면 대소문자를 구분하지 않는다. */
function isWindowsDrivePath(segments: readonly string[]): boolean {
return segments.length > 1 && /^[A-Za-z]:$/.test(segments[1] ?? '')
}
/**
* file URL 이 fileRoot 디렉터리 아래(또는 그 자체)를 가리키는지. 호스트(UNC)·경로를 모두 비교한다.
* fileRoot 끝의 '/' 유무는 상관없다.
*/
export function isFileUrlWithinRoot(url: string, fileRoot: string): boolean {
const parsed = parse(url)
const root = parse(fileRoot)
if (!parsed || !root) return false
if (parsed.protocol !== 'file:' || root.protocol !== 'file:') return false
if (parsed.host.toLowerCase() !== root.host.toLowerCase()) return false
const target = decodedSegments(parsed.pathname)
const rootSegments = decodedSegments(root.pathname)
if (!target || !rootSegments) return false
// 끝의 빈 세그먼트('/renderer/' → ['', 'renderer', ''])는 디렉터리 표기일 뿐이다.
while (rootSegments.length > 1 && rootSegments[rootSegments.length - 1] === '') rootSegments.pop()
if (target.length < rootSegments.length) return false
const caseInsensitive = isWindowsDrivePath(rootSegments)
return rootSegments.every((segment, index) => {
const candidate = target[index] ?? ''
return caseInsensitive ? candidate.toLowerCase() === segment.toLowerCase() : candidate === segment
})
}
/** OS 기본 핸들러(브라우저·메일)로 넘겨도 되는 URL인지. */
export function isAllowedExternalUrl(url: string, options: ExternalUrlPolicyOptions = {}): boolean {
const parsed = parse(url)
@ -42,14 +100,20 @@ export function isAllowedExternalUrl(url: string, options: ExternalUrlPolicyOpti
/**
* URL이 앱 자신의 페이지인지. appOrigins는 'file://' 또는 'http://localhost:5173' 같은 오리진.
* file: 은 오리진이 'null'이라 scheme으로 비교한다.
* file: 은 오리진이 'null'이라 scheme으로 비교하고, options.fileRoot 가 있으면 그 디렉터리 아래로 제한한다.
*/
export function isAppOrigin(url: string, appOrigins: readonly string[]): boolean {
export function isAppOrigin(
url: string,
appOrigins: readonly string[],
options: AppOriginOptions = {},
): boolean {
const parsed = parse(url)
if (!parsed) return false
for (const allowed of appOrigins) {
if (allowed === 'file://') {
if (parsed.protocol === 'file:') return true
if (allowed === FILE_ORIGIN) {
if (parsed.protocol !== 'file:') continue
if (options.fileRoot === undefined) return true
if (isFileUrlWithinRoot(url, options.fileRoot)) return true
continue
}
const allowedParsed = parse(allowed)
@ -57,3 +121,20 @@ export function isAppOrigin(url: string, appOrigins: readonly string[]): boolean
}
return false
}
/**
* 앱 창이 이 URL로 이동(will-navigate/will-redirect)해도 되는지.
* isAppOrigin 에 더해 file: 이면 HTML 문서만 허용한다 — 앱 번들 안이라도 .js·.wasm·리소스 파일로
* 이동하면 UI가 사라지고 frameless 창에서는 복구할 수 없다.
*/
export function isAppNavigationTarget(
url: string,
appOrigins: readonly string[],
options: AppOriginOptions = {},
): boolean {
if (!isAppOrigin(url, appOrigins, options)) return false
const parsed = parse(url)
if (!parsed) return false
if (parsed.protocol !== 'file:') return true
return parsed.pathname.toLowerCase().endsWith('.html')
}