mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
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:
@@ -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}`);
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user