refactor: improve preload security with IPC channel whitelist and fix dependencies

This commit is contained in:
xmk23333 2025-12-28 12:45:07 +08:00
parent 2467306903
commit a159974142
18 changed files with 284 additions and 43 deletions

View File

@ -40,7 +40,10 @@ module.exports = [
{ {
files: ['**/*.{ts,tsx}'], files: ['**/*.{ts,tsx}'],
rules: { rules: {
'@typescript-eslint/no-unused-vars': 0, '@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' }
],
'@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-explicit-any': 'warn' '@typescript-eslint/no-explicit-any': 'warn'
} }

View File

@ -30,23 +30,16 @@
"build:linux:dev": "npm run prepare:dev && electron-vite build && electron-builder --publish never --linux" "build:linux:dev": "npm run prepare:dev && electron-vite build && electron-builder --publish never --linux"
}, },
"dependencies": { "dependencies": {
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
"@heroui/react": "^2.8.5",
"@mihomo-party/sysproxy": "^2.0.8", "@mihomo-party/sysproxy": "^2.0.8",
"@types/crypto-js": "^4.2.2",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"axios": "^1.13.2", "axios": "^1.13.2",
"chart.js": "^4.5.1",
"chokidar": "^4.0.3", "chokidar": "^4.0.3",
"croner": "^9.1.0", "croner": "^9.1.0",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dayjs": "^1.11.19",
"express": "^5.1.0", "express": "^5.1.0",
"i18next": "^25.6.2", "i18next": "^25.6.2",
"iconv-lite": "^0.6.3", "iconv-lite": "^0.6.3",
"react-chartjs-2": "^5.3.1",
"react-i18next": "^15.7.4",
"webdav": "^5.8.0", "webdav": "^5.8.0",
"ws": "^8.18.3", "ws": "^8.18.3",
"yaml": "^2.8.1" "yaml": "^2.8.1"
@ -58,8 +51,10 @@
"@electron-toolkit/eslint-config-prettier": "^3.0.0", "@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.1.0", "@electron-toolkit/eslint-config-ts": "^3.1.0",
"@electron-toolkit/tsconfig": "^1.0.1", "@electron-toolkit/tsconfig": "^1.0.1",
"@heroui/react": "^2.8.5",
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",
"@types/adm-zip": "^0.5.7", "@types/adm-zip": "^0.5.7",
"@types/crypto-js": "^4.2.2",
"@types/express": "^5.0.5", "@types/express": "^5.0.5",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/pubsub-js": "^1.8.6", "@types/pubsub-js": "^1.8.6",
@ -67,7 +62,9 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react": "^4.7.0",
"chart.js": "^4.5.1",
"cron-validator": "^1.4.0", "cron-validator": "^1.4.0",
"dayjs": "^1.11.19",
"driver.js": "^1.3.6", "driver.js": "^1.3.6",
"electron": "^37.10.0", "electron": "^37.10.0",
"electron-builder": "26.0.12", "electron-builder": "26.0.12",
@ -86,8 +83,10 @@
"prettier": "^3.6.2", "prettier": "^3.6.2",
"pubsub-js": "^1.9.5", "pubsub-js": "^1.9.5",
"react": "^19.2.0", "react": "^19.2.0",
"react-chartjs-2": "^5.3.1",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-error-boundary": "^6.0.0", "react-error-boundary": "^6.0.0",
"react-i18next": "^15.7.4",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-monaco-editor": "^0.59.0", "react-monaco-editor": "^0.59.0",

View File

@ -562,7 +562,7 @@ async function waitForCoreReady(): Promise<void> {
await axios.get('/') await axios.get('/')
await managerLogger.info(`Core ready after ${i + 1} attempts (${(i + 1) * retryInterval}ms)`) await managerLogger.info(`Core ready after ${i + 1} attempts (${(i + 1) * retryInterval}ms)`)
return return
} catch (error) { } catch {
if (i === 0) { if (i === 0) {
await managerLogger.info('Waiting for core to be ready...') await managerLogger.info('Waiting for core to be ready...')
} }
@ -791,7 +791,7 @@ async function checkHighPrivilegeMihomoProcess(): Promise<boolean> {
if (processJson.Name.includes('mihomo') && processJson.Path === null) { if (processJson.Name.includes('mihomo') && processJson.Path === null) {
return true return true
} }
} catch (error) { } catch {
await managerLogger.info( await managerLogger.info(
`Cannot get info for process ${pid}, might be high privilege` `Cannot get info for process ${pid}, might be high privilege`
) )
@ -835,7 +835,7 @@ async function checkHighPrivilegeMihomoProcess(): Promise<boolean> {
} }
} }
} }
} catch (error) { } catch {
// ignore // ignore
} }
} }

View File

@ -15,7 +15,7 @@ export async function initProfileUpdater(): Promise<void> {
async () => { async () => {
try { try {
await addProfileItem(item) await addProfileItem(item)
} catch (e) { } catch {
/* ignore */ /* ignore */
} }
}, },
@ -26,7 +26,7 @@ export async function initProfileUpdater(): Promise<void> {
intervalPool[item.id] = new Cron(item.interval, async () => { intervalPool[item.id] = new Cron(item.interval, async () => {
try { try {
await addProfileItem(item) await addProfileItem(item)
} catch (e) { } catch {
/* ignore */ /* ignore */
} }
}) })
@ -34,7 +34,7 @@ export async function initProfileUpdater(): Promise<void> {
try { try {
await addProfileItem(item) await addProfileItem(item)
} catch (e) { } catch {
/* ignore */ /* ignore */
} }
} }
@ -46,7 +46,7 @@ export async function initProfileUpdater(): Promise<void> {
async () => { async () => {
try { try {
await addProfileItem(currentItem) await addProfileItem(currentItem)
} catch (e) { } catch {
/* ignore */ /* ignore */
} }
}, },
@ -57,7 +57,7 @@ export async function initProfileUpdater(): Promise<void> {
async () => { async () => {
try { try {
await addProfileItem(currentItem) await addProfileItem(currentItem)
} catch (e) { } catch {
/* ignore */ /* ignore */
} }
}, },
@ -67,7 +67,7 @@ export async function initProfileUpdater(): Promise<void> {
intervalPool[currentItem.id] = new Cron(currentItem.interval, async () => { intervalPool[currentItem.id] = new Cron(currentItem.interval, async () => {
try { try {
await addProfileItem(currentItem) await addProfileItem(currentItem)
} catch (e) { } catch {
/* ignore */ /* ignore */
} }
}) })
@ -75,7 +75,7 @@ export async function initProfileUpdater(): Promise<void> {
try { try {
await addProfileItem(currentItem) await addProfileItem(currentItem)
} catch (e) { } catch {
/* ignore */ /* ignore */
} }
} }
@ -96,7 +96,7 @@ export async function addProfileUpdater(item: IProfileItem): Promise<void> {
async () => { async () => {
try { try {
await addProfileItem(item) await addProfileItem(item)
} catch (e) { } catch {
/* ignore */ /* ignore */
} }
}, },
@ -106,7 +106,7 @@ export async function addProfileUpdater(item: IProfileItem): Promise<void> {
intervalPool[item.id] = new Cron(item.interval, async () => { intervalPool[item.id] = new Cron(item.interval, async () => {
try { try {
await addProfileItem(item) await addProfileItem(item)
} catch (e) { } catch {
/* ignore */ /* ignore */
} }
}) })

View File

@ -120,7 +120,7 @@ export const buildContextMenu = async (): Promise<Menu> => {
groupsMenu = groupItems groupsMenu = groupItems
groupsMenu.unshift({ type: 'separator' }) groupsMenu.unshift({ type: 'separator' })
} }
} catch (e) { } catch {
// ignore // ignore
// 避免出错时无法创建托盘菜单 // 避免出错时无法创建托盘菜单
} }
@ -206,7 +206,7 @@ export const buildContextMenu = async (): Promise<Menu> => {
await patchAppConfig({ sysProxy: { enable } }) await patchAppConfig({ sysProxy: { enable } })
mainWindow?.webContents.send('appConfigUpdated') mainWindow?.webContents.send('appConfigUpdated')
floatingWindow?.webContents.send('appConfigUpdated') floatingWindow?.webContents.send('appConfigUpdated')
} catch (e) { } catch {
// ignore // ignore
} finally { } finally {
ipcMain.emit('updateTrayMenu') ipcMain.emit('updateTrayMenu')

View File

@ -61,7 +61,7 @@ export async function checkAutoRun(): Promise<boolean> {
`chcp 437 && %SystemRoot%\\System32\\schtasks.exe /query /tn "${appName}"` `chcp 437 && %SystemRoot%\\System32\\schtasks.exe /query /tn "${appName}"`
) )
return stdout.includes(appName) return stdout.includes(appName)
} catch (e) { } catch {
return false return false
} }
} }
@ -96,7 +96,7 @@ export async function enableAutoRun(): Promise<void> {
await execPromise( await execPromise(
`powershell -NoProfile -Command "Start-Process schtasks -Verb RunAs -ArgumentList '/create', '/tn', '${appName}', '/xml', '${taskFilePath}', '/f' -WindowStyle Hidden"` `powershell -NoProfile -Command "Start-Process schtasks -Verb RunAs -ArgumentList '/create', '/tn', '${appName}', '/xml', '${taskFilePath}', '/f' -WindowStyle Hidden"`
) )
} catch (e) { } catch {
await managerLogger.info('Maybe the user rejected the UAC dialog?') await managerLogger.info('Maybe the user rejected the UAC dialog?')
} }
} }
@ -144,7 +144,7 @@ export async function disableAutoRun(): Promise<void> {
await execPromise( await execPromise(
`powershell -NoProfile -Command "Start-Process schtasks -Verb RunAs -ArgumentList '/delete', '/tn', '${appName}', '/f' -WindowStyle Hidden"` `powershell -NoProfile -Command "Start-Process schtasks -Verb RunAs -ArgumentList '/delete', '/tn', '${appName}', '/f' -WindowStyle Hidden"`
) )
} catch (e) { } catch {
await managerLogger.info('Maybe the user rejected the UAC dialog?') await managerLogger.info('Maybe the user rejected the UAC dialog?')
} }
} }

View File

@ -1,6 +1,22 @@
import { ElectronAPI } from '@electron-toolkit/preload'
import { webUtils } from 'electron' import { webUtils } from 'electron'
type IpcListener = (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
interface SafeIpcRenderer {
invoke: (channel: string, ...args: unknown[]) => Promise<unknown>
send: (channel: string, ...args: unknown[]) => void
on: (channel: string, listener: IpcListener) => void
removeListener: (channel: string, listener: IpcListener) => void
removeAllListeners: (channel: string) => void
}
interface ElectronAPI {
ipcRenderer: SafeIpcRenderer
process: {
platform: NodeJS.Platform
}
}
declare global { declare global {
interface Window { interface Window {
electron: ElectronAPI electron: ElectronAPI

View File

@ -1,13 +1,231 @@
import { contextBridge, webUtils } from 'electron' import { contextBridge, ipcRenderer, webUtils } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
// 允许的 invoke channels 白名单
const validInvokeChannels = [
// Mihomo API
'mihomoVersion',
'mihomoCloseConnection',
'mihomoCloseAllConnections',
'mihomoRules',
'mihomoProxies',
'mihomoGroups',
'mihomoProxyProviders',
'mihomoUpdateProxyProviders',
'mihomoRuleProviders',
'mihomoUpdateRuleProviders',
'mihomoChangeProxy',
'mihomoUnfixedProxy',
'mihomoUpgradeGeo',
'mihomoUpgrade',
'mihomoUpgradeUI',
'mihomoUpgradeConfig',
'mihomoProxyDelay',
'mihomoGroupDelay',
'patchMihomoConfig',
'mihomoSmartGroupWeights',
'mihomoSmartFlushCache',
// AutoRun
'checkAutoRun',
'enableAutoRun',
'disableAutoRun',
// Config
'getAppConfig',
'patchAppConfig',
'getControledMihomoConfig',
'patchControledMihomoConfig',
'resetAppConfig',
// Profile
'getProfileConfig',
'setProfileConfig',
'getCurrentProfileItem',
'getProfileItem',
'getProfileStr',
'setProfileStr',
'addProfileItem',
'removeProfileItem',
'updateProfileItem',
'changeCurrentProfile',
'addProfileUpdater',
'removeProfileUpdater',
// Override
'getOverrideConfig',
'setOverrideConfig',
'getOverrideItem',
'addOverrideItem',
'removeOverrideItem',
'updateOverrideItem',
'getOverride',
'setOverride',
// File
'getFileStr',
'setFileStr',
'convertMrsRuleset',
'getRuntimeConfig',
'getRuntimeConfigStr',
'getSmartOverrideContent',
'getRuleStr',
'setRuleStr',
'getFilePath',
'readTextFile',
'openFile',
// Core
'restartCore',
'startMonitor',
'quitWithoutCore',
// System
'triggerSysProxy',
'checkTunPermissions',
'grantTunPermissions',
'manualGrantCorePermition',
'checkAdminPrivileges',
'restartAsAdmin',
'checkMihomoCorePermissions',
'requestTunPermissions',
'checkHighPrivilegeCore',
'showTunPermissionDialog',
'showErrorDialog',
'openUWPTool',
'setupFirewall',
'getInterfaces',
'setNativeTheme',
'copyEnv',
// Update
'checkUpdate',
'downloadAndInstallUpdate',
'getVersion',
'platform',
'fetchMihomoTags',
'installSpecificMihomoCore',
'clearMihomoVersionCache',
// Backup
'webdavBackup',
'webdavRestore',
'listWebdavBackups',
'webdavDelete',
'reinitWebdavBackupScheduler',
'exportLocalBackup',
'importLocalBackup',
// SubStore
'startSubStoreFrontendServer',
'stopSubStoreFrontendServer',
'startSubStoreBackendServer',
'stopSubStoreBackendServer',
'downloadSubStore',
'subStorePort',
'subStoreFrontendPort',
'subStoreSubs',
'subStoreCollections',
// Theme
'resolveThemes',
'fetchThemes',
'importThemes',
'readTheme',
'writeTheme',
'applyTheme',
// Tray
'showTrayIcon',
'closeTrayIcon',
'updateTrayIcon',
'updateTrayIconImmediate',
// Window
'showMainWindow',
'closeMainWindow',
'triggerMainWindow',
'showFloatingWindow',
'closeFloatingWindow',
'showContextMenu',
'setTitleBarOverlay',
'setAlwaysOnTop',
'isAlwaysOnTop',
'openDevTools',
'createHeapSnapshot',
'relaunchApp',
'quitApp',
// Shortcut
'registerShortcut',
// Misc
'getGistUrl',
'getImageDataURL',
'changeLanguage'
] as const
// 允许的 on/removeListener channels 白名单
const validListenChannels = [
'mihomoLogs',
'mihomoConnections',
'mihomoTraffic',
'mihomoMemory',
'appConfigUpdated',
'controledMihomoConfigUpdated',
'profileConfigUpdated',
'groupsUpdated',
'rulesUpdated'
] as const
// 允许的 send channels 白名单
const validSendChannels = [
'updateTrayMenu',
'updateFloatingWindow',
'trayIconUpdate'
] as const
type InvokeChannel = (typeof validInvokeChannels)[number]
type ListenChannel = (typeof validListenChannels)[number]
type SendChannel = (typeof validSendChannels)[number]
type IpcListener = (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
const listenerMap = new Map<ListenChannel, Set<IpcListener>>()
// 安全的 IPC API只暴露白名单内的 channels
const electronAPI = {
ipcRenderer: {
invoke: (channel: InvokeChannel, ...args: unknown[]): Promise<unknown> => {
if (validInvokeChannels.includes(channel)) {
return ipcRenderer.invoke(channel, ...args)
}
return Promise.reject(new Error(`Invalid invoke channel: ${channel}`))
},
send: (channel: SendChannel, ...args: unknown[]): void => {
if (validSendChannels.includes(channel)) {
ipcRenderer.send(channel, ...args)
}
},
on: (channel: ListenChannel, listener: IpcListener): void => {
if (validListenChannels.includes(channel)) {
if (!listenerMap.has(channel)) {
listenerMap.set(channel, new Set())
}
listenerMap.get(channel)!.add(listener)
ipcRenderer.on(channel, listener)
}
},
removeListener: (channel: ListenChannel, listener: IpcListener): void => {
if (validListenChannels.includes(channel)) {
listenerMap.get(channel)?.delete(listener)
ipcRenderer.removeListener(channel, listener)
}
},
removeAllListeners: (channel: ListenChannel): void => {
if (validListenChannels.includes(channel)) {
const listeners = listenerMap.get(channel)
if (listeners) {
listeners.forEach((listener) => {
ipcRenderer.removeListener(channel, listener)
})
listeners.clear()
}
}
}
},
process: {
platform: process.platform
}
}
// Custom APIs for renderer
const api = { const api = {
webUtils: webUtils webUtils: webUtils
} }
// Use `contextBridge` APIs to expose Electron APIs to
// renderer only if context isolation is enabled, otherwise
// just add to the DOM global.
if (process.contextIsolated) { if (process.contextIsolated) {
try { try {
contextBridge.exposeInMainWorld('electron', electronAPI) contextBridge.exposeInMainWorld('electron', electronAPI)

View File

@ -86,7 +86,7 @@ const App: React.FC = () => {
options.color = window.getComputedStyle(document.documentElement).backgroundColor options.color = window.getComputedStyle(document.documentElement).backgroundColor
options.symbolColor = window.getComputedStyle(document.documentElement).color options.symbolColor = window.getComputedStyle(document.documentElement).color
setTitleBarOverlay(options) setTitleBarOverlay(options)
} catch (e) { } catch {
// ignore // ignore
} }
} }

View File

@ -49,7 +49,8 @@ const FloatingApp: React.FC = () => {
}, [spinSpeed, spinFloatingIcon]) }, [spinSpeed, spinFloatingIcon])
useEffect(() => { useEffect(() => {
window.electron.ipcRenderer.on('mihomoTraffic', async (_e, info: IMihomoTrafficInfo) => { window.electron.ipcRenderer.on('mihomoTraffic', async (_e, ...args) => {
const info = args[0] as IMihomoTrafficInfo
setUpload(info.up) setUpload(info.up)
setDownload(info.down) setDownload(info.down)
}) })

View File

@ -32,7 +32,7 @@ const BasePage = forwardRef<HTMLDivElement, Props>((props, ref) => {
// @ts-ignore windowControlsOverlay // @ts-ignore windowControlsOverlay
const windowControlsOverlay = window.navigator.windowControlsOverlay const windowControlsOverlay = window.navigator.windowControlsOverlay
setOverlayWidth(window.innerWidth - windowControlsOverlay.getTitlebarAreaRect().width) setOverlayWidth(window.innerWidth - windowControlsOverlay.getTitlebarAreaRect().width)
} catch (e) { } catch {
// ignore // ignore
} }
} }

View File

@ -149,7 +149,7 @@ const EditInfoModal: React.FC<Props> = (props) => {
// 非纯数字 // 非纯数字
try { try {
setValues({ ...values, interval: v }) setValues({ ...values, interval: v })
} catch (e) { } catch {
// ignore // ignore
} }
} }

View File

@ -66,7 +66,7 @@ const Viewer: React.FC<Props> = (props) => {
}) })
) )
} }
} catch (error) { } catch {
setCurrData(fileContent) setCurrData(fileContent)
} }
} finally { } finally {

View File

@ -126,7 +126,8 @@ const ConnCard: React.FC<Props> = (props) => {
const transform = tf ? { x: tf.x, y: tf.y, scaleX: 1, scaleY: 1 } : null const transform = tf ? { x: tf.x, y: tf.y, scaleX: 1, scaleY: 1 } : null
useEffect(() => { useEffect(() => {
window.electron.ipcRenderer.on('mihomoTraffic', async (_e, info: IMihomoTrafficInfo) => { window.electron.ipcRenderer.on('mihomoTraffic', async (_e, ...args) => {
const info = args[0] as IMihomoTrafficInfo
setUpload(info.up) setUpload(info.up)
setDownload(info.down) setDownload(info.down)
const data = series const data = series

View File

@ -43,7 +43,8 @@ const MihomoCoreCard: React.FC<Props> = (props) => {
const token = PubSub.subscribe('mihomo-core-changed', () => { const token = PubSub.subscribe('mihomo-core-changed', () => {
mutate() mutate()
}) })
window.electron.ipcRenderer.on('mihomoMemory', (_e, info: IMihomoMemoryInfo) => { window.electron.ipcRenderer.on('mihomoMemory', (_e, ...args) => {
const info = args[0] as IMihomoMemoryInfo
setMem(info.inuse) setMem(info.inuse)
}) })
return (): void => { return (): void => {

View File

@ -170,7 +170,8 @@ const Connections: React.FC = () => {
} }
useEffect(() => { useEffect(() => {
const handler = (_e: unknown, info: IMihomoConnectionsInfo): void => { const handler = (_e: unknown, ...args: unknown[]): void => {
const info = args[0] as IMihomoConnectionsInfo
setConnectionsInfo(info) setConnectionsInfo(info)
if (!info.connections) return if (!info.connections) return

View File

@ -26,7 +26,8 @@ const cachedLogs: {
} }
} }
window.electron.ipcRenderer.on('mihomoLogs', (_e, log: IMihomoLogInfo) => { window.electron.ipcRenderer.on('mihomoLogs', (_e, ...args) => {
const log = args[0] as IMihomoLogInfo
log.time = new Date().toLocaleString() log.time = new Date().toLocaleString()
cachedLogs.log.push(log) cachedLogs.log.push(log)
if (cachedLogs.log.length >= 500) { if (cachedLogs.log.length >= 500) {

View File

@ -1,8 +1,8 @@
import { TitleBarOverlayOptions } from 'electron' import { TitleBarOverlayOptions } from 'electron'
function checkIpcError<T>(response: T | { invokeError: unknown }): T { function checkIpcError<T>(response: unknown): T {
if (response && typeof response === 'object' && 'invokeError' in response) { if (response && typeof response === 'object' && 'invokeError' in response) {
throw response.invokeError throw (response as { invokeError: unknown }).invokeError
} }
return response as T return response as T
} }