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>
This commit is contained in:
Grovy311 2026-10-15 09:40:33 +02:00
parent 67476ea59b
commit a7a1595cbf
38 changed files with 5852 additions and 442 deletions

View file

@ -0,0 +1,88 @@
/**
* App-spezifische Sicherheitstests (Leistungsbilanz): die Rollen-Grenze admin/user und die
* Pflicht zum Passwortwechsel – über echte HTTP-Requests gegen createApp().
* Ergänzt baseline.test.ts, das nur Login-Pflicht und Objekt-Zugriff kennt.
*/
import assert from "node:assert/strict";
import type { AddressInfo } from "node:net";
import { after, before, describe, it } from "node:test";
import config, { START_PASSWORD, WRITE_HEADER, signIn } from "./security.config.ts";
type Headers = Record<string, string>;
let server: import("node:http").Server;
let baseUrl = "";
let admin: Headers = {};
let user: Headers = {};
let userId = "";
async function call(method: string, path: string, headers: Headers, body?: unknown) {
return fetch(baseUrl + path, {
method,
headers: { ...headers, "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
}
before(async () => {
const app = await config.createApp();
const { userService } = await import("../../src/server/composition/auth.js");
await userService.createUser({
username: "chefin-admin",
name: "chefin-admin",
role: "admin",
initialPassword: START_PASSWORD,
});
await userService.createUser({
username: "neu-ohne-wechsel",
name: "neu-ohne-wechsel",
role: "user",
initialPassword: START_PASSWORD,
});
server = app.listen(0, "127.0.0.1");
await new Promise((resolve) => server.once("listening", resolve));
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
admin = await signIn(baseUrl, "chefin-admin");
user = await signIn(baseUrl, "planer-a");
const list = (await (await call("GET", "/api/users", admin)).json()) as { id: string; username: string }[];
userId = list.find((entry) => entry.username === "planer-b")!.id;
});
after(() => new Promise<void>((resolve) => server.close(() => resolve())));
describe("Rollen: Benutzerverwaltung nur für admin", () => {
it("normaler Nutzer bekommt 403 auf jede /api/users-Route", async () => {
const cases: [string, string, unknown?][] = [
["GET", "/api/users"],
["POST", "/api/users", { username: "eingeschleust", name: "x", role: "admin", initialPassword: "lang-genug-123" }],
["PATCH", `/api/users/${userId}`, { role: "admin" }],
["POST", `/api/users/${userId}/reset-password`, { newPassword: "lang-genug-123" }],
];
const failures: string[] = [];
for (const [method, path, body] of cases) {
const res = await call(method, path, user, body);
if (res.status !== 403) failures.push(`${method} ${path} als user -> ${res.status}`);
}
assert.deepEqual(failures, [], failures.join("\n"));
});
it("admin erreicht die Benutzerverwaltung (Gegenprobe)", async () => {
const res = await call("GET", "/api/users", admin);
assert.equal(res.status, 200);
});
});
describe("Startpasswort", () => {
it("ohne Passwortwechsel ist außer Konto-Endpunkten alles gesperrt", async () => {
const login = await call("POST", "/api/auth/login", WRITE_HEADER, {
username: "neu-ohne-wechsel",
password: START_PASSWORD,
});
assert.equal(login.status, 200);
const cookie = login.headers.getSetCookie().find((c) => c.startsWith("lb_session="))!.split(";")[0];
const headers = { ...WRITE_HEADER, cookie };
assert.equal((await call("GET", "/api/projects", headers)).status, 403);
assert.equal((await call("POST", "/api/projects", headers, { name: "x" })).status, 403);
assert.equal((await call("GET", "/api/auth/me", headers)).status, 200, "Gegenprobe: /auth/me bleibt erreichbar");
});
});