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. */ // 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']); // Inbound header allowlist for `auth:false` routes (webhooks). A plugin can verify a // provider's signature, but ONLY over an explicit allowlist that NEVER carries an // auth/session credential — mirroring the response-header allowlist above. Cookie, // Authorization, X-Socket-Id and every forwarded-auth header are deliberately absent, // so a forwarded header can never leak a TREK session or be replayed. Signature + // event headers from the common providers (GitHub/Stripe/Svix/GitLab/generic) pass. const SAFE_INBOUND_HEADERS = new Set([ 'content-type', 'user-agent', 'x-request-id', 'x-idempotency-key', 'x-hub-signature', 'x-hub-signature-256', 'x-github-event', 'x-github-delivery', 'stripe-signature', 'svix-id', 'svix-timestamp', 'svix-signature', 'x-gitlab-event', 'x-gitlab-token', 'x-signature', 'x-signature-256', 'x-webhook-signature', 'x-event-type', ]); function pickInboundHeaders(raw: Record | undefined): Record { const out: Record = {}; for (const [k, v] of Object.entries(raw ?? {})) { if (!SAFE_INBOUND_HEADERS.has(k.toLowerCase())) continue; out[k.toLowerCase()] = Array.isArray(v) ? v.join(', ') : String(v ?? ''); } return out; } // Origin used only to parse a plugin-supplied redirect target. Any host works as // long as it can never match a real one, so a target that stays "same-origin" // after WHATWG URL parsing is provably a relative in-app path. const REDIRECT_BASE = 'https://trek.invalid'; /** * Return a plugin redirect `Location` ONLY if it is a same-origin (relative) * target, normalised to path+query+hash; otherwise `null`. Parsing (rather than * regex-matching the raw string) is what makes this safe against the open-redirect * bypasses browsers introduce for special schemes: `\` normalised to `/`, stripped * tab/newline, protocol-relative `//host`, and fully-qualified/other-scheme URLs * all resolve to a different origin and are rejected. */ function toRelativeLocation(loc: unknown): string | null { if (typeof loc !== 'string' || loc === '') return null; try { const u = new URL(loc, REDIRECT_BASE); if (u.origin !== REDIRECT_BASE) return null; return u.pathname + u.search + u.hash; } catch { return null; } } @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 { if (!pluginsEnabled() || !this.runtime.isActive(pluginId)) { res.status(404).json({ error: 'Plugin not found' }); return; } const rest = (req.params as Record).path ?? (req.params as Record)[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; role?: 'admin' | 'user' } | 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, // Webhook (auth:false) routes also receive the raw request bytes (base64, // so a non-UTF-8 signed body survives) — the plugin decodes and verifies a // provider's HMAC over the exact payload; the parsed JSON can't be // re-serialised identically. rawBodyBase64: route.auth === false ? ((req as { rawBody?: Buffer }).rawBody?.toString('base64') ?? null) : undefined, // Only auth:false routes (webhooks) get inbound headers, and only the // allowlisted, credential-free subset — an authenticated route never // needs them and must not see even the safe ones. headers: route.auth === false ? pickInboundHeaders(req.headers as Record) : {}, user: user ? { id: user.id, username: user.username, isAdmin: user.role === '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; body?: unknown }; const status = reply?.status ?? 200; const headers = reply?.headers ?? {}; res.setHeader('X-Content-Type-Options', 'nosniff'); // A genuine redirect (301/302/303/307/308) may only target a RELATIVE in-app // path — never an external URL (open redirect / phishing). This still lets an // OAuth-callback route bounce the user back into the app. 300/304 aren't // redirects and fall through to the normal path. if ([301, 302, 303, 307, 308].includes(status)) { const loc = Object.entries(headers).find(([k]) => k.toLowerCase() === 'location')?.[1]; // Resolve against a throwaway origin and require the result to stay on it: // only a same-origin (relative) target survives. A string check can't do // this safely — browsers normalise `\` to `/` and strip tab/newline for // http(s), so `/\evil.com`, `//evil.com`, `//evil.com`, and any // absolute URL all resolve off-origin and must be rejected. const safeLoc = toRelativeLocation(loc); if (!safeLoc) { res.status(502).json({ error: 'Plugin error', detail: 'unsafe redirect target' }); return; } res.status(status).setHeader('Location', safeLoc); res.end(); return; } for (const [k, v] of Object.entries(headers)) { if (SAFE_RESPONSE_HEADERS.has(k.toLowerCase())) res.setHeader(k, v); } // Never let a non-redirect reply render as a document at TREK's origin // (that would run plugin script at our real origin, outside the sandbox). res.setHeader('Content-Disposition', 'attachment'); res.status(status).send(reply?.body ?? ''); } catch (e) { res.status(502).json({ error: 'Plugin error', detail: e instanceof Error ? e.message : 'unknown' }); } } }