mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
64136484ee
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.
65 lines
2.9 KiB
TypeScript
65 lines
2.9 KiB
TypeScript
/**
|
|
* The production wiring that connects a plugin's capability host to the real
|
|
* privileged modules (#plugins, M1). Verifies the per-plugin data db is cached,
|
|
* a granted db:own call works through the wired host, and trip broadcasts are
|
|
* force-namespaced to plugin:{id}:{event}.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
const { broadcast, broadcastToUser } = vi.hoisted(() => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
|
vi.mock('../../../src/db/database', () => ({
|
|
db: { prepare: () => ({ all: () => [], get: () => null }) },
|
|
canAccessTrip: (tripId: number, userId: number) => (tripId === 1 && userId === 5 ? { id: 1 } : undefined),
|
|
}));
|
|
vi.mock('../../../src/websocket', () => ({ broadcast, broadcastToUser }));
|
|
|
|
import { createRealRpcHost, getPluginDataDb, closePluginDataDb } from '../../../src/nest/plugins/host/create-rpc-host';
|
|
|
|
let tmp: string;
|
|
beforeAll(() => {
|
|
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-crh-'));
|
|
process.env.TREK_PLUGINS_DATA_DIR = tmp;
|
|
});
|
|
afterAll(() => {
|
|
closePluginDataDb('wired');
|
|
delete process.env.TREK_PLUGINS_DATA_DIR;
|
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('create-rpc-host wiring', () => {
|
|
it('caches one data db per plugin id', () => {
|
|
const a = getPluginDataDb('wired');
|
|
const b = getPluginDataDb('wired');
|
|
expect(a).toBe(b);
|
|
});
|
|
|
|
it('a granted db:own call runs against the plugin db, and a trip broadcast is namespaced', async () => {
|
|
const host = createRealRpcHost('wired', new Set(['db:own', 'ws:broadcast:trip']));
|
|
const migrated = await host.dispatch({ k: 'req', id: '1', method: 'db.migrate', params: { id: '001', sql: 'CREATE TABLE t (v TEXT)' } });
|
|
expect(migrated.ok).toBe(true);
|
|
|
|
// acting user 5 is a member of trip 1 (mocked canAccessTrip) → broadcast allowed + namespaced
|
|
await host.dispatch({ k: 'req', id: '2', method: 'ws.broadcastToTrip', params: { tripId: 1, event: 'ping', data: { a: 1 } } }, 5);
|
|
expect(broadcast).toHaveBeenCalledWith(1, 'plugin:wired:ping', { a: 1 });
|
|
|
|
const bcastUser = createRealRpcHost('wired', new Set(['ws:broadcast:user']));
|
|
// a per-user broadcast may only target the acting user themselves
|
|
await bcastUser.dispatch({ k: 'req', id: '3', method: 'ws.broadcastToUser', params: { userId: 5, event: 'hi', data: {} } }, 5);
|
|
expect(broadcastToUser).toHaveBeenCalledWith(5, { type: 'plugin:wired', event: 'hi' });
|
|
});
|
|
|
|
it('closePluginDataDb closes and drops the cached handle', () => {
|
|
getPluginDataDb('transient');
|
|
closePluginDataDb('transient');
|
|
// a fresh get after close returns a NEW instance (cache was cleared)
|
|
const a = getPluginDataDb('transient');
|
|
closePluginDataDb('transient');
|
|
const b = getPluginDataDb('transient');
|
|
expect(a).not.toBe(b);
|
|
closePluginDataDb('transient');
|
|
});
|
|
});
|