super-productivity/electron/grant-resolver.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

236 lines
7.8 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 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 });
}
});