super-productivity/electron/backup.ts
Johannes Millan b729fd941c
fix(electron): restrict loadBackupData IPC to the backup directory (#8206)
* test(e2e): retry supersync time-estimate dialog until input binds

The fill('10m') could fire its `input` event before the duration input's
value-accessor (an `input` HostListener) was wired, so the typed value
never reached the model and submit() saved an empty time — the panel
stayed "-/-" and toContainText('10m') timed out (scheduled run
27197862391, SuperSync 1/6). Retry the whole open→fill→submit cycle so a
fill that didn't stick is re-applied once the control binds.

* fix(electron): restrict loadBackupData IPC to the backup directory

The BACKUP_LOAD_DATA handler passed the renderer-supplied path straight
to readFileSync with no validation. Because plugin background scripts run
via `new Function` in the renderer, any installed plugin (or an XSS
payload in task content) could read arbitrary files through
window.ea.loadBackupData('/etc/shadow').

Constrain the path to BACKUP_DIR / BACKUP_DIR_WINSTORE via a new
isPathInsideDir guard. It uses path.relative (not a startsWith string
compare) so `..` traversal is collapsed and a name-prefixed sibling
directory (backups vs backups-evil) is not mistaken for a child. The only
legitimate caller already passes paths built from BACKUP_DIR, and
getBackupPath() is display-only, so backup restore is unaffected.

Adds electron/file-path-guard.test.cjs (node --test) covering traversal
escape, prefix-sibling, absolute-outside, and normalize-stays-inside.

Refs GHSA-x937-wf3j-88q3

* docs(electron): note lexical-only containment in file-path-guard

Clarify that isPathInsideDir does string-based containment (no fs.realpath),
so a symlink planted inside the dir is out of scope — it requires local
filesystem write access, outside this guard's renderer-input threat model.
Surfaced during multi-agent review of the GHSA-x937-wf3j-88q3 fix.

* test(electron): load guard via computed path for asar require check

The electron-smoke job runs tools/verify-electron-requires.js over the
packaged app.asar, which flags literal relative require() calls that can't
resolve in the package. The new guard test had require('./file-path-guard.ts'),
but .ts source is excluded from app.asar, so the check failed. Load the module
via a computed path (require(path.resolve(__dirname, ...))) — the same pattern
the other electron *.test.cjs files use; the scanner skips computed requires.
Also reworded the comment, which had reintroduced the literal pattern (the
scanner matches raw text, comments included).
2026-06-09 15:29:06 +02:00

145 lines
4.7 KiB
TypeScript

import { app, ipcMain, IpcMainEvent } from 'electron';
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
statSync,
unlinkSync,
writeFileSync,
} from 'fs';
import { IPC } from './shared-with-frontend/ipc-events.const';
import { LocalBackupMeta } from '../src/app/imex/local-backup/local-backup.model';
import * as path from 'path';
import { error, log } from 'electron-log/main';
import type { AppDataCompleteLegacy } from '../src/app/imex/sync/sync.model';
import type { AppDataComplete } from '../src/app/op-log/model/model-config';
import { getBackupTimestamp } from './shared-with-frontend/get-backup-timestamp';
import { isPathInsideDir } from './file-path-guard';
import {
DEFAULT_MAX_BACKUP_FILES,
selectBackupFilesToDelete,
} from './shared-with-frontend/backup-file-cleanup.util';
export const BACKUP_DIR = path.join(app.getPath('userData'), `backups`);
export const BACKUP_DIR_WINSTORE = BACKUP_DIR.replace(
'Roaming',
`Local\\Packages\\53707johannesjo.SuperProductivity_ch45amy23cdv6\\LocalCache\\Roaming`,
);
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
export function initBackupAdapter(): void {
console.log('Saving backups to', BACKUP_DIR);
log('Saving backups to', BACKUP_DIR);
// BACKUP
ipcMain.on(IPC.BACKUP, backupData);
// IS_BACKUP_AVAILABLE
ipcMain.handle(IPC.BACKUP_IS_AVAILABLE, (): LocalBackupMeta | false => {
if (!existsSync(BACKUP_DIR)) {
return false;
}
const files = readdirSync(BACKUP_DIR);
if (!files.length) {
return false;
}
const filesWithMeta: LocalBackupMeta[] = files.map(
(fileName: string): LocalBackupMeta => ({
name: fileName,
path: path.join(BACKUP_DIR, fileName),
folder: BACKUP_DIR,
created: statSync(path.join(BACKUP_DIR, fileName)).mtime.getTime(),
}),
);
filesWithMeta.sort((a: LocalBackupMeta, b: LocalBackupMeta) => a.created - b.created);
log(
'Avilable Backup Files: ',
filesWithMeta?.map && filesWithMeta.map((f) => f.path),
);
return filesWithMeta.reverse()[0];
});
// RESTORE_BACKUP
ipcMain.handle(IPC.BACKUP_LOAD_DATA, (ev, backupPath: string): string => {
// `backupPath` comes from the renderer, which runs untrusted plugin code,
// so it must be constrained to the backup directory. Otherwise any plugin
// (or XSS payload) could read arbitrary files via window.ea.loadBackupData.
// See GHSA-x937-wf3j-88q3. Both the regular and the Windows-Store backup
// dirs are accepted; the legitimate caller only ever passes paths built
// from BACKUP_DIR (see IPC.BACKUP_IS_AVAILABLE above).
if (
!isPathInsideDir(BACKUP_DIR, backupPath) &&
!isPathInsideDir(BACKUP_DIR_WINSTORE, backupPath)
) {
throw new Error('BACKUP_LOAD_DATA: refused path outside backup directory');
}
const resolved = path.resolve(backupPath);
log('Reading backup file: ', resolved);
return readFileSync(resolved, { encoding: 'utf8' });
});
}
interface BackupDataArgs {
data: AppDataCompleteLegacy | AppDataComplete;
maxBackupFiles?: number | null;
}
const isBackupDataArgs = (arg: unknown): arg is BackupDataArgs =>
!!arg &&
typeof arg === 'object' &&
'data' in arg &&
typeof (arg as { data?: unknown }).data === 'object';
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
function backupData(
ev: IpcMainEvent,
dataOrArgs: AppDataCompleteLegacy | BackupDataArgs,
): void {
if (!existsSync(BACKUP_DIR)) {
mkdirSync(BACKUP_DIR);
}
const filePath = `${BACKUP_DIR}/${getBackupTimestamp()}.json`;
const data = isBackupDataArgs(dataOrArgs) ? dataOrArgs.data : dataOrArgs;
const maxBackupFiles = isBackupDataArgs(dataOrArgs)
? dataOrArgs.maxBackupFiles
: DEFAULT_MAX_BACKUP_FILES;
try {
const backup = JSON.stringify(data);
writeFileSync(filePath, backup);
cleanupOldBackups(maxBackupFiles);
} catch (e) {
log('Error while backing up');
error(e);
}
}
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
function cleanupOldBackups(maxBackupFiles?: number | null): void {
if (!existsSync(BACKUP_DIR)) {
return;
}
try {
const files = readdirSync(BACKUP_DIR).filter((f) => f.endsWith('.json'));
const filesWithMtime = files.map((fileName) => {
const filePath = path.join(BACKUP_DIR, fileName);
return { fileName, filePath, mtime: statSync(filePath).mtime.getTime() };
});
for (const file of selectBackupFilesToDelete(filesWithMtime, maxBackupFiles)) {
try {
unlinkSync(file.filePath);
} catch (e) {
log(`Error deleting backup file ${file.fileName}`);
error(e);
}
}
} catch (e) {
log('Error during backup cleanup');
error(e);
}
}