mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
ffb73fac20
Nothing in src/nest/** was an APP_GUARD. Every protected route said so itself, 62 times, which means a forgotten @UseGuards was a silent bypass rather than an error. GlobalAuthGuard inverts that: a route is authenticated unless it carries @Public() or @OptionalAuth(), or declares its own guard chain. Reading the declared chain is what makes this behaviour-neutral. Nest runs global guards before route guards, so without that check the global guard would 401 an anonymous request to an addon-gated controller before its AddonGuard could answer 404 - inverting an order four e2e suites pin, and leaking which addons are installed. A controller that declared a chain keeps deciding for itself. It is a new class rather than JwtAuthGuard gaining a Reflector: that guard is constructed positionally in its own test with a context stub that has no getHandler, and the two want opposite behaviour anyway. @Public() takes a required reason, and the reasons are the diff worth reading: the invite token IS the credential, help assets are loaded by <img> which cannot send one, logout has to work with an expired session or nobody can sign out. The real deliverable is the inventory. validateRouteGuards runs in buildApp next to the body-contract gate and refuses to start when a route answers anonymously without being on a reviewed list of 25 - or when a listed one no longer does. An allow list nobody prunes stops being a list. Default-deny found two controllers on its own: SharedController and OidcController both sit in a file next to a guarded controller, so a file-level search for unguarded ones missed them. 37 tests went red and named them. That is the mechanism working before the change shipped, not after.
171 lines
8.2 KiB
TypeScript
171 lines
8.2 KiB
TypeScript
import { All, Controller, Param, Req, Res } from '@nestjs/common';
|
|
import type { Request, Response } from 'express';
|
|
import { extractToken, verifyJwtAndLoadUser } from '../auth/jwt-verify';
|
|
import { pluginsEnabled } from './kill-switch';
|
|
import { PluginRuntimeService } from './plugin-runtime.service';
|
|
import { Public } from '../auth/public.decorator';
|
|
|
|
/**
|
|
* 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<string, unknown> | undefined): Record<string, string> {
|
|
const out: Record<string, string> = {};
|
|
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;
|
|
}
|
|
}
|
|
|
|
@Public('auth is data-driven per plugin route (route.auth in the manifest), asserted in the handler')
|
|
@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; 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<string, unknown>) : {},
|
|
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<string, string>; 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`, `/<tab>/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' });
|
|
}
|
|
}
|
|
}
|