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
+1 -1
View File
@@ -464,7 +464,7 @@ export const adminApi = {
pluginBrowse: () => apiClient.get('/admin/plugins/registry').then(r => r.data),
pluginDetail: (id: string) => apiClient.get(`/admin/plugins/registry/${encodeURIComponent(id)}`).then(r => r.data),
pluginInstall: (id: string, version?: string) => apiClient.post('/admin/plugins/install', { id, version }).then(r => r.data),
pluginActivate: (id: string) => apiClient.post(`/admin/plugins/${id}/activate`).then(r => r.data),
pluginActivate: (id: string, consent?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, consent ? { consent: true } : {}).then(r => r.data),
pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data),
pluginUpdate: (id: string) => apiClient.post(`/admin/plugins/${id}/update`).then(r => r.data),
pluginUninstall: (id: string, deleteData: boolean) => apiClient.post(`/admin/plugins/${id}/uninstall`, { deleteData }).then(r => r.data),
@@ -157,7 +157,9 @@ export default function AdminPluginsPanel() {
const [detailFor, setDetailFor] = useState<RegistryItem | null>(null)
const [errorsFor, setErrorsFor] = useState<{ id: string; rows: Array<{ ts: string; level: string; message: string }> } | null>(null)
const [confirmUninstall, setConfirmUninstall] = useState<PluginRow | null>(null)
const [consentUpdate, setConsentUpdate] = useState<{ plugin: PluginRow; version: string; newPermissions: string[]; newEgress: string[] } | null>(null)
// A QUEUE, not one slot: "Update All" can produce several re-consent prompts —
// each must be shown, not silently overwritten by the last one.
const [consentQueue, setConsentQueue] = useState<Array<{ plugin: PluginRow; version: string; newPermissions: string[]; newEgress: string[] }>>([])
const [menu, setMenu] = useState<string | null>(null)
// Toolbar state.
@@ -209,12 +211,32 @@ export default function AdminPluginsPanel() {
const restart = (id: string) => act(id, async () => { await adminApi.pluginDeactivate(id); await adminApi.pluginActivate(id) }, t('admin.plugins.restarted'))
const installedIds = new Set(plugins.map(p => p.id))
// Enable/disable a plugin. Re-enabling one whose update widened its permissions
// must NOT grant them silently — the server answers 409 CONSENT_REQUIRED and we
// route through the same consent dialog the Update button uses.
const toggle = (p: PluginRow) => {
if (busy === p.id) return
if (p.enabled === 1) { void act(p.id, () => adminApi.pluginDeactivate(p.id), t('admin.plugins.deactivated')); return }
setBusy(p.id); setMenu(null)
adminApi.pluginActivate(p.id)
.then(() => toast.success(t('admin.plugins.activated')))
.catch((e: { response?: { status?: number; data?: { code?: string; error?: string; newPermissions?: string[]; newEgress?: string[] } } }) => {
const d = e?.response?.data
if (e?.response?.status === 409 && d?.code === 'CONSENT_REQUIRED') {
setConsentQueue(qq => [...qq, { plugin: p, version: latest[p.id] ?? p.version ?? '', newPermissions: d.newPermissions ?? [], newEgress: d.newEgress ?? [] }])
} else {
toast.error(d?.error || t('admin.plugins.actionError'))
}
})
.finally(() => { setBusy(null); refresh() })
}
const runUpdate = (p: PluginRow) => {
setBusy(p.id); setMenu(null)
adminApi.pluginUpdate(p.id)
.then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }) => {
if (r.activated || (r.newPermissions.length === 0 && r.newEgress.length === 0)) toast.success(t('admin.plugins.updated'))
else setConsentUpdate({ plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress })
else setConsentQueue(qq => [...qq, { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }])
})
.catch(e => toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError')))
.finally(() => { setBusy(null); refresh() })
@@ -380,8 +402,7 @@ export default function AdminPluginsPanel() {
) : shownInstalled.map(p => (
<InstalledRow key={p.id} p={p} t={t} busy={busy} menu={menu} setMenu={setMenu}
hasUpdate={updateAvailable(p)} latestVer={latest[p.id]}
onToggle={() => busy !== p.id && act(p.id, () => p.enabled === 1 ? adminApi.pluginDeactivate(p.id) : adminApi.pluginActivate(p.id),
p.enabled === 1 ? t('admin.plugins.deactivated') : t('admin.plugins.activated'))}
onToggle={() => toggle(p)}
onUpdate={() => runUpdate(p)} onRestart={() => restart(p.id)}
onErrors={() => openErrors(p.id)} onUninstall={() => { setMenu(null); setConfirmUninstall(p) }} />
))}
@@ -430,14 +451,15 @@ export default function AdminPluginsPanel() {
message={t('admin.plugins.uninstallBody')}
/>
{consentUpdate && (
{consentQueue[0] && (
<UpdateConsentDialog
data={consentUpdate} t={t}
data={consentQueue[0]} t={t}
onApprove={async () => {
const c = consentUpdate; setConsentUpdate(null)
await act(c.plugin.id, () => adminApi.pluginActivate(c.plugin.id), t('admin.plugins.updated'))
const c = consentQueue[0]; setConsentQueue(qq => qq.slice(1))
// consent:true — the ONLY path that may widen a plugin's granted rights.
await act(c.plugin.id, () => adminApi.pluginActivate(c.plugin.id, true), t('admin.plugins.updated'))
}}
onLater={() => { setConsentUpdate(null); toast.success(t('admin.plugins.updateKeptOff')) }}
onLater={() => { setConsentQueue(qq => qq.slice(1)); toast.success(t('admin.plugins.updateKeptOff')) }}
/>
)}
</div>
@@ -35,6 +35,11 @@ type Inbound =
export default function PluginFrame({ pluginId, tripId = null, className, title }: PluginFrameProps) {
const frameRef = useRef<HTMLIFrameElement | null>(null)
// A sandboxed frame may navigate ITSELF (connect-src can't stop that). If the
// plugin's document navigates away, its window identity still matches our
// iframe, so we'd keep answering trek:invoke/context to the new (possibly
// attacker) origin. Track loads: after the initial document, refuse the bridge.
const loadsRef = useRef(0)
const { locale } = useTranslation()
const navigate = useNavigate()
const toast = useToast()
@@ -59,6 +64,9 @@ export default function PluginFrame({ pluginId, tripId = null, className, title
const onMessage = async (ev: MessageEvent) => {
// The ONLY trusted identity: the message came from OUR iframe's window.
if (ev.source !== frame.contentWindow) return
// …AND that window still holds the original plugin document (loaded once).
// A 2nd load means the frame navigated elsewhere — stop bridging to it.
if (loadsRef.current > 1) return
const msg = ev.data as Inbound
if (!msg || typeof msg !== 'object') return
@@ -104,6 +112,7 @@ export default function PluginFrame({ pluginId, tripId = null, className, title
<iframe
ref={frameRef}
src={`/plugin-frame/${pluginId}/index.html`}
onLoad={() => { loadsRef.current += 1 }}
sandbox="allow-scripts allow-forms"
referrerPolicy="no-referrer"
loading="lazy"
+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') {
@@ -32,6 +32,18 @@ export function createRealRpcHost(id: string, granted: ReadonlySet<string>): Plu
data: getPluginDataDb(id),
db,
canAccessTrip: (tripId, userId) => canAccessTrip(tripId, userId),
// Two users "share a trip" when both are owner-or-member of the same trip.
canSeeUser: (actingUserId, targetUserId) =>
!!db
.prepare(
`SELECT 1 FROM trips t
LEFT JOIN trip_members m1 ON m1.trip_id = t.id AND m1.user_id = ?
LEFT JOIN trip_members m2 ON m2.trip_id = t.id AND m2.user_id = ?
WHERE (t.user_id = ? OR m1.user_id IS NOT NULL)
AND (t.user_id = ? OR m2.user_id IS NOT NULL)
LIMIT 1`,
)
.get(actingUserId, targetUserId, actingUserId, targetUserId),
broadcastToTrip: (tripId, event, payload) => broadcast(tripId, `plugin:${id}:${event}`, payload),
broadcastToUser: (userId, payload) => broadcastToUser(userId, { type: `plugin:${id}`, ...payload }),
audit: (entry) => appendAudit(db, entry),
@@ -17,6 +17,14 @@ import { pluginDataDir, pluginDbFile } from '../paths';
const MAX_SQL_LENGTH = 100_000;
const FORBIDDEN = /\b(ATTACH|DETACH|VACUUM|PRAGMA)\b/i;
// Per-plugin on-disk quota. better-sqlite3 is synchronous and runs in the HOST
// process, so an unbounded plugin DB is both a disk-exhaustion DoS on the shared
// trek.db volume and (via a huge scan) an event-loop stall. max_page_count caps
// the file (writes past it fail SQLITE_FULL, contained to the plugin) and bounds
// the worst-case scan cost. Result sets are additionally row-capped below so a
// recursive CTE / cartesian product can't materialize an unbounded array.
const QUOTA_BYTES = 256 * 1024 * 1024;
const MAX_ROWS = 100_000;
export class PluginDataDb {
private db: Db;
@@ -26,6 +34,9 @@ export class PluginDataDb {
this.db = new Database(pluginDbFile(pluginId));
this.db.pragma('journal_mode = WAL');
this.db.pragma('foreign_keys = ON');
// Cap the file size (per-connection; not persisted, so set on every open).
const pageSize = Number(this.db.pragma('page_size', { simple: true })) || 4096;
this.db.pragma(`max_page_count = ${Math.max(1, Math.floor(QUOTA_BYTES / pageSize))}`);
// Track applied migrations so db.migrate is idempotent per (plugin, id).
this.db.exec(
`CREATE TABLE IF NOT EXISTS _plugin_migrations (id TEXT PRIMARY KEY, applied_at INTEGER)`,
@@ -38,10 +49,17 @@ export class PluginDataDb {
if (FORBIDDEN.test(sql)) throw new Error('statement type not allowed for plugin databases');
}
/** Read query — returns all rows. Single statement only (better-sqlite3 prepare). */
/** Read query — returns rows up to MAX_ROWS. Single statement only. */
query(sql: string, args: unknown[] = []): unknown[] {
this.guard(sql);
return this.db.prepare(sql).all(...(args as never[]));
// iterate() pulls one row at a time, so a recursive CTE that would yield
// unboundedly is halted at the cap instead of materializing via all().
const rows: unknown[] = [];
for (const row of this.db.prepare(sql).iterate(...(args as never[]))) {
rows.push(row);
if (rows.length > MAX_ROWS) throw new Error(`query returned more than ${MAX_ROWS} rows`);
}
return rows;
}
/** Write statement(s). exec() allows multiple statements (e.g. a small setup script). */
+26 -9
View File
@@ -36,6 +36,8 @@ export interface HostDeps {
db: CoreDb;
/** Returns the trip row if the user may access it, else undefined. */
canAccessTrip(tripId: number, userId: number): unknown;
/** True if the target user is the acting user or co-members a trip with them. */
canSeeUser(actingUserId: number, targetUserId: number): boolean;
/** Namespaced trip broadcast (host forces the plugin:{id}:{event} event type). */
broadcastToTrip(tripId: number, eventType: string, payload: Record<string, unknown>): void;
/** Namespaced per-user broadcast. */
@@ -86,22 +88,37 @@ export class PluginRpcHost {
}
if (has('db:read:users')) {
this.methods.set('users.getById', (p) =>
deps.db
.prepare('SELECT id, username, display_name, avatar FROM users WHERE id = ?')
.get(num(p.id, 'id')),
);
// Scope to people the acting user can actually see (self or a trip they
// share) so a plugin can't enumerate every account's profile by looping ids.
this.methods.set('users.getById', (p, uid) => {
const id = num(p.id, 'id');
if (uid === undefined) throw new ForbiddenResource('user reads require an authenticated user context');
if (id !== uid && !this.deps.canSeeUser(uid, id)) throw new ForbiddenResource(`no access to user ${id}`);
return deps.db.prepare('SELECT id, username, display_name, avatar FROM users WHERE id = ?').get(id);
});
}
if (has('ws:broadcast:trip')) {
this.methods.set('ws.broadcastToTrip', (p) => {
deps.broadcastToTrip(num(p.tripId, 'tripId'), str(p.event, 'event'), asPayload(p.data));
// Gate the TARGET the same way reads are gated: a plugin may only push to a
// trip room the acting user is a member of — never an arbitrary/other-tenant
// trip. (Event-type namespacing alone doesn't cross the membership boundary.)
this.methods.set('ws.broadcastToTrip', (p, uid) => {
const tripId = num(p.tripId, 'tripId');
if (uid === undefined) throw new ForbiddenResource('broadcasts require an authenticated user context');
if (!this.deps.canAccessTrip(tripId, uid)) throw new ForbiddenResource(`no access to trip ${tripId}`);
deps.broadcastToTrip(tripId, str(p.event, 'event'), asPayload(p.data));
return { ok: true };
});
}
if (has('ws:broadcast:user')) {
this.methods.set('ws.broadcastToUser', (p) => {
deps.broadcastToUser(num(p.userId, 'userId'), { event: str(p.event, 'event'), ...asPayload(p.data) });
// Restrict to the acting user's own connections — a plugin may not push to
// an arbitrary user it has no relationship to.
this.methods.set('ws.broadcastToUser', (p, uid) => {
const userId = num(p.userId, 'userId');
if (uid === undefined || userId !== uid) {
throw new ForbiddenResource('a plugin may only broadcast to the acting user');
}
deps.broadcastToUser(userId, { event: str(p.event, 'event'), ...asPayload(p.data) });
return { ok: true };
});
}
@@ -51,6 +51,11 @@ export interface PluginManifest {
}
const ID_RE = /^[a-z][a-z0-9-]{2,39}$/;
// An outbound host: an exact FQDN or a `*.`-prefixed wildcard, always with a real
// multi-label suffix. Rejects `*`, `*.`, whole-TLD `*.com`, schemes, and any
// embedded space — all of which would otherwise widen egress or inject a CSP
// source token when the host is interpolated into connect-src.
const HOST_RE = /^(\*\.)?([a-z0-9-]+\.)+[a-z0-9-]+$/i;
// Static path segments under /api/admin/plugins — a plugin id must never shadow them
// (id "registry" would collide with GET registry/:id vs :id/errors routing).
const RESERVED_IDS = new Set(['registry', 'install', 'rescan']);
@@ -77,11 +82,21 @@ export function parseManifest(raw: unknown): PluginManifest {
const unknown = permissions.filter((p) => !isKnownPermission(p));
if (unknown.length) throw new ManifestError(`unknown permission(s): ${unknown.join(', ')}`);
// Validate the host portion of every per-host outbound permission: this is the
// string the runtime egress guard AND the iframe CSP connect-src are built from.
const badOutbound = permissions
.filter((p) => p.startsWith('http:outbound:'))
.map((p) => p.slice('http:outbound:'.length))
.find((h) => !HOST_RE.test(h));
if (badOutbound !== undefined) throw new ManifestError(`invalid http:outbound host "${badOutbound}"`);
const egress = arr(m.egress).map(String);
if (permissions.some((p) => p === 'http:outbound' || p.startsWith('http:outbound:')) && egress.length === 0) {
throw new ManifestError('http:outbound declared but egress[] is empty');
}
if (egress.includes('*')) throw new ManifestError('egress[] must not contain a bare "*"');
const badEgress = egress.find((h) => !HOST_RE.test(h));
if (badEgress !== undefined) throw new ManifestError(`invalid egress host "${badEgress}"`);
const trek = optStr(m.trek);
return {
@@ -21,7 +21,9 @@ export function scanForNativeBinaries(dir: string): string[] {
return;
}
for (const e of entries) {
if (++seen > MAX_ENTRIES) return;
// Never silently stop scanning — an artifact padded past the cap must be
// rejected, not passed clean (it could hide a .node past the limit).
if (++seen > MAX_ENTRIES) throw new Error('artifact has too many files to scan for native binaries');
const childRel = rel ? `${rel}/${e.name}` : e.name;
if (e.isSymbolicLink()) continue; // never follow symlinks
if (e.isDirectory()) {
@@ -47,7 +47,7 @@ export function extractArchive(buf: Buffer, dest: string, limits: ExtractLimits
const lim = { ...DEFAULTS, ...limits };
const isGzip = buf[0] === 0x1f && buf[1] === 0x8b;
const isZip = buf[0] === 0x50 && buf[1] === 0x4b;
const members = isGzip ? readTarGz(buf) : isZip ? readZip(buf) : null;
const members = isGzip ? readTarGz(buf, lim) : isZip ? readZip(buf, lim) : null;
if (!members) throw new ExtractError('unsupported archive format (expected .tar.gz or .zip)');
let total = 0;
@@ -72,8 +72,8 @@ export function extractArchive(buf: Buffer, dest: string, limits: ExtractLimits
}
// ── minimal tar.gz reader ────────────────────────────────────────────────────
function readTarGz(buf: Buffer): Member[] {
const tar = zlib.gunzipSync(buf, { maxOutputLength: DEFAULTS.maxTotalBytes + 1024 * 1024 });
function readTarGz(buf: Buffer, lim: Required<ExtractLimits>): Member[] {
const tar = zlib.gunzipSync(buf, { maxOutputLength: lim.maxTotalBytes + 1024 * 1024 });
const members: Member[] = [];
let off = 0;
while (off + 512 <= tar.length) {
@@ -96,7 +96,7 @@ function readTarGz(buf: Buffer): Member[] {
}
// ── minimal ZIP reader (central directory) ───────────────────────────────────
function readZip(buf: Buffer): Member[] {
function readZip(buf: Buffer, lim: Required<ExtractLimits>): Member[] {
// Find End Of Central Directory.
let eocd = -1;
for (let i = buf.length - 22; i >= 0 && i > buf.length - 22 - 65536; i--) {
@@ -104,8 +104,14 @@ function readZip(buf: Buffer): Member[] {
}
if (eocd < 0) throw new ExtractError('invalid zip (no EOCD)');
const count = buf.readUInt16LE(eocd + 10);
// Bound the entry count from the EOCD (up to 65535) BEFORE inflating anything.
if (count > lim.maxEntries) throw new ExtractError('too many entries');
let p = buf.readUInt32LE(eocd + 16); // central dir offset
const members: Member[] = [];
// Enforce the cumulative uncompressed budget AS WE GO, so a decompression bomb
// (many entries each inflating to ~maxFileBytes) can't materialize gigabytes in
// memory before the caller's write loop ever runs.
let total = 0;
for (let i = 0; i < count; i++) {
if (buf.readUInt32LE(p) !== 0x02014b50) throw new ExtractError('invalid zip (central header)');
const method = buf.readUInt16LE(p + 10);
@@ -128,9 +134,14 @@ function readZip(buf: Buffer): Member[] {
if (isSymlink) { members.push({ name, type: 'other' }); continue; }
if (name.endsWith('/')) { members.push({ name, type: 'dir' }); continue; }
let data: Buffer;
if (method === 0) data = Buffer.from(comp);
else if (method === 8) data = zlib.inflateRawSync(comp, { maxOutputLength: DEFAULTS.maxFileBytes + 1024 });
else throw new ExtractError(`unsupported zip compression method ${method}`);
if (method === 0) {
if (compSize > lim.maxFileBytes) throw new ExtractError(`file too large: ${name}`);
data = Buffer.from(comp);
} else if (method === 8) {
data = zlib.inflateRawSync(comp, { maxOutputLength: lim.maxFileBytes + 1024 });
} else throw new ExtractError(`unsupported zip compression method ${method}`);
total += data.length;
if (total > lim.maxTotalBytes) throw new ExtractError('archive exceeds size limit');
members.push({ name, type: 'file', data });
}
return members;
+31 -16
View File
@@ -1,5 +1,6 @@
import { createHash } from 'node:crypto';
import dns from 'node:dns/promises';
import { isBlockedIp } from '../runtime/egress-policy';
/**
* SSRF-hardened download for the plugin installer (#plugins, M4). The primary
@@ -27,20 +28,9 @@ function isAllowedHost(hostname: string): boolean {
}
export function isPrivateIp(ip: string): boolean {
const v4 = ip.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
if (v4) {
const [a, b] = [Number(v4[1]), Number(v4[2])];
return (
a === 10 ||
a === 127 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 169 && b === 254) ||
a === 0
);
}
const v6 = ip.toLowerCase();
return v6 === '::1' || v6.startsWith('fc') || v6.startsWith('fd') || v6.startsWith('fe80') || v6.startsWith('::ffff:127.');
// Reuse the egress guard's canonicalizing check so non-canonical IPv6 forms
// (hex IPv4-mapped, compressed) and CGNAT/multicast are all covered here too.
return isBlockedIp(ip);
}
async function assertSafeHost(urlStr: string): Promise<void> {
@@ -73,13 +63,38 @@ export async function safeDownload(urlStr: string, maxBytes = MAX_BYTES): Promis
if (!resp.ok) throw new DownloadError(`download failed: ${resp.status}`);
const declared = Number(resp.headers.get('content-length') || 0);
if (declared && declared > maxBytes) throw new DownloadError('artifact exceeds size limit');
const bytes = Buffer.from(await resp.arrayBuffer());
if (bytes.length > maxBytes) throw new DownloadError('artifact exceeds size limit');
// Stream the body and abort the moment it crosses the cap — a chunked response
// (e.g. codeload archives) carries no content-length, so buffering the whole
// body first (arrayBuffer) could OOM the server before any post-check fires.
const bytes = await readCapped(resp, maxBytes);
return { bytes, sha256: createHash('sha256').update(bytes).digest('hex') };
}
throw new DownloadError('too many redirects');
}
/** Read a response body into a Buffer, throwing once it exceeds maxBytes. */
async function readCapped(resp: Response, maxBytes: number): Promise<Buffer> {
const reader = resp.body?.getReader();
if (!reader) {
const b = Buffer.from(await resp.arrayBuffer());
if (b.length > maxBytes) throw new DownloadError('artifact exceeds size limit');
return b;
}
const chunks: Buffer[] = [];
let received = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
received += value.length;
if (received > maxBytes) {
await reader.cancel().catch(() => {});
throw new DownloadError('artifact exceeds size limit');
}
chunks.push(Buffer.from(value));
}
return Buffer.concat(chunks);
}
/** Constant-time compare of two hex digests. */
export function sha256Matches(actual: string, expected: string): boolean {
if (actual.length !== expected.length) return false;
@@ -67,8 +67,13 @@ export class PluginFrameController {
/** Per-plugin, locked-down CSP for the sandboxed frame document. */
private frameCsp(pluginId: string): string {
const outbound = this.runtime.outboundHostsOf(pluginId);
const connect = ["'self'", ...outbound.map((h) => (h.includes('://') ? h : `https://${h}`))].join(' ');
// Defense in depth: the manifest validator already constrains these hosts, but
// never interpolate anything that isn't a clean host/wildcard into connect-src
// (a stray space or `*` would inject an extra CSP source token).
const outbound = this.runtime
.outboundHostsOf(pluginId)
.filter((h) => /^(\*\.)?[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(h));
const connect = ["'self'", ...outbound.map((h) => `https://${h}`)].join(' ');
return [
"default-src 'none'",
// The frame runs at an OPAQUE origin (sandbox without allow-same-origin),
@@ -13,6 +13,13 @@ import { PluginRegistryService } from './registry/registry.service';
const HTTP_OUTBOUND = 'http:outbound:';
/** Thrown when (re-)activating would grant permissions the admin hasn't consented to. */
export class PluginConsentRequired extends Error {
constructor(message: string, readonly newPermissions: string[] = [], readonly newEgress: string[] = []) {
super(message);
}
}
/**
* Owns the isolated-plugin runtime lifecycle inside NestJS (#plugins, M2).
* Bridges the DB registry (`plugins` rows) to the process supervisor: activate
@@ -79,16 +86,30 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
await this.supervisor.shutdownAll();
}
/** Spawn a plugin from its DB row (granted permissions + decrypted config). */
async activate(id: string): Promise<void> {
/**
* Spawn a plugin from its DB row (granted permissions + decrypted config).
*
* A plain activate may NEVER widen what the admin already consented to — that is
* what stops a plugin left off pending an update's re-consent from silently
* gaining the new rights via the row's enable toggle. The FIRST activation of a
* freshly-installed plugin (no prior grant) is itself the consent for its
* declared set; widening an already-consented set requires `consentWiden` (the
* update consent dialog), and otherwise throws PluginConsentRequired.
*/
async activate(id: string, consentWiden = false): Promise<void> {
const row = db.prepare('SELECT id, status, permissions, granted_permissions, config FROM plugins WHERE id = ?').get(id) as
| PluginRow
| undefined;
if (!row) throw new Error(`plugin ${id} not found`);
// Activating IS the consent gate: grant the plugin's DECLARED permissions
// (the admin reviewed them on the consent screen), persist, then spawn.
const declared = parseArray(row.permissions).filter(isKnownPermission);
const granted = parseArray(row.granted_permissions);
const newGrants = declared.filter((p) => !granted.includes(p));
if (granted.length > 0 && newGrants.length > 0 && !consentWiden) {
const newEgress = newGrants.filter((p) => p.startsWith(HTTP_OUTBOUND)).map((p) => p.slice(HTTP_OUTBOUND.length)).filter(Boolean);
const newPermissions = newGrants.filter((p) => !p.startsWith(HTTP_OUTBOUND));
throw new PluginConsentRequired(`plugin ${id} requests new permissions; explicit re-consent is required`, newPermissions, newEgress);
}
// Mark it enabled (admin intent) so it reboots after restarts/crashes.
db.prepare('UPDATE plugins SET granted_permissions = ?, enabled = 1 WHERE id = ?').run(JSON.stringify(declared), id);
const config = decryptConfig(parseObject(row.config));
@@ -14,7 +14,13 @@ import { PluginRuntimeService } from './plugin-runtime.service';
* isolated child over RPC. The plugin never sees raw headers or the session
* cookie, so it cannot replay the user's credentials.
*/
const SAFE_RESPONSE_HEADERS = new Set(['content-type', 'cache-control', 'content-disposition', 'location']);
// A plugin fully controls the reply's content-type + body, and this route is on
// TREK's REAL origin, so we must never let the browser RENDER it as a document
// (that would run plugin script at our origin, outside the iframe sandbox) nor
// follow a plugin-chosen redirect. `location` / `content-disposition` are NOT
// passthrough; every reply is forced to nosniff + attachment below. fetch-based
// consumers (the plugin's own client) are unaffected — fetch ignores both.
const SAFE_RESPONSE_HEADERS = new Set(['content-type', 'cache-control']);
@Controller('api/plugins/:pluginId')
export class PluginsProxyController {
@@ -66,10 +72,17 @@ export class PluginsProxyController {
user?.id,
)) as { status?: number; headers?: Record<string, string>; body?: unknown };
res.status(reply?.status ?? 200);
// A plugin may not choose a 3xx (open redirect via the reflected Location);
// clamp the status to a non-redirect range.
const status = reply?.status ?? 200;
res.status(status >= 300 && status < 400 ? 502 : status);
for (const [k, v] of Object.entries(reply?.headers ?? {})) {
if (SAFE_RESPONSE_HEADERS.has(k.toLowerCase())) res.setHeader(k, v);
}
// Neutralize the "render plugin HTML at TREK's origin" sandbox-escape: never
// let the response be treated as a document, and never MIME-sniff it.
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Disposition', 'attachment');
res.send(reply?.body ?? '');
} catch (e) {
res.status(502).json({ error: 'Plugin error', detail: e instanceof Error ? e.message : 'unknown' });
@@ -1,6 +1,6 @@
import { Body, Controller, Delete, Get, HttpCode, HttpException, Param, Post, Put, UseGuards } from '@nestjs/common';
import { PluginsService } from './plugins.service';
import { PluginRuntimeService } from './plugin-runtime.service';
import { PluginRuntimeService, PluginConsentRequired } from './plugin-runtime.service';
import { PluginRegistryService } from './registry/registry.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { AdminGuard } from '../auth/admin.guard';
@@ -66,11 +66,16 @@ export class PluginsController {
@Post(':id/activate')
@HttpCode(200)
async activate(@Param('id') id: string) {
async activate(@Param('id') id: string, @Body() body: { consent?: boolean } = {}) {
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
try {
await this.runtime.activate(id);
await this.runtime.activate(id, !!body?.consent);
} catch (e) {
// Re-enabling a plugin whose update widened its permissions must NOT grant
// them silently — surface a distinct code so the UI opens the consent dialog.
if (e instanceof PluginConsentRequired) {
throw new HttpException({ error: e.message, code: 'CONSENT_REQUIRED', newPermissions: e.newPermissions, newEgress: e.newEgress }, 409);
}
throw new HttpException({ error: e instanceof Error ? e.message : 'activation failed' }, 400);
}
return { status: this.runtime.isActive(id) ? 'active' : 'error' };
@@ -29,19 +29,52 @@ export function isBlockedIp(ip: string): boolean {
a >= 224 // multicast + reserved
);
}
const h = ip.toLowerCase().replace(/^\[/, '').replace(/\]$/, '');
if (h === '::' || h === '::1' || h === '0:0:0:0:0:0:0:0' || h === '0:0:0:0:0:0:0:1') return true;
if (h.startsWith('fe80') || h.startsWith('fc') || h.startsWith('fd')) return true; // link-local + ULA
const mapped = h.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (mapped) return isBlockedIp(mapped[1]);
// IPv6: expand to 8 hextets so EVERY spelling is checked (compressed `::`,
// hex IPv4-mapped `::ffff:a9fe:a9fe`, dotted `::ffff:169.254.169.254`, `0::1`).
const g = expandV6(ip.replace(/^\[/, '').replace(/\]$/, ''));
if (g) {
if (g.every((x) => x === 0)) return true; // :: (unspecified)
// IPv4-mapped (::ffff:x) and IPv4-compatible (::x) → check the embedded IPv4.
if (g.slice(0, 5).every((x) => x === 0) && (g[5] === 0xffff || g[5] === 0)) {
return isBlockedIp(`${g[6] >> 8}.${g[6] & 0xff}.${g[7] >> 8}.${g[7] & 0xff}`);
}
if ((g[0] & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local
if ((g[0] & 0xfe00) === 0xfc00) return true; // fc00::/7 ULA
}
return false;
}
/** Expand an IPv6 literal into its 8 numeric hextets, or null if not a valid IPv6. */
function expandV6(ip: string): number[] | null {
let h = ip.toLowerCase().replace(/%.*$/, '');
if (!h.includes(':')) return null;
const dotted = h.match(/^(.*:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
if (dotted) {
const v4 = dotted[2].split('.').map(Number);
if (v4.some((n) => n > 255)) return null;
h = dotted[1] + ((v4[0] << 8) | v4[1]).toString(16) + ':' + ((v4[2] << 8) | v4[3]).toString(16);
}
const halves = h.split('::');
if (halves.length > 2) return null;
const head = halves[0] ? halves[0].split(':') : [];
const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
const groups =
halves.length === 2 ? [...head, ...Array(8 - head.length - tail.length).fill('0'), ...tail] : head;
if (groups.length !== 8) return null;
const nums = groups.map((x) => (x === '' ? NaN : parseInt(x, 16)));
return nums.some((n) => !Number.isInteger(n) || n < 0 || n > 0xffff) ? null : nums;
}
/** Build a declared-egress host matcher (exact host or `*.suffix` wildcard). */
export function makeHostAllow(egress: string[]): (host: string) => boolean {
const patterns = egress.map((h) => h.trim().toLowerCase()).filter(Boolean);
const patterns = egress
.map((h) => h.trim().toLowerCase().replace(/\.$/, '')) // drop a trailing dot (FQDN trick)
.filter((p) => p && p !== '*')
// A wildcard must have a real multi-label suffix — reject the degenerate
// `*.` (matched any trailing-dot host = allow-all) and whole-TLD `*.com`.
.filter((p) => !p.startsWith('*.') || p.slice(2).includes('.'));
return (host: string) => {
const h = host.toLowerCase();
const h = host.toLowerCase().replace(/\.$/, '');
return patterns.some((p) => (p.startsWith('*.') ? h === p.slice(2) || h.endsWith(p.slice(1)) : h === p));
};
}
@@ -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
@@ -1,3 +1,4 @@
import fs from 'node:fs';
import { randomUUID } from 'node:crypto';
import { fork, type ChildProcess } from 'node:child_process';
import { resolveChildEntry, pluginCodeDir, pluginRealCodeDir, pluginPermissionArgs, ensurePluginModuleType } from '../paths';
@@ -51,6 +52,7 @@ interface Supervised {
pending: Map<string, Pending>; // host→child invokes awaiting a response
invocations: Map<string, number | undefined>; // reqId -> acting user of that invoke (undefined = no user, e.g. a job)
activation?: { resolve: () => void; reject: (e: Error) => void };
activationTimer?: ReturnType<typeof setTimeout>; // deadline for the plugin to reach 'active'
}
export interface SupervisorTuning {
@@ -60,6 +62,7 @@ export interface SupervisorTuning {
backoffCapMs?: number;
killGraceMs?: number;
maxRssBytes?: number;
activationTimeoutMs?: number;
}
const DEFAULTS: Required<SupervisorTuning> = {
@@ -68,6 +71,9 @@ const DEFAULTS: Required<SupervisorTuning> = {
crashLimit: 5,
backoffCapMs: 30_000,
killGraceMs: 3000,
// A plugin that never reaches 'loaded' (e.g. a synchronous infinite loop in
// onLoad) must not hang the admin's activate() forever nor peg a core unreaped.
activationTimeoutMs: 30_000,
// Hard RSS ceiling — the real memory cap. --max-old-space-size only bounds the
// V8 heap; Buffers/ArrayBuffers/native allocations sail past it, so a plugin
// could OOM the box while staying "under" the heap limit. Overridable via env.
@@ -110,15 +116,36 @@ export class PluginSupervisor {
this.ensureSweep();
return new Promise<void>((resolve, reject) => {
sup.activation = { resolve, reject };
// Deadline: if the plugin never reports 'loaded' (stuck onLoad), reject the
// activation and kill the child rather than hanging + leaking a busy core.
sup.activationTimer = setTimeout(() => {
if (sup.status === 'active') return;
this.hooks.onLog?.(sup.id, 'error', 'activation timed out; killing');
this.setStatus(sup, 'error', 'activation timed out');
sup.activation?.reject(new Error('plugin did not finish loading in time'));
sup.activation = undefined;
this.running.delete(sup.id);
void this.kill(sup);
sup.rpcHost.dispose();
}, this.tuning.activationTimeoutMs);
sup.activationTimer.unref?.();
this.spawn(sup);
});
}
private clearActivationTimer(sup: Supervised): void {
if (sup.activationTimer) {
clearTimeout(sup.activationTimer);
sup.activationTimer = undefined;
}
}
/** Stop a plugin: ask it to unload, then kill. Idempotent. */
async disable(id: string): Promise<void> {
const sup = this.running.get(id);
if (!sup) return;
this.running.delete(id);
this.clearActivationTimer(sup);
this.setStatus(sup, 'stopped');
await this.kill(sup);
sup.rpcHost.dispose();
@@ -254,6 +281,7 @@ export class PluginSupervisor {
const d = msg.data as { routes?: PluginRouteInfo[]; jobs?: string[] };
sup.routes = d.routes ?? [];
sup.jobs = d.jobs ?? [];
this.clearActivationTimer(sup);
this.setStatus(sup, 'active');
sup.activation?.resolve();
sup.activation = undefined;
@@ -261,6 +289,7 @@ export class PluginSupervisor {
}
case 'load-error': {
const message = (msg.data as { message?: string })?.message || 'plugin load failed';
this.clearActivationTimer(sup);
this.setStatus(sup, 'error', message);
sup.activation?.reject(new Error(message));
sup.activation = undefined;
@@ -299,6 +328,7 @@ export class PluginSupervisor {
sup.crashes.push(Date.now());
const recent = sup.crashes.filter((t) => t > Date.now() - this.tuning.crashWindowMs);
if (recent.length >= this.tuning.crashLimit) {
this.clearActivationTimer(sup);
this.setStatus(sup, 'error', `auto-disabled after ${recent.length} crashes`);
sup.activation?.reject(new Error('plugin crashed repeatedly'));
sup.activation = undefined;
@@ -327,20 +357,40 @@ export class PluginSupervisor {
reapStale(now = Date.now()): void {
for (const sup of this.running.values()) {
if (sup.status !== 'active') continue;
const rss = this.childRss(sup);
if (now - sup.lastBeat > this.tuning.heartbeatTimeoutMs) {
this.hooks.onLog?.(sup.id, 'warn', 'missed heartbeats; killing');
sup.child?.kill('SIGKILL');
} else if (sup.lastRss > this.tuning.maxRssBytes) {
} else if (rss > this.tuning.maxRssBytes) {
this.hooks.onLog?.(
sup.id,
'warn',
`exceeded memory ceiling (${Math.round(sup.lastRss / 1048576)}MB > ${Math.round(this.tuning.maxRssBytes / 1048576)}MB); killing`,
`exceeded memory ceiling (${Math.round(rss / 1048576)}MB > ${Math.round(this.tuning.maxRssBytes / 1048576)}MB); killing`,
);
sup.child?.kill('SIGKILL');
}
}
}
/**
* Resident memory of the child, measured HOST-side from the OS — never trusting
* the child's self-reported heartbeat rss (a malicious plugin can spoof it).
* On Linux (prod runs in a Linux container) this reads /proc/<pid>/statm; where
* that isn't available (dev on win/mac) it falls back to the reported value.
*/
private childRss(sup: Supervised): number {
const pid = sup.child?.pid;
if (pid) {
try {
const resident = Number(fs.readFileSync(`/proc/${pid}/statm`, 'utf8').split(' ')[1]);
if (Number.isFinite(resident)) return resident * 4096; // pages → bytes
} catch {
/* not Linux / process gone — fall back */
}
}
return sup.lastRss;
}
private async kill(sup: Supervised): Promise<void> {
const child = sup.child;
if (!child) return;
@@ -41,12 +41,14 @@ describe('create-rpc-host wiring', () => {
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);
await host.dispatch({ k: 'req', id: '2', method: 'ws.broadcastToTrip', params: { tripId: 1, event: 'ping', data: { a: 1 } } });
// 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']));
await bcastUser.dispatch({ k: 'req', id: '3', method: 'ws.broadcastToUser', params: { userId: 7, event: 'hi', data: {} } });
expect(broadcastToUser).toHaveBeenCalledWith(7, { type: 'plugin:wired', event: 'hi' });
// 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', () => {
@@ -16,6 +16,8 @@ describe('isBlockedIp', () => {
'0.0.0.0', '10.1.2.3', '127.0.0.1', '172.16.0.1', '172.31.255.255',
'192.168.1.1', '169.254.169.254', '100.64.0.1', '224.0.0.1', '255.255.255.255',
'::1', '::', 'fe80::1', 'fc00::1', 'fd12:3456::1', '::ffff:127.0.0.1', '::ffff:10.0.0.1',
// non-canonical IPv6 spellings must be blocked too (canonicalization)
'0::1', '::ffff:a9fe:a9fe', '::ffff:169.254.169.254', '0:0:0:0:0:0:0:1', 'fD00::1', '::ffff:7f00:1',
])('blocks %s', (ip) => {
expect(isBlockedIp(ip)).toBe(true);
});
@@ -43,6 +45,21 @@ describe('makeHostAllow', () => {
const allow = makeHostAllow([]);
expect(allow('anything.com')).toBe(false);
});
it('rejects degenerate wildcards that would be allow-all', () => {
// `*.` matched any trailing-dot host; `*.com`/`*` are whole-TLD/allow-all.
expect(makeHostAllow(['*.'])('evil.com')).toBe(false);
expect(makeHostAllow(['*.'])('evil.com.')).toBe(false);
expect(makeHostAllow(['*.com'])('evil.com')).toBe(false);
expect(makeHostAllow(['*'])('evil.com')).toBe(false);
});
it('normalizes a trailing dot so an FQDN host cannot dodge an exact match', () => {
const allow = makeHostAllow(['api.example.com']);
expect(allow('api.example.com.')).toBe(true); // trailing-dot FQDN still matches
// and the FQDN trick can't turn a real wildcard into allow-all
expect(makeHostAllow(['*.example.com'])('sink.attacker.com.')).toBe(false);
});
});
describe('classifyConnect', () => {
@@ -32,6 +32,11 @@ describe('parseManifest', () => {
expect(m.settings[0]).toMatchObject({ key: 'api_key', secret: true, scope: 'instance' });
});
it('accepts a valid exact + wildcard outbound host', () => {
const m = parseManifest({ ...base, permissions: ['http:outbound:api.x.com', 'http:outbound:*.example.com'], egress: ['api.x.com', '*.example.com'] });
expect(m.permissions).toContain('http:outbound:*.example.com');
});
it.each([
['Bad-Id', { ...base, id: 'Bad-Id' }, /invalid id/],
['reserved id', { ...base, id: 'registry' }, /reserved id/],
@@ -41,6 +46,11 @@ describe('parseManifest', () => {
['unknown perm', { ...base, permissions: ['fs:read'] }, /unknown permission/],
['outbound no egress', { ...base, permissions: ['http:outbound'] }, /egress\[\] is empty/],
['wildcard egress', { ...base, permissions: ['http:outbound'], egress: ['*'] }, /bare "\*"/],
['allow-all outbound host', { ...base, permissions: ['http:outbound:*'], egress: ['x.com'] }, /invalid http:outbound host/],
['degenerate wildcard host', { ...base, permissions: ['http:outbound:*.'], egress: ['x.com'] }, /invalid http:outbound host/],
['whole-TLD outbound host', { ...base, permissions: ['http:outbound:*.com'], egress: ['x.com'] }, /invalid http:outbound host/],
['host with a space', { ...base, permissions: ['http:outbound:legit.com x'], egress: ['legit.com'] }, /invalid http:outbound host/],
['bad egress host', { ...base, permissions: ['http:outbound:api.x.com'], egress: ['api.x.com', 'no spaces here'] }, /invalid egress host/],
['not an object', 'nope', /not an object/],
['missing name', { id: 'x-plugin', version: '1.0.0', type: 'page' }, /missing\/invalid "name"/],
])('rejects: %s', (_label, input, re) => {
+32 -8
View File
@@ -28,6 +28,8 @@ function makeDeps(): HostDeps {
},
// trip 1 is accessible to user 42; everything else is not
canAccessTrip: vi.fn((tripId: number, userId: number) => (tripId === 1 && userId === 42 ? { id: 1 } : undefined)),
// user 42 may see user 3 (they share a trip); nobody else
canSeeUser: vi.fn((actingUserId: number, targetUserId: number) => actingUserId === 42 && targetUserId === 3),
broadcastToTrip: vi.fn(),
broadcastToUser: vi.fn(),
};
@@ -97,22 +99,41 @@ describe('PluginRpcHost — capability enforcement', () => {
expect((allowed as RpcResponse).result).toEqual([{ id: 7, name: 'Place' }]);
});
it('db:read:users returns only the public projection', async () => {
it('db:read:users returns only the public projection for a visible user', async () => {
const host = new PluginRpcHost('p', new Set(['db:read:users']), deps);
const res = await host.dispatch(req('users.getById', { id: 3 }));
const res = await host.dispatch(req('users.getById', { id: 3 }), 42);
expect(ok(res)).toBe(true);
expect((res as RpcResponse).result).toEqual({ id: 3, username: 'ada', display_name: 'Ada', avatar: null });
// the SELECT column list is host-controlled — no password/token columns
expect(deps.db.prepare).toHaveBeenCalledWith(expect.stringContaining('id, username, display_name, avatar'));
});
it('ws:broadcast:trip forwards to the (namespaced) broadcaster', async () => {
it('db:read:users is RESOURCE_FORBIDDEN for a user the acting user cannot see (no enumeration)', async () => {
const host = new PluginRpcHost('p', new Set(['db:read:users']), deps);
const forbidden = await host.dispatch(req('users.getById', { id: 999 }), 42);
expect((forbidden as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
// and with no bound acting user (job / forged call), also denied
const noUser = await host.dispatch(req('users.getById', { id: 3 }), undefined);
expect((noUser as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
});
it('ws:broadcast:trip forwards to the (namespaced) broadcaster for a member', async () => {
const host = new PluginRpcHost('p', new Set(['ws:broadcast:trip']), deps);
const res = await host.dispatch(req('ws.broadcastToTrip', { tripId: 1, event: 'ping', data: { a: 1 } }));
const res = await host.dispatch(req('ws.broadcastToTrip', { tripId: 1, event: 'ping', data: { a: 1 } }), 42);
expect(ok(res)).toBe(true);
expect(deps.broadcastToTrip).toHaveBeenCalledWith(1, 'ping', { a: 1 });
});
it('ws:broadcast:trip is RESOURCE_FORBIDDEN for a non-member trip (no cross-tenant push)', async () => {
const host = new PluginRpcHost('p', new Set(['ws:broadcast:trip']), deps);
const forbidden = await host.dispatch(req('ws.broadcastToTrip', { tripId: 999, event: 'x', data: {} }), 42);
expect((forbidden as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
expect(deps.broadcastToTrip).not.toHaveBeenCalled();
// and a broadcast with no bound acting user is denied too
const noUser = await host.dispatch(req('ws.broadcastToTrip', { tripId: 1, event: 'x', data: {} }), undefined);
expect((noUser as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
});
it('bad params are BAD_PARAMS', async () => {
const host = new PluginRpcHost('p', new Set(['db:own']), deps);
const res = await host.dispatch(req('db.query', { args: [] }));
@@ -127,11 +148,14 @@ describe('PluginRpcHost — capability enforcement', () => {
}
});
it('ws:broadcast:user forwards to the per-user broadcaster', async () => {
it('ws:broadcast:user forwards only to the acting user (never an arbitrary one)', async () => {
const host = new PluginRpcHost('p', new Set(['ws:broadcast:user']), deps);
const res = await host.dispatch(req('ws.broadcastToUser', { userId: 9, event: 'poke', data: { x: 2 } }));
const res = await host.dispatch(req('ws.broadcastToUser', { userId: 42, event: 'poke', data: { x: 2 } }), 42);
expect(ok(res)).toBe(true);
expect(deps.broadcastToUser).toHaveBeenCalledWith(9, { event: 'poke', x: 2 });
expect(deps.broadcastToUser).toHaveBeenCalledWith(42, { event: 'poke', x: 2 });
// broadcasting to a DIFFERENT user is refused
const forbidden = await host.dispatch(req('ws.broadcastToUser', { userId: 9, event: 'poke', data: {} }), 42);
expect((forbidden as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
});
it('non-array db args are BAD_PARAMS; a primitive ws payload is wrapped', async () => {
@@ -139,7 +163,7 @@ describe('PluginRpcHost — capability enforcement', () => {
const bad = await host.dispatch(req('db.query', { sql: 'SELECT 1', args: 'nope' }));
expect((bad as RpcError).error.code).toBe('BAD_PARAMS');
await host.dispatch(req('ws.broadcastToTrip', { tripId: 1, event: 'ping', data: 'primitive' }));
await host.dispatch(req('ws.broadcastToTrip', { tripId: 1, event: 'ping', data: 'primitive' }), 42);
expect(deps.broadcastToTrip).toHaveBeenCalledWith(1, 'ping', { value: 'primitive' });
});
+3 -2
View File
@@ -171,8 +171,9 @@ and resolves to `commitSha` · manifest parity at that commit (`id`, `version`,
`type`, `apiVersion`, and `nativeModules` must not be `true`) · **the downloaded
artifact's SHA-256 matches the pin** and its size is within bounds · **no native
binaries** in the archive · `egress[]` present (and no bare `*`) when
`http:outbound` is declared. Any unique slug is fine — **there are no reserved
namespaces**.
`http:outbound` is declared. Any unique slug is fine **except `registry`,
`install` and `rescan`**, which the install loader refuses (they collide with
admin API route segments).
**README** (`check-readme.mjs`, fetched at the pinned commit): must exist at the
repo root, contain the sections **What it does / Screenshots / Permissions /
+3 -2
View File
@@ -153,8 +153,9 @@ registry entry, so you never hand-compute a SHA-256 or hand-write registry JSON:
Run them via `npx trek-plugin-sdk …`. See [[Plugin Development|Plugin-Development]]
for the SDK and manifest, and [[Publishing a Plugin|Plugin-Publishing]] for the
registry PR flow. Publishing no longer reserves any namespaces — any unique slug
is fine — but an `id` stays bound to the GitHub owner who first registered it, so
registry PR flow. Any unique slug works — only `registry`, `install` and `rescan`
are refused (they'd collide with admin API routes) — and an `id` stays bound to
the GitHub owner who first registered it, so
nobody can repoint an existing plugin. Entries may optionally carry an author
signing key (`authorPublicKey` + a per-version `signature`) for offline signature
verification on top of the SHA-256 pin.