From e99bb2a255909fbc1bf4c8c41b66ece9c84d0746 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:03:54 +0200 Subject: [PATCH] fix: restrict managed backup IPC senders --- main.js | 78 ++++++++++++++++++++------ tests/package-build-files.test.js | 91 +++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 16 deletions(-) diff --git a/main.js b/main.js index bf4b651..7bc49fb 100644 --- a/main.js +++ b/main.js @@ -4,6 +4,7 @@ const { app, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme, Tray, Menu, const { createStartupWindow, createStartupQuery } = require('./lib/startup-renderer'); nativeTheme.themeSource = 'dark'; const path = require('path'); +const { pathToFileURL } = require('node:url'); const fs = require('fs'); const ConfigStore = require('./lib/config-store'); const UploadManager = require('./lib/upload-manager'); @@ -168,6 +169,43 @@ async function waitForConfigStoreWrites() { await configStore.drainWrites(); } +const ONLINE_BACKUP_RENDERER_URL = pathToFileURL(path.join(__dirname, 'renderer', 'index.html')); + +function isExpectedOnlineBackupRendererUrl(value) { + try { + const candidate = new URL(value); + return candidate.protocol === ONLINE_BACKUP_RENDERER_URL.protocol + && candidate.username === ONLINE_BACKUP_RENDERER_URL.username + && candidate.password === ONLINE_BACKUP_RENDERER_URL.password + && candidate.host === ONLINE_BACKUP_RENDERER_URL.host + && candidate.pathname === ONLINE_BACKUP_RENDERER_URL.pathname + && candidate.hash === ''; + } catch { + return false; + } +} + +function isTrustedOnlineBackupIpcEvent(event) { + try { + if (!mainWindow || mainWindow.isDestroyed()) return false; + const webContents = mainWindow.webContents; + if (!webContents || webContents.isDestroyed()) return false; + const mainFrame = webContents.mainFrame; + if (!mainFrame || event?.sender !== webContents || event.senderFrame !== mainFrame) return false; + if (mainFrame.url !== webContents.getURL()) return false; + return isExpectedOnlineBackupRendererUrl(mainFrame.url); + } catch { + return false; + } +} + +function invokeTrustedOnlineBackupIpc(event, operation) { + if (!isTrustedOnlineBackupIpcEvent(event)) { + return { ok: false, error: 'Online-Sicherungsanfrage wurde abgewiesen' }; + } + return operation(); +} + function requireCanonicalOnlineBackupId(id) { if (typeof id !== 'string' || !/^[A-Za-z0-9_-]{22}$/.test(id)) { throw new Error('Online-Sicherungs-ID ist ungültig'); @@ -2806,25 +2844,33 @@ ipcMain.handle('import-backup', async (_event, legacyPassword) => { } }); -ipcMain.handle('online-backup:list-managed', () => onlineBackupManager.listManaged()); +ipcMain.handle('online-backup:list-managed', (event) => ( + invokeTrustedOnlineBackupIpc(event, () => onlineBackupManager.listManaged()) +)); -ipcMain.handle('online-backup:create-managed', () => onlineBackupManager.createManaged()); +ipcMain.handle('online-backup:create-managed', (event) => ( + invokeTrustedOnlineBackupIpc(event, () => onlineBackupManager.createManaged()) +)); -ipcMain.handle('online-backup:copy-managed', (_event, id) => { - try { - return onlineBackupManager.copyManaged(requireCanonicalOnlineBackupId(id)); - } catch (error) { - return { ok: false, error: error.message || String(error) }; - } -}); +ipcMain.handle('online-backup:copy-managed', (event, id) => ( + invokeTrustedOnlineBackupIpc(event, () => { + try { + return onlineBackupManager.copyManaged(requireCanonicalOnlineBackupId(id)); + } catch (error) { + return { ok: false, error: error.message || String(error) }; + } + }) +)); -ipcMain.handle('online-backup:delete-managed', (_event, id) => { - try { - return onlineBackupManager.deleteManaged(requireCanonicalOnlineBackupId(id)); - } catch (error) { - return { ok: false, error: error.message || String(error) }; - } -}); +ipcMain.handle('online-backup:delete-managed', (event, id) => ( + invokeTrustedOnlineBackupIpc(event, () => { + try { + return onlineBackupManager.deleteManaged(requireCanonicalOnlineBackupId(id)); + } catch (error) { + return { ok: false, error: error.message || String(error) }; + } + }) +)); ipcMain.handle('online-backup:restore', async (_event, key) => { try { diff --git a/tests/package-build-files.test.js b/tests/package-build-files.test.js index 6a4be5a..b4e0e9d 100644 --- a/tests/package-build-files.test.js +++ b/tests/package-build-files.test.js @@ -3,6 +3,8 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const Module = require('node:module'); const path = require('node:path'); +const vm = require('node:vm'); +const { pathToFileURL } = require('node:url'); const packageJson = require('../package.json'); const projectRoot = path.join(__dirname, '..'); @@ -34,6 +36,95 @@ test('exposes managed online backup operations through narrow IPC boundaries', ( assert.match(mainSource, /ipcMain\.handle\('online-backup:delete-managed',[\s\S]*?onlineBackupManager\.deleteManaged\(/u); }); +test('managed online backup handlers reject every sender outside the local main frame', async () => { + const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8'); + const boundaryStart = mainSource.indexOf('const ONLINE_BACKUP_RENDERER_URL'); + const boundaryEnd = mainSource.indexOf('\nfunction clearCloseFlushTimer', boundaryStart); + const handlersStart = mainSource.indexOf("ipcMain.handle('online-backup:list-managed'"); + const handlersEnd = mainSource.indexOf("\nipcMain.handle('online-backup:restore'", handlersStart); + assert.notEqual(boundaryStart, -1, 'central online backup sender boundary is missing'); + assert.notEqual(boundaryEnd, -1, 'central online backup sender boundary is incomplete'); + assert.notEqual(handlersStart, -1, 'managed online backup handlers are missing'); + assert.notEqual(handlersEnd, -1, 'managed online backup handler block is incomplete'); + + const expectedUrl = `${pathToFileURL(path.join(projectRoot, 'renderer', 'index.html')).href}?language=de&version=2.1.31`; + const state = { + frameUrl: expectedUrl, + currentUrl: expectedUrl, + windowDestroyed: false, + webContentsDestroyed: false + }; + const mainFrame = {}; + Object.defineProperty(mainFrame, 'url', { get: () => state.frameUrl }); + const webContents = { + mainFrame, + getURL: () => state.currentUrl, + isDestroyed: () => state.webContentsDestroyed + }; + const mainWindow = { + webContents, + isDestroyed: () => state.windowDestroyed + }; + const managerCalls = []; + const onlineBackupManager = { + listManaged: () => { managerCalls.push('list'); return { ok: true, entries: [] }; }, + createManaged: () => { managerCalls.push('create'); return { ok: true, entry: { id: 'AAAAAAAAAAAAAAAAAAAAAA' } }; }, + copyManaged: (id) => { managerCalls.push(`copy:${id}`); return { ok: true }; }, + deleteManaged: (id) => { managerCalls.push(`delete:${id}`); return { ok: true, removedId: id }; } + }; + const handlers = new Map(); + const context = { + Buffer, + URL, + __dirname: projectRoot, + ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) }, + mainWindow, + onlineBackupManager, + path, + pathToFileURL + }; + vm.runInNewContext( + `${mainSource.slice(boundaryStart, boundaryEnd)}\n${mainSource.slice(handlersStart, handlersEnd)}`, + context + ); + + const id = 'AAAAAAAAAAAAAAAAAAAAAA'; + const invokeAll = async (event) => Promise.all([ + handlers.get('online-backup:list-managed')(event), + handlers.get('online-backup:create-managed')(event), + handlers.get('online-backup:copy-managed')(event, id), + handlers.get('online-backup:delete-managed')(event, id) + ].map(result => Promise.resolve(result))); + const legitimateEvent = { sender: webContents, senderFrame: mainFrame }; + const legitimateResults = await invokeAll(legitimateEvent); + assert.deepEqual(managerCalls, ['list', 'create', `copy:${id}`, `delete:${id}`]); + assert.equal(legitimateResults.every(result => result.ok === true), true); + assert.equal(JSON.stringify(legitimateResults).includes('MHU2-'), false); + + const foreignSender = { mainFrame: { url: expectedUrl }, getURL: () => expectedUrl, isDestroyed: () => false }; + const cases = [ + ['foreign sender', { sender: foreignSender, senderFrame: foreignSender.mainFrame }, () => {}], + ['destroyed main window', legitimateEvent, () => { state.windowDestroyed = true; }], + ['destroyed sender', legitimateEvent, () => { state.webContentsDestroyed = true; }], + ['subframe', { sender: webContents, senderFrame: { url: expectedUrl } }, () => {}], + ['remote URL', legitimateEvent, () => { state.frameUrl = 'https://example.invalid/renderer/index.html'; state.currentUrl = state.frameUrl; }], + ['different local file', legitimateEvent, () => { state.frameUrl = pathToFileURL(path.join(projectRoot, 'renderer', 'drop-target.html')).href; state.currentUrl = state.frameUrl; }] + ]; + + for (const [name, event, arrange] of cases) { + state.frameUrl = expectedUrl; + state.currentUrl = expectedUrl; + state.windowDestroyed = false; + state.webContentsDestroyed = false; + managerCalls.length = 0; + arrange(); + const results = await invokeAll(event); + assert.equal(managerCalls.length, 0, `${name} reached the manager`); + assert.equal(results.every(result => result.ok === false), true, `${name} was not rejected`); + assert.equal(JSON.stringify(results).includes('MHU2-'), false, `${name} leaked a complete key`); + } +}); + test('afterPack brands the executable metadata shown by Windows', async () => { let editCall = null; const originalLoad = Module._load;