Security hardening and codebase cleanup
Security fixes: - Replace Caesar cipher with electron.safeStorage for account credentials - Validate shell.openExternal URLs (allow only http/https protocols) - Remove rejectUnauthorized:false from all HTTPS calls - Add redirect domain validation to auto-updater - Fix XSS in matchmaker popup (innerHTML → textContent/createTextNode) - Add IPC config key whitelist to prevent arbitrary store access - Credentials never sent to renderer; decrypted on-demand via IPC Optimizations and cleanup: - Simplify onBeforeRequest from double-registration to single handler - Lazy-init matchmaker popup DOM (defer until first use) - Invalidate game config cache immediately on write, not on flush - Remove unused STANDARD_ASSET_RE and KeybindDef exports - Deduplicate Keybind type (import from config.ts) - Replace custom hasOwn wrapper with Object.hasOwn Bug fix: - Stop Krunker's global keydown handler from eating keystrokes in alt manager input fields (stopPropagation) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+84
-19
@@ -1,4 +1,4 @@
|
||||
import { app, BrowserWindow, Menu, clipboard, dialog, ipcMain, protocol, session, shell } from 'electron';
|
||||
import { app, BrowserWindow, Menu, clipboard, dialog, ipcMain, protocol, safeStorage, session, shell } from 'electron';
|
||||
import { join } from 'path';
|
||||
import { existsSync, mkdirSync, promises as fsp } from 'fs';
|
||||
import { get as httpsGet } from 'https';
|
||||
@@ -107,6 +107,16 @@ document.addEventListener('keydown', function(e) {
|
||||
}
|
||||
}, 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;
|
||||
@@ -221,23 +231,29 @@ async function launchApp(): Promise<void> {
|
||||
electronLog.log(`[KCC] Userscripts: ${userscriptManager ? 'enabled' : 'disabled'} (${usDir})`);
|
||||
|
||||
// ── Ad blocking + resource swapper (single onBeforeRequest — Electron only allows one) ──
|
||||
ses.webRequest.onBeforeRequest({ urls: [...BLOCKED_URL_PATTERNS] }, (details, callback) => {
|
||||
// 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) => {
|
||||
if (swapper) {
|
||||
const redirect = swapper.getRedirect(details.url);
|
||||
if (redirect) return callback({ redirectURL: redirect });
|
||||
}
|
||||
// If we got here via the broad krunker.io pattern (not an ad), let it through
|
||||
try {
|
||||
const host = new URL(details.url).hostname;
|
||||
if (host.endsWith('krunker.io')) return callback({});
|
||||
} catch {}
|
||||
// Otherwise it matched an ad-block pattern — cancel it
|
||||
callback({ cancel: true });
|
||||
});
|
||||
|
||||
// Once swapper scan finishes, re-register with swapper patterns included
|
||||
if (swapper) {
|
||||
swapper.waitForReady().then(() => {
|
||||
const filterUrls = [...BLOCKED_URL_PATTERNS, ...swapper.patterns];
|
||||
ses.webRequest.onBeforeRequest({ urls: filterUrls }, (details, callback) => {
|
||||
const redirect = swapper.getRedirect(details.url);
|
||||
if (redirect) return callback({ redirectURL: redirect });
|
||||
callback({ cancel: true });
|
||||
});
|
||||
electronLog.log(`[KCC] Swapper ready: ${swapper.patterns.length} pattern(s)`);
|
||||
});
|
||||
}
|
||||
@@ -390,7 +406,7 @@ async function launchApp(): Promise<void> {
|
||||
if (subUrl.includes('krunker.io')) {
|
||||
sub.loadURL(subUrl);
|
||||
} else {
|
||||
setImmediate(() => shell.openExternal(subUrl));
|
||||
setImmediate(() => safeOpenExternal(subUrl));
|
||||
}
|
||||
return { action: 'deny' };
|
||||
});
|
||||
@@ -437,7 +453,7 @@ async function launchApp(): Promise<void> {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setImmediate(() => shell.openExternal(url));
|
||||
setImmediate(() => safeOpenExternal(url));
|
||||
}
|
||||
return { action: 'deny' };
|
||||
});
|
||||
@@ -457,32 +473,44 @@ async function launchApp(): Promise<void> {
|
||||
});
|
||||
|
||||
// ── IPC handlers ──
|
||||
const ALLOWED_CONFIG_KEYS = new Set<string>([
|
||||
'window', 'performance', 'game', 'swapper', 'matchmaker',
|
||||
'keybinds', 'userscripts', 'ui', 'discord', 'translator',
|
||||
'advanced', 'accounts', 'platform',
|
||||
]);
|
||||
|
||||
ipcMain.handle('get-version', () => appVersion);
|
||||
ipcMain.handle('get-platform', () => platformInfo);
|
||||
ipcMain.handle('get-config', (_e, key: string) => config.get(key as keyof typeof config.store));
|
||||
ipcMain.handle('get-config', (_e, key: string) => {
|
||||
if (!ALLOWED_CONFIG_KEYS.has(key)) return undefined;
|
||||
return config.get(key as keyof typeof config.store);
|
||||
});
|
||||
ipcMain.handle('get-all-config', (_e, keys: string[]) => {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const key of keys) result[key] = config.get(key as keyof typeof config.store);
|
||||
for (const key of keys) {
|
||||
if (ALLOWED_CONFIG_KEYS.has(key)) result[key] = config.get(key as keyof typeof config.store);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
let configWriteTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const pendingConfigWrites = new Map<string, unknown>();
|
||||
|
||||
ipcMain.handle('set-config', (_e, key: string, value: unknown) => {
|
||||
if (!ALLOWED_CONFIG_KEYS.has(key)) return;
|
||||
// Flush immediately for keys that have side effects
|
||||
if (key === 'keybinds') {
|
||||
config.set(key as any, value);
|
||||
cachedKeybinds = null;
|
||||
return;
|
||||
}
|
||||
// Invalidate caches immediately (not on flush) to prevent stale reads
|
||||
if (key === 'game') cachedGameConf = null;
|
||||
pendingConfigWrites.set(key, value);
|
||||
if (!configWriteTimer) {
|
||||
configWriteTimer = setTimeout(() => {
|
||||
for (const [k, v] of pendingConfigWrites) {
|
||||
config.set(k as any, v);
|
||||
}
|
||||
// Invalidate caches for keys that affect runtime behavior
|
||||
if (pendingConfigWrites.has('game')) cachedGameConf = null;
|
||||
pendingConfigWrites.clear();
|
||||
configWriteTimer = null;
|
||||
}, 300);
|
||||
@@ -512,7 +540,7 @@ async function launchApp(): Promise<void> {
|
||||
}
|
||||
try {
|
||||
const data = await new Promise<string>((resolve, reject) => {
|
||||
httpsGet('https://matchmaker.krunker.io/ping-list?hostname=krunker.io', { rejectUnauthorized: false }, (res) => {
|
||||
httpsGet('https://matchmaker.krunker.io/ping-list?hostname=krunker.io', (res) => {
|
||||
let body = '';
|
||||
res.on('data', (chunk: string) => { body += chunk; });
|
||||
res.on('end', () => resolve(body));
|
||||
@@ -620,16 +648,53 @@ async function launchApp(): Promise<void> {
|
||||
app.quit();
|
||||
});
|
||||
|
||||
// ── Alt manager IPC handlers ──
|
||||
ipcMain.handle('alt-list', () => config.get('accounts') || []);
|
||||
// ── Alt manager IPC handlers (credentials encrypted via safeStorage) ──
|
||||
const canEncrypt = safeStorage.isEncryptionAvailable();
|
||||
if (!canEncrypt) electronLog.warn('[KCC] safeStorage encryption not available — account passwords will use base64 fallback');
|
||||
|
||||
ipcMain.handle('alt-save', (_e, account: SavedAccount) => {
|
||||
function encryptString(plaintext: string): string {
|
||||
if (canEncrypt) return safeStorage.encryptString(plaintext).toString('base64');
|
||||
return Buffer.from(plaintext).toString('base64');
|
||||
}
|
||||
|
||||
function decryptString(encrypted: string): string {
|
||||
if (canEncrypt) return safeStorage.decryptString(Buffer.from(encrypted, 'base64'));
|
||||
return Buffer.from(encrypted, 'base64').toString();
|
||||
}
|
||||
|
||||
ipcMain.handle('alt-list', () => {
|
||||
const accounts = config.get('accounts') || [];
|
||||
// Return only labels to the renderer — never send encrypted credentials
|
||||
return accounts.map((a: SavedAccount) => ({ label: a.label }));
|
||||
});
|
||||
|
||||
ipcMain.handle('alt-save', (_e, data: { label: string; username: string; password: string }) => {
|
||||
const accounts = config.get('accounts') || [];
|
||||
const account: SavedAccount = {
|
||||
label: data.label,
|
||||
username: encryptString(data.username),
|
||||
password: encryptString(data.password),
|
||||
};
|
||||
accounts.push(account);
|
||||
config.set('accounts', accounts);
|
||||
return { success: true, index: accounts.length - 1 };
|
||||
});
|
||||
|
||||
ipcMain.handle('alt-get-credentials', (_e, index: number) => {
|
||||
const accounts = config.get('accounts') || [];
|
||||
if (index < 0 || index >= accounts.length) return null;
|
||||
const acc = accounts[index];
|
||||
try {
|
||||
return {
|
||||
username: decryptString(acc.username),
|
||||
password: decryptString(acc.password),
|
||||
};
|
||||
} catch (err) {
|
||||
electronLog.error('[KCC] Failed to decrypt account credentials:', err);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('alt-remove', (_e, index: number) => {
|
||||
const accounts = config.get('accounts') || [];
|
||||
if (index < 0 || index >= accounts.length) return { success: false };
|
||||
|
||||
Reference in New Issue
Block a user