super-productivity/electron/grant-store.test.cjs
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

210 lines
6.9 KiB
JavaScript

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