Commit Graph

5 Commits

Author SHA1 Message Date
Maurice 64136484ee fix(plugins): security hardening from the PR #1415 audit
Remediates the findings from the adversarial audit (threat model: malicious
plugin author + malicious artifact). Highlights:

Critical
- proxy: force nosniff + Content-Disposition: attachment on every proxied reply
  and drop location/content-disposition + non-2xx from the passthrough, so a
  plugin can't serve an HTML document at TREK's origin (sandbox-escape → account
  takeover) or an open redirect.

High
- db:own runs synchronously in the host: cap the plugin DB (max_page_count) and
  row-cap query() via iterate() so a recursive CTE / huge blob can't stall the
  event loop, OOM, or exhaust the shared volume.
- supervisor: measure child RSS host-side (/proc/<pid>/statm) instead of trusting
  the spoofable heartbeat; add an activation timeout so a stuck onLoad can't hang
  activate() or peg a core unreaped.
- safe-extract: enforce entry-count + cumulative-size limits INSIDE readZip
  before inflating (decompression-bomb OOM).
- re-consent: activate() never widens granted permissions without explicit
  consent (409 CONSENT_REQUIRED); the row toggle + "Update All" route through the
  consent dialog, which now queues instead of overwriting.

Medium/low
- egress: gate dgram hostnames through the IP-vetting resolver; block the
  low-level socket escape (process.binding) + lock the wrapped prototypes;
  canonicalize IPv6 in isBlockedIp (hex-mapped/compressed metadata); reject
  degenerate `*.` / whole-TLD / spaced outbound hosts in the manifest + CSP.
- ws:broadcast is membership-gated to the acting user's trips / own connections;
  users.getById is scoped to users the acting user can see (no enumeration).
- safe-fetch streams + aborts at the byte cap (chunked codeload OOM); isPrivateIp
  reuses the canonicalizing check.
- native-scan throws instead of silently passing past its entry cap.
- SDK: dev serves binary assets as raw buffers + handles EADDRINUSE; manifest
  validator gains the reserved-id + outbound-host checks; wikis corrected.

Tests updated for the new membership-gated behaviour + regression tests added
(IPv6 canonicalization, wildcard hardening, outbound-host validation, ws/user
scoping). 234 plugin tests + 24 SDK tests green.
2026-07-04 19:11:23 +02:00
Maurice 332e5c3c42 feat(plugins): sandbox hardening — OS permission model, egress choke point, bound acting user, author signatures
Closes the four gaps the security review surfaced:

- OS permission model on the prod plugin child (Node --permission with
  fs-read scoped to the compiled server dir + the plugin's own code dir, no
  fs-write/child_process/worker/native). A plugin can no longer read trek.db
  or the .jwt_secret/.encryption_key files, nor shell out — the direct-fs and
  RCE escapes that bypassed the RPC layer. Opt-out via TREK_PLUGIN_PERMISSIONS=off.
- Egress guard extended from fetch to the net.Socket connect choke point, so
  node:http/https/net/tls obey the declared-egress allowlist too (no declared
  egress = no outbound). Under the permission model there is no clean escape to
  an unwrapped runtime. Kernel/network-namespace containment remains the
  container step.
- Trip reads are membership-checked against the acting user the HOST binds from
  the authenticated invocation, not an asUserId the plugin supplies; a job/onLoad
  (no user) can't read user-scoped trips.
- Optional minisign (Ed25519) author signatures verified offline, TOFU-pinned
  (migration 158). Unsigned plugins install on sha256 alone; a signed plugin
  can't silently drop its signature or swap its author key.

Server suite green (permission-model activation verified against the Koffi
reference plugin on dev1).
2026-07-04 19:11:23 +02:00
mauriceboe 29c2979da4 fix(plugins): trips.getById returns the actual trip row, not the access check
canAccessTrip only returns { id, user_id } (it's a membership check), but the
rpc-host's trips.getById returned it verbatim — so plugins saw a trip with no
title/start_date/etc. Fetch the real row after the access check. Also fix the
reference plugin to read t.title (the trips column is 'title', not 'name').
2026-07-04 19:11:23 +02:00
mauriceboe de49cde363 feat(plugins): activation, HTTP route proxy + instance settings (M2)
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.
2026-07-04 19:11:23 +02:00
mauriceboe 5e3f7095a5 feat(plugins): isolated per-plugin runtime + capability RPC (M1)
Every plugin now runs in its own forked child process with a scrubbed env
(no JWT_SECRET, no db path, nothing inherited). It talks to TREK only over a
JSON-RPC channel, and the host's capability router registers ONLY the methods a
plugin's granted permissions unlock — so an ungranted call is unreachable, not
merely refused. The plugin's own data lives in a separate sqlite file it can
never open directly; core reads (trips/users) go through membership-checked,
column-projected host methods; ws broadcasts are force-namespaced.

- protocol/envelope: the wire types + method→permission map (pure, shared by
  host and the isolated child)
- host/rpc-host: the capability router = the enforcement point (dispatch,
  BAD_PARAMS / PERMISSION_DENIED / RESOURCE_FORBIDDEN / UNKNOWN_METHOD)
- host/plugin-data: the per-plugin sqlite file (db:own), guarded against
  ATTACH/PRAGMA escape, idempotent migrations
- host/create-rpc-host: wires the router to the real db/websocket (host-only)
- runtime/plugin-sdk + plugin-host-entry: the child bootstrap + definePlugin
  ctx; turns each ctx call into an RPC, never imports a privileged module
- supervisor: spawn on activate, heartbeat/reap, crash backoff + auto-disable,
  graceful shutdown — a plugin crash/OOM/hang can never reach the Nest loop
- paths: code/data layout, dist-vs-tsx child entry resolution

Nothing is wired into activation yet (that's the next slice); exercised by unit
tests for the router/sdk/data and an integration test that forks a real child.
2026-07-04 19:11:23 +02:00