feat(cert-manager): Zusammenführen mit Fullchain und Nur Kette
- Kettenbau mit Aussteller- und Signaturprüfung (checkIssued plus verify), Wurzel nur auf Wunsch - build-Route für Fullchain und Nur Kette (PEM), eigene JSON-Grenze 512 KiB mit 413 und Code tooLarge - Reiter Zusammenführen mit Kettenansicht, Hinweis bei fehlendem Zwischenzertifikat Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -43,8 +43,20 @@ describe('analyzeWorkingSet', () => {
|
||||
expect(result.ignored).toEqual([{ file: 6, path: 'readme.txt', reason: 'unknown' }]);
|
||||
});
|
||||
|
||||
it('Ketten und gesperrte Eintraege sind in diesem Stand leer', () => {
|
||||
expect(result.chains).toEqual([]);
|
||||
it('liefert zwei Ketten: RSA unvollstaendig (Luecke nach der CA), EC vollstaendig mit Wurzel', () => {
|
||||
const certs = result.items.filter((i): i is CertItem => i.kind === 'certificate');
|
||||
expect(result.chains).toHaveLength(2);
|
||||
const byHead = (cn: string) =>
|
||||
result.chains.find((c) => certs.find((x) => x.id === c.headId)?.cn === cn);
|
||||
const rsa = byHead('www.example.test');
|
||||
expect(rsa?.complete).toBe(false);
|
||||
expect(rsa?.gap).toMatchObject({ kind: 'afterCa', missingIssuerCn: 'Tessera Test Root RSA' });
|
||||
const ec = byHead('ec.example.test');
|
||||
expect(ec?.complete).toBe(true);
|
||||
expect(certs.find((x) => x.id === ec?.rootId)?.cn).toBe('Tessera Test Root EC');
|
||||
});
|
||||
|
||||
it('gesperrte Eintraege sind in diesem Stand leer', () => {
|
||||
expect(result.locked).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { buildChains } from './cert-chain';
|
||||
import { detectBlob } from './cert-model';
|
||||
import type {
|
||||
AnalysisResult,
|
||||
@@ -11,7 +12,7 @@ import type {
|
||||
/**
|
||||
* Fassade der Analyse (quick-261009-ikt, D-15): alle hochgeladenen Dateien erkennen,
|
||||
* gleiche Teile zusammenfassen und ordnen. Zustandslos; nichts wird gespeichert.
|
||||
* Ketten (Task 2), Schluessel/CSR-Zuordnung und gesperrte Container (Task 4) folgen.
|
||||
* Ketten ab Task 2; Schluessel/CSR-Zuordnung und gesperrte Container (Task 4) folgen.
|
||||
*/
|
||||
|
||||
export interface AnalyzeFile {
|
||||
@@ -71,5 +72,7 @@ export function analyzeWorkingSet(files: AnalyzeFile[], passwords: string[] = []
|
||||
locked.push(...result.locked);
|
||||
});
|
||||
|
||||
return { items: orderItems([...byId.values()]), chains: [], locked, ignored };
|
||||
const items = orderItems([...byId.values()]);
|
||||
const certs = items.filter((i): i is CertItem => i.kind === 'certificate');
|
||||
return { items, chains: buildChains(certs).chains, locked, ignored };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildChains } from './cert-chain';
|
||||
import { detectBlob } from './cert-model';
|
||||
import type { CertItem } from './cert-types';
|
||||
|
||||
const fx = (name: string) => readFileSync(join(__dirname, '__fixtures__', name));
|
||||
|
||||
function load(...names: string[]): CertItem[] {
|
||||
const items: CertItem[] = [];
|
||||
names.forEach((name, file) => {
|
||||
const result = detectBlob(fx(name), { file, path: name, passwords: [] });
|
||||
for (const item of result.items) if (item.kind === 'certificate') items.push(item);
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
const cnOf = (certs: CertItem[], id: string) => certs.find((c) => c.id === id)?.cn;
|
||||
const pathCns = (certs: CertItem[], path: string[]) => path.map((id) => cnOf(certs, id));
|
||||
|
||||
describe('buildChains', () => {
|
||||
it('Server + Zwischenzertifikat ohne Wurzel: unvollstaendig, Luecke nach der CA', () => {
|
||||
const certs = load('rsa-leaf.pem', 'rsa-inter.pem');
|
||||
const { chains } = buildChains(certs);
|
||||
expect(chains).toHaveLength(1);
|
||||
expect(pathCns(certs, chains[0].path)).toEqual(['www.example.test', 'Tessera Test Inter RSA']);
|
||||
expect(chains[0].complete).toBe(false);
|
||||
expect(chains[0].rootId).toBeNull();
|
||||
expect(chains[0].gap).toMatchObject({
|
||||
kind: 'afterCa',
|
||||
missingIssuerCn: 'Tessera Test Root RSA',
|
||||
});
|
||||
});
|
||||
|
||||
it('mit Wurzel: vollstaendig, rootId gesetzt, keine Luecke', () => {
|
||||
const certs = load('rsa-leaf.pem', 'rsa-inter.pem', 'rsa-root.pem');
|
||||
const { chains } = buildChains(certs);
|
||||
expect(chains).toHaveLength(1);
|
||||
expect(chains[0].complete).toBe(true);
|
||||
expect(cnOf(certs, chains[0].rootId as string)).toBe('Tessera Test Root RSA');
|
||||
expect(chains[0].gap).toBeNull();
|
||||
expect(chains[0].path).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('nur das Serverzertifikat: Luecke nach dem Server mit Adresse des Ausstellers', () => {
|
||||
const certs = load('rsa-leaf.pem');
|
||||
const { chains } = buildChains(certs);
|
||||
expect(chains[0].gap).toMatchObject({
|
||||
kind: 'afterLeaf',
|
||||
missingIssuerCn: 'Tessera Test Inter RSA',
|
||||
aiaUrls: ['http://pki.example.test/rsa-inter.cer'],
|
||||
});
|
||||
});
|
||||
|
||||
it('gleichnamige CA mit anderem Schluessel (Attrappe) wird nie genommen', () => {
|
||||
const certs = load(
|
||||
'rsa-leaf-noaki.pem',
|
||||
'rsa-inter-decoy.pem',
|
||||
'rsa-inter.pem',
|
||||
'rsa-root.pem',
|
||||
);
|
||||
const { chains } = buildChains(certs);
|
||||
expect(chains).toHaveLength(1);
|
||||
const names = chains[0].path.map((id) => certs.find((c) => c.id === id));
|
||||
expect(names[1]?.sources[0].path).toBe('rsa-inter.pem');
|
||||
expect(chains[0].complete).toBe(true);
|
||||
});
|
||||
|
||||
it('nur die Attrappe vorhanden: die Signaturpruefung lehnt sie ab, Luecke nach dem Server', () => {
|
||||
const certs = load('rsa-leaf-noaki.pem', 'rsa-inter-decoy.pem', 'rsa-root.pem');
|
||||
const { chains } = buildChains(certs);
|
||||
expect(chains).toHaveLength(1);
|
||||
expect(chains[0].path).toHaveLength(1);
|
||||
expect(chains[0].gap?.kind).toBe('afterLeaf');
|
||||
});
|
||||
|
||||
it('kreuzsigniertes Zwischenzertifikat fuehrt zur zweiten Wurzel', () => {
|
||||
const certs = load('rsa-leaf.pem', 'rsa-inter-cross.pem', 'rsa-root2.pem');
|
||||
const { chains } = buildChains(certs);
|
||||
expect(chains[0].complete).toBe(true);
|
||||
expect(cnOf(certs, chains[0].rootId as string)).toBe('Tessera Test Root RSA 2');
|
||||
});
|
||||
|
||||
it('beide Varianten vorhanden: Hauptkette ueber rsa-inter und rsa-root, mindestens eine Alternative', () => {
|
||||
const certs = load('rsa-leaf.pem', 'rsa-inter.pem', 'rsa-inter-cross.pem', 'rsa-root.pem');
|
||||
const { chains } = buildChains(certs);
|
||||
expect(chains).toHaveLength(1);
|
||||
expect(chains[0].complete).toBe(true);
|
||||
expect(cnOf(certs, chains[0].rootId as string)).toBe('Tessera Test Root RSA');
|
||||
expect(chains[0].alternatives).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('abgelaufenes Zwischenzertifikat wird nicht als Hauptkette genommen', () => {
|
||||
const certs = load('rsa-leaf.pem', 'rsa-inter-expired.pem', 'rsa-inter.pem', 'rsa-root.pem');
|
||||
const { chains } = buildChains(certs);
|
||||
const middle = certs.find((c) => c.id === chains[0].path[1]);
|
||||
expect(middle?.isExpired).toBe(false);
|
||||
expect(middle?.sources[0].path).toBe('rsa-inter.pem');
|
||||
expect(chains[0].alternatives).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('die Reihenfolge der Eingabe aendert die Hauptkette nicht', () => {
|
||||
const names = ['rsa-leaf.pem', 'rsa-inter-expired.pem', 'rsa-inter.pem', 'rsa-root.pem'];
|
||||
const forward = load(...names);
|
||||
const reversed = load(...[...names].reverse());
|
||||
const a = buildChains(forward).chains[0];
|
||||
const b = buildChains(reversed).chains[0];
|
||||
expect(a.path).toEqual(b.path);
|
||||
expect(a.alternatives).toBe(b.alternatives);
|
||||
});
|
||||
|
||||
it('Zwischenzertifikat + Wurzel ohne Serverzertifikat: eine Kette mit dem Zwischenzertifikat als Kopf', () => {
|
||||
const certs = load('rsa-inter.pem', 'rsa-root.pem');
|
||||
const { chains } = buildChains(certs);
|
||||
expect(chains).toHaveLength(1);
|
||||
expect(cnOf(certs, chains[0].headId)).toBe('Tessera Test Inter RSA');
|
||||
expect(chains[0].complete).toBe(true);
|
||||
});
|
||||
|
||||
it('selbstsigniertes Serverzertifikat: Kette aus ihm selbst, vollstaendig, ohne Wurzel', () => {
|
||||
const certs = load('selfsigned-leaf.pem');
|
||||
const { chains } = buildChains(certs);
|
||||
expect(chains[0].path).toEqual([certs[0].id]);
|
||||
expect(chains[0].complete).toBe(true);
|
||||
expect(chains[0].rootId).toBeNull();
|
||||
expect(chains[0].gap).toBeNull();
|
||||
});
|
||||
|
||||
it('EC-Kette wird genauso gebaut', () => {
|
||||
const certs = load('ec-leaf.pem', 'ec-inter.pem', 'ec-root.pem');
|
||||
const { chains } = buildChains(certs);
|
||||
expect(pathCns(certs, chains[0].path)).toEqual([
|
||||
'ec.example.test',
|
||||
'Tessera Test Inter EC',
|
||||
'Tessera Test Root EC',
|
||||
]);
|
||||
expect(chains[0].complete).toBe(true);
|
||||
});
|
||||
|
||||
it('mit vorgegebenem Kopf wird genau diese Kette gebaut', () => {
|
||||
const certs = load('rsa-leaf.pem', 'rsa-inter.pem', 'rsa-root.pem');
|
||||
const inter = certs.find((c) => c.role === 'intermediate') as CertItem;
|
||||
const { chains } = buildChains(certs, [inter.id]);
|
||||
expect(chains).toHaveLength(1);
|
||||
expect(chains[0].headId).toBe(inter.id);
|
||||
});
|
||||
|
||||
it('leere Menge ergibt keine Ketten', () => {
|
||||
expect(buildChains([])).toEqual({ chains: [] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { X509Certificate } from 'node:crypto';
|
||||
import type { CertItem, ChainGap, ChainInfo } from './cert-types';
|
||||
|
||||
/**
|
||||
* Kettenbau des Zertifikat-Managers (quick-261009-ikt, D-18). Reine Funktionen, kein Netz.
|
||||
*
|
||||
* Aussteller eines Zertifikats C ist jedes andere Zertifikat I der Menge mit
|
||||
* `C.checkIssued(I) && C.verify(I.publicKey)`: checkIssued vergleicht Namen, Schluesselkennungen
|
||||
* und Schluesselverwendung, die echte Signaturpruefung entscheidet. Nur beides zusammen
|
||||
* schuetzt vor einer gleichnamigen CA mit anderem Schluessel. Ein Abgleich nur ueber Namen
|
||||
* waere falsch (kreuzsignierte und neu ausgestellte Zwischenzertifikate tragen denselben Namen).
|
||||
*/
|
||||
|
||||
const MAX_DEPTH = 10;
|
||||
const MAX_PATHS = 200;
|
||||
const MAX_ALTERNATIVES = 10;
|
||||
|
||||
interface Node {
|
||||
item: CertItem;
|
||||
x: X509Certificate;
|
||||
/** Menge der Aussteller, die beide Pruefungen bestehen (Indizes in `nodes`) */
|
||||
issuers: number[];
|
||||
}
|
||||
|
||||
function toNodes(certs: CertItem[]): Node[] {
|
||||
const nodes: Node[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of certs) {
|
||||
if (seen.has(item.id)) continue;
|
||||
seen.add(item.id);
|
||||
try {
|
||||
nodes.push({ item, x: new X509Certificate(item.pem), issuers: [] });
|
||||
} catch {
|
||||
// kein lesbares Zertifikat: gehoert nicht in eine Kette
|
||||
}
|
||||
}
|
||||
nodes.forEach((child, ci) => {
|
||||
nodes.forEach((candidate, ii) => {
|
||||
if (ci === ii) return;
|
||||
try {
|
||||
if (child.x.checkIssued(candidate.x) && child.x.verify(candidate.x.publicKey)) {
|
||||
child.issuers.push(ii);
|
||||
}
|
||||
} catch {
|
||||
// nicht pruefbar (z. B. unbekannter Schluesseltyp): kein Aussteller
|
||||
}
|
||||
});
|
||||
});
|
||||
return nodes;
|
||||
}
|
||||
|
||||
interface FoundPath {
|
||||
indices: number[];
|
||||
/** endet an einem selbstsignierten Zertifikat */
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
function findPaths(nodes: Node[], start: number): FoundPath[] {
|
||||
const found: FoundPath[] = [];
|
||||
const walk = (indices: number[]) => {
|
||||
if (found.length >= MAX_PATHS) return;
|
||||
const last = nodes[indices[indices.length - 1]];
|
||||
if (last.item.selfSigned) {
|
||||
found.push({ indices: [...indices], complete: true });
|
||||
return;
|
||||
}
|
||||
const next = last.issuers.filter((i) => !indices.includes(i));
|
||||
if (next.length === 0 || indices.length >= MAX_DEPTH) {
|
||||
found.push({ indices: [...indices], complete: false });
|
||||
return;
|
||||
}
|
||||
for (const i of next) walk([...indices, i]);
|
||||
};
|
||||
walk([start]);
|
||||
return found;
|
||||
}
|
||||
|
||||
function notCurrentlyValid(item: CertItem, now: number): boolean {
|
||||
return Date.parse(item.notBefore) > now || Date.parse(item.notAfter) < now;
|
||||
}
|
||||
|
||||
function rank(nodes: Node[], paths: FoundPath[], now: number): FoundPath[] {
|
||||
const score = (p: FoundPath) => ({
|
||||
complete: p.complete ? 0 : 1,
|
||||
invalid: p.indices.filter((i) => notCurrentlyValid(nodes[i].item, now)).length,
|
||||
length: p.indices.length,
|
||||
firstIssuerEnd: p.indices.length > 1 ? Date.parse(nodes[p.indices[1]].item.notAfter) : 0,
|
||||
key: p.indices.map((i) => nodes[i].item.sha256).join('|'),
|
||||
});
|
||||
const scored = paths.map((p) => ({ p, s: score(p) }));
|
||||
scored.sort(
|
||||
(a, b) =>
|
||||
a.s.complete - b.s.complete ||
|
||||
a.s.invalid - b.s.invalid ||
|
||||
a.s.length - b.s.length ||
|
||||
b.s.firstIssuerEnd - a.s.firstIssuerEnd ||
|
||||
(a.s.key < b.s.key ? -1 : a.s.key > b.s.key ? 1 : 0),
|
||||
);
|
||||
return scored.map((e) => e.p);
|
||||
}
|
||||
|
||||
function defaultHeads(nodes: Node[]): number[] {
|
||||
const endEntities = nodes.flatMap((n, i) => (n.item.role === 'end-entity' ? [i] : []));
|
||||
if (endEntities.length > 0) return endEntities;
|
||||
const issuing = new Set<number>();
|
||||
for (const n of nodes) for (const i of n.issuers) issuing.add(i);
|
||||
return nodes.flatMap((_, i) => (issuing.has(i) ? [] : [i]));
|
||||
}
|
||||
|
||||
function chainOf(nodes: Node[], head: number, now: number): ChainInfo {
|
||||
const ranked = rank(nodes, findPaths(nodes, head), now);
|
||||
const primary = ranked[0];
|
||||
const last = nodes[primary.indices[primary.indices.length - 1]].item;
|
||||
let gap: ChainGap | null = null;
|
||||
if (!primary.complete) {
|
||||
gap = {
|
||||
certId: last.id,
|
||||
kind: primary.indices.length === 1 && last.role === 'end-entity' ? 'afterLeaf' : 'afterCa',
|
||||
missingIssuerCn: last.issuerCn,
|
||||
aiaUrls: [...last.aiaIssuerUrls],
|
||||
};
|
||||
}
|
||||
return {
|
||||
headId: nodes[head].item.id,
|
||||
path: primary.indices.map((i) => nodes[i].item.id),
|
||||
rootId: primary.complete && last.role === 'root' ? last.id : null,
|
||||
complete: primary.complete,
|
||||
gap,
|
||||
alternatives: Math.min(ranked.length - 1, MAX_ALTERNATIVES),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut je Kopf eine Kette (Kopf zuerst, dann jeder Aussteller). Standardkoepfe: jedes Serverzertifikat,
|
||||
* sonst jedes Zertifikat, das kein anderes ausgestellt hat. `headIds` waehlt eigene Koepfe.
|
||||
* Das Ergebnis haengt nicht von der Reihenfolge der Eingabe ab.
|
||||
*/
|
||||
export function buildChains(certs: CertItem[], headIds?: string[]): { chains: ChainInfo[] } {
|
||||
const nodes = toNodes(certs);
|
||||
const heads = headIds
|
||||
? headIds.flatMap((id) => {
|
||||
const index = nodes.findIndex((n) => n.item.id === id);
|
||||
return index < 0 ? [] : [index];
|
||||
})
|
||||
: defaultHeads(nodes);
|
||||
const now = Date.now();
|
||||
return { chains: heads.map((h) => chainOf(nodes, h, now)) };
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { PassThrough } from 'node:stream';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
CERT_BUILD_JSON_LIMIT,
|
||||
CERT_BUILD_ROUTE,
|
||||
certBuildBodyErrors,
|
||||
certBuildJsonBody,
|
||||
} from './cert-json-body';
|
||||
import {
|
||||
CERT_BASENAME_MAX,
|
||||
CERT_PASSWORD_MAX,
|
||||
CERT_PEM_MAX,
|
||||
CERT_POOL_MAX,
|
||||
} from './dto/cert-build.dto';
|
||||
|
||||
/** PEM-aehnlicher Text: eine Zeile je 64 Zeichen, genau `chars` Zeichen lang. */
|
||||
function pemLike(chars: number): string {
|
||||
let out = '';
|
||||
while (out.length < chars) out += `${'A'.repeat(64)}\n`;
|
||||
return out.slice(0, chars);
|
||||
}
|
||||
|
||||
interface FakeReq extends PassThrough {
|
||||
headers: Record<string, string>;
|
||||
method: string;
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
function fakeRequest(raw: string): FakeReq {
|
||||
const req = new PassThrough() as FakeReq;
|
||||
req.headers = {
|
||||
'content-type': 'application/json',
|
||||
'content-length': String(Buffer.byteLength(raw)),
|
||||
};
|
||||
req.method = 'POST';
|
||||
req.end(raw);
|
||||
return req;
|
||||
}
|
||||
|
||||
function runParser(raw: string): Promise<{ req: FakeReq; error: unknown }> {
|
||||
const req = fakeRequest(raw);
|
||||
return new Promise((resolve) => {
|
||||
certBuildJsonBody(req as never, {} as never, (error?: unknown) => resolve({ req, error }));
|
||||
});
|
||||
}
|
||||
|
||||
function fakeResponse() {
|
||||
const res = {
|
||||
statusCode: 0,
|
||||
payload: undefined as unknown,
|
||||
status(code: number) {
|
||||
res.statusCode = code;
|
||||
return res;
|
||||
},
|
||||
json(body: unknown) {
|
||||
res.payload = body;
|
||||
return res;
|
||||
},
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
describe('cert-json-body (D-26)', () => {
|
||||
it('traegt den Namen certBuildJsonBody, nie jsonParser oder urlencodedParser', () => {
|
||||
expect(certBuildJsonBody.name).toBe('certBuildJsonBody');
|
||||
expect(CERT_BUILD_ROUTE).toBe('/modules/cert-manager/build');
|
||||
expect(CERT_BUILD_JSON_LIMIT).toBe(512 * 1024);
|
||||
});
|
||||
|
||||
it('die groesste Anfrage innerhalb der DTO-Grenzen bleibt unter dem Grenzwert und wird gelesen', async () => {
|
||||
const body = {
|
||||
content: 'fullchain',
|
||||
certPem: pemLike(CERT_PEM_MAX),
|
||||
poolPems: Array.from({ length: CERT_POOL_MAX }, () => pemLike(CERT_PEM_MAX)),
|
||||
keyPem: pemLike(CERT_PEM_MAX),
|
||||
csrPem: pemLike(CERT_PEM_MAX),
|
||||
password: 'p'.repeat(CERT_PASSWORD_MAX),
|
||||
baseName: 'b'.repeat(CERT_BASENAME_MAX),
|
||||
};
|
||||
const raw = JSON.stringify(body);
|
||||
expect(Buffer.byteLength(raw)).toBeLessThan(CERT_BUILD_JSON_LIMIT);
|
||||
const { req, error } = await runParser(raw);
|
||||
expect(error).toBeUndefined();
|
||||
expect((req.body as typeof body).poolPems).toHaveLength(CERT_POOL_MAX);
|
||||
});
|
||||
|
||||
it('eine Anfrage ueber 512 KiB wird mit 413 und Code tooLarge beantwortet', async () => {
|
||||
const { error } = await runParser(JSON.stringify({ content: 'x'.repeat(600 * 1024) }));
|
||||
const res = fakeResponse();
|
||||
const next = vi.fn();
|
||||
certBuildBodyErrors(error, {} as never, res as never, next);
|
||||
expect(res.statusCode).toBe(413);
|
||||
expect(res.payload).toMatchObject({ code: 'tooLarge' });
|
||||
expect(typeof (res.payload as { message: string }).message).toBe('string');
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fehlerhaftes JSON ergibt 400 mit Code invalidInput', async () => {
|
||||
const { error } = await runParser('{"content": ');
|
||||
const res = fakeResponse();
|
||||
const next = vi.fn();
|
||||
certBuildBodyErrors(error, {} as never, res as never, next);
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.payload).toMatchObject({ code: 'invalidInput' });
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('jeden anderen Fehler reicht die Funktion unveraendert weiter', () => {
|
||||
const other = new Error('boom');
|
||||
const res = fakeResponse();
|
||||
const next = vi.fn();
|
||||
certBuildBodyErrors(other, {} as never, res as never, next);
|
||||
expect(next).toHaveBeenCalledWith(other);
|
||||
expect(res.statusCode).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
|
||||
/**
|
||||
* Eigener JSON-Leser fuer POST build (quick-261009-ikt, D-26).
|
||||
*
|
||||
* Nests Standardgrenze fuer JSON ist 100 kB. Eine Anfrage innerhalb der DTO-Grenzen
|
||||
* (cert-build.dto.ts) kann aber bis etwa 384 kB gross werden: viele Zertifikate im Pool, dazu
|
||||
* Schluessel und Anfrage. Darum bekommt genau diese Route 512 KiB; jede andere Route behaelt
|
||||
* die 100 kB. Eine groessere Anfrage wird mit 413 und dem Code tooLarge beantwortet (statt mit
|
||||
* einer Antwort ohne Code), fehlerhaftes JSON mit 400 und dem Code invalidInput.
|
||||
*
|
||||
* Registriert wird der Leser in main.ts mit app.use(CERT_BUILD_ROUTE, ...) vor app.listen.
|
||||
* Wichtig:
|
||||
* - Die Funktion darf NICHT jsonParser heissen. Nests Express-Adapter ueberspringt seinen
|
||||
* globalen JSON-Leser fuer ALLE Routen, sobald eine Schicht mit diesem Namen existiert; dann
|
||||
* wuerde auch die Anmeldung kein JSON mehr lesen. Darum der Name certBuildJsonBody.
|
||||
* - `express` ist vom API-Paket aus nicht direkt aufloesbar. Es wird ueber @nestjs/platform-express
|
||||
* geladen, also aus derselben Kopie, die Nest selbst benutzt. Ein neues Paket kommt nicht hinzu.
|
||||
* - body-parser lehnt eine Anfrage ab, deren Koerper schon gelesen ist; Nests globaler Leser liest
|
||||
* den Koerper daher kein zweites Mal.
|
||||
*/
|
||||
|
||||
export const CERT_BUILD_ROUTE = '/modules/cert-manager/build';
|
||||
export const CERT_BUILD_JSON_LIMIT = 512 * 1024;
|
||||
|
||||
const expressFromNest = createRequire(
|
||||
createRequire(__filename).resolve('@nestjs/platform-express'),
|
||||
)('express') as typeof import('express');
|
||||
|
||||
const parseJson = expressFromNest.json({ limit: CERT_BUILD_JSON_LIMIT });
|
||||
|
||||
export function certBuildJsonBody(req: Request, res: Response, next: NextFunction): void {
|
||||
parseJson(req, res, next);
|
||||
}
|
||||
|
||||
interface BodyParserError {
|
||||
type?: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
/** Fehler des Lesers: zu gross -> 413 tooLarge, kein gueltiges JSON -> 400 invalidInput, sonst weiter. */
|
||||
export function certBuildBodyErrors(
|
||||
error: unknown,
|
||||
_req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
const e = (error ?? {}) as BodyParserError;
|
||||
if (e.type === 'entity.too.large') {
|
||||
res.status(413).json({ code: 'tooLarge', message: 'Request body exceeds 512 KiB' });
|
||||
return;
|
||||
}
|
||||
if (e.type === 'entity.parse.failed') {
|
||||
res.status(400).json({ code: 'invalidInput', message: 'Request body is not valid JSON' });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
@@ -32,15 +32,32 @@ describe('CertManagerController', () => {
|
||||
expect(Reflect.getMetadata(MODULE_SLUG_KEY, CertManagerController)).toBe('cert-manager');
|
||||
});
|
||||
|
||||
it('bietet in diesem Stand genau den Handler analyze (POST analyze, Code 200)', () => {
|
||||
it('bietet in diesem Stand genau die Handler analyze und build (POST, Code 200)', () => {
|
||||
const handlers = Object.getOwnPropertyNames(CertManagerController.prototype).filter(
|
||||
(n) => n !== 'constructor',
|
||||
);
|
||||
expect(handlers).toEqual(['analyze']);
|
||||
const handler = CertManagerController.prototype.analyze;
|
||||
expect(Reflect.getMetadata('path', handler)).toBe('analyze');
|
||||
expect(Reflect.getMetadata('method', handler)).toBe(RequestMethod.POST);
|
||||
expect(Reflect.getMetadata('__httpCode__', handler)).toBe(200);
|
||||
expect(handlers).toEqual(['analyze', 'build']);
|
||||
for (const [name, path] of [
|
||||
['analyze', 'analyze'],
|
||||
['build', 'build'],
|
||||
] as const) {
|
||||
const handler = CertManagerController.prototype[name];
|
||||
expect(Reflect.getMetadata('path', handler)).toBe(path);
|
||||
expect(Reflect.getMetadata('method', handler)).toBe(RequestMethod.POST);
|
||||
expect(Reflect.getMetadata('__httpCode__', handler)).toBe(200);
|
||||
}
|
||||
});
|
||||
|
||||
it('build baut die Fullchain aus den gesendeten Zertifikaten', () => {
|
||||
const text = (n: string) => fx(n).toString('utf8');
|
||||
const result = controller.build({
|
||||
content: 'fullchain',
|
||||
format: 'pem',
|
||||
certPem: text('ec-leaf.pem'),
|
||||
poolPems: [text('ec-inter.pem'), text('ec-root.pem')],
|
||||
});
|
||||
expect(result.files[0].filename).toBe('ec.example.test-fullchain.pem');
|
||||
expect(result.chainComplete).toBe(true);
|
||||
});
|
||||
|
||||
it('ohne Dateien: 400 invalidInput', () => {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Controller, HttpCode, Post, UploadedFiles, UseInterceptors } from '@nestjs/common';
|
||||
import { Body, Controller, HttpCode, Post, UploadedFiles, UseInterceptors } from '@nestjs/common';
|
||||
import { FilesInterceptor } from '@nestjs/platform-express';
|
||||
import type { UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { UseModule } from '../module-registry/module.guard';
|
||||
import { analyzeWorkingSet } from './cert-analyze';
|
||||
import { type AnalysisResult, certError } from './cert-types';
|
||||
import { buildOutput } from './cert-output';
|
||||
import { type AnalysisResult, type BuildResult, certError } from './cert-types';
|
||||
import { BuildOutputDto } from './dto/cert-build.dto';
|
||||
|
||||
/** Obergrenzen (D-17): je Datei 5 MiB, alle Dateien zusammen 20 MiB, hoechstens 30 Dateien. */
|
||||
export const CERT_MAX_FILES = 30;
|
||||
@@ -28,7 +30,7 @@ export function repairFileName(name: string): string {
|
||||
*
|
||||
* Routen (alle POST, 200):
|
||||
* - analyze Task 1 mehrere Dateien (multipart) erkennen und zusammenfassen
|
||||
* - build Task 2 Ausgabe bauen (JSON), ab Task 5/6 erweitert
|
||||
* - build Task 2 Ausgabe bauen (JSON, eigene Grenze 512 KiB, siehe cert-json-body.ts), ab Task 5/6 erweitert
|
||||
* - fetch-issuer Task 7 fehlendes Zwischenzertifikat nur auf Knopfdruck holen
|
||||
*/
|
||||
@Controller('modules/cert-manager')
|
||||
@@ -51,4 +53,10 @@ export class CertManagerController {
|
||||
files.map((f) => ({ originalname: repairFileName(f.originalname), buffer: f.buffer })),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('build')
|
||||
@HttpCode(200)
|
||||
build(@Body() dto: BuildOutputDto): BuildResult {
|
||||
return buildOutput(dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash, type KeyObject, X509Certificate } from 'node:crypto';
|
||||
import { safeBaseName } from './cert-output';
|
||||
import { safeBaseName } from './cert-names';
|
||||
import type {
|
||||
AnyItem,
|
||||
CertItem,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Dateinamen-Helfer des Zertifikat-Managers. Eigene Datei, damit cert-model.ts und cert-output.ts
|
||||
* einander nicht gegenseitig einbinden muessen.
|
||||
*/
|
||||
|
||||
/** Dateiname ohne Pfad und ohne gefaehrliche Zeichen, z. B. „*.example.de“ -> „wildcard.example.de“. */
|
||||
export function safeBaseName(raw: string, fallback: string): string {
|
||||
const cleaned = raw
|
||||
.replace(/^\*\./, 'wildcard.')
|
||||
.replace(/[^A-Za-z0-9._-]+/g, '_')
|
||||
.replace(/^[._]+/, '')
|
||||
.slice(0, 80);
|
||||
return cleaned || fallback;
|
||||
}
|
||||
@@ -1,5 +1,30 @@
|
||||
import { X509Certificate } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { safeBaseName } from './cert-output';
|
||||
import { buildOutput, safeBaseName } from './cert-output';
|
||||
|
||||
const fxText = (name: string) => readFileSync(join(__dirname, '__fixtures__', name), 'utf8');
|
||||
|
||||
function decode(file: { content: string }): string {
|
||||
return Buffer.from(file.content, 'base64').toString('utf8');
|
||||
}
|
||||
|
||||
function subjects(pem: string): string[] {
|
||||
return (pem.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g) ?? []).map(
|
||||
(block) => String(new X509Certificate(block).toLegacyObject().subject.CN),
|
||||
);
|
||||
}
|
||||
|
||||
function codeOf(fn: () => unknown): { status: number; code: string } {
|
||||
try {
|
||||
fn();
|
||||
} catch (error) {
|
||||
const e = error as { getStatus(): number; getResponse(): { code: string } };
|
||||
return { status: e.getStatus(), code: e.getResponse().code };
|
||||
}
|
||||
throw new Error('expected a throw');
|
||||
}
|
||||
|
||||
describe('safeBaseName', () => {
|
||||
it('Platzhalter, Leerzeichen und Pfadteile werden entschaerft', () => {
|
||||
@@ -16,3 +41,102 @@ describe('safeBaseName', () => {
|
||||
expect(safeBaseName('...', 'ersatz')).toBe('ersatz');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildOutput fullchain und chain', () => {
|
||||
const ecLeaf = fxText('ec-leaf.pem');
|
||||
// absichtlich falsche Reihenfolge plus ein fremdes Zwischenzertifikat
|
||||
const pool = [fxText('ec-root.pem'), fxText('ec-inter.pem'), fxText('rsa-inter.pem')];
|
||||
|
||||
it('Fullchain: Server zuerst, dann Zwischenzertifikat, ohne Wurzel und ohne Fremdes', () => {
|
||||
const r = buildOutput({ content: 'fullchain', format: 'pem', certPem: ecLeaf, poolPems: pool });
|
||||
expect(r.files).toHaveLength(1);
|
||||
expect(r.files[0].filename).toBe('ec.example.test-fullchain.pem');
|
||||
expect(r.files[0].mimeType).toBe('application/x-pem-file');
|
||||
expect(subjects(decode(r.files[0]))).toEqual(['ec.example.test', 'Tessera Test Inter EC']);
|
||||
expect(decode(r.files[0]).endsWith('\n')).toBe(true);
|
||||
expect(r.chainComplete).toBe(true);
|
||||
expect(r.missingIssuerCn).toBeNull();
|
||||
});
|
||||
|
||||
it('Fullchain mit Wurzel: drei Bloecke, Wurzel zuletzt', () => {
|
||||
const r = buildOutput({
|
||||
content: 'fullchain',
|
||||
format: 'pem',
|
||||
certPem: ecLeaf,
|
||||
poolPems: pool,
|
||||
includeRoot: true,
|
||||
});
|
||||
expect(subjects(decode(r.files[0]))).toEqual([
|
||||
'ec.example.test',
|
||||
'Tessera Test Inter EC',
|
||||
'Tessera Test Root EC',
|
||||
]);
|
||||
});
|
||||
|
||||
it('Nur Kette: nur das Zwischenzertifikat, mit Wurzel beide', () => {
|
||||
const only = buildOutput({ content: 'chain', format: 'pem', certPem: ecLeaf, poolPems: pool });
|
||||
expect(only.files[0].filename).toBe('ec.example.test-chain.pem');
|
||||
expect(subjects(decode(only.files[0]))).toEqual(['Tessera Test Inter EC']);
|
||||
const withRoot = buildOutput({
|
||||
content: 'chain',
|
||||
format: 'pem',
|
||||
certPem: ecLeaf,
|
||||
poolPems: pool,
|
||||
includeRoot: true,
|
||||
});
|
||||
expect(subjects(decode(withRoot.files[0]))).toEqual([
|
||||
'Tessera Test Inter EC',
|
||||
'Tessera Test Root EC',
|
||||
]);
|
||||
});
|
||||
|
||||
it('Nur Kette ohne Zwischenzertifikat: 400 noChain', () => {
|
||||
expect(
|
||||
codeOf(() =>
|
||||
buildOutput({ content: 'chain', format: 'pem', certPem: fxText('rsa-leaf.pem') }),
|
||||
),
|
||||
).toEqual({ status: 400, code: 'noChain' });
|
||||
});
|
||||
|
||||
it('kein Zertifikat in certPem oder im Pool: 400 notACertificate', () => {
|
||||
expect(
|
||||
codeOf(() =>
|
||||
buildOutput({ content: 'fullchain', format: 'pem', certPem: 'kein Zertifikat' }),
|
||||
),
|
||||
).toEqual({ status: 400, code: 'notACertificate' });
|
||||
expect(
|
||||
codeOf(() =>
|
||||
buildOutput({ content: 'fullchain', format: 'pem', certPem: ecLeaf, poolPems: ['x'] }),
|
||||
),
|
||||
).toEqual({ status: 400, code: 'notACertificate' });
|
||||
});
|
||||
|
||||
it('ohne certPem: 400 invalidInput', () => {
|
||||
expect(codeOf(() => buildOutput({ content: 'fullchain', format: 'pem' }))).toEqual({
|
||||
status: 400,
|
||||
code: 'invalidInput',
|
||||
});
|
||||
});
|
||||
|
||||
it('Luecke wird gemeldet: Server + Zwischenzertifikat ohne Wurzel', () => {
|
||||
const r = buildOutput({
|
||||
content: 'fullchain',
|
||||
format: 'pem',
|
||||
certPem: fxText('rsa-leaf.pem'),
|
||||
poolPems: [fxText('rsa-inter.pem')],
|
||||
});
|
||||
expect(r.chainComplete).toBe(false);
|
||||
expect(r.missingIssuerCn).toBe('Tessera Test Root RSA');
|
||||
expect(subjects(decode(r.files[0]))).toEqual(['www.example.test', 'Tessera Test Inter RSA']);
|
||||
});
|
||||
|
||||
it('Dateiname aus baseName, bereinigt', () => {
|
||||
const r = buildOutput({
|
||||
content: 'fullchain',
|
||||
format: 'pem',
|
||||
certPem: ecLeaf,
|
||||
baseName: '../mein Server',
|
||||
});
|
||||
expect(r.files[0].filename).toBe('mein_Server-fullchain.pem');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,82 @@
|
||||
import { X509Certificate } from 'node:crypto';
|
||||
import { buildChains } from './cert-chain';
|
||||
import { certItemFromDer } from './cert-model';
|
||||
import { safeBaseName } from './cert-names';
|
||||
import {
|
||||
type BuildFile,
|
||||
type BuildInput,
|
||||
type BuildResult,
|
||||
type CertItem,
|
||||
certError,
|
||||
} from './cert-types';
|
||||
|
||||
/**
|
||||
* Ausgabe-Bausteine des Zertifikat-Managers (quick-261009-ikt).
|
||||
* Task 1: nur der Dateiname-Helfer; die Ausgabeformate (build) kommen in den folgenden Tasks.
|
||||
* Ausgabe-Bausteine des Zertifikat-Managers (quick-261009-ikt, D-19).
|
||||
* Task 2: Fullchain und Nur Kette als PEM. Die weiteren Inhalte und Formate (Task 5) kommen in
|
||||
* dieselbe Funktion. Die Reihenfolge baut die API immer selbst aus den gesendeten Zertifikaten
|
||||
* (buildChains); eine vom Browser mitgeschickte Reihenfolge wird nie uebernommen.
|
||||
*/
|
||||
|
||||
/** Dateiname ohne Pfad und ohne gefaehrliche Zeichen, z. B. „*.example.de“ -> „wildcard.example.de“. */
|
||||
export function safeBaseName(raw: string, fallback: string): string {
|
||||
const cleaned = raw
|
||||
.replace(/^\*\./, 'wildcard.')
|
||||
.replace(/[^A-Za-z0-9._-]+/g, '_')
|
||||
.replace(/^[._]+/, '')
|
||||
.slice(0, 80);
|
||||
return cleaned || fallback;
|
||||
export { safeBaseName };
|
||||
|
||||
const PEM_MIME = 'application/x-pem-file';
|
||||
|
||||
function parseCertificate(pem: unknown): CertItem {
|
||||
if (typeof pem !== 'string' || pem.trim() === '') {
|
||||
certError('notACertificate', 400, 'Value is not a certificate');
|
||||
}
|
||||
try {
|
||||
return certItemFromDer(new X509Certificate(pem).raw, { file: 0, path: '' });
|
||||
} catch {
|
||||
return certError('notACertificate', 400, 'Value is not a certificate');
|
||||
}
|
||||
}
|
||||
|
||||
/** PEM-Bloecke in Kettenreihenfolge, jeder genau einmal mit abschliessendem Zeilenumbruch. */
|
||||
function joinPem(certs: CertItem[]): string {
|
||||
return certs.map((c) => `${c.pem.trim()}\n`).join('');
|
||||
}
|
||||
|
||||
function pemFile(filename: string, certs: CertItem[]): BuildFile {
|
||||
return {
|
||||
filename,
|
||||
content: Buffer.from(joinPem(certs), 'utf8').toString('base64'),
|
||||
mimeType: PEM_MIME,
|
||||
};
|
||||
}
|
||||
|
||||
/** Baut die gewuenschte Ausgabe. Wirft Nest-Ausnahmen mit Code (D-24); nie mit Passwort oder Schluessel im Text. */
|
||||
export function buildOutput(input: BuildInput): BuildResult {
|
||||
if (!input.certPem) certError('invalidInput', 400, 'certPem is required');
|
||||
const head = parseCertificate(input.certPem);
|
||||
const pool = (input.poolPems ?? []).map(parseCertificate);
|
||||
|
||||
const unique = new Map<string, CertItem>();
|
||||
for (const c of [head, ...pool]) if (!unique.has(c.id)) unique.set(c.id, c);
|
||||
const all = [...unique.values()];
|
||||
|
||||
const chain = buildChains(all, [head.id]).chains[0];
|
||||
const byId = new Map(all.map((c) => [c.id, c]));
|
||||
const path = chain.path.map((id) => byId.get(id) as CertItem);
|
||||
const includeRoot = input.includeRoot === true;
|
||||
const withoutRoot = path.filter((c) => !(c.role === 'root' && c.id !== head.id));
|
||||
const base = safeBaseName(input.baseName ?? '', head.baseName);
|
||||
|
||||
let files: BuildFile[];
|
||||
if (input.content === 'fullchain') {
|
||||
files = [pemFile(`${base}-fullchain.pem`, includeRoot ? path : withoutRoot)];
|
||||
} else if (input.content === 'chain') {
|
||||
const issuers = (includeRoot ? path : withoutRoot).filter((c) => c.id !== head.id);
|
||||
if (issuers.length === 0)
|
||||
certError('noChain', 400, 'No intermediate or root certificate available');
|
||||
files = [pemFile(`${base}-chain.pem`, issuers)];
|
||||
} else {
|
||||
return certError('formatNotPossible', 400, 'Output not available');
|
||||
}
|
||||
|
||||
return {
|
||||
files,
|
||||
chainComplete: chain.complete,
|
||||
missingIssuerCn: chain.gap?.missingIssuerCn ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* Anfrage fuer POST build (quick-261009-ikt, D-19). Die Obergrenzen stehen hier als Konstanten,
|
||||
* weil cert-json-body.ts daraus die groesste gueltige Anfrage berechnet (D-26):
|
||||
*
|
||||
* certPem 16 384 + 20 x poolPems 16 384 + keyPem 16 384 + csrPem 16 384 Zeichen
|
||||
* + password 256 + baseName 120 + JSON-Maskierung der Zeilenumbrueche (etwa +1,6 %)
|
||||
* = rund 384 kB, also deutlich unter dem Grenzwert von 512 KiB.
|
||||
*
|
||||
* Task 2 kennt nur Fullchain und Nur Kette als PEM; weitere Inhalte, Formate und Felder
|
||||
* (Schluessel, Passwort, Vorlage) folgen in Task 5 und 6.
|
||||
*/
|
||||
export const CERT_PEM_MAX = 16_384;
|
||||
export const CERT_POOL_MAX = 20;
|
||||
export const CERT_PASSWORD_MAX = 256;
|
||||
export const CERT_BASENAME_MAX = 120;
|
||||
|
||||
export const BUILD_CONTENTS = ['fullchain', 'chain'] as const;
|
||||
export const BUILD_FORMATS = ['pem'] as const;
|
||||
|
||||
export class BuildOutputDto {
|
||||
@IsIn(BUILD_CONTENTS)
|
||||
content!: (typeof BUILD_CONTENTS)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(BUILD_FORMATS)
|
||||
format?: (typeof BUILD_FORMATS)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(CERT_PEM_MAX)
|
||||
certPem?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(CERT_POOL_MAX)
|
||||
@IsString({ each: true })
|
||||
@MaxLength(CERT_PEM_MAX, { each: true })
|
||||
poolPems?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
includeRoot?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(CERT_BASENAME_MAX)
|
||||
baseName?: string;
|
||||
}
|
||||
@@ -3,6 +3,11 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { AppModule } from './app.module';
|
||||
import {
|
||||
CERT_BUILD_ROUTE,
|
||||
certBuildBodyErrors,
|
||||
certBuildJsonBody,
|
||||
} from './cert-manager/cert-json-body';
|
||||
import { requestLogMiddleware } from './common/request-log';
|
||||
import { formatAppVersionLine } from './health/app-version';
|
||||
|
||||
@@ -16,6 +21,10 @@ async function bootstrap() {
|
||||
// Cookie parser for JWT httpOnly cookies
|
||||
app.use(cookieParser());
|
||||
|
||||
// Eigene JSON-Grenze (512 KiB) nur fuer POST build des Zertifikat-Managers, vor Nests globalem Leser
|
||||
// (quick-261009-ikt D-26)
|
||||
app.use(CERT_BUILD_ROUTE, certBuildJsonBody, certBuildBodyErrors);
|
||||
|
||||
// Global validation pipe with whitelist and transform
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
|
||||
Reference in New Issue
Block a user