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.
This commit is contained in:
Maurice
2026-07-04 18:32:26 +02:00
committed by Maurice
parent 16e27926c5
commit 64136484ee
25 changed files with 442 additions and 85 deletions
@@ -174,6 +174,20 @@ function installEgressGuard(egress: string[]): void {
const allowed = makeHostAllow(egress);
const blockPrivate = (process.env.TREK_PLUGIN_ALLOW_PRIVATE_EGRESS ?? '').toLowerCase() !== 'on';
// The wrappers below are the ONLY egress choke point (the OS --permission model
// does not restrict the network). Deny the low-level escape hatch a plugin could
// use to build a raw socket handle BELOW those wrappers. node:net/dns/dgram/http
// are already imported above, so they keep their captured bindings; only fresh
// access via process.binding is cut off.
for (const name of ['binding', '_linkedBinding'] as const) {
try {
Object.defineProperty(process, name, {
value: () => { throw new Error(`egress: process.${name} is disabled for plugins`); },
writable: false, configurable: false,
});
} catch { /* already locked / non-configurable */ }
}
const realFetch = globalThis.fetch;
if (typeof realFetch === 'function') {
globalThis.fetch = ((input: unknown, init?: unknown) => {
@@ -261,6 +275,25 @@ function installEgressGuard(egress: string[]): void {
guardDatagram(dgramConnectTarget(args));
return realDgramConnect.apply(this, args);
};
// A dgram HOSTNAME target is resolved via the socket's `lookup` before the packet
// is sent; force the IP-vetting guardedLookup so a declared name that resolves to
// a private/metadata address is refused (the TCP path's rebind backstop, for UDP).
const dgramApi = dgram as unknown as { createSocket: (...a: unknown[]) => unknown };
const realCreateSocket = dgramApi.createSocket;
dgramApi.createSocket = function (this: unknown, ...args: unknown[]): unknown {
const first = args[0];
if (first && typeof first === 'object') (first as { lookup?: unknown }).lookup = guardedLookup;
else if (typeof first === 'string') args[0] = { type: first, lookup: guardedLookup };
return realCreateSocket.apply(this, args);
};
// Lock the wrapped choke points so a plugin can't restore the originals.
const lock = (obj: object, key: string, value: unknown) => {
try { Object.defineProperty(obj, key, { value, writable: false, configurable: false }); } catch { /* noop */ }
};
lock(proto, 'connect', proto.connect);
lock(dgramProto, 'send', dgramProto.send);
lock(dgramProto, 'connect', dgramProto.connect);
// Gate the dns resolver family: a forward lookup for an undeclared name is a
// DNS-tunnel exfiltration channel even when no socket is ever opened. The name