From 5322d981cca5eb21148427d8285abb7e37e8f282 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 28 Sep 2026 02:16:18 +0900 Subject: [PATCH] fix(security): trust file: URLs only inside the app renderer directory --- .../main/windows/web-contents-hardening.ts | 60 ++++++++++-- ...eb-contents-hardening-redteam-r1-0.test.ts | 7 +- ...eb-contents-hardening-redteam-r2-9.test.ts | 91 +++++++++++++++++++ .../__tests__/url-policy-redteam-r2-9.test.ts | 69 ++++++++++++++ packages/core/src/url-policy.ts | 89 +++++++++++++++++- 5 files changed, 299 insertions(+), 17 deletions(-) create mode 100644 apps/desktop/tests/main/windows/web-contents-hardening-redteam-r2-9.test.ts create mode 100644 packages/core/__tests__/url-policy-redteam-r2-9.test.ts diff --git a/apps/desktop/src/main/windows/web-contents-hardening.ts b/apps/desktop/src/main/windows/web-contents-hardening.ts index 643ec97..6e371e5 100644 --- a/apps/desktop/src/main/windows/web-contents-hardening.ts +++ b/apps/desktop/src/main/windows/web-contents-hardening.ts @@ -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): 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) => { diff --git a/apps/desktop/tests/main/windows/web-contents-hardening-redteam-r1-0.test.ts b/apps/desktop/tests/main/windows/web-contents-hardening-redteam-r1-0.test.ts index 4ae6f3c..a39095e 100644 --- a/apps/desktop/tests/main/windows/web-contents-hardening-redteam-r1-0.test.ts +++ b/apps/desktop/tests/main/windows/web-contents-hardening-redteam-r1-0.test.ts @@ -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 }) diff --git a/apps/desktop/tests/main/windows/web-contents-hardening-redteam-r2-9.test.ts b/apps/desktop/tests/main/windows/web-contents-hardening-redteam-r2-9.test.ts new file mode 100644 index 0000000..54df79f --- /dev/null +++ b/apps/desktop/tests/main/windows/web-contents-hardening-redteam-r2-9.test.ts @@ -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() + const webContents = { + on: vi.fn((event: string, listener: Handler) => { + listeners.set(event, listener) + }), + setWindowOpenHandler: vi.fn(), + } + return { + win: { webContents } as unknown as Parameters[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() + } + }) +}) diff --git a/packages/core/__tests__/url-policy-redteam-r2-9.test.ts b/packages/core/__tests__/url-policy-redteam-r2-9.test.ts new file mode 100644 index 0000000..ce6389f --- /dev/null +++ b/packages/core/__tests__/url-policy-redteam-r2-9.test.ts @@ -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) + }) +}) diff --git a/packages/core/src/url-policy.ts b/packages/core/src/url-policy.ts index 94a1877..9dd7d29 100644 --- a/packages/core/src/url-policy.ts +++ b/packages/core/src/url-policy.ts @@ -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') +}