mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
Lays the groundwork for issue #8228: today the FILE_SYNC_* IPCs accept any renderer-supplied absolute path outside userData, which means any plugin or XSS in the renderer can read/write arbitrary user files (~/.ssh, /etc/passwd, etc.). The goal is to replace string-path authorization with capability-based grants: the renderer holds only an opaque grantId, the main process holds the canonical root, and FS ops take (grantId, relativePath). This commit adds the main-side primitives but does not wire them into any IPC yet: - grant-store: persisted, main-only, feature-scoped grants backed by simple-store under a new 'fileGrants' key. Opaque hex ids from crypto.randomBytes. Roots are canonicalized at create time, and revalidated at startup so a folder that was moved, deleted, or swapped for a symlink-elsewhere invalidates the grant cleanly. - grant-resolver: resolveGrantPath(grantId, relativePath, feature) returns the absolute path the IPC handler may touch. Enforces the feature scope match, rejects absolute paths and '..' traversal, and refuses symlinks. For not-yet-existing leaves it canonicalizes the deepest existing ancestor so a directory symlink mid-path cannot sneak a write outside the root. Errors are opaque (PathNotAllowedError) and never embed the offending path. Notes on tests: ts-node's transpile-only loader does not downlevel 'for...of map' iteration, so the store uses Array.from(map.entries()) where it iterates the cache. The existing electron *.test.cjs use the same loader and the same workaround was already implicit in their patterns. Phase 2 (switching FILE_SYNC_SAVE/LOAD/REMOVE/LIST_FILES + CHECK_DIR_EXISTS to grant-based, with re-confirm migration of the existing syncFolderPath) and Phase 3 (image picker copy-to-cache) follow in separate commits.
116 lines
4 KiB
TypeScript
116 lines
4 KiB
TypeScript
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { getGrant, type GrantFeature } from './grant-store';
|
|
|
|
/**
|
|
* Resolve a renderer-supplied (grantId, relativePath) pair to an absolute path
|
|
* the main process is allowed to touch.
|
|
*
|
|
* Layered defenses (any failure → opaque deny, no path leaked back):
|
|
* 1. Grant exists and matches the feature scope expected by the caller.
|
|
* (A 'background-image' grant must not be reusable in a sync IPC.)
|
|
* 2. `relativePath` is a string, not absolute, and does not traverse out of
|
|
* the grant root (`path.relative(root, joined)` must not start with '..').
|
|
* 3. The leaf (if it already exists) is not a symlink. v1 policy is "refuse
|
|
* symlinks" — simpler than O_NOFOLLOW-on-open and sufficient for the sync
|
|
* use case. The TOCTOU window between this check and the subsequent fs
|
|
* op is documented as a known limitation; replacing the path-based
|
|
* check with `open(O_NOFOLLOW)` is a follow-up.
|
|
* 4. The canonicalized resolved path is still inside the canonicalized root.
|
|
* Catches case-fold / 8.3-shortname aliases (existing concern, same
|
|
* reasoning as `electron/file-path-guard.ts`).
|
|
*/
|
|
|
|
const _denied = (): Error => {
|
|
const e = new Error('Path not allowed for this grant');
|
|
e.name = 'PathNotAllowedError';
|
|
delete (e as { stack?: string }).stack;
|
|
return e;
|
|
};
|
|
|
|
const _hasTraversal = (root: string, joined: string): boolean => {
|
|
const rel = path.relative(root, joined);
|
|
return rel === '' || rel.startsWith('..') || path.isAbsolute(rel);
|
|
};
|
|
|
|
export interface ResolvedGrantPath {
|
|
/** Absolute filesystem path the IPC handler may operate on. */
|
|
readonly absolutePath: string;
|
|
/** The granted root the path resolved under. Useful for error/log messages. */
|
|
readonly root: string;
|
|
}
|
|
|
|
/**
|
|
* Throws `PathNotAllowedError` if the (grantId, relativePath) pair does not
|
|
* resolve cleanly inside the grant root for `expectedFeature`. On success,
|
|
* returns the absolute path. The returned error never embeds path content.
|
|
*/
|
|
export const resolveGrantPath = (
|
|
grantId: string,
|
|
relativePath: string,
|
|
expectedFeature: GrantFeature,
|
|
): ResolvedGrantPath => {
|
|
if (typeof grantId !== 'string' || typeof relativePath !== 'string') {
|
|
throw _denied();
|
|
}
|
|
const grant = getGrant(grantId);
|
|
if (!grant || grant.feature !== expectedFeature) {
|
|
throw _denied();
|
|
}
|
|
if (path.isAbsolute(relativePath)) {
|
|
throw _denied();
|
|
}
|
|
|
|
const joined = path.resolve(grant.root, relativePath);
|
|
if (_hasTraversal(grant.root, joined)) {
|
|
throw _denied();
|
|
}
|
|
|
|
let leafLstat: fs.Stats | null = null;
|
|
try {
|
|
leafLstat = fs.lstatSync(joined);
|
|
} catch {
|
|
// Leaf may legitimately not exist yet (e.g. FILE_SYNC_SAVE target).
|
|
leafLstat = null;
|
|
}
|
|
if (leafLstat && leafLstat.isSymbolicLink()) {
|
|
throw _denied();
|
|
}
|
|
|
|
// Defense-in-depth: if the leaf exists, its canonical form must still resolve
|
|
// inside the canonical root. Catches case-fold/short-name aliases AND a
|
|
// symlinked intermediate directory (only the LEAF was lstat'd above).
|
|
if (leafLstat) {
|
|
let canonicalLeaf: string;
|
|
try {
|
|
canonicalLeaf = fs.realpathSync.native(joined);
|
|
} catch {
|
|
throw _denied();
|
|
}
|
|
if (_hasTraversal(grant.root, canonicalLeaf)) {
|
|
throw _denied();
|
|
}
|
|
} else {
|
|
// Leaf doesn't exist: check the deepest existing ancestor instead, so a
|
|
// symlinked directory in the middle of the path can't sneak the write
|
|
// outside the root.
|
|
let cursor = path.dirname(joined);
|
|
while (cursor !== path.dirname(cursor)) {
|
|
let realAncestor: string | null = null;
|
|
try {
|
|
realAncestor = fs.realpathSync.native(cursor);
|
|
} catch {
|
|
realAncestor = null;
|
|
}
|
|
if (realAncestor !== null) {
|
|
if (realAncestor !== grant.root && _hasTraversal(grant.root, realAncestor)) {
|
|
throw _denied();
|
|
}
|
|
break;
|
|
}
|
|
cursor = path.dirname(cursor);
|
|
}
|
|
}
|
|
|
|
return { absolutePath: joined, root: grant.root };
|
|
};
|