mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
* 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).
145 lines
4.7 KiB
TypeScript
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);
|
|
}
|
|
}
|