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:
parent
67476ea59b
commit
a7a1595cbf
38 changed files with 5852 additions and 442 deletions
|
|
@ -1,8 +1,10 @@
|
|||
import path from "node:path";
|
||||
import { createDatabaseContext } from "./database-context.js";
|
||||
|
||||
// LB_DATABASE_PATH ist nur fuer Tests gedacht (z. B. ":memory:" in tests/security);
|
||||
// der Betrieb nutzt weiterhin data/leistungsbilanz.db.
|
||||
const defaultDatabaseContext = createDatabaseContext(
|
||||
path.resolve("data", "leistungsbilanz.db")
|
||||
process.env.LB_DATABASE_PATH || path.resolve("data", "leistungsbilanz.db")
|
||||
);
|
||||
|
||||
export const db = defaultDatabaseContext.db;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ import { circuits } from "../schema/circuits.js";
|
|||
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
|
||||
function temporaryIdentifierKey(table: "circuit" | "component", id: string): string {
|
||||
return `${table}:${id}`;
|
||||
}
|
||||
|
||||
export class CircuitGroupRenumberProjectCommandRepository
|
||||
implements CircuitGroupRenumberProjectCommandStore
|
||||
{
|
||||
|
|
@ -69,7 +73,7 @@ export class CircuitGroupRenumberProjectCommandRepository
|
|||
table: "component" as const,
|
||||
})),
|
||||
]) {
|
||||
const key = `${assignment.table}:${assignment.id}`;
|
||||
const key = temporaryIdentifierKey(assignment.table, assignment.id);
|
||||
const temporary = createTemporaryIdentifier(
|
||||
key,
|
||||
occupiedIdentifiers
|
||||
|
|
@ -145,7 +149,7 @@ export class CircuitGroupRenumberProjectCommandRepository
|
|||
"circuit",
|
||||
assignment.circuitId,
|
||||
circuitListId,
|
||||
temporaryIdentifiers.get(`circuit:${assignment.circuitId}`)!,
|
||||
temporaryIdentifiers.get(temporaryIdentifierKey("circuit", assignment.circuitId))!,
|
||||
assignment.targetEquipmentIdentifier
|
||||
);
|
||||
}
|
||||
|
|
@ -155,7 +159,7 @@ export class CircuitGroupRenumberProjectCommandRepository
|
|||
"component",
|
||||
assignment.componentId,
|
||||
circuitListId,
|
||||
temporaryIdentifiers.get(`component:${assignment.componentId}`)!,
|
||||
temporaryIdentifiers.get(temporaryIdentifierKey("component", assignment.componentId))!,
|
||||
assignment.targetEquipmentIdentifier
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import {
|
||||
assertDistributionBoardDeleteProjectCommand,
|
||||
assertDistributionBoardInsertProjectCommand,
|
||||
distributionBoardDeleteCommandType,
|
||||
distributionBoardInsertCommandType,
|
||||
invertDistributionBoardStructureProjectCommand,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import { externalModelObjects } from "../schema/external-model-objects.js";
|
|||
import { externalModelSources } from "../schema/external-model-sources.js";
|
||||
import { externalRoomMappings } from "../schema/external-room-mappings.js";
|
||||
import { projectDevices } from "../schema/project-devices.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
import { rooms } from "../schema/rooms.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
import { ExternalModelStateRepository } from "./external-model-state.repository.js";
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import {
|
|||
invertExternalObjectNewCircuitProjectCommand,
|
||||
type ExternalObjectNewCircuitProjectCommand,
|
||||
} from "../../domain/models/external-object-new-circuit-project-command.model.js";
|
||||
import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
|
||||
import type { CircuitSnapshot } from "../../domain/models/circuit-structure-project-command.model.js";
|
||||
import type { ExternalObjectNewCircuitProjectCommandStore } from "../../domain/ports/external-object-new-circuit-project-command.store.js";
|
||||
import { parseGroupedEquipmentIdentifier } from "../../domain/services/circuit-group-numbering.js";
|
||||
|
|
|
|||
|
|
@ -3252,7 +3252,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||
showLabel
|
||||
/>
|
||||
<IconButton
|
||||
disabled={!Boolean(sortState) && !hasActiveFilters}
|
||||
disabled={!sortState && !hasActiveFilters}
|
||||
icon={FilterX}
|
||||
label="Sortierung/Filter zurücksetzen"
|
||||
onClick={clearSortAndFilters}
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ function Open() {
|
|||
|
||||
function Logo({ src, className }: { src: string | null; className: string }) {
|
||||
return src ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
// Plain <img> on purpose: the print document renders the stored logo as-is.
|
||||
<img alt="Logo" className={`pd-logo ${className}`} src={src} />
|
||||
) : (
|
||||
<span className={`pd-logo-missing ${className}`}>Logo</span>
|
||||
|
|
|
|||
91
src/server/app.ts
Normal file
91
src/server/app.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import express from "express";
|
||||
import { globalDeviceRouter } from "./routes/global-device.routes.js";
|
||||
import { projectDeviceRouter } from "./routes/project-device.routes.js";
|
||||
import { documentBrandingRouter } from "./routes/document-branding.routes.js";
|
||||
import { projectRouter } from "./routes/project.routes.js";
|
||||
import { cableSizingRouter } from "./routes/cable-sizing.routes.js";
|
||||
import { authRouter } from "./routes/auth.routes.js";
|
||||
import { userRouter } from "./routes/user.routes.js";
|
||||
import { errorMiddleware } from "./middleware/error.middleware.js";
|
||||
import {
|
||||
createAuthGuard,
|
||||
requirePasswordChanged,
|
||||
requireSameOriginForWrites,
|
||||
} from "./auth/auth.middleware.js";
|
||||
import { authConfig, sessionStore } from "./composition/auth.js";
|
||||
import { userRepository } from "./composition/application-repositories.js";
|
||||
import { createLogger } from "../shared/logging/logger.js";
|
||||
|
||||
const logger = createLogger("api");
|
||||
|
||||
/**
|
||||
* Builds the Express app without listening, so tests (for example the
|
||||
* security baseline under tests/security) can start it on a free port.
|
||||
* Process concerns (listen, signals, bootstrap admin) stay in index.ts.
|
||||
*/
|
||||
export function createApp(): express.Express {
|
||||
const app = express();
|
||||
|
||||
app.disable("x-powered-by");
|
||||
// The web container sets the same header for pages (next.config.mjs); the
|
||||
// API sets it itself so it also holds when the API is reached directly.
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
next();
|
||||
});
|
||||
app.use(express.json({ limit: "25mb" }));
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (req.path === "/health") {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
logger.debug("request started", { method: req.method, path: req.originalUrl });
|
||||
res.on("finish", () => {
|
||||
const meta = {
|
||||
method: req.method,
|
||||
path: req.originalUrl,
|
||||
status: res.statusCode,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
if (res.statusCode >= 500) logger.error("request completed", meta);
|
||||
else if (res.statusCode >= 400) logger.warn("request completed", meta);
|
||||
else logger.info("request completed", meta);
|
||||
});
|
||||
res.on("close", () => {
|
||||
if (!res.writableEnded) {
|
||||
logger.warn("request aborted before response finished", {
|
||||
method: req.method,
|
||||
path: req.originalUrl,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
}
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
app.get("/health", (_req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Everything below /api requires a signed-in, active user, except
|
||||
// /auth/login, /auth/status and /auth/logout (see createAuthGuard's
|
||||
// openPaths). authRouter is mounted AFTER the guard (not before) so that
|
||||
// /auth/me and /auth/change-password still get req.authUser populated by it.
|
||||
app.use("/api", requireSameOriginForWrites());
|
||||
app.use("/api", createAuthGuard(authConfig, sessionStore, userRepository));
|
||||
app.use("/api", requirePasswordChanged());
|
||||
|
||||
app.use("/api/auth", authRouter);
|
||||
app.use("/api/users", userRouter);
|
||||
app.use("/api/cable-sizing", cableSizingRouter);
|
||||
app.use("/api/projects", projectRouter);
|
||||
app.use("/api/global-devices", globalDeviceRouter);
|
||||
app.use("/api/project-devices", projectDeviceRouter);
|
||||
app.use("/api/document-branding", documentBrandingRouter);
|
||||
|
||||
app.use(errorMiddleware);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
|
@ -171,8 +171,16 @@ function parseSelectedIds(value: unknown) {
|
|||
return value as string[];
|
||||
}
|
||||
|
||||
function safeFileName(value: string) {
|
||||
return value.trim().replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_") || "Projekt";
|
||||
const FORBIDDEN_FILE_NAME_CHARACTERS = new Set(["<", ">", ":", '"', "/", "\\", "|", "?", "*"]);
|
||||
|
||||
/** Replaces characters Windows forbids in file names and ASCII control characters. */
|
||||
export function safeFileName(value: string) {
|
||||
const sanitized = Array.from(value.trim(), (character) =>
|
||||
character.charCodeAt(0) <= 0x1f || FORBIDDEN_FILE_NAME_CHARACTERS.has(character)
|
||||
? "_"
|
||||
: character
|
||||
).join("");
|
||||
return sanitized || "Projekt";
|
||||
}
|
||||
|
||||
function hash(bytes: Uint8Array) {
|
||||
|
|
|
|||
|
|
@ -1,83 +1,14 @@
|
|||
import express from "express";
|
||||
import { globalDeviceRouter } from "./routes/global-device.routes.js";
|
||||
import { projectDeviceRouter } from "./routes/project-device.routes.js";
|
||||
import { documentBrandingRouter } from "./routes/document-branding.routes.js";
|
||||
import { projectRouter } from "./routes/project.routes.js";
|
||||
import { cableSizingRouter } from "./routes/cable-sizing.routes.js";
|
||||
import { authRouter } from "./routes/auth.routes.js";
|
||||
import { userRouter } from "./routes/user.routes.js";
|
||||
import { errorMiddleware } from "./middleware/error.middleware.js";
|
||||
import {
|
||||
createAuthGuard,
|
||||
requirePasswordChanged,
|
||||
requireSameOriginForWrites,
|
||||
} from "./auth/auth.middleware.js";
|
||||
import { authConfig, sessionStore, userService } from "./composition/auth.js";
|
||||
import { userRepository } from "./composition/application-repositories.js";
|
||||
import { createApp } from "./app.js";
|
||||
import { userService } from "./composition/auth.js";
|
||||
import { createLogger, toErrorMeta } from "../shared/logging/logger.js";
|
||||
import { closeDefaultDatabase } from "../db/client.js";
|
||||
|
||||
const logger = createLogger("api");
|
||||
const app = express();
|
||||
const app = createApp();
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
const heartbeatIntervalMs = 5 * 60 * 1000;
|
||||
const shutdownTimeoutMs = 15 * 1000;
|
||||
|
||||
app.disable("x-powered-by");
|
||||
app.use(express.json({ limit: "25mb" }));
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (req.path === "/health") {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
logger.debug("request started", { method: req.method, path: req.originalUrl });
|
||||
res.on("finish", () => {
|
||||
const meta = {
|
||||
method: req.method,
|
||||
path: req.originalUrl,
|
||||
status: res.statusCode,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
if (res.statusCode >= 500) logger.error("request completed", meta);
|
||||
else if (res.statusCode >= 400) logger.warn("request completed", meta);
|
||||
else logger.info("request completed", meta);
|
||||
});
|
||||
res.on("close", () => {
|
||||
if (!res.writableEnded) {
|
||||
logger.warn("request aborted before response finished", {
|
||||
method: req.method,
|
||||
path: req.originalUrl,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
}
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
app.get("/health", (_req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Everything below /api requires a signed-in, active user, except
|
||||
// /auth/login, /auth/status and /auth/logout (see createAuthGuard's
|
||||
// openPaths). authRouter is mounted AFTER the guard (not before) so that
|
||||
// /auth/me and /auth/change-password still get req.authUser populated by it.
|
||||
app.use("/api", requireSameOriginForWrites());
|
||||
app.use("/api", createAuthGuard(authConfig, sessionStore, userRepository));
|
||||
app.use("/api", requirePasswordChanged());
|
||||
|
||||
app.use("/api/auth", authRouter);
|
||||
app.use("/api/users", userRouter);
|
||||
app.use("/api/cable-sizing", cableSizingRouter);
|
||||
app.use("/api/projects", projectRouter);
|
||||
app.use("/api/global-devices", globalDeviceRouter);
|
||||
app.use("/api/project-devices", projectDeviceRouter);
|
||||
app.use("/api/document-branding", documentBrandingRouter);
|
||||
|
||||
app.use(errorMiddleware);
|
||||
|
||||
process.on("uncaughtException", (error) => {
|
||||
logger.error("uncaught exception, exiting", toErrorMeta(error));
|
||||
process.exit(1);
|
||||
|
|
|
|||
|
|
@ -3,12 +3,36 @@ import { createLogger, toErrorMeta } from "../../shared/logging/logger.js";
|
|||
|
||||
const logger = createLogger("api");
|
||||
|
||||
/**
|
||||
* Client errors raised before a handler runs (body-parser: malformed JSON,
|
||||
* body too large, unsupported charset) carry an http-errors status and
|
||||
* `expose: true`. They are answered with that 4xx status instead of a 500,
|
||||
* still without echoing parser details back to the client.
|
||||
*/
|
||||
function clientErrorStatus(error: unknown): number | undefined {
|
||||
if (typeof error !== "object" || error === null) return undefined;
|
||||
const { status, expose } = error as { status?: unknown; expose?: unknown };
|
||||
if (expose !== true || typeof status !== "number") return undefined;
|
||||
return status >= 400 && status < 500 ? status : undefined;
|
||||
}
|
||||
|
||||
export function errorMiddleware(
|
||||
error: unknown,
|
||||
req: Request,
|
||||
res: Response,
|
||||
_next: NextFunction
|
||||
) {
|
||||
const status = clientErrorStatus(error);
|
||||
if (status !== undefined) {
|
||||
logger.warn("request rejected", {
|
||||
method: req.method,
|
||||
path: req.originalUrl,
|
||||
status,
|
||||
...toErrorMeta(error),
|
||||
});
|
||||
res.status(status).json({ error: "Ungültige Anfrage." });
|
||||
return;
|
||||
}
|
||||
logger.error("request handler threw", {
|
||||
method: req.method,
|
||||
path: req.originalUrl,
|
||||
|
|
@ -16,4 +40,3 @@ export function errorMiddleware(
|
|||
});
|
||||
res.status(500).json({ error: "Internal Server Error" });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export const createProjectSchema = z.object({
|
|||
isPublicBuilding: z.boolean().optional(),
|
||||
singlePhaseVoltageV: z.number().positive().optional(),
|
||||
threePhaseVoltageV: z.number().positive().optional(),
|
||||
});
|
||||
}).strict();
|
||||
|
||||
export const updateProjectSettingsSchema = z
|
||||
.object({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue