fix(plugins): close the 4 PARTIAL findings + regressions from the fix-verify pass

A second adversarial pass over the first remediation found four findings only
partially closed and five issues the fixes themselves introduced. This closes
them:

Partial → closed
- re-consent gate keyed on `granted.length > 0`, so a plugin first activated with
  ZERO permissions (granted '[]') was treated as never-consented and a later
  widening was granted silently. Now discovery marks a never-consented plugin with
  granted_permissions '' and activate() gates on "ever consented" (any non-empty
  string, including '[]').
- db:own DoS: block WITH RECURSIVE outright (the one construct that spins the
  synchronous host unboundedly regardless of the size/row caps, via query OR exec).
- dgram: also wrap `new dgram.Socket(...)` (bypassed createSocket) to inject the
  IP-vetting lookup, and lock createSocket/Socket.
- frame self-navigation: documented as a bounded best-effort mitigation (inherent
  to sandboxed iframes; exposure is the plugin's own routes + already-held context,
  never the httpOnly cookie).

Regressions introduced by the first pass → fixed
- proxy: only real redirects (301/302/303/307/308) are gated, to a RELATIVE in-app
  Location (supports OAuth-callback bounce, blocks open redirect); 300/304 pass
  through; attachment only on non-redirects.
- supervisor: measure RSS via /proc/<pid>/status VmRSS (page-size independent);
  activation-timeout awaits kill() before disposing the db handle.
- manifest HOST_RE: allow single-label hosts (self-hoster sibling services) while
  keeping wildcards multi-label; mirrored in the SDK + frame CSP filter.

Regression tests added (re-consent incl. the '[]' case, WITH RECURSIVE + row cap,
single-label host). 236 plugin tests + 24 SDK tests green.
This commit is contained in:
Maurice
2026-07-04 19:03:06 +02:00
committed by Maurice
parent 64136484ee
commit b16a7d7b77
13 changed files with 103 additions and 33 deletions
@@ -12,7 +12,7 @@ const { testDb } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec(`CREATE TABLE plugins (
id TEXT PRIMARY KEY, status TEXT, enabled INTEGER DEFAULT 0, permissions TEXT DEFAULT '[]', granted_permissions TEXT DEFAULT '[]',
id TEXT PRIMARY KEY, status TEXT, enabled INTEGER DEFAULT 0, permissions TEXT DEFAULT '[]', granted_permissions TEXT DEFAULT '',
config TEXT DEFAULT '{}', last_error TEXT, updated_at TEXT);
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER);
@@ -109,6 +109,18 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
await expect(runtime.activate('ghost')).rejects.toThrow(/not found/);
});
it('re-consent gate: activating a consented plugin with WIDER permissions is refused without consent', async () => {
// consented to db:own; a version now declaring db:read:users too must not auto-grant
testDb.prepare("INSERT INTO plugins (id, status, permissions, granted_permissions, config) VALUES ('widener','inactive','[\"db:own\",\"db:read:users\"]','[\"db:own\"]','{}')").run();
await expect(runtime.activate('widener')).rejects.toThrow(/re-consent|new permissions/);
// the key case: a plugin consented to ZERO perms ('[]') is still "consented" —
// a later widening to db:own is blocked, not silently granted.
testDb.prepare("INSERT INTO plugins (id, status, permissions, granted_permissions, config) VALUES ('zeroperm','inactive','[\"db:own\"]','[]','{}')").run();
await expect(runtime.activate('zeroperm')).rejects.toThrow(/re-consent|new permissions/);
// (a NEVER-consented plugin — granted '' — consenting to its declared set on
// first activate is covered by the 'counter' happy-path test above.)
});
it('onModuleInit is a no-op when the runtime is disabled', () => {
process.env.TREK_PLUGINS_ENABLED = 'false';
expect(() => new PluginRuntimeService().onModuleInit()).not.toThrow();