From b76ac46cc05aae2089d1d5b39fe25cd39ace76e7 Mon Sep 17 00:00:00 2001 From: bigjakk Date: Fri, 10 Apr 2026 13:20:08 -0700 Subject: [PATCH] fix: harden Electron security (theme injection, update window, navigation) --- src/main/index.ts | 1738 +++++++++++++++++++------------------ src/main/update-window.ts | 24 +- 2 files changed, 881 insertions(+), 881 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 829e258..e108087 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,866 +1,872 @@ -import { app, BrowserWindow, Menu, clipboard, ipcMain, safeStorage, session, shell } from 'electron'; -import { join } from 'path'; -import { existsSync, mkdirSync, promises as fsp } from 'fs'; -import { get as httpsGet } from 'https'; -import { execFile } from 'child_process'; -import * as os from 'os'; -import { detectPlatform, applyPlatformFlags } from './platform'; -import { config, Keybind, DEFAULT_KEYBINDS, SavedAccount } from './config'; -import { initSwapperProtocol, registerSwapperFileProtocol, ResourceSwapper } from './swapper'; -import { UserscriptManager } from './userscripts'; -import { ALL_CLIENT_CSS } from './client-ui'; -import { electronLog, getLogPath, closeLogStreams } from './logger'; -import { checkForUpdate, downloadUpdate, installUpdate } from './updater'; -import { showUpdateWindow } from './update-window'; -import { DiscordRPC } from './discord-rpc'; -import { listThemes, getThemeCSS, listLoadingThemes, getLoadingScreenCSS } from './css-themes'; -import { TabManager } from './tab-manager'; -import { openRankedQueue } from './ranked-queue'; - -// ── App version for API calls ── -// eslint-disable-next-line @typescript-eslint/no-require-imports -const appVersion: string = require('../../package.json').version; - -// ── Region ping cache ── -const SERVER_MAP: Record = { - 'us-ca-sv': 'SV', 'jb-hnd': 'TOK', 'de-fra': 'FRA', - 'as-mb': 'MBI', 'au-syd': 'SYD', 'sgp': 'SIN', - 'us-tx': 'DAL', 'me-bhn': 'BHN', 'brz': 'BRZ', 'us-nj': 'NY', -}; -let pingCache: Record = {}; -let pingCacheTime = 0; - -function osPing(host: string): Promise { - return new Promise((resolve) => { - const isWin = process.platform === 'win32'; - const args = isWin ? ['-n', '1', '-w', '1500', host] : ['-c', '1', '-W', '2', host]; - execFile('ping', args, { timeout: 3000 }, (err, stdout) => { - if (err) { resolve(-1); return; } - const match = stdout.match(/time[=<]([\d.]+)\s*ms/i); - if (match) resolve(Math.round(parseFloat(match[1]))); - else resolve(-1); - }); - }); -} - -// ── Platform flags (must run before app.ready) ── -const platformInfo = detectPlatform(); -const advancedDefaults = { - removeUselessFeatures: true, - gpuRasterizing: false, - helpfulFlags: true, - increaseLimits: false, - lowLatency: false, - experimentalFlags: false, -}; -const advancedConfig = { ...advancedDefaults, ...config.get('advanced') }; -const perfConfig = { fpsUnlocked: true, ...config.get('performance') }; -applyPlatformFlags(platformInfo, advancedConfig, perfConfig); - -// ── App identity (must match electron-builder appId for taskbar pin persistence) ── -app.setAppUserModelId('com.krunkercivilian.client'); - -// ── Resource swapper protocol (must register before app.ready) ── -initSwapperProtocol(); - -// ── Ad-blocking URL patterns (matched in C++ layer, never hits JS for non-matches) ── -const BLOCKED_URL_PATTERNS = [ - '*://*.pollfish.com/*', - '*://www.paypalobjects.com/*', - '*://fran-cdn.frvr.com/*', - '*://c.amazon-adsystem.com/*', - '*://cdn.frvr.com/fran/*', - '*://cookiepro.com/*', - '*://*.cookiepro.com/*', - '*://www.googletagmanager.com/*', - '*://*.doubleclick.net/*', - '*://storage.googleapis.com/pollfish_production/*', - '*://coeus.frvr.com/*', - '*://apis.google.com/js/platform.js', - '*://imasdk.googleapis.com/*', -]; - -// ── CSS to hide ad containers ── -const HIDE_ADS_CSS = ` -.endAHolder, -#aHider, -#adCon, -#rightABox, -#aContainer, -#topRightAdHolder, -div#aContainer, -#braveWarning, -#topRightAdHolder { - display: none !important; -}`; - -// ── Consent dismiss script (polling only — NO MutationObserver on main frame) ── -const CONSENT_DISMISS_MAIN_JS = ` -(function dismissConsent() { - let attempts = 0; - const timer = setInterval(() => { - attempts++; - const btn = document.querySelector('.fc-cta-consent, [aria-label="Consent"], .css-47sehv'); - if (btn) { btn.click(); clearInterval(timer); } - if (attempts > 30) clearInterval(timer); - }, 500); -})();`; - -// ── Escape pointer lock fix ── -const ESCAPE_POINTERLOCK_FIX_JS = ` -document.addEventListener('keydown', function(e) { - if (e.key === 'Escape' && document.pointerLockElement) { - document.exitPointerLock(); - } -}, true);`; - -// ── Safe external URL opener (only http/https) ── -function safeOpenExternal(url: string): void { - try { - const parsed = new URL(url); - if (parsed.protocol === 'https:' || parsed.protocol === 'http:') { - shell.openExternal(url); - } - } catch { /* malformed URL — ignore */ } -} - -// ── Keybind matching ── -function matchesKeybind(input: { key: string; control: boolean; shift: boolean; alt: boolean }, bind: Keybind | undefined): boolean { - if (!bind) return false; - return input.key === bind.key - && input.control === bind.ctrl - && input.shift === bind.shift - && input.alt === bind.alt; -} - -// ── Cached keybinds (avoid re-reading electron-store on every keypress) ── -let cachedKeybinds: Record | null = null; - -function getKeybinds(): Record { - if (!cachedKeybinds) { - cachedKeybinds = { ...DEFAULT_KEYBINDS, ...config.get('keybinds') }; - } - return cachedKeybinds; -} - -// ── Debounced window state persistence ── -let saveTimer: ReturnType | null = null; - -function saveWindowState(win: BrowserWindow): void { - if (saveTimer) clearTimeout(saveTimer); - saveTimer = setTimeout(() => { - if (win.isDestroyed()) return; - const bounds = win.getBounds(); - config.set('window', { - width: bounds.width, - height: bounds.height, - x: bounds.x, - y: bounds.y, - maximized: win.isMaximized(), - fullscreen: win.isFullScreen(), - }); - }, 1000); -} - -app.whenReady().then(async () => { - electronLog.log('[KCC] App ready'); - - // ── Auto-update check (mandatory, Windows NSIS install only) ── - const isPortable = !!process.env.PORTABLE_EXECUTABLE_DIR; - const isAppImage = !!process.env.APPIMAGE; - const isDev = !app.isPackaged; - if (isDev || process.platform !== 'win32' || isPortable || isAppImage) { - electronLog.log('[KCC] Skipping auto-update (portable or non-Windows)'); - } else { - try { - electronLog.log('[KCC] Checking for updates...'); - const update = await checkForUpdate(appVersion); - if (update) { - electronLog.log(`[KCC] Update available: v${update.version}`); - const { window: updateWin, sendProgress } = showUpdateWindow(); - sendProgress(`Update available (v${update.version})`, 0); - - const tempDir = join(app.getPath('temp'), 'kcc-update'); - if (!existsSync(tempDir)) mkdirSync(tempDir, { recursive: true }); - const installerPath = join(tempDir, `KCC-${update.version}-Setup.exe`); - - let cancelled = false; - updateWin.on('closed', () => { cancelled = true; }); - - try { - await downloadUpdate(update.downloadUrl, installerPath, (pct) => { - if (!cancelled && !updateWin.isDestroyed()) { - sendProgress(`Downloading update... ${pct}%`, pct); - } - }, update.sha256); - - if (!cancelled) { - sendProgress('Installing update...', 100); - installUpdate(installerPath); - return; // app.quit() called by installUpdate - } - } catch (err) { - electronLog.error('[KCC] Update download failed:', err); - if (!updateWin.isDestroyed()) updateWin.close(); - } - } else { - electronLog.log('[KCC] No updates available'); - } - } catch (err) { - electronLog.error('[KCC] Update check failed:', err); - } - } - - await launchApp(); -}); - -async function launchApp(): Promise { - electronLog.log('[KCC] Starting initialization'); - - // ── Session: persistent partition + clean user-agent ── - const ses = session.fromPartition('persist:krunker'); - const rawUA = ses.getUserAgent(); - ses.setUserAgent(rawUA.replace(/\s*krunker-civilian-client\/\S+/i, '')); - - // ── Register swapper file protocol on this session ── - registerSwapperFileProtocol(ses); - - // ── Resource swapper ── - const swapperConfig = config.get('swapper'); - const swapDir = swapperConfig.path || join(app.getPath('userData'), 'Krunker Civilian Client', 'swapper'); - // Ensure swap subdirectories exist (themes/, backgrounds/) - for (const sub of ['themes', 'backgrounds']) { - const dir = join(swapDir, sub); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - } - - const swapper = swapperConfig.enabled ? new ResourceSwapper(swapDir) : null; - electronLog.log(`[KCC] Resource swapper: ${swapper ? 'enabled' : 'disabled'} (${swapDir})`); - - // ── Userscript manager ── - const usConfig = config.get('userscripts') || { enabled: true, path: '' }; - const usDir = usConfig.path || join(app.getPath('userData'), 'Krunker Civilian Client'); - const userscriptManager = usConfig.enabled ? new UserscriptManager(usDir) : null; - electronLog.log(`[KCC] Userscripts: ${userscriptManager ? 'enabled' : 'disabled'} (${usDir})`); - - // ── Ad blocking + resource swapper (single onBeforeRequest — Electron only allows one) ── - // The broad *://*.krunker.io/* pattern lets the swapper intercept any krunker asset. - // swapper.getRedirect() returns null before its async scan completes, so swapped - // resources simply pass through until the scan finishes — no re-registration needed. - const requestFilterUrls = swapper - ? [...BLOCKED_URL_PATTERNS, '*://*.krunker.io/*'] - : [...BLOCKED_URL_PATTERNS]; - - ses.webRequest.onBeforeRequest({ urls: requestFilterUrls }, (details, callback) => { - // Check swapper first — redirect matching assets to local files - if (swapper) { - const redirect = swapper.getRedirect(details.url); - if (redirect) return callback({ redirectURL: redirect }); - } - // Determine if this URL is a krunker.io request (matched by the broad swapper pattern) - // vs an ad-block pattern. krunker.io requests that weren't swapped pass through normally. - try { - if (new URL(details.url).hostname.endsWith('krunker.io')) return callback({}); - } catch { /* invalid URL — fall through to cancel */ } - // Matched an ad-block pattern — cancel it - callback({ cancel: true }); - }); - - if (swapper) { - swapper.waitForReady().then(() => { - electronLog.log(`[KCC] Swapper ready: ${swapper.patterns.length} pattern(s)`); - }); - } - - // ── CORS fix for swapped resources ── - if (swapper) { - ses.webRequest.onHeadersReceived(({ responseHeaders }, callback) => { - if (!responseHeaders) return callback({}); - for (const key in responseHeaders) { - const lowercase = key.toLowerCase(); - if (lowercase === 'access-control-allow-credentials' && responseHeaders[key][0] === 'true') { - return callback({ responseHeaders }); - } - if (lowercase === 'access-control-allow-origin') { - delete responseHeaders[key]; - break; - } - } - return callback({ - responseHeaders: { ...responseHeaders, 'access-control-allow-origin': ['*'] }, - }); - }); - } - - // ── Restore saved window bounds ── - const savedWindow = config.get('window'); - - const win = new BrowserWindow({ - width: savedWindow.width, - height: savedWindow.height, - x: savedWindow.x, - y: savedWindow.y, - frame: true, - backgroundColor: '#000000', - webPreferences: { - preload: join(__dirname, '..', 'preload', 'index.js'), - session: ses, - contextIsolation: false, - nodeIntegration: false, - sandbox: false, - spellcheck: false, - backgroundThrottling: false, - }, - }); - - if (savedWindow.fullscreen) win.setFullScreen(true); - else if (savedWindow.maximized) win.maximize(); - - // ── No application menu (prevents Escape/Alt interception) ── - Menu.setApplicationMenu(null); - - // ── Discord Rich Presence ── - let discordRpc: DiscordRPC | null = null; - { - const discordConf = config.get('discord') || { enabled: false }; - if (discordConf.enabled) { - discordRpc = new DiscordRPC(); - discordRpc.connect(); - electronLog.log('[KCC] Discord Rich Presence enabled'); - } - } - - // ── Process Priority (Windows only) ── - if (process.platform === 'win32') { - const PRIORITY_MAP: Record = { - 'High': -14, - 'Above Normal': -7, - 'Below Normal': 7, - 'Low': 19, - }; - const prioritySetting = config.get('performance')?.processPriority || 'Normal'; - const priorityVal = PRIORITY_MAP[prioritySetting]; - if (priorityVal !== undefined) { - try { os.setPriority(process.pid, priorityVal); } catch { /* ignore */ } - // Apply to child processes periodically - setInterval(() => { - for (const m of app.getAppMetrics()) { - if (m.pid !== process.pid) { - try { os.setPriority(m.pid, priorityVal); } catch { /* ignore */ } - } - } - }, 1000); - electronLog.log(`[KCC] Process priority set to ${prioritySetting}`); - } - } - - // ── CPU Throttling via Chrome DevTools Protocol ── - const throttledContents = new WeakSet(); - - function applyCpuThrottle(wc: Electron.WebContents, rate: number): void { - const clamped = Math.max(1, Math.min(3, rate)); - try { - if (!throttledContents.has(wc)) { - wc.debugger.attach('1.3'); - throttledContents.add(wc); - } - wc.debugger.sendCommand('Emulation.setCPUThrottlingRate', { rate: clamped }); - } catch { /* debugger may already be attached or detached */ } - } - - // ── Keybind capture lock (suppresses shortcuts while the keybind dialog is open) ── - let keybindCapturing = false; - ipcMain.on('keybind-capture', (_e, capturing: boolean) => { - keybindCapturing = capturing; - }); - - // ── Configurable keybinds via before-input-event ── - win.webContents.on('before-input-event', (event, input) => { - if (input.type !== 'keyDown') return; - if (keybindCapturing) return; - - const binds = getKeybinds(); - - if (matchesKeybind(input, binds.reload)) { - win.reload(); - event.preventDefault(); - } else if (matchesKeybind(input, binds.newMatch)) { - const mm = config.get('matchmaker'); - if (mm.enabled) { - win.webContents.send('matchmaker-find', { - ...mm, - acceptKey: binds.matchmakerAccept, - cancelKey: binds.matchmakerCancel, - }); - } else { - win.loadURL('https://krunker.io'); - } - event.preventDefault(); - } else if (matchesKeybind(input, binds.joinFromClipboard)) { - const text = clipboard.readText(); - try { const u = new URL(text); if (u.protocol === 'https:' && u.hostname.endsWith('krunker.io')) win.loadURL(text); } catch { /* ignore invalid URLs */ } - event.preventDefault(); - } else if (matchesKeybind(input, binds.copyGameLink)) { - clipboard.writeText(win.webContents.getURL()); - event.preventDefault(); - } else if (matchesKeybind(input, binds.devTools)) { - win.webContents.toggleDevTools(); - event.preventDefault(); - } else if (matchesKeybind(input, binds.matchmaker)) { - const mm = config.get('matchmaker'); - if (mm.enabled) { - win.webContents.send('matchmaker-find', { - ...mm, - acceptKey: binds.matchmakerAccept, - cancelKey: binds.matchmakerCancel, - }); - } else { - win.loadURL('https://krunker.io'); - } - event.preventDefault(); - } else if (matchesKeybind(input, binds.pauseChat)) { - win.webContents.send('toggle-chat-pause'); - event.preventDefault(); - } else if (matchesKeybind(input, binds.fullscreenToggle)) { - win.setFullScreen(!win.isFullScreen()); - event.preventDefault(); - } else if (input.key === 't' && input.control && !input.shift && !input.alt) { - tabManager.openTab('https://krunker.io/social.html'); - event.preventDefault(); - } else if (input.key === 'T' && input.control && input.shift && !input.alt) { - tabManager.reopenTab(); - event.preventDefault(); - } - }); - - // ── Window state persistence (debounced) ── - win.on('resize', () => saveWindowState(win)); - win.on('move', () => saveWindowState(win)); - win.on('maximize', () => saveWindowState(win)); - win.on('unmaximize', () => saveWindowState(win)); - win.on('enter-full-screen', () => saveWindowState(win)); - win.on('leave-full-screen', () => saveWindowState(win)); - - // ── URL classification ── - const GAME_PAGE_PATHS = ['/', '']; - function isGameURL(url: string): boolean { - try { - const parsed = new URL(url); - if (!parsed.hostname.includes('krunker.io')) return false; - return GAME_PAGE_PATHS.includes(parsed.pathname); - } catch { return false; } - } - - // ── Cached game config (invalidated on set-config writes to 'game') ── - const gameDefaults = { lastServer: '', socialTabBehaviour: 'New Window' }; - let cachedGameConf: typeof gameDefaults | null = null; - function getGameConf(): typeof gameDefaults { - if (!cachedGameConf) cachedGameConf = { ...gameDefaults, ...config.get('game') }; - return cachedGameConf; - } - - // ── Tab Manager ── - const preloadPath = join(__dirname, '..', 'preload', 'index.js'); - let tabMode: 'same' | 'new' = getGameConf().socialTabBehaviour === 'Same Window' ? 'same' : 'new'; - let tabManager = new TabManager( - win, ses, preloadPath, tabMode, isGameURL, - () => config.get('tabWindow'), - (state) => config.set('tabWindow', state), - ); - - // Intercept in-page navigation (e.g. window.location = '/social.html') - win.webContents.on('will-navigate', (event, url) => { - if (url.includes('krunker.io') && !isGameURL(url)) { - event.preventDefault(); - tabManager.openTab(url); - } - }); - - // Intercept target="_blank" / window.open links - win.webContents.setWindowOpenHandler(({ url }) => { - if (url.includes('krunker.io')) { - if (isGameURL(url)) { - win.loadURL(url); - } else { - setImmediate(() => tabManager.openTab(url)); - } - } else { - setImmediate(() => safeOpenExternal(url)); - } - return { action: 'deny' }; - }); - - // Right-click context menu on main window with "Open in New Tab" - win.webContents.on('context-menu', (_e, params) => { - if (!params.linkURL) return; - const items: Electron.MenuItemConstructorOptions[] = []; - if (params.linkURL.includes('krunker.io') && !isGameURL(params.linkURL)) { - items.push({ label: 'Open in New Tab', click: () => tabManager.openTab(params.linkURL) }); - } - items.push({ label: 'Copy Link', click: () => clipboard.writeText(params.linkURL) }); - if (!params.linkURL.includes('krunker.io')) { - items.push({ label: 'Open in Browser', click: () => safeOpenExternal(params.linkURL) }); - } - if (items.length) Menu.buildFromTemplate(items).popup(); - }); - - // ── Inject scripts after page loads ── - win.webContents.on('did-finish-load', () => { - electronLog.log(`[KCC] Page loaded: ${win.webContents.getURL()}`); - // Rescan swap directory so new/changed files are picked up on refresh - if (swapper) swapper.rescan().catch(() => {}); - - const cssInjections = [ - win.webContents.insertCSS(HIDE_ADS_CSS), - win.webContents.insertCSS(ALL_CLIENT_CSS), - ]; - - // Inject user CSS theme via