mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
332e5c3c42
Closes the four gaps the security review surfaced: - OS permission model on the prod plugin child (Node --permission with fs-read scoped to the compiled server dir + the plugin's own code dir, no fs-write/child_process/worker/native). A plugin can no longer read trek.db or the .jwt_secret/.encryption_key files, nor shell out — the direct-fs and RCE escapes that bypassed the RPC layer. Opt-out via TREK_PLUGIN_PERMISSIONS=off. - Egress guard extended from fetch to the net.Socket connect choke point, so node:http/https/net/tls obey the declared-egress allowlist too (no declared egress = no outbound). Under the permission model there is no clean escape to an unwrapped runtime. Kernel/network-namespace containment remains the container step. - Trip reads are membership-checked against the acting user the HOST binds from the authenticated invocation, not an asUserId the plugin supplies; a job/onLoad (no user) can't read user-scoped trips. - Optional minisign (Ed25519) author signatures verified offline, TOFU-pinned (migration 158). Unsigned plugins install on sha256 alone; a signed plugin can't silently drop its signature or swap its author key. Server suite green (permission-model activation verified against the Koffi reference plugin on dev1).
79 lines
3.2 KiB
TypeScript
79 lines
3.2 KiB
TypeScript
import { All, Controller, Param, Req, Res } from '@nestjs/common';
|
|
import type { Request, Response } from 'express';
|
|
import { extractToken, verifyJwtAndLoadUser } from '../../middleware/auth';
|
|
import { pluginsEnabled } from './kill-switch';
|
|
import { PluginRuntimeService } from './plugin-runtime.service';
|
|
|
|
/**
|
|
* Proxies a plugin's own HTTP routes at /api/plugins/:id/* (#plugins, M2).
|
|
*
|
|
* This is a SINGLE static Nest route — it never registers per-plugin routes at
|
|
* runtime. It matches the request against the plugin's declared routes, enforces
|
|
* per-route auth (routes with `auth:false` — OAuth callbacks/webhooks — skip the
|
|
* session check), and forwards a minimal, whitelisted request view to the
|
|
* 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']);
|
|
|
|
@Controller('api/plugins/:pluginId')
|
|
export class PluginsProxyController {
|
|
constructor(private readonly runtime: PluginRuntimeService) {}
|
|
|
|
@All('*path')
|
|
async proxy(@Param('pluginId') pluginId: string, @Req() req: Request, @Res() res: Response): Promise<void> {
|
|
if (!pluginsEnabled() || !this.runtime.isActive(pluginId)) {
|
|
res.status(404).json({ error: 'Plugin not found' });
|
|
return;
|
|
}
|
|
|
|
const rest = (req.params as Record<string, unknown>).path ?? (req.params as Record<string, unknown>)[0] ?? '';
|
|
const sub = '/' + (Array.isArray(rest) ? rest.join('/') : String(rest)).replace(/^\/+/, '');
|
|
const route = this.runtime.routesOf(pluginId).find((r) => r.method === req.method && r.path === sub);
|
|
if (!route) {
|
|
res.status(404).json({ error: 'Not found' });
|
|
return;
|
|
}
|
|
|
|
// Per-route auth: default-on; `auth:false` routes are public (OAuth cb/webhook).
|
|
let user: { id: number; username: string; is_admin?: boolean } | null = null;
|
|
if (route.auth) {
|
|
const token = extractToken(req);
|
|
const loaded = token ? verifyJwtAndLoadUser(token) : null;
|
|
if (!loaded) {
|
|
res.status(401).json({ error: 'Access token required', code: 'AUTH_REQUIRED' });
|
|
return;
|
|
}
|
|
user = loaded;
|
|
}
|
|
|
|
try {
|
|
const reply = (await this.runtime.invoke(
|
|
pluginId,
|
|
'invoke.route',
|
|
{
|
|
routeId: route.i,
|
|
req: {
|
|
method: req.method,
|
|
path: sub,
|
|
query: req.query,
|
|
body: req.body ?? null,
|
|
user: user ? { id: user.id, username: user.username, isAdmin: !!user.is_admin } : null,
|
|
},
|
|
},
|
|
// Bind the authenticated session user as the acting user for any trip
|
|
// reads this invocation makes — the plugin cannot override it.
|
|
user?.id,
|
|
)) as { status?: number; headers?: Record<string, string>; body?: unknown };
|
|
|
|
res.status(reply?.status ?? 200);
|
|
for (const [k, v] of Object.entries(reply?.headers ?? {})) {
|
|
if (SAFE_RESPONSE_HEADERS.has(k.toLowerCase())) res.setHeader(k, v);
|
|
}
|
|
res.send(reply?.body ?? '');
|
|
} catch (e) {
|
|
res.status(502).json({ error: 'Plugin error', detail: e instanceof Error ? e.message : 'unknown' });
|
|
}
|
|
}
|
|
}
|