mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
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.
This commit is contained in:
parent
595e7cef2a
commit
5cb83b41a0
4 changed files with 729 additions and 0 deletions
236
electron/grant-resolver.test.cjs
Normal file
236
electron/grant-resolver.test.cjs
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { promises: fs } = require('node:fs');
|
||||
const fsModule = require('node:fs');
|
||||
const Module = require('node:module');
|
||||
|
||||
require('ts-node/register/transpile-only');
|
||||
|
||||
const originalModuleLoad = Module._load;
|
||||
|
||||
let userDataDir;
|
||||
|
||||
const simpleStoreModulePath = path.resolve(__dirname, 'simple-store.ts');
|
||||
const grantStoreModulePath = path.resolve(__dirname, 'grant-store.ts');
|
||||
const grantResolverModulePath = path.resolve(__dirname, 'grant-resolver.ts');
|
||||
|
||||
const installMocks = () => {
|
||||
Module._load = function patchedLoad(request, parent, isMain) {
|
||||
if (request === 'electron') {
|
||||
return {
|
||||
app: {
|
||||
getPath: () => userDataDir,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (request === 'electron-log/main') {
|
||||
return { log: () => {}, error: () => {} };
|
||||
}
|
||||
return originalModuleLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
};
|
||||
|
||||
const resetModules = () => {
|
||||
delete require.cache[simpleStoreModulePath];
|
||||
delete require.cache[grantStoreModulePath];
|
||||
delete require.cache[grantResolverModulePath];
|
||||
};
|
||||
|
||||
const loadModules = () => {
|
||||
resetModules();
|
||||
return {
|
||||
grantStore: require(grantStoreModulePath),
|
||||
resolver: require(grantResolverModulePath),
|
||||
};
|
||||
};
|
||||
|
||||
const setupGrant = async (grantStore, syncDir, feature = 'sync') => {
|
||||
await grantStore.revalidateGrants();
|
||||
const grant = await grantStore.createGrant(feature, syncDir);
|
||||
assert.ok(grant);
|
||||
return grant;
|
||||
};
|
||||
|
||||
test.beforeEach(async () => {
|
||||
userDataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-grant-resolver-'));
|
||||
installMocks();
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
Module._load = originalModuleLoad;
|
||||
resetModules();
|
||||
await fs.rm(userDataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('resolves a relative file path inside the grant root', async () => {
|
||||
const { grantStore, resolver } = loadModules();
|
||||
const syncDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-sync-'));
|
||||
try {
|
||||
const grant = await setupGrant(grantStore, syncDir);
|
||||
const result = resolver.resolveGrantPath(grant.id, 'main.json', 'sync');
|
||||
assert.equal(
|
||||
result.absolutePath,
|
||||
path.join(fsModule.realpathSync.native(syncDir), 'main.json'),
|
||||
);
|
||||
assert.equal(result.root, fsModule.realpathSync.native(syncDir));
|
||||
} finally {
|
||||
await fs.rm(syncDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects absolute renderer-supplied paths', async () => {
|
||||
const { grantStore, resolver } = loadModules();
|
||||
const syncDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-sync-'));
|
||||
try {
|
||||
const grant = await setupGrant(grantStore, syncDir);
|
||||
assert.throws(
|
||||
() => resolver.resolveGrantPath(grant.id, '/etc/passwd', 'sync'),
|
||||
/Path not allowed/,
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(syncDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects .. traversal that escapes the grant root', async () => {
|
||||
const { grantStore, resolver } = loadModules();
|
||||
const syncDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-sync-'));
|
||||
try {
|
||||
const grant = await setupGrant(grantStore, syncDir);
|
||||
assert.throws(
|
||||
() => resolver.resolveGrantPath(grant.id, '../escape', 'sync'),
|
||||
/Path not allowed/,
|
||||
);
|
||||
assert.throws(
|
||||
() => resolver.resolveGrantPath(grant.id, 'a/../../b', 'sync'),
|
||||
/Path not allowed/,
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(syncDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects an unknown grantId', async () => {
|
||||
const { grantStore, resolver } = loadModules();
|
||||
await grantStore.revalidateGrants();
|
||||
assert.throws(
|
||||
() => resolver.resolveGrantPath('deadbeef', 'main.json', 'sync'),
|
||||
/Path not allowed/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a grant whose feature does not match the expected scope', async () => {
|
||||
const { grantStore, resolver } = loadModules();
|
||||
const syncDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-sync-'));
|
||||
try {
|
||||
const grant = await setupGrant(grantStore, syncDir, 'sync');
|
||||
// A future 'background-image' grant must not be reusable in a 'sync' IPC.
|
||||
// We can't construct one yet (only 'sync' is defined), so simulate by
|
||||
// asking the resolver for the wrong feature via a forged TS-side cast.
|
||||
assert.throws(
|
||||
() => resolver.resolveGrantPath(grant.id, 'main.json', 'background-image'),
|
||||
/Path not allowed/,
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(syncDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects a leaf that is a symlink (even pointing inside the root)', async () => {
|
||||
const { grantStore, resolver } = loadModules();
|
||||
const syncDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-sync-'));
|
||||
try {
|
||||
await fs.writeFile(path.join(syncDir, 'real.json'), '{}', 'utf8');
|
||||
await fs.symlink(
|
||||
path.join(syncDir, 'real.json'),
|
||||
path.join(syncDir, 'link.json'),
|
||||
'file',
|
||||
);
|
||||
const grant = await setupGrant(grantStore, syncDir);
|
||||
assert.throws(
|
||||
() => resolver.resolveGrantPath(grant.id, 'link.json', 'sync'),
|
||||
/Path not allowed/,
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(syncDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects writing under a directory symlink that escapes the root', async () => {
|
||||
const { grantStore, resolver } = loadModules();
|
||||
const syncDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-sync-'));
|
||||
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-outside-'));
|
||||
try {
|
||||
await fs.symlink(outsideDir, path.join(syncDir, 'escape'), 'dir');
|
||||
const grant = await setupGrant(grantStore, syncDir);
|
||||
// 'escape/new.json' lexically lives inside the root, but writing there
|
||||
// would land in outsideDir. Ancestor canonicalization catches this.
|
||||
assert.throws(
|
||||
() => resolver.resolveGrantPath(grant.id, 'escape/new.json', 'sync'),
|
||||
/Path not allowed/,
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(syncDir, { recursive: true, force: true });
|
||||
await fs.rm(outsideDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('allows writing under a directory symlink that stays inside the root', async () => {
|
||||
const { grantStore, resolver } = loadModules();
|
||||
const syncDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-sync-'));
|
||||
try {
|
||||
await fs.mkdir(path.join(syncDir, 'real-sub'), { recursive: true });
|
||||
await fs.symlink(path.join(syncDir, 'real-sub'), path.join(syncDir, 'sub'), 'dir');
|
||||
const grant = await setupGrant(grantStore, syncDir);
|
||||
const result = resolver.resolveGrantPath(grant.id, 'sub/new.json', 'sync');
|
||||
assert.equal(
|
||||
result.absolutePath,
|
||||
path.join(fsModule.realpathSync.native(syncDir), 'sub', 'new.json'),
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(syncDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects non-string inputs (fail-closed)', async () => {
|
||||
const { grantStore, resolver } = loadModules();
|
||||
const syncDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-sync-'));
|
||||
try {
|
||||
const grant = await setupGrant(grantStore, syncDir);
|
||||
assert.throws(
|
||||
() => resolver.resolveGrantPath(grant.id, undefined, 'sync'),
|
||||
/Path not allowed/,
|
||||
);
|
||||
assert.throws(
|
||||
() => resolver.resolveGrantPath(undefined, 'main.json', 'sync'),
|
||||
/Path not allowed/,
|
||||
);
|
||||
assert.throws(
|
||||
() => resolver.resolveGrantPath(grant.id, 42, 'sync'),
|
||||
/Path not allowed/,
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(syncDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('error never includes the offending path', async () => {
|
||||
const { grantStore, resolver } = loadModules();
|
||||
const syncDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-sync-'));
|
||||
try {
|
||||
const grant = await setupGrant(grantStore, syncDir);
|
||||
let captured;
|
||||
try {
|
||||
resolver.resolveGrantPath(grant.id, '/etc/passwd', 'sync');
|
||||
} catch (e) {
|
||||
captured = e;
|
||||
}
|
||||
assert.ok(captured);
|
||||
assert.ok(!String(captured.message).includes('/etc/passwd'));
|
||||
assert.equal(captured.name, 'PathNotAllowedError');
|
||||
} finally {
|
||||
await fs.rm(syncDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
116
electron/grant-resolver.ts
Normal file
116
electron/grant-resolver.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
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 };
|
||||
};
|
||||
210
electron/grant-store.test.cjs
Normal file
210
electron/grant-store.test.cjs
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { promises: fs } = require('node:fs');
|
||||
const fsModule = require('node:fs');
|
||||
const Module = require('node:module');
|
||||
|
||||
require('ts-node/register/transpile-only');
|
||||
|
||||
const originalModuleLoad = Module._load;
|
||||
|
||||
let userDataDir;
|
||||
|
||||
const simpleStoreModulePath = path.resolve(__dirname, 'simple-store.ts');
|
||||
const grantStoreModulePath = path.resolve(__dirname, 'grant-store.ts');
|
||||
|
||||
const installMocks = () => {
|
||||
Module._load = function patchedLoad(request, parent, isMain) {
|
||||
if (request === 'electron') {
|
||||
return {
|
||||
app: {
|
||||
getPath: (key) => {
|
||||
assert.equal(key, 'userData');
|
||||
return userDataDir;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (request === 'electron-log/main') {
|
||||
return {
|
||||
log: () => {},
|
||||
error: () => {},
|
||||
};
|
||||
}
|
||||
return originalModuleLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
};
|
||||
|
||||
const resetModules = () => {
|
||||
delete require.cache[simpleStoreModulePath];
|
||||
delete require.cache[grantStoreModulePath];
|
||||
};
|
||||
|
||||
const loadGrantStore = () => {
|
||||
resetModules();
|
||||
return require(grantStoreModulePath);
|
||||
};
|
||||
|
||||
test.beforeEach(async () => {
|
||||
userDataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-grant-store-'));
|
||||
installMocks();
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
Module._load = originalModuleLoad;
|
||||
resetModules();
|
||||
await fs.rm(userDataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('createGrant canonicalizes the path and persists the grant', async () => {
|
||||
const grantStore = loadGrantStore();
|
||||
const syncDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-sync-'));
|
||||
try {
|
||||
await grantStore.revalidateGrants();
|
||||
const grant = await grantStore.createGrant('sync', syncDir);
|
||||
assert.ok(grant, 'grant should be created');
|
||||
assert.equal(grant.feature, 'sync');
|
||||
assert.equal(grant.root, fsModule.realpathSync.native(syncDir));
|
||||
assert.match(grant.id, /^[a-f0-9]{32}$/);
|
||||
|
||||
// Reload from disk: grant survives.
|
||||
grantStore.__resetGrantCacheForTests();
|
||||
await grantStore.revalidateGrants();
|
||||
const reloaded = grantStore.getGrant(grant.id);
|
||||
assert.ok(reloaded);
|
||||
assert.equal(reloaded.root, grant.root);
|
||||
} finally {
|
||||
await fs.rm(syncDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('createGrant returns null for a non-existent path', async () => {
|
||||
const grantStore = loadGrantStore();
|
||||
await grantStore.revalidateGrants();
|
||||
const grant = await grantStore.createGrant(
|
||||
'sync',
|
||||
path.join(userDataDir, 'does-not-exist'),
|
||||
);
|
||||
assert.equal(grant, null);
|
||||
});
|
||||
|
||||
test('getGrant returns null for an unknown id (renderer cannot guess)', async () => {
|
||||
const grantStore = loadGrantStore();
|
||||
await grantStore.revalidateGrants();
|
||||
assert.equal(grantStore.getGrant('deadbeef'), null);
|
||||
assert.equal(grantStore.getGrant(''), null);
|
||||
// Non-string defends against renderer-supplied bad input round-tripping JSON.
|
||||
assert.equal(grantStore.getGrant(12345), null);
|
||||
});
|
||||
|
||||
test('revokeGrant removes the grant and persists the removal', async () => {
|
||||
const grantStore = loadGrantStore();
|
||||
const syncDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-sync-'));
|
||||
try {
|
||||
await grantStore.revalidateGrants();
|
||||
const grant = await grantStore.createGrant('sync', syncDir);
|
||||
assert.ok(grant);
|
||||
await grantStore.revokeGrant(grant.id);
|
||||
assert.equal(grantStore.getGrant(grant.id), null);
|
||||
|
||||
grantStore.__resetGrantCacheForTests();
|
||||
await grantStore.revalidateGrants();
|
||||
assert.equal(grantStore.getGrant(grant.id), null);
|
||||
} finally {
|
||||
await fs.rm(syncDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('revalidateGrants drops grants whose root no longer exists', async () => {
|
||||
const grantStore = loadGrantStore();
|
||||
const syncDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-sync-'));
|
||||
await grantStore.revalidateGrants();
|
||||
const grant = await grantStore.createGrant('sync', syncDir);
|
||||
assert.ok(grant);
|
||||
|
||||
await fs.rm(syncDir, { recursive: true, force: true });
|
||||
|
||||
grantStore.__resetGrantCacheForTests();
|
||||
await grantStore.revalidateGrants();
|
||||
assert.equal(grantStore.getGrant(grant.id), null);
|
||||
});
|
||||
|
||||
test('revalidateGrants drops grants whose root canonical path changed', async () => {
|
||||
const grantStore = loadGrantStore();
|
||||
const originalDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-sync-'));
|
||||
const replacementDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-other-'));
|
||||
try {
|
||||
await grantStore.revalidateGrants();
|
||||
const grant = await grantStore.createGrant('sync', originalDir);
|
||||
assert.ok(grant);
|
||||
|
||||
// Replace the original directory with a symlink to a different real dir.
|
||||
await fs.rm(originalDir, { recursive: true, force: true });
|
||||
await fs.symlink(replacementDir, originalDir, 'dir');
|
||||
|
||||
grantStore.__resetGrantCacheForTests();
|
||||
await grantStore.revalidateGrants();
|
||||
// Canonical root changed (symlink resolves elsewhere) → grant invalidated.
|
||||
assert.equal(grantStore.getGrant(grant.id), null);
|
||||
} finally {
|
||||
await fs.rm(originalDir, { recursive: true, force: true }).catch(() => {});
|
||||
await fs.rm(replacementDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('listGrantsByFeature returns only matching grants', async () => {
|
||||
const grantStore = loadGrantStore();
|
||||
const a = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-sync-a-'));
|
||||
const b = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-sync-b-'));
|
||||
try {
|
||||
await grantStore.revalidateGrants();
|
||||
const ga = await grantStore.createGrant('sync', a);
|
||||
const gb = await grantStore.createGrant('sync', b);
|
||||
assert.ok(ga && gb);
|
||||
const list = grantStore.listGrantsByFeature('sync');
|
||||
assert.equal(list.length, 2);
|
||||
} finally {
|
||||
await fs.rm(a, { recursive: true, force: true });
|
||||
await fs.rm(b, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('persisted store with corrupt grant entries is filtered, not crashed on', async () => {
|
||||
const grantStore = loadGrantStore();
|
||||
await grantStore.revalidateGrants();
|
||||
// Forge a corrupt simpleSettings file mixing a valid-shape grant with junk.
|
||||
const validRoot = fsModule.realpathSync.native(
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), 'sp-sync-')),
|
||||
);
|
||||
try {
|
||||
const corrupt = {
|
||||
fileGrants: {
|
||||
grants: [
|
||||
{
|
||||
id: 'abc',
|
||||
feature: 'sync',
|
||||
root: validRoot,
|
||||
createdAt: 1,
|
||||
},
|
||||
{ id: 'bad', feature: 'unknown', root: '/x', createdAt: 1 },
|
||||
null,
|
||||
'not-an-object',
|
||||
{ id: '', feature: 'sync', root: validRoot, createdAt: 1 },
|
||||
],
|
||||
},
|
||||
};
|
||||
await fs.writeFile(
|
||||
path.join(userDataDir, 'simpleSettings'),
|
||||
JSON.stringify(corrupt),
|
||||
'utf8',
|
||||
);
|
||||
grantStore.__resetGrantCacheForTests();
|
||||
await grantStore.revalidateGrants();
|
||||
assert.ok(grantStore.getGrant('abc'));
|
||||
assert.equal(grantStore.getGrant('bad'), null);
|
||||
} finally {
|
||||
await fs.rm(validRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
167
electron/grant-store.ts
Normal file
167
electron/grant-store.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import { randomBytes } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import { loadSimpleStoreAll, saveSimpleStore } from './simple-store';
|
||||
|
||||
/**
|
||||
* Main-owned, persisted capability grants for filesystem access.
|
||||
*
|
||||
* Background: the renderer (and any plugin/XSS running inside it) can ask the
|
||||
* main process to read/write files via the FILE_SYNC_* IPCs. Before grants
|
||||
* those IPCs accepted any absolute path outside `userData` — i.e. nearly the
|
||||
* whole filesystem. A grant narrows that authority to a specific user-approved
|
||||
* root directory and a specific feature (e.g. 'sync').
|
||||
*
|
||||
* The renderer only ever sees the opaque `id`; the canonicalized `root` never
|
||||
* leaves the main process. Grants live in `simple-store` (inside userData),
|
||||
* which is itself protected from renderer writes by `assertPathOutside`.
|
||||
*/
|
||||
|
||||
export type GrantFeature = 'sync';
|
||||
|
||||
export interface FileGrant {
|
||||
readonly id: string;
|
||||
readonly feature: GrantFeature;
|
||||
/**
|
||||
* Canonicalized absolute path (resolved via `fs.realpathSync.native` at
|
||||
* creation time so symlink/case-fold aliases cannot be slipped past the
|
||||
* containment check at resolve time).
|
||||
*/
|
||||
readonly root: string;
|
||||
readonly createdAt: number;
|
||||
}
|
||||
|
||||
const SIMPLE_STORE_KEY = 'fileGrants';
|
||||
|
||||
interface PersistedGrants {
|
||||
readonly grants: readonly FileGrant[];
|
||||
}
|
||||
|
||||
const GRANT_ID_BYTES = 16;
|
||||
|
||||
let _cache: Map<string, FileGrant> | null = null;
|
||||
let _loadOnce: Promise<void> | null = null;
|
||||
|
||||
const _isFileGrant = (v: unknown): v is FileGrant => {
|
||||
if (typeof v !== 'object' || v === null) return false;
|
||||
const g = v as Record<string, unknown>;
|
||||
return (
|
||||
typeof g.id === 'string' &&
|
||||
g.id.length > 0 &&
|
||||
g.feature === 'sync' &&
|
||||
typeof g.root === 'string' &&
|
||||
g.root.length > 0 &&
|
||||
typeof g.createdAt === 'number'
|
||||
);
|
||||
};
|
||||
|
||||
const _loadIntoCache = async (): Promise<void> => {
|
||||
if (_loadOnce) return _loadOnce;
|
||||
_loadOnce = (async () => {
|
||||
const all = await loadSimpleStoreAll();
|
||||
const raw = all[SIMPLE_STORE_KEY] as PersistedGrants | undefined;
|
||||
const list = Array.isArray(raw?.grants) ? raw!.grants.filter(_isFileGrant) : [];
|
||||
_cache = new Map(list.map((g) => [g.id, g]));
|
||||
})();
|
||||
return _loadOnce;
|
||||
};
|
||||
|
||||
const _persist = async (): Promise<void> => {
|
||||
const grants = Array.from((_cache ?? new Map<string, FileGrant>()).values());
|
||||
await saveSimpleStore(SIMPLE_STORE_KEY, { grants } satisfies PersistedGrants);
|
||||
};
|
||||
|
||||
/**
|
||||
* Drop grants whose root no longer resolves to the same canonical path it had
|
||||
* at grant time (folder moved, deleted, replaced by a symlink to elsewhere).
|
||||
* Called at startup; invalid grants force the user back through the picker.
|
||||
*/
|
||||
export const revalidateGrants = async (): Promise<void> => {
|
||||
await _loadIntoCache();
|
||||
if (!_cache) return;
|
||||
const toRemove: string[] = [];
|
||||
// Array.from() instead of `for...of map` because ts-node's transpile-only
|
||||
// loader (used by *.test.cjs) does not downlevel Map iteration.
|
||||
for (const [id, grant] of Array.from(_cache.entries())) {
|
||||
let stillValid = false;
|
||||
try {
|
||||
const real = fs.realpathSync.native(grant.root);
|
||||
stillValid = real === grant.root;
|
||||
} catch {
|
||||
stillValid = false;
|
||||
}
|
||||
if (!stillValid) toRemove.push(id);
|
||||
}
|
||||
if (toRemove.length === 0) return;
|
||||
for (const id of toRemove) _cache.delete(id);
|
||||
await _persist();
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a grant for `absolutePath`. Caller must have proven user intent
|
||||
* (e.g. the path came from a main-process file dialog). The path is
|
||||
* canonicalized; if it does not exist or canonicalization fails, the grant
|
||||
* is not created and `null` is returned.
|
||||
*/
|
||||
export const createGrant = async (
|
||||
feature: GrantFeature,
|
||||
absolutePath: string,
|
||||
): Promise<FileGrant | null> => {
|
||||
await _loadIntoCache();
|
||||
if (!_cache) return null;
|
||||
let canonical: string;
|
||||
try {
|
||||
canonical = fs.realpathSync.native(absolutePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const grant: FileGrant = {
|
||||
id: randomBytes(GRANT_ID_BYTES).toString('hex'),
|
||||
feature,
|
||||
root: canonical,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
_cache.set(grant.id, grant);
|
||||
await _persist();
|
||||
return grant;
|
||||
};
|
||||
|
||||
/**
|
||||
* Look up a grant by id. Returns null when the id is unknown — callers MUST
|
||||
* treat null as a deny, never as "no constraint".
|
||||
*/
|
||||
export const getGrant = (id: string): FileGrant | null => {
|
||||
if (!_cache) {
|
||||
throw new Error('grant-store: revalidateGrants() must be called before getGrant()');
|
||||
}
|
||||
if (typeof id !== 'string' || id.length === 0) return null;
|
||||
return _cache.get(id) ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Drop a grant by id. No-op if unknown.
|
||||
*/
|
||||
export const revokeGrant = async (id: string): Promise<void> => {
|
||||
await _loadIntoCache();
|
||||
if (!_cache) return;
|
||||
if (!_cache.delete(id)) return;
|
||||
await _persist();
|
||||
};
|
||||
|
||||
/**
|
||||
* Return all grants for a given feature. Used by callers that need to
|
||||
* surface "current sync folder" in the UI without re-prompting the picker.
|
||||
*/
|
||||
export const listGrantsByFeature = (feature: GrantFeature): readonly FileGrant[] => {
|
||||
if (!_cache) {
|
||||
throw new Error(
|
||||
'grant-store: revalidateGrants() must be called before listGrantsByFeature()',
|
||||
);
|
||||
}
|
||||
return Array.from(_cache.values()).filter((g) => g.feature === feature);
|
||||
};
|
||||
|
||||
/** Test-only: clear in-memory cache so a fresh `revalidateGrants()` re-reads disk. */
|
||||
export const __resetGrantCacheForTests = (): void => {
|
||||
_cache = null;
|
||||
_loadOnce = null;
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue