super-productivity/electron/grant-resolver.ts
johannesjo 5cb83b41a0 feat(electron): add capability grant store for filesystem IPC
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.
2026-06-09 22:13:54 +02:00

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 };
};