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.
Updating a plugin used to just reinstall and reactivate, which silently
granted whatever the new version declared — so a plugin could quietly
widen its own rights on the next release.
Route updates through a new server-side update() that diffs the new
version's declared permissions against what the admin already granted:
- nothing new -> the plugin is restarted transparently on the new code.
- new permissions or a new outbound host -> the new code is installed
but the plugin is left OFF, and the delta is handed back so the admin
has to approve it before it turns on.
Install runs first, so a failed download/signature check leaves the
running plugin untouched. The client shows the delta in a consent dialog
and only then activates. An update can never widen a plugin behind your back.
The deactivation-on-deploy bug: `status` conflated the admin's ON/OFF intent with
runtime health, so a boot crash flipped status to 'error' and the plugin never
rebooted after the next deploy. Migration 156 adds an `enabled` flag (admin
intent) separate from `status` (runtime health); boot now retries every enabled
plugin regardless of last status, and a crash no longer erases the intent.
Admin → Plugins redesign:
- ON/OFF is a ToggleSwitch bound to `enabled`; runtime health shows separately as
a coloured status dot, with the last error inline when it crashed.
- "Update → vX" badge when the registry has a newer version (one click updates
and reactivates).
- Reviewed/unreviewed trust badges, cleaner cards, nicer empty state, registry
browser marks already-installed plugins. i18n across all locales.
- Uninstall with data disposition: POST /api/admin/plugins/:id/uninstall kills the
plugin, removes its code + DB metadata, and (deleteData) drops its data dir,
error log and per-user settings.
- Error log: GET/DELETE /api/admin/plugins/:id/errors — the plugin's own crash /
request-failure log, surfaced in the admin panel.
- Egress guard: the isolated child wraps global fetch and refuses any outbound
host not in the plugin's declared egress[]; with none declared, all outbound is
blocked. Process-level defense in depth (the container runtime enforces it at
the network layer in v2).
- Admin → Plugins is now actionable: activate / deactivate / uninstall, a
registry browser (install), and per-plugin error log. i18n across all locales.
- Dashboard widget slot: active widget plugins render as sandboxed cards.
The trek:request core-data bridge + photo/calendar hook consumers remain follow-ups.
Plugins placed on the /plugins volume are now discovered, validated and registered
as inactive, ready to activate.
- manifest.ts: strict trek-plugin.json validation (id/version/type, known
permissions only, egress required with http:outbound, native modules rejected).
- discovery.ts: scans the volume on startup + on demand (POST /api/admin/plugins/
rescan), upserts rows INACTIVE, refreshes settings-field descriptors, and never
downgrades or wipes an already-installed plugin's status / grants / config.
Invalid or native-carrying plugins are skipped and logged.
- Activation now grants the DECLARED permissions (the consent gate) and persists
them before spawning.
- install/ utilities for the registry installer (M5), each independently tested:
- safe-fetch: host allowlist (GitHub only) + private-IP refusal + manual
redirect following + size cap + sha256 (constant-time compare).
- safe-extract: zip/tar-slip-safe extraction with its own minimal tar.gz + zip
readers; rejects traversal, absolute paths, symlinks, oversized/too-many
entries, and unsupported formats.
- native-scan: refuses .node / binding.gyp / prebuilds, never follows symlinks.
Plugins can now render UI. Page plugins appear as a nav entry and open a
full-page sandboxed iframe; the frame talks to TREK only over the postMessage
bridge.
- Server serves plugin client assets at /plugin-frame/:id/* with a strict path
guard and a locked-down, per-plugin CSP (default-src none; connect-src limited
to declared outbound hosts; sandbox WITHOUT allow-same-origin -> opaque origin,
so the frame can't read the session cookie or the parent DOM). Global CSP
frameSrc relaxed from 'none' to 'self' for exactly these frames.
- GET /api/plugins feed lists active plugins for the client.
- Client: pluginStore + PluginFrame (the trekBridge host) authenticates every
inbound message by SENDER WINDOW IDENTITY (event.source), not by a claimed id
or origin; pushes context (theme/locale/tripId/userId), validates navigation,
renders notifications as text, resizes widgets, and proxies trek:invoke to the
plugin's own routes host-side (session cookie stays with the host).
- Page route /plugins/:id + Navbar nav injection for page plugins.
Dashboard widget slot and the trek:request core-data bridge are deferred to a
later slice.
Wires the isolated runtime into TREK. Admins can now activate a plugin from the
panel and its HTTP routes work end to end, still behind the kill switch.
- PluginRuntimeService owns the supervisor: activate spawns the child with its
granted permissions + decrypted instance config, deactivate kills it, status
and errors are persisted to the plugins / plugin_error_log tables, and active
plugins are booted on startup (OnModuleInit).
- Bidirectional RPC: the child now handles host→child invokes (routes/jobs) and
reports its declared routes on load; the supervisor gained invoke()/routesOf().
- /api/plugins/:id/* proxy controller — a single static route that matches the
plugin's declared routes, enforces per-route auth (auth:false routes are public
for OAuth callbacks/webhooks), forwards only a whitelisted request view (never
the session cookie), and strips unsafe response headers.
- Admin endpoints: POST :id/activate, POST :id/deactivate, GET/PUT :id/config —
instance settings with secret fields encrypted (apiKeyCrypto) and masked.
- Kill switch moved to its own module so it never collides with test config mocks.
Photo/calendar hook consumers are deferred to a later slice.