- 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>
82 lines
3.6 KiB
TypeScript
82 lines
3.6 KiB
TypeScript
/**
|
||
* 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);
|
||
}
|