mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
* fix(electron): fail-safe exec confirmation dialog (GHSA-256q) The EXEC confirmation was the only gate before an arbitrary shell command runs with the user's privileges, but it did not fail safe: - defaultId: 2 was out of range for a two-button dialog, leaving the focused default per-platform-undefined, so an accidental Enter could execute. Cancel is now defaultId + cancelId (Enter/Escape never runs). - 'Remember my answer' defaulted to checked, so one careless click could whitelist a command to the silent allow-list forever. It is now opt-in. Add electron/ipc-handlers/exec.test.cjs as a regression guard. * docs(plugins): correct misleading plugin sandboxing claims Docs claimed JS plugins run in 'isolated VM contexts' and iframes run 'without allow-same-origin' — both are false. JS plugins run in the host renderer via new Function, and iframes use allow-same-origin (required for #8467), so both can reach the privileged window.ea bridge. Align the docs with the code (plugin-iframe.util.ts) and stress the trust model. * fix(electron): fail closed on corrupt exec allow-list, cover error paths The EXEC handler is wired to ipcMain.on (fire-and-forget), so a throw on a corrupt allow-list surfaced as an unhandled promise rejection with no user feedback. Wrap the handler body in try/catch and route failures through errorHandlerWithFrontendInform (the same channel exec errors already use), failing closed so a corrupt store never falls through to executing. Expand the regression tests: assert the corrupt-config path informs the error and runs nothing, cover exec-error routing, and guard allow-list append (an overwrite regression that wipes remembered commands previously passed green). * docs(plugins): document window.ea.exec shell path in trust model The trust-model section implied executeNodeScript() was the only process path; on desktop window.ea.exec also runs arbitrary shell commands via child_process.exec behind a separate, weaker gate (confirmation dialog + persistent allow-list, not the nodeExecution consent). * test(electron): run exec security regression tests in CI The test:electron runner globs electron/*.test.cjs (non-recursive), so the guard at electron/ipc-handlers/exec.test.cjs was never executed by CI — verified: the suite went 160 -> 168 tests once discovered. Move it to electron/exec.test.cjs (matching every sibling electron test) and point execModulePath at ipc-handlers/exec.ts. * refactor(electron): narrow exec allow-list without an unsafe cast Drop the `as string[]` cast that asserted away the very corruption the Array.isArray guard is meant to catch; narrow the unknown value honestly so the guard is a real type-check. Behavior is unchanged (falsy -> empty list, truthy non-array -> fail closed). * fix(electron): stop logging exec command content Per CLAUDE.md rule 9 (log history is exportable, never log user content), a command can carry a secret in its arguments; log a content-free line instead. Also fold the duplicated exec-spawn into a single runCommand() helper so the allow-listed and just-confirmed paths share one audited call site. * fix(electron): remove exec IPC to close GHSA-256q at the root window.ea.exec exposed arbitrary shell (child_process.exec) to the whole renderer: JS plugins (new Function in the host realm), same-origin iframe plugins (window.parent.ea), and any renderer XSS — bypassing the per-plugin nodeExecution consent gate entirely. Its only consumer, COMMAND task attachments, is dormant: no UI creates them (the edit dialog offers only LINK/IMG/FILE). Rather than guard a dormant RCE primitive, remove it: delete the EXEC IPC handler + preload.exec + the ElectronAPI method + the directive's COMMAND branch. This closes the vector for plugins, iframes, AND host-realm XSS at once (a bootstrap handoff would only hide it from plugins), and supersedes the earlier dialog hardening (that primitive no longer exists). Keep the 'COMMAND' literal in the synced TaskAttachment type (removing a synced union member breaks typia validation on peers/legacy data); a click on a legacy COMMAND attachment now shows an informational snack instead of executing. * docs(plugins): reflect exec IPC removal in the trust model window.ea.exec no longer exists (removed to close GHSA-256q); the docs now state executeNodeScript is the only sanctioned native-code path. * test(electron): guard exec IPC stays removed (GHSA-256q) The interim exec security tests were deleted together with the executor, so the shipped fix had no regression coverage. Add electron/exec.test.cjs (picked up by the electron/*.test.cjs CI glob) asserting the bare exec primitive stays gone: no IPC.EXEC event, no exec.ts handler, no preload exec bridge, no ElectronAPI.exec method, no initExecIpc wiring. Targets only the removed surface, not the sanctioned PLUGIN_EXEC_NODE_SCRIPT/executeScript nodeExecution path. * chore(electron): mark ALLOWED_COMMANDS store key as legacy Its only reader/writer was the deleted exec handler (GHSA-256q). Document that it is retained purely so older persisted stores keep loading.
264 lines
7.3 KiB
TypeScript
264 lines
7.3 KiB
TypeScript
import { IpcRendererEvent } from 'electron';
|
|
import {
|
|
GlobalConfigState,
|
|
TakeABreakConfig,
|
|
TaskWidgetConfig,
|
|
} from '../src/app/features/config/global-config.model';
|
|
import { KeyboardConfig } from './shared-with-frontend/keyboard-config.model';
|
|
import { JiraCfg } from '../src/app/features/issue/providers/jira/jira.model';
|
|
import { AppDataCompleteLegacy } from '../src/app/imex/sync/sync.model';
|
|
import { Task } from '../src/app/features/tasks/task.model';
|
|
import { LocalBackupMeta } from '../src/app/imex/local-backup/local-backup.model';
|
|
import { AppDataComplete } from '../src/app/op-log/model/model-config';
|
|
import { PluginNodeExecutionElectronApi } from './shared-with-frontend/plugin-node-execution.model';
|
|
import {
|
|
LocalRestApiRequestPayload,
|
|
LocalRestApiResponsePayload,
|
|
} from './shared-with-frontend/local-rest-api.model';
|
|
import { ElectronDistChannel } from './shared-with-frontend/get-dist-channel';
|
|
|
|
export interface ElectronAPI {
|
|
on(
|
|
channel: string,
|
|
listener: (event: IpcRendererEvent, ...args: unknown[]) => void,
|
|
): void;
|
|
|
|
// SYNC
|
|
// ----
|
|
getDistChannel(): ElectronDistChannel | null;
|
|
|
|
// INVOKE
|
|
// ------
|
|
getUserDataPath(): Promise<string>;
|
|
|
|
getBackupPath(): Promise<string>;
|
|
|
|
checkBackupAvailable(): Promise<false | LocalBackupMeta>;
|
|
|
|
loadBackupData(backupPath: string): Promise<string>;
|
|
|
|
fileSyncSave(args: {
|
|
relativePath: string;
|
|
localRev: string | null;
|
|
dataStr: string;
|
|
}): Promise<string | Error>;
|
|
|
|
fileSyncLoad(args: {
|
|
relativePath: string;
|
|
localRev: string | null;
|
|
}): Promise<{ rev: string; dataStr: string | undefined } | Error>;
|
|
|
|
fileSyncRemove(args: { relativePath: string }): Promise<unknown | Error>;
|
|
|
|
fileSyncListFiles(args: { relativePath?: string }): Promise<string[] | Error>;
|
|
|
|
checkDirExists(args: { relativePath?: string }): Promise<true | Error>;
|
|
|
|
/**
|
|
* Opens the native folder picker for the sync folder. Resolves to:
|
|
* - `string`: the canonicalized, persisted folder path on success
|
|
* - `undefined`: the user cancelled the picker
|
|
* - `Error`: the pick succeeded but main could not canonicalize/persist it
|
|
* (e.g. the folder was deleted between pick and commit, EACCES, or the
|
|
* folder lives inside the app's private dir). Nothing is persisted in
|
|
* this case; the renderer must treat it as a failure, not a picked path.
|
|
*/
|
|
pickDirectory(): Promise<string | Error | undefined>;
|
|
|
|
/**
|
|
* Returns the main-owned sync folder path for display, or null if not yet
|
|
* configured. The renderer must not pass this value back to file-sync IPCs
|
|
* — those take only the relative path; main resolves against its own copy.
|
|
*/
|
|
getSyncFolderPath(): Promise<string | null>;
|
|
|
|
showOpenDialog(options: {
|
|
properties: string[];
|
|
title?: string;
|
|
defaultPath?: string;
|
|
filters?: { name: string; extensions: string[] }[];
|
|
}): Promise<string[] | undefined>;
|
|
|
|
/**
|
|
* Open the native image picker, copy the chosen file into the main-owned
|
|
* cache, and return an opaque id. The renderer never holds the absolute
|
|
* path. Returns null when the user cancels and a safe Error when the picked
|
|
* file fails validation/import. Old cached images are not deleted here,
|
|
* because the surrounding config save may still fail or be cancelled.
|
|
*/
|
|
imagePickAndImport(): Promise<{ id: string; mimeType: string } | null | Error>;
|
|
|
|
/**
|
|
* Resolve a cached image id to a `data:` URL the renderer can use as a
|
|
* CSS background. Returns null when the id is unknown or the file
|
|
* disappeared.
|
|
*/
|
|
imageCacheGetDataUrl(id: string): Promise<string | null>;
|
|
|
|
// checkDirExists(dirPath: string): Promise<true | Error>;
|
|
|
|
// STANDARD
|
|
// --------
|
|
setZoomFactor(zoomFactor: number): void;
|
|
|
|
getZoomFactor(): number;
|
|
|
|
openPath(path: string): void;
|
|
|
|
openExternalUrl(url: string): void;
|
|
|
|
saveFileDialog(
|
|
filename: string,
|
|
data: string,
|
|
): Promise<{ success: boolean; path?: string }>;
|
|
|
|
shareNative(payload: {
|
|
text?: string;
|
|
url?: string;
|
|
title?: string;
|
|
files?: string[];
|
|
}): Promise<{ success: boolean; error?: string }>;
|
|
|
|
isLinux(): boolean;
|
|
|
|
isGnomeDesktop(): boolean;
|
|
|
|
isGnomeWayland(): boolean;
|
|
|
|
isMacOS(): boolean;
|
|
|
|
isAppleSilicon(): boolean;
|
|
|
|
isSnap(): boolean;
|
|
|
|
isFlatpak(): boolean;
|
|
|
|
// CLIPBOARD IMAGES
|
|
// ----------------
|
|
saveClipboardImage(
|
|
basePath: string,
|
|
fileName: string,
|
|
base64Data: string,
|
|
mimeType: string,
|
|
): Promise<string>;
|
|
|
|
loadClipboardImage(
|
|
basePath: string,
|
|
imageId: string,
|
|
): Promise<{ base64: string; mimeType: string } | null>;
|
|
|
|
deleteClipboardImage(basePath: string, imageId: string): Promise<boolean>;
|
|
|
|
listClipboardImages(
|
|
basePath: string,
|
|
): Promise<{ id: string; mimeType: string; createdAt: number; size: number }[]>;
|
|
|
|
getClipboardImagePath(basePath: string, imageId: string): Promise<string | null>;
|
|
|
|
getClipboardFilePaths(): Promise<string[]>;
|
|
copyClipboardImageFile(
|
|
basePath: string,
|
|
filePath: string,
|
|
): Promise<{
|
|
id: string;
|
|
mimeType: string;
|
|
size: number;
|
|
createdAt: number;
|
|
} | null>;
|
|
|
|
readClipboardImage(basePath: string): Promise<{
|
|
id: string;
|
|
mimeType: string;
|
|
size: number;
|
|
createdAt: number;
|
|
} | null>;
|
|
|
|
getPathForFile(file: File): string | null;
|
|
|
|
// SEND
|
|
// ----
|
|
reloadMainWin(): void;
|
|
|
|
openDevTools(): void;
|
|
|
|
showEmojiPanel(): void;
|
|
|
|
relaunch(): void;
|
|
|
|
exit(exitCode: number): void;
|
|
|
|
shutdownNow(): void;
|
|
|
|
flashFrame(): void;
|
|
|
|
showOrFocus(): void;
|
|
|
|
lockScreen(): void;
|
|
|
|
informAboutAppReady(): void;
|
|
|
|
scheduleRegisterBeforeClose(id: string): void;
|
|
|
|
unscheduleRegisterBeforeClose(id: string): void;
|
|
|
|
setDoneRegisterBeforeClose(id: string): void;
|
|
|
|
setProgressBar(args: {
|
|
progress: number;
|
|
progressBarMode: 'normal' | 'pause' | 'none';
|
|
}): void;
|
|
|
|
sendAppSettingsToElectron(globalCfg: GlobalConfigState): void;
|
|
|
|
sendSettingsUpdate(globalCfg: GlobalConfigState): void;
|
|
|
|
updateTaskWidgetSettings(cfg: TaskWidgetConfig): void;
|
|
|
|
updateTitleBarDarkMode(isDarkMode: boolean): void;
|
|
|
|
registerGlobalShortcuts(keyboardConfig: KeyboardConfig): void;
|
|
|
|
showFullScreenBlocker(args: { msg?: string; takeABreakCfg: TakeABreakConfig }): void;
|
|
|
|
// TODO use invoke instead
|
|
makeJiraRequest(args: {
|
|
requestId: string;
|
|
url: string;
|
|
requestInit: RequestInit;
|
|
jiraCfg: JiraCfg;
|
|
}): void;
|
|
|
|
jiraSetupImgHeaders(args: { jiraCfg: JiraCfg }): void;
|
|
|
|
backupAppData(args: {
|
|
data: AppDataCompleteLegacy | AppDataComplete;
|
|
maxBackupFiles?: number | null;
|
|
}): void;
|
|
|
|
updateCurrentTask(
|
|
task: Task | null,
|
|
isPomodoroEnabled: boolean,
|
|
currentPomodoroSessionTime: number,
|
|
isFocusModeEnabled?: boolean,
|
|
currentFocusSessionTime?: number,
|
|
focusModeMode?: string,
|
|
);
|
|
|
|
updateTodayTasks(
|
|
tasks: { id: string; title: string; timeEstimate: number; timeSpent: number }[],
|
|
): void;
|
|
|
|
onSwitchTask(listener: (taskId: string) => void): void;
|
|
|
|
consumePluginNodeExecutionApi(): PluginNodeExecutionElectronApi | null;
|
|
|
|
// Plugin OAuth
|
|
pluginOAuthPrepare(port?: number): Promise<{ port: number }>;
|
|
pluginOAuthStart(url: string): void;
|
|
onPluginOAuthCb(
|
|
listener: (data: { code?: string; error?: string; state?: string }) => void,
|
|
): void;
|
|
|
|
onLocalRestApiRequest(listener: (payload: LocalRestApiRequestPayload) => void): void;
|
|
sendLocalRestApiResponse(payload: LocalRestApiResponsePayload): void;
|
|
}
|