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
+13 -5
View File
@@ -149,7 +149,7 @@ export async function runDev(dir: string, opts: { port?: number } = {}): Promise
async function handle(request: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
const url = new URL(request.url || '/', 'http://localhost');
const send = (status: number, body: string, type = 'text/plain; charset=utf-8', headers: Record<string, string> = {}) => {
const send = (status: number, body: string | Buffer, type = 'text/plain; charset=utf-8', headers: Record<string, string> = {}) => {
res.writeHead(status, { 'content-type': type, ...headers }); res.end(body);
};
@@ -162,10 +162,14 @@ export async function runDev(dir: string, opts: { port?: number } = {}): Promise
const file = path.join(abs, 'client', relFile);
if (!file.startsWith(path.join(abs, 'client'))) return send(403, 'forbidden');
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) return send(404, 'not found — build your client/ bundle');
let data: Buffer | string = fs.readFileSync(file);
const raw = fs.readFileSync(file);
const type = contentType(file);
if (type.startsWith('text/html')) data = String(data).replace('</body>', `${LIVE_RELOAD}</body>`);
return send(200, data.toString(), type);
// Only the HTML doc is rewritten (live-reload inject) as a string; every other
// asset is sent as the raw Buffer — a UTF-8 round-trip corrupts binary files.
const body: string | Buffer = type.startsWith('text/html')
? String(raw).replace('</body>', `${LIVE_RELOAD}</body>`)
: raw;
return send(200, body, type);
}
// Plugin API routes mounted under /api/<route path>
@@ -191,7 +195,11 @@ export async function runDev(dir: string, opts: { port?: number } = {}): Promise
}
const port = opts.port ?? 4317;
await new Promise<void>((resolve) => server.listen(port, resolve));
await new Promise<void>((resolve, reject) => {
server.once('error', (e: NodeJS.ErrnoException) =>
reject(e.code === 'EADDRINUSE' ? new Error(`port ${port} is already in use — pass --port <n> to pick another`) : e));
server.listen(port, resolve);
});
const routes = plugin.routes ?? [];
console.log(`\n trek-plugin dev — ${id} (${String(manifest.type)})`);
console.log(` ${dbNote}`);
+13
View File
@@ -21,7 +21,13 @@ export interface ValidationResult {
}
const ID_RE = /^[a-z][a-z0-9-]{2,39}$/;
// Ids that would collide with admin API route segments — refused by the server's
// install loader, so surface it locally too.
const RESERVED_IDS = new Set(['registry', 'install', 'rescan']);
const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
// An outbound host: exact FQDN or `*.`-wildcard, always multi-label. No `*`, no
// `*.`, no whole-TLD `*.com`, no spaces (mirrors the server manifest validator).
const HOST_RE = /^(\*\.)?([a-z0-9-]+\.)+[a-z0-9-]+$/i;
const TYPES = ['integration', 'page', 'widget'];
const KNOWN_PERMISSIONS = [
'db:own', 'db:read:trips', 'db:read:users', 'ws:broadcast:trip', 'ws:broadcast:user',
@@ -43,6 +49,7 @@ export function validateManifest(raw: unknown): ValidationResult {
req('id'); req('name'); req('version'); req('type');
if (typeof m.id === 'string' && !ID_RE.test(m.id)) errors.push(`id "${m.id}" must be a lowercase slug (3–40 chars)`);
if (typeof m.id === 'string' && RESERVED_IDS.has(m.id)) errors.push(`id "${m.id}" is reserved`);
if (typeof m.version === 'string' && !SEMVER_RE.test(m.version)) errors.push(`version "${m.version}" is not semver`);
if (typeof m.type === 'string' && !TYPES.includes(m.type)) errors.push(`type must be one of ${TYPES.join('/')}`);
if (m.apiVersion !== undefined && typeof m.apiVersion !== 'number') errors.push('apiVersion must be a number');
@@ -50,11 +57,17 @@ export function validateManifest(raw: unknown): ValidationResult {
const permissions = Array.isArray(m.permissions) ? m.permissions.map(String) : [];
for (const p of permissions) if (!isKnownPermission(p)) errors.push(`unknown permission: ${p}`);
for (const p of permissions) {
if (p.startsWith('http:outbound:') && !HOST_RE.test(p.slice('http:outbound:'.length))) {
errors.push(`invalid http:outbound host "${p.slice('http:outbound:'.length)}"`);
}
}
const egress = Array.isArray(m.egress) ? m.egress.map(String) : [];
const wantsOutbound = permissions.some((p) => p === 'http:outbound' || p.startsWith('http:outbound:'));
if (wantsOutbound && egress.length === 0) errors.push('http:outbound declared but egress[] is empty');
if (egress.includes('*')) errors.push('egress[] must not contain a bare "*"');
for (const h of egress) if (!HOST_RE.test(h)) errors.push(`invalid egress host "${h}"`);
const widget = (m.capabilities as { widget?: { slot?: unknown } } | undefined)?.widget;
if (widget?.slot !== undefined && widget.slot !== 'sidebar' && widget.slot !== 'hero') {