leistungsbilanz-ts/tests/security/route-inventory.ts
Grovy311 a7a1595cbf Add test and security baseline
- make quick/check/mutation from cj/security-baseline (Makefile unchanged,
  settings in baseline.mk), pre-commit, gitleaks, Semgrep rules, ESLint
  security rules, Forgejo workflow
- tests/security: generic baseline tests plus app config and role tests
  against createApp() with an in-memory database
- extract createApp() from src/server/index.ts; LB_DATABASE_PATH for tests
- fix gaps found by the tests: reject unknown fields on project create,
  answer malformed JSON with 400 instead of 500, set nosniff on the API
- npm audit fix (next 16.4.1 and others, no high/critical left)
- clear ESLint errors, safeFileName without control-character regex
- per-project ownership stays an open, documented gap (idorGap -> TODO)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:40:33 +02:00

82 lines
3.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Routen-Inventar für Express 5.
*
* Express 5 (Paket `router` v2) speichert an gemounteten Routern den Mount-Pfad nicht
* mehr. Ein Test, der nur `app.router.stack` liest, findet deshalb die Routen unter
* `app.use("/api", apiRouter)` nicht mit vollem Pfad – und läuft grün, ohne zu prüfen.
* Darum wird `Router.prototype.use` VOR dem Import der App instrumentiert und jeder
* Mount-Pfad mitgeschrieben.
*
* Reihenfolge ist Pflicht: `installRouteRecorder()` aufrufen, DANACH die App importieren.
*/
import { createRequire } from "node:module";
import path from "node:path";
type Layer = { route?: { path: string | string[]; methods: Record<string, boolean> }; handle: unknown };
type RouterLike = { stack: Layer[] };
const mountPaths = new WeakMap<object, string[]>();
let RouterProto: Record<string, unknown> | undefined;
function loadRouterPrototype(): Record<string, unknown> {
const fromApp = createRequire(path.join(process.cwd(), "package.json"));
const expressEntry = fromApp.resolve("express");
// Dasselbe `router`-Modul, das Express selbst lädt (gleiche Datei = gleicher Cache-Eintrag).
const Router = createRequire(expressEntry)("router");
return Router.prototype;
}
export function installRouteRecorder(): void {
if (RouterProto) return;
RouterProto = loadRouterPrototype();
const originalUse = RouterProto.use as (...args: unknown[]) => unknown;
RouterProto.use = function patchedUse(this: RouterLike, ...args: unknown[]) {
const mount = typeof args[0] === "string" ? (args[0] as string) : Array.isArray(args[0]) ? null : "/";
const before = this.stack.length;
const result = originalUse.apply(this, args);
for (const layer of this.stack.slice(before)) {
const existing = mountPaths.get(layer) ?? [];
// Arrays/RegExp als Mount-Pfad werden als "?" markiert und fallen im Test auf.
mountPaths.set(layer, [...existing, mount ?? "?"]);
}
return result;
};
}
function isRouter(handle: unknown): handle is RouterLike {
return typeof handle === "function" && Array.isArray((handle as Partial<RouterLike>).stack);
}
function join(prefix: string, suffix: string): string {
const joined = `${prefix}/${suffix}`.replace(/\/+/g, "/");
return joined.length > 1 ? joined.replace(/\/$/, "") : joined;
}
export type Route = { method: string; path: string };
/** Alle Routen der App mit vollem Pfad, z. B. { method: "GET", path: "/api/projects/:id" }. */
export function collectRoutes(app: { router: RouterLike }): Route[] {
if (!RouterProto) throw new Error("installRouteRecorder() wurde nicht vor dem App-Import aufgerufen");
const routes: Route[] = [];
const walk = (router: RouterLike, prefix: string) => {
for (const layer of router.stack) {
if (layer.route) {
const paths = Array.isArray(layer.route.path) ? layer.route.path : [layer.route.path];
for (const routePath of paths) {
for (const [method, enabled] of Object.entries(layer.route.methods)) {
if (enabled && method !== "_all") routes.push({ method: method.toUpperCase(), path: join(prefix, routePath) });
}
}
} else if (isRouter(layer.handle)) {
for (const mount of mountPaths.get(layer) ?? ["?"]) walk(layer.handle, join(prefix, mount));
}
}
};
walk(app.router, "/");
return routes.sort((a, b) => `${a.path} ${a.method}`.localeCompare(`${b.path} ${b.method}`));
}
/** Ersetzt :param und *wildcard durch einen Beispielwert. */
export function fillPath(routePath: string, value = "1"): string {
return routePath.replace(/:[A-Za-z0-9_]+/g, value).replace(/\*[A-Za-z0-9_]*/g, value);
}