super-productivity/electron/plugin-node-consent-store.test.cjs
Johannes Millan 7182ff4fba
feat(plugins): persist nodeExecution consent per plugin (#8512) (#8600)
* feat(plugins): persist nodeExecution consent per plugin (#8512)

Phase 2 of #8512: remember an uploaded plugin's nodeExecution consent so
it is asked once, not every app session, while keeping the trust decision
local to the device.

- Main-owned, local-only store (electron/plugin-node-consent-store.ts,
  wrapping simple-store under key 'pluginNodeExecutionConsent'); never
  pfapi-synced, so a grant on one device never auto-grants on another.
- Ask-once is scoped to UPLOADED plugins; built-in plugins (sync-md) keep
  the per-session verified prompt unchanged (regression-safe).
- Consent is written only after a native Allow in main; the renderer has a
  delete-only clearConsent IPC (fail-safe) and no way to self-grant.
- Cleared on disable / uninstall / re-upload (never in generic teardown),
  so revoke = the existing disable toggle and changed code re-consents.
- No code hash: re-ask-on-change is structural (re-upload clears consent);
  a renderer hash would be forgeable and security-worthless. version:1 is
  the migration anchor if main-owned hashing is ever added.

Tests: electron 154 pass (consent-store + executor ask-once/deny/clear/
built-in-never-persisted); 474 plugin specs pass incl. the sync-exclusion
guard. Docs updated.

* fix(plugins): harden persisted consent against prototype-pollution ids

Multi-review (security) found a CRITICAL in the Phase 2 consent store: the
consents map was a plain object keyed on an attacker-controlled pluginId, so
an uploaded plugin with id 'constructor' / 'toString' / 'valueOf' /
'hasOwnProperty' resolved consents[id] to the inherited Object.prototype
member (a truthy function). The executor's ask-once check treated that as a
prior grant and minted a nodeExecution token with NO consent dialog on a fresh
install — full code execution with zero user approval. ('__proto__' was already
blocked by the id allowlist; these names pass it.) Unit tests missed it because
the executor test stub used a Map, which is immune to the footgun.

Fix:
- Store: null-prototype consents map (Object.create(null)) + own-property
  (hasOwnProperty) guarded reads + reject non-object entries. Closes the class
  for any prototype-member id, including across a disk round-trip.
- Executor: reject __proto__/prototype/constructor in assertSafePluginId as
  defense-in-depth at the boundary.
- Regression tests: consent store returns null for prototype-member ids (fresh,
  after a real set, after clear); grant request for these ids is rejected with
  no dialog and no mint.

Also from review: ask-once path now re-checks the sender URL after the consent
read (parity with the dialog path); clarified why the consent mutation queue is
not redundant with simple-store's save queue.

Electron suite 156/156 pass.

* refactor(plugins): log consent persist-failure via electron-log

Multi-review follow-ups (non-blocking):
- Route the best-effort consent persist-failure to electron-log/main (the
  user-exportable host log) instead of console; console in the executor is
  otherwise the sandboxed plugin's own output. Only the validated id is logged.
- Clarify the disable-path comment: clearing revokes the live session grant
  always, and the persisted consent only for uploaded plugins (built-ins have
  none).

* fix(plugins): clear persisted consent on cache-clear and disclose persistence in dialog

Two gaps found in multi-agent review of the Phase 2 persisted-consent feature:

- clearUploadedPluginsFromMemory() (the 'Clear plugin cache' button) wiped the
  plugin code from IndexedDB but left the main-owned persisted nodeExecution
  consent behind. A later re-upload of the same id has no existingState, so the
  re-upload consent-clear in loadPluginFromZip never fired and the (possibly
  different) code was silently granted node execution with no prompt — defeating
  the 'replacing code under an id always re-asks' invariant. Now clears consent
  for every evicted uploaded id, mirroring removeUploadedPlugin.

- The uploaded-plugin native consent dialog still said access was valid 'for this
  app session', but Allow is now persisted across sessions. The prompt now
  discloses that the choice is remembered on the device until disable / remove /
  re-upload, so the user consents to the actual scope.

Regression tests added on both sides.

* refactor(plugins): key persisted consent on a Map, not a null-prototype object

Multi-review simplification. The consent store keyed an attacker-controlled
pluginId into a plain object, defended against `Object.prototype` member names
(constructor/toString/…) with a null-prototype object + hasOwn guards + a
typeof-object read check. A `Map` makes that safety structural and self-evident —
an unstored key is simply `undefined` — and matches the sibling `grants` Map in
the executor. The on-disk format is unchanged (a plain {version, consents} object);
the Map is serialized via Object.fromEntries (define-semantics, no prototype write)
and rebuilt via Object.entries with the well-formedness guard moved to load time.

Also corrects the stale 'never downgrade-corrupt it' comment with the accurate
downgrade behavior, and adds a round-trip test proving a hand-edited on-disk
__proto__ data key loads inertly without polluting Object.prototype.

* refactor(plugins): funnel disable through PluginService.disablePlugin and de-dup dialog display

Two more multi-review items, now that we own the PR:

- 'Disabling a node plugin revokes its consent' previously lived only in the
  plugin-management UI handler, so a future programmatic disable path could unload
  the plugin yet leave persisted consent behind — re-enabling would then silently
  re-grant node execution. Added PluginService.disablePlugin(setEnabled=false +
  unload + clearNodeExecutionConsent) and routed the UI through it, making the
  revoke a structural invariant. The consent clear is a safe no-op for non-node
  plugins, so the previous requiresNodeExecution gate is dropped.

- The uploaded-plugin name/version were sanitized once for the dialog and again for
  persistence (same lengths/fallbacks, duplicated). Extracted sanitizedUploadedDisplay
  as the single source of truth so the persisted record always matches what the user
  saw in the prompt.

Tests added for the disablePlugin invariant.

* fix(plugins): harden persisted nodeExecution consent (multi-review)

Follow-ups from a multi-agent review of #8600:

- Re-ask structurally on every upload: clear consent unconditionally in
  loadPluginFromZip (outside the `existingState` branch) so a same-id
  re-upload always re-prompts even if consent was orphaned (crash
  mid-uninstall, IndexedDB eviction, external/partial wipe).
- Fail closed on upload: clearNodeExecutionConsent reports a persist failure
  via its return value; loadPluginFromZip aborts the upload if the prior
  consent could not be revoked, so replacement code can't inherit a stale
  grant. Lifecycle edges (disable/uninstall/cache-clear) ignore the result so
  a rare disk failure can't abort their bookkeeping.
- Mint the grant before the best-effort consent persist in the executor so a
  navigation/destroy during the write drops it via cleanup and a persist
  failure can't lose an approved grant.
- Validate the full consent record shape on load so a corrupt {}/array entry
  can't read as a grant.
- Log only the validated id + error code on persist failure (no userData path).
- Fix a stale comment (the store keys consent in a Map, not null-proto objects).

Adds regression tests: mint-before-persist ordering, best-effort persist,
malformed-entry rejection, and the consent-clear fail-closed return contract.

* test(plugins): add clearNodeExecutionConsent to PluginBridgeService spy

loadPluginFromZip now clears prior persisted nodeExecution consent
before loading replacement code (#8512 Phase 2). The spy in this spec
lacked the method, so the call threw, was caught, returned false, and
aborted the upload, failing both load-from-zip tests.
2026-06-29 16:12:00 +02:00

233 lines
8.1 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 Module = require('node:module');
require('ts-node/register/transpile-only');
const originalModuleLoad = Module._load;
const consentStoreModulePath = path.resolve(__dirname, 'plugin-node-consent-store.ts');
const simpleStoreModulePath = path.resolve(__dirname, 'simple-store.ts');
let userDataDir;
const getStorePath = () => path.join(userDataDir, 'simpleSettings');
const readStoreFile = async () => JSON.parse(await fs.readFile(getStorePath(), 'utf8'));
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);
};
};
// Both the consent store and the simple store it wraps hold module-level state (save /
// mutation queues), so reset both caches to get a clean store bound to the new temp dir.
const resetModules = () => {
delete require.cache[consentStoreModulePath];
delete require.cache[simpleStoreModulePath];
};
const loadConsentStore = () => {
resetModules();
return require(consentStoreModulePath);
};
test.beforeEach(async () => {
userDataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-node-consent-'));
installMocks();
});
test.afterEach(async () => {
Module._load = originalModuleLoad;
resetModules();
await fs.rm(userDataDir, { recursive: true, force: true });
});
test('set then get round-trips consent and pins the v1 format on disk', async () => {
const store = loadConsentStore();
await store.setNodeExecutionConsent('community.plugin', {
name: 'Community Plugin',
version: '1.2.3',
grantedAt: 1000,
});
assert.deepEqual(await store.getNodeExecutionConsent('community.plugin'), {
name: 'Community Plugin',
version: '1.2.3',
grantedAt: 1000,
});
// On-disk shape: a top-level version field (the migration anchor) keyed under the
// dedicated simpleSettings key — never in any pfapi-synced model.
const onDisk = await readStoreFile();
assert.equal(onDisk.pluginNodeExecutionConsent.version, 1);
assert.ok(onDisk.pluginNodeExecutionConsent.consents['community.plugin']);
});
test('get returns null for an unknown plugin id', async () => {
const store = loadConsentStore();
assert.equal(await store.getNodeExecutionConsent('never-granted'), null);
});
test('clear removes a persisted consent', async () => {
const store = loadConsentStore();
await store.setNodeExecutionConsent('p', { name: 'P', version: '1', grantedAt: 1 });
await store.clearNodeExecutionConsent('p');
assert.equal(await store.getNodeExecutionConsent('p'), null);
});
test('clear is a no-op (no throw) when nothing is persisted', async () => {
const store = loadConsentStore();
await assert.doesNotReject(() => store.clearNodeExecutionConsent('absent'));
});
test('ignores an on-disk blob with an unknown future version (forward-safe)', async () => {
// A future client may write version 2+; an older client must re-prompt (treat as
// empty) rather than mis-read a format it does not understand into a spurious grant.
await fs.writeFile(
getStorePath(),
JSON.stringify({
pluginNodeExecutionConsent: {
version: 999,
consents: { x: { name: 'X', version: '1', grantedAt: 1 } },
},
}),
'utf8',
);
const store = loadConsentStore();
assert.equal(await store.getNodeExecutionConsent('x'), null);
});
test('never treats a prototype-member id as a stored consent', async () => {
// SECURITY regression: the consents map is keyed on an attacker-controlled pluginId.
// A plain object would resolve e.g. consents['constructor'] to Object.prototype.constructor
// (a truthy function), which the executor would mistake for a prior grant and mint a
// token with NO dialog. A `Map` returns undefined for any unstored key, so reads must
// return null for every prototype-member id.
const store = loadConsentStore();
const protoIds = ['constructor', 'toString', 'valueOf', 'hasOwnProperty', '__proto__'];
for (const id of protoIds) {
assert.equal(await store.getNodeExecutionConsent(id), null, `fresh store: ${id}`);
}
// Persisting a real plugin must not make prototype-member ids start resolving either.
await store.setNodeExecutionConsent('real-plugin', {
name: 'Real',
version: '1',
grantedAt: 1,
});
for (const id of protoIds) {
assert.equal(
await store.getNodeExecutionConsent(id),
null,
`after a real set: ${id}`,
);
}
// Clearing a prototype-member id is a safe no-op that does not corrupt real entries.
await store.clearNodeExecutionConsent('constructor');
assert.deepEqual(await store.getNodeExecutionConsent('real-plugin'), {
name: 'Real',
version: '1',
grantedAt: 1,
});
});
test('a hand-edited on-disk `__proto__` data key round-trips inertly without polluting Object.prototype', async () => {
// The store serializes the consents Map via Object.fromEntries and rebuilds it via
// Object.entries. A maliciously hand-edited file naming a prototype member as a data key
// must load as an ordinary, ignorable entry — never a prototype write, never a grant.
await fs.writeFile(
getStorePath(),
'{"pluginNodeExecutionConsent":{"version":1,"consents":' +
'{"__proto__":{"name":"X","version":"1","grantedAt":1},' +
'"real-plugin":{"name":"Real","version":"1","grantedAt":2}}}}',
'utf8',
);
const store = loadConsentStore();
// A real (non-prototype) plugin still loads; loading did not pollute Object.prototype.
assert.deepEqual(await store.getNodeExecutionConsent('real-plugin'), {
name: 'Real',
version: '1',
grantedAt: 2,
});
assert.equal({}.name, undefined, 'Object.prototype must not be polluted on load');
// A subsequent write re-serializes via Object.fromEntries without polluting either.
await store.setNodeExecutionConsent('another', {
name: 'A',
version: '1',
grantedAt: 3,
});
assert.equal({}.name, undefined, 'Object.prototype must not be polluted on save');
assert.deepEqual(await store.getNodeExecutionConsent('another'), {
name: 'A',
version: '1',
grantedAt: 3,
});
});
test('keeps other consents when one is cleared', async () => {
const store = loadConsentStore();
await store.setNodeExecutionConsent('a', { name: 'A', version: '1', grantedAt: 1 });
await store.setNodeExecutionConsent('b', { name: 'B', version: '1', grantedAt: 2 });
await store.clearNodeExecutionConsent('a');
assert.equal(await store.getNodeExecutionConsent('a'), null);
assert.deepEqual(await store.getNodeExecutionConsent('b'), {
name: 'B',
version: '1',
grantedAt: 2,
});
});
test('ignores a malformed on-disk consent entry (presence alone must not authorize)', async () => {
// SECURITY: mere presence of an entry authorizes execution, so a corrupt/tampered value
// (empty object, array, or a record missing name/version/grantedAt) must be dropped — the
// user re-prompts — rather than read as a grant. Only a fully well-formed record loads.
await fs.writeFile(
getStorePath(),
JSON.stringify({
pluginNodeExecutionConsent: {
version: 1,
consents: {
'empty-object': {},
'array-entry': [],
'missing-version': { name: 'P', grantedAt: 1 },
'non-number-grantedAt': { name: 'P', version: '1', grantedAt: 'soon' },
'good-plugin': { name: 'Good', version: '1', grantedAt: 5 },
},
},
}),
'utf8',
);
const store = loadConsentStore();
for (const id of [
'empty-object',
'array-entry',
'missing-version',
'non-number-grantedAt',
]) {
assert.equal(
await store.getNodeExecutionConsent(id),
null,
`malformed entry ${id} must be ignored`,
);
}
assert.deepEqual(await store.getNodeExecutionConsent('good-plugin'), {
name: 'Good',
version: '1',
grantedAt: 5,
});
});