fix(security): trust file: URLs only inside the app renderer directory
This commit is contained in:
parent
b306034bfc
commit
5322d981cc
5 changed files with 299 additions and 17 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue