From a2fc2cb30002d2822f34359468f60b2e3069669f Mon Sep 17 00:00:00 2001
From: Schalli
Date: Fri, 9 Oct 2026 16:10:39 +0200
Subject: [PATCH] =?UTF-8?q?feat(cert-manager):=20Fehlendes=20Zertifikat=20?=
=?UTF-8?q?holen,=20geh=C3=A4rteter=20Adressschutz?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Neuer Knopf „Fehlendes Zertifikat holen“ nur auf Klick: POST fetch-issuer liest die
Aussteller-Adresse (AIA) serverseitig aus dem Zertifikat, nie vom Browser; nur Standardport,
Adressschutz vor jedem Sprung, Aufloesung beim Verbinden geprueft, 8 s und 256 KiB, hoechstens
3 Weiterleitungen; angenommen wird nur ein Zertifikat, das wirklich ausgestellt hat
- Geholte Zertifikate erscheinen als „nachgeladen von “ in der Liste und auf der Karte
- Gemeinsamer Adressschutz gehaertet: versteckte IPv6-Schreibweisen interner Adressen
(IPv4-gemappt in Hex, NAT64, 6to4, Teredo, Zonenkennung u. a.), neues Spec
- Modul-Changelog 1.2.0, CHANGELOG (Sicherheit), drei Anleitungen
Co-Authored-By: Claude Opus 5.5 (1M context)
---
CHANGELOG.md | 3 +-
apps/api/src/cert-manager/cert-aia.spec.ts | 361 ++++++++++++++++
apps/api/src/cert-manager/cert-aia.ts | 392 ++++++++++++++++++
.../cert-manager/cert-manager.changelog.ts | 5 +
.../cert-manager.controller.spec.ts | 33 +-
.../cert-manager/cert-manager.controller.ts | 11 +-
apps/api/src/cert-manager/cert-model.ts | 2 +-
.../cert-manager/dto/cert-fetch-issuer.dto.ts | 14 +
apps/api/src/common/public-url-guard.spec.ts | 124 ++++++
apps/api/src/common/public-url-guard.ts | 107 ++++-
.../(portal)/modules/cert-manager/actions.ts | 24 ++
.../components/AnalyzeTab.test.tsx | 20 +
.../cert-manager/components/AnalyzeTab.tsx | 7 +-
.../components/ChainView.test.tsx | 188 +++++++++
.../cert-manager/components/ChainView.tsx | 101 ++++-
.../components/ConvertTab.test.tsx | 1 +
.../cert-manager/components/FilesTab.test.tsx | 44 ++
.../cert-manager/components/FilesTab.tsx | 3 +
.../cert-manager/components/ItemCard.tsx | 23 +
.../cert-manager/components/MergeTab.test.tsx | 1 +
.../cert-manager/components/MergeTab.tsx | 2 +-
.../cert-manager/components/SplitTab.test.tsx | 1 +
.../components/TemplatesTab.test.tsx | 1 +
.../cert-manager/components/TemplatesTab.tsx | 2 +-
.../cert-manager/use-cert-workspace.ts | 19 +
.../modules/cert-manager/working-set.test.ts | 47 +++
.../modules/cert-manager/working-set.ts | 57 +++
apps/web/src/messages/de.json | 13 +-
apps/web/src/messages/en.json | 13 +-
docs/anleitung-anwender.md | 11 +-
docs/anleitung-betrieb.md | 3 +-
docs/anleitung-entwicklung.md | 33 ++
32 files changed, 1630 insertions(+), 36 deletions(-)
create mode 100644 apps/api/src/cert-manager/cert-aia.spec.ts
create mode 100644 apps/api/src/cert-manager/cert-aia.ts
create mode 100644 apps/api/src/cert-manager/dto/cert-fetch-issuer.dto.ts
create mode 100644 apps/api/src/common/public-url-guard.spec.ts
create mode 100644 apps/web/src/app/(portal)/modules/cert-manager/components/ChainView.test.tsx
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a38e06b..91df54a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,7 +20,7 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
- Dateien, Teilen: Im Zeilenmenü einer Datei oder eines Ordners finden Sie jetzt „Teilen“. Dort suchen Sie Kolleginnen, Kollegen und Gruppen aus Ihrer Nextcloud und geben die Datei zum „Ansehen“ oder „Bearbeiten“ frei; die Berechtigung ändern Sie später, und eine Freigabe nehmen Sie mit „Entfernen“ wieder zurück. Außerdem erstellen Sie öffentliche Links mit „Ansehen“ oder „Bearbeiten“, bei Ordnern auch mit „Nur hochladen“, und kopieren sie mit „Link kopieren“. Passwort und Ablaufdatum richten sich nach den Regeln der Firmen-Nextcloud: Verlangt sie ein Passwort, fragt Tessera danach und kann auf Wunsch eines erzeugen. Tessera speichert das Passwort nie und zeigt es nur, solange der Dialog offen ist. Zum Schutz der Nextcloud legt Tessera höchstens 10 neue Freigaben innerhalb von 10 Minuten an.
- Dateien, Übersicht der Freigaben: Die Reiter „Von mir geteilt“ und „Mit mir geteilt“ zeigen alle Freigaben auf einen Blick. Sie öffnen Einträge direkt im zugehörigen Ordner, ändern oder entfernen Ihre eigenen Freigaben, verlassen Freigaben anderer und nehmen offene Freigaben an oder lehnen sie ab. Geteilte Einträge sind in der Dateiliste markiert. Freigaben per E-Mail und an andere Server zeigt Tessera nicht an; sie bleiben der Nextcloud vorbehalten.
- Zertifikat-Manager, ein gemeinsamer Arbeitsbereich: Im ersten Reiter „Dateien“ laden Sie mehrere Dateien nacheinander oder auf einmal hoch, auch ZIP-Dateien vom Zertifikatsaussteller, und können PEM-Text einfügen. Jede Datei bleibt in der Liste, zu jeder steht, was Tessera darin erkannt hat; einzelne Dateien nehmen Sie mit ihrem eigenen Knopf wieder heraus. Passwörter geben Sie je Datei ein. Die Liste, Schlüssel und Passwörter bleiben nur in diesem Browserfenster und sind nach dem Neuladen weg; Tessera speichert sie nirgends. Analysieren, Aufteilen, Zusammenführen, Konvertieren und Vorlagen arbeiten alle mit dieser Liste. Modulversion 1.2.0.
-- Zertifikat-Manager, Zusammenführen und Formate: Tessera ordnet Serverzertifikat, Zwischenzertifikate und Wurzel selbst und prüft dabei die echte Unterschrift jedes Zertifikats. „Fullchain“ und „Nur Kette“ gibt es als PEM, .p7b und .p7c, dazu Zertifikat und Schlüssel in einer PEM-Datei und PFX-Dateien mit eigenem Passwort, wahlweise „Kompatibel“ (auch für ältere Windows-Server, Vorgabe) oder „Modern“ (AES-256). Das Wurzelzertifikat kommt nur mit, wenn Sie „Root-Zertifikat mitnehmen“ ankreuzen. Fehlt ein Zwischenzertifikat, meldet Tessera es mit Namen. Neu im Reiter „Konvertieren“: jedes erkannte Zertifikat, jeder Schlüssel und jede Anfrage in jedes passende Format, auch Schlüssel mit Passwort.
+- Zertifikat-Manager, Zusammenführen und Formate: Tessera ordnet Serverzertifikat, Zwischenzertifikate und Wurzel selbst und prüft dabei die echte Unterschrift jedes Zertifikats. „Fullchain“ und „Nur Kette“ gibt es als PEM, .p7b und .p7c, dazu Zertifikat und Schlüssel in einer PEM-Datei und PFX-Dateien mit eigenem Passwort, wahlweise „Kompatibel“ (auch für ältere Windows-Server, Vorgabe) oder „Modern“ (AES-256). Das Wurzelzertifikat kommt nur mit, wenn Sie „Root-Zertifikat mitnehmen“ ankreuzen. Fehlt ein Zwischenzertifikat, meldet Tessera es mit Namen und bietet „Fehlendes Zertifikat holen“ an: Nur wenn Sie darauf klicken, holt Tessera das Zertifikat von der Adresse des Ausstellers, die im Zertifikat selbst steht, prüft, dass es das Zertifikat wirklich ausgestellt hat, und kennzeichnet es als „nachgeladen“. Neu im Reiter „Konvertieren“: jedes erkannte Zertifikat, jeder Schlüssel und jede Anfrage in jedes passende Format, auch Schlüssel mit Passwort.
- Zertifikat-Manager, Vorlagen: Für Nginx, Apache (ab und vor 2.4.8), Windows/IIS, Nginx Proxy Manager, HAProxy und Tomcat erzeugt Tessera mit einem Klick die passenden Dateien samt den Zeilen für die Einrichtung und einer kurzen Anleitung. Dafür braucht es das Zertifikat und den passenden privaten Schlüssel.
### Geändert
@@ -32,6 +32,7 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
### Behoben
+- Sicherheit: Der Schutz davor, dass Tessera interne Adressen abruft (Favoriten-Symbole, Logos in Nextcloud-Status und das neue „Fehlendes Zertifikat holen“), erkennt jetzt auch versteckte Schreibweisen interner IPv6-Adressen, zum Beispiel die gemappte Form ::ffff:7f00:1, NAT64 und 6to4. Beim neuen Abruf prüft Tessera die Adresse außerdem noch einmal im Moment des Verbindens.
- Zertifikat-Manager: Beim Zusammenführen ersetzte eine zweite Datei die erste; jetzt bleiben alle Dateien in der Liste. Zertifikate und Schlüssel mit elliptischen Kurven (EC) wurden bisher nicht erkannt; jetzt funktionieren sie in allen Reitern.
- Desktop-App: Dateien lassen sich jetzt auch in der Desktop-App per Ziehen und Ablegen hochladen, zum Beispiel im Modul Dateien oder im Zertifikat-Manager. Bisher übernahm die App das Ablegen selbst, und auf der Seite kam nichts an (Linux und Windows). Dafür ist die neue Version der Desktop-App nötig.
- Nextcloud-Status: Das Formular zum Hinzufügen und Bearbeiten einer Cloud zeigte bei manchen Eingaben englische Meldungen (zum Beispiel bei einer Bildadresse mit http://). Alle Hinweise in diesem Formular erscheinen jetzt auf Deutsch (bei englischer Spracheinstellung auf Englisch).
diff --git a/apps/api/src/cert-manager/cert-aia.spec.ts b/apps/api/src/cert-manager/cert-aia.spec.ts
new file mode 100644
index 0000000..0d2ff07
--- /dev/null
+++ b/apps/api/src/cert-manager/cert-aia.spec.ts
@@ -0,0 +1,361 @@
+import { X509Certificate } from 'node:crypto';
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { Logger } from '@nestjs/common';
+import type { fetch as undiciFetch } from 'undici';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { createGuardedLookup, fetchIssuer } from './cert-aia';
+
+const fx = (name: string) => readFileSync(join(__dirname, '__fixtures__', name));
+const text = (name: string) => fx(name).toString('utf8');
+const cert = (name: string) => new X509Certificate(fx(name));
+
+type FetchImpl = typeof undiciFetch;
+type Handler = (url: string, init: Record) => Response | Promise;
+
+function fakeFetch(handler: Handler) {
+ const calls: { url: string; init: Record }[] = [];
+ const impl = (async (url: string, init: Record) => {
+ calls.push({ url, init });
+ return handler(url, init);
+ }) as unknown as FetchImpl;
+ return { impl, calls };
+}
+
+const answer = (body: ConstructorParameters[0], init: ResponseInit = {}) =>
+ new Response(body, init);
+const publicAlways = async () => true;
+
+async function failure(promise: Promise): Promise<{ status: number; code: string }> {
+ try {
+ await promise;
+ } 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');
+}
+
+let warn: ReturnType;
+beforeEach(() => {
+ warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
+});
+afterEach(() => {
+ warn.mockRestore();
+});
+
+describe('fetchIssuer: Antwortformen', () => {
+ it('nimmt das DER-Zertifikat des Ausstellers an und meldet Server, Name und Dateiname', async () => {
+ const { impl, calls } = fakeFetch(() => answer(cert('rsa-inter.pem').raw));
+ const result = await fetchIssuer(text('rsa-leaf.pem'), {
+ fetchImpl: impl,
+ isPublic: publicAlways,
+ });
+ expect(result).toMatchObject({
+ host: 'pki.example.test',
+ cn: 'Tessera Test Inter RSA',
+ filename: 'Tessera_Test_Inter_RSA.crt',
+ });
+ expect(calls).toHaveLength(1);
+ expect(calls[0].url).toBe('http://pki.example.test/rsa-inter.cer');
+ const accepted = new X509Certificate(result.pem);
+ expect(cert('rsa-leaf.pem').checkIssued(accepted)).toBe(true);
+ expect(cert('rsa-leaf.pem').verify(accepted.publicKey)).toBe(true);
+ });
+
+ it('aus einer PKCS#7-Antwort (p7c) kommt nur der echte Aussteller zurueck', async () => {
+ const { impl } = fakeFetch(() => answer(fx('rsa-chain.p7c')));
+ const result = await fetchIssuer(text('rsa-leaf.pem'), {
+ fetchImpl: impl,
+ isPublic: publicAlways,
+ });
+ expect(result.cn).toBe('Tessera Test Inter RSA');
+ expect(result.pem.match(/BEGIN CERTIFICATE/g)).toHaveLength(1);
+ });
+
+ it('versteht auch PEM-Text und PKCS#7 als PEM', async () => {
+ const pemAnswer = fakeFetch(() => answer(text('rsa-inter.pem')));
+ const fromPem = await fetchIssuer(text('rsa-leaf.pem'), {
+ fetchImpl: pemAnswer.impl,
+ isPublic: publicAlways,
+ });
+ expect(fromPem.cn).toBe('Tessera Test Inter RSA');
+
+ const p7bAnswer = fakeFetch(() => answer(text('rsa-chain.p7b')));
+ const fromP7b = await fetchIssuer(text('rsa-leaf.pem'), {
+ fetchImpl: p7bAnswer.impl,
+ isPublic: publicAlways,
+ });
+ expect(fromP7b.cn).toBe('Tessera Test Inter RSA');
+ });
+
+ it('lehnt ein Zertifikat ab, das den Zielschluessel nicht ausgestellt hat (aiaNotIssuer)', async () => {
+ const { impl } = fakeFetch(() => answer(cert('ec-inter.pem').raw));
+ const result = await failure(
+ fetchIssuer(text('rsa-leaf.pem'), { fetchImpl: impl, isPublic: publicAlways }),
+ );
+ expect(result).toEqual({ status: 422, code: 'aiaNotIssuer' });
+ });
+
+ it('lehnt ein gleichnamiges Zertifikat mit anderem Schluessel ab (Name allein genuegt nicht)', async () => {
+ const { impl } = fakeFetch(() => answer(cert('rsa-inter-decoy.pem').raw));
+ const result = await failure(
+ fetchIssuer(text('rsa-leaf.pem'), { fetchImpl: impl, isPublic: publicAlways }),
+ );
+ expect(result).toEqual({ status: 422, code: 'aiaNotIssuer' });
+ });
+
+ it('lehnt Antworttext ab, der kein Zertifikat ist', async () => {
+ const { impl } = fakeFetch(() => answer('Not found'));
+ const result = await failure(
+ fetchIssuer(text('rsa-leaf.pem'), { fetchImpl: impl, isPublic: publicAlways }),
+ );
+ expect(result).toEqual({ status: 422, code: 'aiaNotIssuer' });
+ });
+
+ it('ein selbstsigniertes Zertifikat ohne AIA-Adresse ergibt aiaMissing', async () => {
+ const { impl } = fakeFetch(() => answer(cert('selfsigned-leaf.pem').raw));
+ const result = await failure(
+ fetchIssuer(text('selfsigned-leaf.pem'), { fetchImpl: impl, isPublic: publicAlways }),
+ );
+ expect(result.code).toBe('aiaMissing');
+ });
+});
+
+describe('fetchIssuer: Eingabe', () => {
+ it('weist Text ab, der kein Zertifikat ist (notACertificate)', async () => {
+ const { impl, calls } = fakeFetch(() => answer(''));
+ const result = await failure(fetchIssuer('kein Zertifikat', { fetchImpl: impl }));
+ expect(result).toEqual({ status: 400, code: 'notACertificate' });
+ expect(calls).toHaveLength(0);
+ });
+
+ it('ohne AIA-Adresse: aiaMissing, keine Anfrage', async () => {
+ const { impl, calls } = fakeFetch(() => answer(''));
+ const result = await failure(
+ fetchIssuer(text('rsa-leaf-noaki.pem'), { fetchImpl: impl, isPublic: publicAlways }),
+ );
+ expect(result).toEqual({ status: 422, code: 'aiaMissing' });
+ expect(calls).toHaveLength(0);
+ });
+
+ it('interne Adressen im Zertifikat: aiaInternal, es wird nie angefragt (echter Adressschutz)', async () => {
+ const { impl, calls } = fakeFetch(() => answer(cert('rsa-inter.pem').raw));
+ const result = await failure(fetchIssuer(text('aia-private-leaf.pem'), { fetchImpl: impl }));
+ expect(result).toEqual({ status: 422, code: 'aiaInternal' });
+ expect(calls).toHaveLength(0);
+ expect(warn).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('fetchIssuer: Weiterleitungen, Grenzen, Zeit', () => {
+ const redirect = (location: string) => answer('', { status: 302, headers: { location } });
+
+ it('verweigert eine Weiterleitung auf eine interne Adresse vor der zweiten Anfrage', async () => {
+ const { impl, calls } = fakeFetch(() => redirect('http://10.0.0.5/x'));
+ const result = await failure(
+ fetchIssuer(text('rsa-leaf.pem'), {
+ fetchImpl: impl,
+ isPublic: async (url) => !url.hostname.startsWith('10.'),
+ }),
+ );
+ expect(result).toEqual({ status: 422, code: 'aiaInternal' });
+ expect(calls).toHaveLength(1);
+ });
+
+ it('folgt einer Weiterleitung auf eine oeffentliche Adresse und meldet deren Server', async () => {
+ const { impl, calls } = fakeFetch((url) =>
+ url.startsWith('http://pki.example.test/')
+ ? redirect('https://cdn.example.test/rsa-inter.cer')
+ : answer(cert('rsa-inter.pem').raw),
+ );
+ const result = await fetchIssuer(text('rsa-leaf.pem'), {
+ fetchImpl: impl,
+ isPublic: publicAlways,
+ });
+ expect(calls.map((c) => c.url)).toEqual([
+ 'http://pki.example.test/rsa-inter.cer',
+ 'https://cdn.example.test/rsa-inter.cer',
+ ]);
+ expect(result.host).toBe('cdn.example.test');
+ });
+
+ it('verweigert eine Weiterleitung auf einen anderen Port als 80 oder 443', async () => {
+ const { impl, calls } = fakeFetch(() => redirect('http://pki.example.test:8080/x'));
+ const result = await failure(
+ fetchIssuer(text('rsa-leaf.pem'), { fetchImpl: impl, isPublic: publicAlways }),
+ );
+ expect(result).toEqual({ status: 422, code: 'aiaInternal' });
+ expect(calls).toHaveLength(1);
+ });
+
+ it('verweigert eine Weiterleitung mit Zugangsdaten und eine auf ein anderes Protokoll', async () => {
+ const withLogin = fakeFetch(() => redirect('http://user:pw@pki.example.test/x'));
+ expect(
+ await failure(
+ fetchIssuer(text('rsa-leaf.pem'), { fetchImpl: withLogin.impl, isPublic: publicAlways }),
+ ),
+ ).toEqual({ status: 422, code: 'aiaInternal' });
+ const ftp = fakeFetch(() => redirect('ftp://pki.example.test/x'));
+ expect(
+ await failure(
+ fetchIssuer(text('rsa-leaf.pem'), { fetchImpl: ftp.impl, isPublic: publicAlways }),
+ ),
+ ).toEqual({ status: 502, code: 'aiaUnreachable' });
+ });
+
+ it('bricht nach drei Weiterleitungen ab (vier sind zu viel)', async () => {
+ const { impl, calls } = fakeFetch(() => redirect('http://pki.example.test/weiter'));
+ const result = await failure(
+ fetchIssuer(text('rsa-leaf.pem'), { fetchImpl: impl, isPublic: publicAlways }),
+ );
+ expect(result).toEqual({ status: 502, code: 'aiaUnreachable' });
+ expect(calls).toHaveLength(4);
+ });
+
+ it('weist eine angekuendigte Groesse ueber 256 KiB ab, ohne zu lesen', async () => {
+ let pulled = 0;
+ const body = new ReadableStream(
+ {
+ pull(controller) {
+ pulled++;
+ controller.enqueue(new Uint8Array(10));
+ controller.close();
+ },
+ },
+ { highWaterMark: 0 },
+ );
+ const { impl } = fakeFetch(() => answer(body, { headers: { 'content-length': '300000' } }));
+ const result = await failure(
+ fetchIssuer(text('rsa-leaf.pem'), { fetchImpl: impl, isPublic: publicAlways }),
+ );
+ expect(result).toEqual({ status: 502, code: 'aiaTooLarge' });
+ expect(pulled).toBe(0);
+ });
+
+ it('bricht beim Lesen ab, sobald mehr als 256 KiB eintreffen', async () => {
+ let chunks = 0;
+ const body = new ReadableStream({
+ pull(controller) {
+ chunks++;
+ controller.enqueue(new Uint8Array(100 * 1024));
+ },
+ });
+ const { impl } = fakeFetch(() => answer(body));
+ const result = await failure(
+ fetchIssuer(text('rsa-leaf.pem'), { fetchImpl: impl, isPublic: publicAlways }),
+ );
+ expect(result).toEqual({ status: 502, code: 'aiaTooLarge' });
+ expect(chunks).toBeLessThan(10);
+ });
+
+ it('meldet einen haengenden Server nach dem Zeitlimit als aiaUnreachable', async () => {
+ const { impl } = fakeFetch(() => new Promise(() => {}));
+ const started = Date.now();
+ const result = await failure(
+ fetchIssuer(text('rsa-leaf.pem'), { fetchImpl: impl, isPublic: publicAlways, timeoutMs: 40 }),
+ );
+ expect(result).toEqual({ status: 502, code: 'aiaUnreachable' });
+ expect(Date.now() - started).toBeLessThan(2000);
+ });
+
+ it('meldet HTTP-Fehler und Verbindungsfehler als aiaUnreachable', async () => {
+ const notFound = fakeFetch(() => answer('', { status: 404 }));
+ expect(
+ await failure(
+ fetchIssuer(text('rsa-leaf.pem'), { fetchImpl: notFound.impl, isPublic: publicAlways }),
+ ),
+ ).toEqual({ status: 502, code: 'aiaUnreachable' });
+ const broken = fakeFetch(() => {
+ throw new Error('ECONNREFUSED');
+ });
+ expect(
+ await failure(
+ fetchIssuer(text('rsa-leaf.pem'), { fetchImpl: broken.impl, isPublic: publicAlways }),
+ ),
+ ).toEqual({ status: 502, code: 'aiaUnreachable' });
+ });
+});
+
+describe('fetchIssuer: Anfrage und Log', () => {
+ it('sendet keine Cookies, keine Zugangsdaten und keinen eigenen User-Agent', async () => {
+ const { impl, calls } = fakeFetch(() => answer(cert('rsa-inter.pem').raw));
+ await fetchIssuer(text('rsa-leaf.pem'), { fetchImpl: impl, isPublic: publicAlways });
+ const init = calls[0].init;
+ const headers = Object.keys(init.headers as Record).map((h) => h.toLowerCase());
+ expect(headers).not.toContain('cookie');
+ expect(headers).not.toContain('authorization');
+ expect(headers).not.toContain('user-agent');
+ expect(init.method).toBe('GET');
+ expect(init.redirect).toBe('manual');
+ expect(init.credentials).toBe('omit');
+ });
+
+ it('schreibt bei einem Fehler genau eine Warnzeile mit Server und Code, ohne Zertifikatstext', async () => {
+ const { impl } = fakeFetch(() => answer('', { status: 500 }));
+ await failure(fetchIssuer(text('rsa-leaf.pem'), { fetchImpl: impl, isPublic: publicAlways }));
+ expect(warn).toHaveBeenCalledTimes(1);
+ const line = String(warn.mock.calls[0][0]);
+ expect(line).toContain('pki.example.test');
+ expect(line).toContain('aiaUnreachable');
+ expect(line).not.toContain('BEGIN');
+ expect(line).not.toContain('rsa-inter.cer');
+ });
+
+ it('schreibt bei Erfolg keine Warnzeile', async () => {
+ const { impl } = fakeFetch(() => answer(cert('rsa-inter.pem').raw));
+ await fetchIssuer(text('rsa-leaf.pem'), { fetchImpl: impl, isPublic: publicAlways });
+ expect(warn).not.toHaveBeenCalled();
+ });
+});
+
+describe('createGuardedLookup', () => {
+ type Addr = { address: string; family: number };
+ const resolver = (addresses: Addr[] | Error) =>
+ ((_host: string, _options: unknown, cb: (e: Error | null, a: Addr[]) => void) => {
+ if (addresses instanceof Error) cb(addresses, []);
+ else cb(null, addresses);
+ }) as Parameters[0];
+
+ const run = (
+ lookup: ReturnType,
+ options: { all?: boolean } = {},
+ ): Promise<{ error: Error | null; address: unknown; family?: number }> =>
+ new Promise((resolve) => {
+ lookup('pki.example.test', options, (error, address, family) =>
+ resolve({ error, address, family }),
+ );
+ });
+
+ it('bricht ab, wenn der Name auf eine interne Adresse zeigt, ohne die Adresse zu nennen', async () => {
+ const result = await run(createGuardedLookup(resolver([{ address: '10.0.0.1', family: 4 }])));
+ expect(result.error).toBeInstanceOf(Error);
+ expect(result.error?.message).not.toContain('10.0.0.1');
+ });
+
+ it('bricht ab, wenn nur eine von mehreren Adressen intern ist', async () => {
+ const lookup = createGuardedLookup(
+ resolver([
+ { address: '93.184.215.14', family: 4 },
+ { address: '::ffff:7f00:1', family: 6 },
+ ]),
+ );
+ expect((await run(lookup, { all: true })).error).toBeInstanceOf(Error);
+ });
+
+ it('gibt eine oeffentliche Adresse weiter (einzeln und als Liste)', async () => {
+ const lookup = createGuardedLookup(resolver([{ address: '93.184.215.14', family: 4 }]));
+ const single = await run(lookup);
+ expect(single).toMatchObject({ error: null, address: '93.184.215.14', family: 4 });
+ const all = await run(lookup, { all: true });
+ expect(all.error).toBeNull();
+ expect(all.address).toEqual([{ address: '93.184.215.14', family: 4 }]);
+ });
+
+ it('reicht Aufloesungsfehler und leere Antworten als Fehler weiter', async () => {
+ const failed = await run(createGuardedLookup(resolver(new Error('ENOTFOUND'))));
+ expect(failed.error?.message).toBe('ENOTFOUND');
+ const empty = await run(createGuardedLookup(resolver([])));
+ expect(empty.error).toBeInstanceOf(Error);
+ });
+});
diff --git a/apps/api/src/cert-manager/cert-aia.ts b/apps/api/src/cert-manager/cert-aia.ts
new file mode 100644
index 0000000..e949ae8
--- /dev/null
+++ b/apps/api/src/cert-manager/cert-aia.ts
@@ -0,0 +1,392 @@
+import { X509Certificate } from 'node:crypto';
+import * as dns from 'node:dns';
+import { Logger } from '@nestjs/common';
+import { Agent, type Dispatcher, fetch as undiciFetch } from 'undici';
+import { isPrivateIpAddress, isPublicHttpUrl } from '../common/public-url-guard';
+import { leadingDerSequence, pkcs7Certificates } from './cert-model';
+import { safeBaseName } from './cert-names';
+import { type CertErrorCode, certError } from './cert-types';
+
+/**
+ * „Fehlendes Zertifikat holen“ (quick-261009-ikt, D-03, D-22).
+ *
+ * Der Server holt das Zwischenzertifikat von der Adresse, die im Zertifikat selbst steht
+ * (Eintrag „CA Issuers“ der Zugriffsinformationen, AIA). Das geschieht nur auf Knopfdruck, ein
+ * Sprung je Klick. Die Adresse kommt nie vom Browser, sondern wird hier aus dem Zertifikat gelesen.
+ *
+ * Schutz (der Server ruft eine Adresse auf, die in einer hochgeladenen Datei steht):
+ * - Nur http/https, ohne Benutzername und Kennwort, hoechstens 2048 Zeichen, nur der
+ * Standardport (80/443); hoechstens drei Adressen werden der Reihe nach versucht.
+ * - Gemeinsamer Adressschutz (`isPublicHttpUrl`) vor der ersten Anfrage UND vor jeder
+ * Weiterleitung; eine abgelehnte Adresse bekommt gar keine Anfrage.
+ * - redirect 'manual', hoechstens 3 Weiterleitungen, jede mit denselben Pruefungen.
+ * - Aufloesung beim Verbinden: der echte Verbindungsaufbau laeuft ueber einen eigenen undici-Agent,
+ * dessen `lookup` (`createGuardedLookup`) jede aufgeloeste Adresse prueft und bei einer nicht
+ * oeffentlichen abbricht. Das schliesst das Fenster fuer DNS-Rebinding fuer diese Funktion.
+ * - Ein Zeitlimit (8 s) je Adresse fuer alle Spruenge und das Lesen; gegen haengende Server wird
+ * zusaetzlich gegen den Abbruch gewettet.
+ * - Groessendeckel 256 KiB: content-length vorab, danach beim Lesen.
+ * - Keine Cookies, keine Zugangsdaten, kein eigener User-Agent.
+ * - Angenommen wird nur ein Zertifikat, das das Zielzertifikat wirklich ausgestellt hat
+ * (`checkIssued` und Signaturpruefung); alles andere ergibt aiaNotIssuer.
+ * - Im Log steht bei einem Fehler genau eine Zeile mit Server und Fehlercode, nie ein
+ * Zertifikat, nie der Pfad der Adresse.
+ *
+ * Bewusst akzeptierter Rest: jeder angemeldete Benutzer des Moduls kann den API-Server dazu
+ * bringen, einen einzigen GET an eine oeffentliche Adresse zu senden, die in einem von ihm
+ * hochgeladenen Zertifikat steht. Zurueck kommt nur ein geprueftes Ausstellerzertifikat, nie
+ * der Antworttext.
+ */
+
+export const AIA_TIMEOUT_MS = 8000;
+export const AIA_MAX_REDIRECTS = 3;
+export const AIA_MAX_BYTES = 256 * 1024;
+export const AIA_MAX_URLS = 3;
+const MAX_URL_LENGTH = 2048;
+const MAX_ANSWER_CERTIFICATES = 20;
+
+export interface FetchIssuerResult {
+ filename: string;
+ /** die angenommenen Ausstellerzertifikate als PEM */
+ pem: string;
+ /** der Server, von dem die Antwort kam */
+ host: string;
+ cn: string;
+}
+
+export interface FetchIssuerOptions {
+ fetchImpl?: typeof undiciFetch;
+ isPublic?: (url: URL) => Promise;
+ timeoutMs?: number;
+ dispatcher?: Dispatcher;
+}
+
+type LookupResolver = (
+ hostname: string,
+ options: dns.LookupAllOptions,
+ callback: (error: NodeJS.ErrnoException | null, addresses: dns.LookupAddress[]) => void,
+) => void;
+
+type GuardedLookup = (
+ hostname: string,
+ options: dns.LookupOptions,
+ callback: (
+ error: NodeJS.ErrnoException | null,
+ address: string | dns.LookupAddress[],
+ family?: number,
+ ) => void,
+) => void;
+
+/**
+ * `lookup` fuer `net.connect`: loest den Namen auf und bricht ab, sobald EINE der Adressen nicht
+ * oeffentlich ist. Der Verbindungsaufbau benutzt danach genau die geprueften Adressen, so kann
+ * der Name zwischen Pruefung und Verbindung nicht auf intern wechseln. Die Fehlermeldung nennt
+ * keine Adresse.
+ */
+export function createGuardedLookup(
+ resolve: LookupResolver = dns.lookup as unknown as LookupResolver,
+): GuardedLookup {
+ return (hostname, options, callback) => {
+ resolve(hostname, { ...options, all: true }, (error, addresses) => {
+ if (error) {
+ callback(error, '');
+ return;
+ }
+ if (!addresses || addresses.length === 0) {
+ callback(Object.assign(new Error('No address found'), { code: 'ENOTFOUND' }), '');
+ return;
+ }
+ if (addresses.some((entry) => isPrivateIpAddress(entry.address))) {
+ callback(
+ Object.assign(new Error('Refusing to connect to a non-public address'), {
+ code: 'EAIBLOCKED',
+ }),
+ '',
+ );
+ return;
+ }
+ if (options.all) {
+ callback(null, addresses);
+ } else {
+ callback(null, addresses[0].address, addresses[0].family);
+ }
+ });
+ };
+}
+
+let sharedAgent: Agent | undefined;
+
+function guardedAgent(): Agent {
+ sharedAgent ??= new Agent({
+ connect: { lookup: createGuardedLookup() as never },
+ });
+ return sharedAgent;
+}
+
+const logger = new Logger('CertAia');
+
+function discard(response: { body?: { cancel(): Promise } | null }): void {
+ try {
+ response.body?.cancel().catch(() => {});
+ } catch {
+ // schon verbraucht — nichts zu tun
+ }
+}
+
+/** Die „CA Issuers“-Adressen eines Zertifikats: nur http/https, ohne Zugangsdaten, hoechstens drei. */
+function issuerUrls(target: X509Certificate): URL[] {
+ const info = (target.toLegacyObject() as { infoAccess?: Record }).infoAccess;
+ const raw = info?.['CA Issuers - URI'];
+ const list = Array.isArray(raw) ? raw : typeof raw === 'string' ? [raw] : [];
+ const urls: URL[] = [];
+ for (const entry of list) {
+ if (typeof entry !== 'string' || entry.length > MAX_URL_LENGTH) continue;
+ try {
+ const url = new URL(entry);
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') continue;
+ if (url.username || url.password) continue;
+ urls.push(url);
+ } catch {
+ // keine gueltige Adresse: ueberspringen
+ }
+ if (urls.length >= AIA_MAX_URLS) break;
+ }
+ return urls;
+}
+
+/** Nur der Standardport (leer = 80/443), sonst waere der Abruf ein Portscanner. */
+function hasDefaultPort(url: URL): boolean {
+ return url.port === '';
+}
+
+const PEM_BLOCK = /-----BEGIN ([A-Z0-9 ]+)-----([\s\S]*?)-----END \1-----/g;
+
+/** Zertifikate aus einer Antwort: DER-Zertifikat, PKCS#7 (DER oder PEM) oder PEM-Text. */
+function certificatesFromAnswer(data: Buffer): X509Certificate[] {
+ const found: X509Certificate[] = [];
+ const add = (input: Buffer | string): void => {
+ if (found.length >= MAX_ANSWER_CERTIFICATES) return;
+ try {
+ found.push(new X509Certificate(input));
+ } catch {
+ // kein lesbares Zertifikat: ueberspringen
+ }
+ };
+ const addPkcs7 = (der: Buffer): void => {
+ try {
+ for (const certDer of pkcs7Certificates(der)) add(certDer);
+ } catch {
+ // kein lesbares PKCS#7: ueberspringen
+ }
+ };
+
+ if (data.includes('-----BEGIN ')) {
+ const text = data.toString('latin1');
+ for (const match of text.matchAll(PEM_BLOCK)) {
+ const label = match[1];
+ if (label === 'CERTIFICATE' || label === 'X509 CERTIFICATE') {
+ add(match[0]);
+ } else if (label === 'PKCS7' || label === 'CMS') {
+ addPkcs7(Buffer.from(match[2].replace(/\s+/g, ''), 'base64'));
+ }
+ }
+ return found;
+ }
+
+ const sequence = leadingDerSequence(data);
+ if (sequence) {
+ add(sequence);
+ if (found.length === 0) addPkcs7(data);
+ }
+ return found;
+}
+
+function issuedBy(target: X509Certificate, candidate: X509Certificate): boolean {
+ try {
+ return (
+ candidate.fingerprint256 !== target.fingerprint256 &&
+ target.checkIssued(candidate) &&
+ target.verify(candidate.publicKey)
+ );
+ } catch {
+ return false;
+ }
+}
+
+type AttemptResult =
+ | { ok: true; issuers: X509Certificate[]; host: string }
+ | { ok: false; code: Extract };
+
+/** Die Rangfolge, wenn alle Adressen scheitern: die Meldung mit dem meisten Fortschritt gewinnt. */
+const FAILURE_RANK: Record = {
+ aiaNotIssuer: 4,
+ aiaTooLarge: 3,
+ aiaUnreachable: 2,
+ aiaInternal: 1,
+};
+
+const FAILURE_STATUS: Record = {
+ aiaNotIssuer: 422,
+ aiaInternal: 422,
+ aiaTooLarge: 502,
+ aiaUnreachable: 502,
+};
+
+const FAILURE_TEXT: Record = {
+ aiaNotIssuer: 'The downloaded certificate did not issue this certificate',
+ aiaInternal: 'The issuer address is not a public address',
+ aiaTooLarge: 'The issuer answer is too large',
+ aiaUnreachable: 'The issuer address could not be reached',
+};
+
+/**
+ * Holt das Ausstellerzertifikat zum uebergebenen Zertifikat (PEM). Fehler: notACertificate 400,
+ * aiaMissing 422, aiaInternal 422, aiaNotIssuer 422, aiaUnreachable 502, aiaTooLarge 502.
+ */
+export async function fetchIssuer(
+ pem: string,
+ opts: FetchIssuerOptions = {},
+): Promise {
+ let target: X509Certificate;
+ try {
+ target = new X509Certificate(pem);
+ } catch {
+ return certError('notACertificate', 400, 'The provided text is not a certificate');
+ }
+
+ const urls = issuerUrls(target);
+ if (urls.length === 0) {
+ return certError('aiaMissing', 422, 'The certificate names no issuer address');
+ }
+
+ const fetchImpl = opts.fetchImpl ?? undiciFetch;
+ const isPublic = opts.isPublic ?? isPublicHttpUrl;
+ const timeoutMs = opts.timeoutMs ?? AIA_TIMEOUT_MS;
+ const dispatcher = opts.dispatcher ?? (opts.fetchImpl ? undefined : guardedAgent());
+
+ const attempt = async (first: URL): Promise => {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
+ // Fuer haengende Server, die ein abgebrochenes fetch/read nicht beenden.
+ const aborted = new Promise<'timeout'>((resolve) => {
+ controller.signal.addEventListener('abort', () => resolve('timeout'));
+ });
+
+ const run = async (): Promise => {
+ let current = first;
+ for (let hop = 0; ; hop++) {
+ if (!hasDefaultPort(current) || !(await isPublic(current))) {
+ return { ok: false, code: 'aiaInternal' };
+ }
+
+ let response: Awaited>;
+ try {
+ response = await fetchImpl(current.toString(), {
+ method: 'GET',
+ redirect: 'manual',
+ signal: controller.signal,
+ credentials: 'omit',
+ headers: {
+ Accept: 'application/pkix-cert, application/x-pkcs7-certificates, */*;q=0.1',
+ },
+ ...(dispatcher ? { dispatcher } : {}),
+ });
+ } catch {
+ return { ok: false, code: 'aiaUnreachable' };
+ }
+
+ if (response.status >= 300 && response.status < 400) {
+ const location = response.headers.get('location');
+ discard(response);
+ if (!location || hop >= AIA_MAX_REDIRECTS) return { ok: false, code: 'aiaUnreachable' };
+ let next: URL;
+ try {
+ next = new URL(location, current);
+ } catch {
+ return { ok: false, code: 'aiaUnreachable' };
+ }
+ if (next.protocol !== 'http:' && next.protocol !== 'https:') {
+ return { ok: false, code: 'aiaUnreachable' };
+ }
+ if (next.username || next.password || next.href.length > MAX_URL_LENGTH) {
+ return { ok: false, code: 'aiaInternal' };
+ }
+ current = next;
+ continue;
+ }
+
+ if (!response.ok) {
+ discard(response);
+ return { ok: false, code: 'aiaUnreachable' };
+ }
+
+ const declared = Number(response.headers.get('content-length'));
+ if (Number.isFinite(declared) && declared > AIA_MAX_BYTES) {
+ discard(response);
+ return { ok: false, code: 'aiaTooLarge' };
+ }
+
+ const chunks: Uint8Array[] = [];
+ let total = 0;
+ if (response.body) {
+ const reader = response.body.getReader();
+ try {
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ total += value.length;
+ if (total > AIA_MAX_BYTES) {
+ reader.cancel().catch(() => {});
+ return { ok: false, code: 'aiaTooLarge' };
+ }
+ chunks.push(value);
+ }
+ } catch {
+ reader.cancel().catch(() => {});
+ return { ok: false, code: 'aiaUnreachable' };
+ }
+ }
+
+ const issuers = certificatesFromAnswer(Buffer.concat(chunks)).filter((candidate) =>
+ issuedBy(target, candidate),
+ );
+ if (issuers.length === 0) return { ok: false, code: 'aiaNotIssuer' };
+ return { ok: true, issuers, host: current.hostname };
+ }
+ };
+
+ try {
+ const outcome = await Promise.race([run(), aborted]);
+ return outcome === 'timeout' ? { ok: false, code: 'aiaUnreachable' } : outcome;
+ } finally {
+ clearTimeout(timer);
+ }
+ };
+
+ let worst: AttemptResult & { ok: false } = { ok: false, code: 'aiaInternal' };
+ let rank = 0;
+ for (const url of urls) {
+ const result = await attempt(url);
+ if (result.ok) {
+ const first = result.issuers[0];
+ const subject = (first.toLegacyObject() as { subject?: Record }).subject;
+ const rawCn = subject?.CN;
+ const cn = (Array.isArray(rawCn) ? rawCn[0] : rawCn) as unknown;
+ const name = typeof cn === 'string' ? cn : '';
+ return {
+ filename: `${safeBaseName(name, 'zertifikat')}.crt`,
+ pem: result.issuers.map((c) => `${c.toString().trim()}\n`).join(''),
+ host: result.host,
+ cn: name,
+ };
+ }
+ if ((FAILURE_RANK[result.code] ?? 0) > rank) {
+ rank = FAILURE_RANK[result.code] ?? 0;
+ worst = result;
+ }
+ }
+
+ const hosts = [...new Set(urls.map((u) => u.hostname))].join(', ');
+ logger.warn(`Abruf des Ausstellerzertifikats von ${hosts} fehlgeschlagen: ${worst.code}`);
+ return certError(worst.code, FAILURE_STATUS[worst.code], FAILURE_TEXT[worst.code]);
+}
diff --git a/apps/api/src/cert-manager/cert-manager.changelog.ts b/apps/api/src/cert-manager/cert-manager.changelog.ts
index 2eac893..92a477b 100644
--- a/apps/api/src/cert-manager/cert-manager.changelog.ts
+++ b/apps/api/src/cert-manager/cert-manager.changelog.ts
@@ -30,6 +30,11 @@ export const CERT_MANAGER_CHANGELOG: ModuleChangelog = [
de: 'Vorlagen für Nginx, Apache, Windows/IIS, Nginx Proxy Manager, HAProxy und Tomcat liefern die passenden Dateien mit einem Klick.',
en: 'Templates for Nginx, Apache, Windows/IIS, Nginx Proxy Manager, HAProxy and Tomcat deliver the right files with one click.',
},
+ {
+ kind: 'new',
+ de: 'Fehlt ein Zwischenzertifikat, holt „Fehlendes Zertifikat holen“ es auf Knopfdruck beim Aussteller.',
+ en: 'If an intermediate certificate is missing, “Fetch missing certificate” gets it from the issuer at the click of a button.',
+ },
{
kind: 'fixed',
de: 'Beim Zusammenführen ersetzt eine zweite Datei nicht mehr die erste.',
diff --git a/apps/api/src/cert-manager/cert-manager.controller.spec.ts b/apps/api/src/cert-manager/cert-manager.controller.spec.ts
index 5ec1934..528434e 100644
--- a/apps/api/src/cert-manager/cert-manager.controller.spec.ts
+++ b/apps/api/src/cert-manager/cert-manager.controller.spec.ts
@@ -6,6 +6,7 @@ import { describe, expect, it } from 'vitest';
import { MODULE_SLUG_KEY } from '../module-registry/module.guard';
import { CertManagerController, parsePasswords, repairFileName } from './cert-manager.controller';
import { BuildOutputDto, CERT_PEM_MAX } from './dto/cert-build.dto';
+import { FetchIssuerDto } from './dto/cert-fetch-issuer.dto';
const fx = (name: string) => readFileSync(join(__dirname, '__fixtures__', name));
const upload = (name: string, buffer: Buffer = fx(name)) => ({
@@ -33,14 +34,15 @@ describe('CertManagerController', () => {
expect(Reflect.getMetadata(MODULE_SLUG_KEY, CertManagerController)).toBe('cert-manager');
});
- it('bietet in diesem Stand genau die Handler analyze und build (POST, Code 200)', () => {
+ it('bietet genau die Handler analyze, build und fetchIssuer (POST, Code 200)', () => {
const handlers = Object.getOwnPropertyNames(CertManagerController.prototype).filter(
(n) => n !== 'constructor',
);
- expect(handlers).toEqual(['analyze', 'build']);
+ expect(handlers).toEqual(['analyze', 'build', 'fetchIssuer']);
for (const [name, path] of [
['analyze', 'analyze'],
['build', 'build'],
+ ['fetchIssuer', 'fetch-issuer'],
] as const) {
const handler = CertManagerController.prototype[name];
expect(Reflect.getMetadata('path', handler)).toBe(path);
@@ -193,3 +195,30 @@ describe('build: Anfrage und Pruefung', () => {
expect((dto as unknown as Record).url).toBeUndefined();
});
});
+
+describe('fetch-issuer: Anfrage', () => {
+ const pipe = new ValidationPipe({ whitelist: true, transform: true });
+ const validate = (body: object) =>
+ pipe.transform(body, { type: 'body', metatype: FetchIssuerDto }) as Promise;
+ const pem = fx('rsa-leaf.pem').toString('utf8');
+
+ it('nimmt ein Zertifikat an und entfernt jedes weitere Feld, auch eine Adresse', async () => {
+ const dto = await validate({ pem, url: 'http://127.0.0.1/', host: 'x' });
+ expect(dto.pem).toBe(pem);
+ expect((dto as unknown as Record).url).toBeUndefined();
+ expect((dto as unknown as Record).host).toBeUndefined();
+ });
+
+ it('weist ein zu langes, ein leeres und ein fehlendes Zertifikat ab', async () => {
+ await expect(validate({ pem: 'p'.repeat(CERT_PEM_MAX + 1) })).rejects.toMatchObject({
+ status: 400,
+ });
+ await expect(validate({ pem: '' })).rejects.toMatchObject({ status: 400 });
+ await expect(validate({})).rejects.toMatchObject({ status: 400 });
+ });
+
+ it('nimmt genau die Obergrenze noch an', async () => {
+ const dto = await validate({ pem: 'p'.repeat(CERT_PEM_MAX) });
+ expect(dto.pem.length).toBe(CERT_PEM_MAX);
+ });
+});
diff --git a/apps/api/src/cert-manager/cert-manager.controller.ts b/apps/api/src/cert-manager/cert-manager.controller.ts
index 5d0447c..92f422c 100644
--- a/apps/api/src/cert-manager/cert-manager.controller.ts
+++ b/apps/api/src/cert-manager/cert-manager.controller.ts
@@ -2,10 +2,12 @@ import { Body, Controller, HttpCode, Post, UploadedFiles, UseInterceptors } from
import { FilesInterceptor } from '@nestjs/platform-express';
import type { UploadedFileLike } from '../auth/types/auth-user';
import { UseModule } from '../module-registry/module.guard';
+import { type FetchIssuerResult, fetchIssuer } from './cert-aia';
import { analyzeWorkingSet } from './cert-analyze';
import { buildOutput } from './cert-output';
import { type AnalysisResult, type BuildResult, certError } from './cert-types';
import { BuildOutputDto } from './dto/cert-build.dto';
+import { FetchIssuerDto } from './dto/cert-fetch-issuer.dto';
/** Obergrenzen (D-17): je Datei 5 MiB, alle Dateien zusammen 20 MiB, hoechstens 30 Dateien. */
export const CERT_MAX_FILES = 30;
@@ -60,7 +62,8 @@ export function repairFileName(name: string): string {
* Routen (alle POST, 200):
* - analyze Task 1 mehrere Dateien (multipart) erkennen und zusammenfassen; Task 4: optionales Feld `passwords`
* - 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
+ * - fetch-issuer Task 7 fehlendes Zwischenzertifikat nur auf Knopfdruck holen (JSON, nur `pem`;
+ * die Adresse liest der Server aus dem Zertifikat, siehe cert-aia.ts)
*/
@Controller('modules/cert-manager')
@UseModule('cert-manager')
@@ -93,4 +96,10 @@ export class CertManagerController {
build(@Body() dto: BuildOutputDto): BuildResult {
return buildOutput(dto);
}
+
+ @Post('fetch-issuer')
+ @HttpCode(200)
+ fetchIssuer(@Body() dto: FetchIssuerDto): Promise {
+ return fetchIssuer(dto.pem);
+ }
}
diff --git a/apps/api/src/cert-manager/cert-model.ts b/apps/api/src/cert-manager/cert-model.ts
index 514dd5a..54b111d 100644
--- a/apps/api/src/cert-manager/cert-model.ts
+++ b/apps/api/src/cert-manager/cert-model.ts
@@ -218,7 +218,7 @@ const OID_SIGNED_DATA = '1.2.840.113549.1.7.2';
* Reiner ASN.1-Lauf: ContentInfo -> [0] SignedData -> [0] certificates. Jedes Zertifikat wird
* als DER an node:crypto gegeben; die RSA-only-Zertifikatsleser von forge kommen nie vor.
*/
-function pkcs7Certificates(der: Buffer): Buffer[] {
+export function pkcs7Certificates(der: Buffer): Buffer[] {
// Die Typdefinition kennt nur `strict: boolean`; forge nimmt zur Laufzeit ein Optionsobjekt.
// decodeBitStrings aus: Bitfolgen bleiben unveraendert, damit die Zertifikats-Bytes beim
// erneuten Schreiben mit den Originalen uebereinstimmen (gleicher Fingerabdruck).
diff --git a/apps/api/src/cert-manager/dto/cert-fetch-issuer.dto.ts b/apps/api/src/cert-manager/dto/cert-fetch-issuer.dto.ts
new file mode 100644
index 0000000..a2d087c
--- /dev/null
+++ b/apps/api/src/cert-manager/dto/cert-fetch-issuer.dto.ts
@@ -0,0 +1,14 @@
+import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
+import { CERT_PEM_MAX } from './cert-build.dto';
+
+/**
+ * Anfrage fuer POST fetch-issuer (quick-261009-ikt, D-22): nur das Zertifikat, dessen Aussteller
+ * fehlt. Eine Adresse wird nie angenommen: die globale ValidationPipe (whitelist) entfernt jedes
+ * weitere Feld, die Adresse liest der Server selbst aus dem Zertifikat.
+ */
+export class FetchIssuerDto {
+ @IsString()
+ @IsNotEmpty()
+ @MaxLength(CERT_PEM_MAX)
+ pem!: string;
+}
diff --git a/apps/api/src/common/public-url-guard.spec.ts b/apps/api/src/common/public-url-guard.spec.ts
new file mode 100644
index 0000000..1d3e515
--- /dev/null
+++ b/apps/api/src/common/public-url-guard.spec.ts
@@ -0,0 +1,124 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const lookupMock = vi.hoisted(() => vi.fn());
+vi.mock('node:dns/promises', () => ({ lookup: lookupMock }));
+
+import { isPrivateIpAddress, isPublicHttpUrl } from './public-url-guard';
+
+describe('isPrivateIpAddress', () => {
+ const blocked = [
+ // IPv4
+ '127.0.0.1',
+ '10.1.2.3',
+ '100.64.0.1',
+ '169.254.169.254',
+ '172.16.0.1',
+ '192.168.1.1',
+ '0.0.0.0',
+ // IPv6: unspezifiziert, Loopback
+ '::',
+ '::1',
+ // IPv4-gemappt in allen Schreibweisen
+ '::ffff:127.0.0.1',
+ '::ffff:7f00:1',
+ '0:0:0:0:0:ffff:7f00:1',
+ '::ffff:a9fe:a9fe',
+ '::FFFF:7F00:1',
+ // IPv4-kompatibel
+ '::7f00:1',
+ // NAT64
+ '64:ff9b::7f00:1',
+ '64:ff9b::10.0.0.1',
+ '64:ff9b:1::1',
+ // 6to4
+ '2002:7f00:1::1',
+ '2002:c0a8:101::1',
+ // Unique-Local, Link-Local, Site-Local, Multicast
+ 'fc00::1',
+ 'fd12::1',
+ 'fe80::1',
+ 'fe80::1%eth0',
+ 'febf::1',
+ 'fec0::1',
+ 'ff02::1',
+ // Dokumentation, Verwerfen, Teredo
+ '2001:db8::1',
+ '3fff::1',
+ '100::1',
+ '2001::1',
+ // nicht lesbar
+ 'not-an-address',
+ '',
+ '1:2:3:4:5:6:7:8:9',
+ ':::',
+ '12345::1',
+ '::1::2',
+ '::ffff:300.1.1.1',
+ ];
+ const allowed = [
+ '8.8.8.8',
+ '93.184.215.14',
+ '2606:4700:4700::1111',
+ '64:ff9b::808:808',
+ '2002:808:808::1',
+ '2a00:1450:4001:82a::200e',
+ ];
+
+ it.each(blocked)('sperrt %j', (address) => {
+ expect(isPrivateIpAddress(address)).toBe(true);
+ });
+
+ it.each(allowed)('erlaubt %s', (address) => {
+ expect(isPrivateIpAddress(address)).toBe(false);
+ });
+});
+
+describe('isPublicHttpUrl', () => {
+ beforeEach(() => {
+ lookupMock.mockReset();
+ });
+
+ it('lehnt eine Adresse ab, deren AAAA-Eintrag versteckt auf 127.0.0.1 zeigt', async () => {
+ lookupMock.mockResolvedValue([{ address: '::ffff:7f00:1', family: 6 }]);
+ expect(await isPublicHttpUrl(new URL('http://versteckt.example.test/'))).toBe(false);
+ });
+
+ it('lehnt ab, sobald eine von mehreren Adressen intern ist', async () => {
+ lookupMock.mockResolvedValue([
+ { address: '8.8.8.8', family: 4 },
+ { address: '64:ff9b::7f00:1', family: 6 },
+ ]);
+ expect(await isPublicHttpUrl(new URL('http://gemischt.example.test/'))).toBe(false);
+ });
+
+ it('erlaubt eine Adresse, die nur oeffentlich aufloest', async () => {
+ lookupMock.mockResolvedValue([{ address: '8.8.8.8', family: 4 }]);
+ expect(await isPublicHttpUrl(new URL('https://oeffentlich.example.test/'))).toBe(true);
+ });
+
+ it('lehnt andere Protokolle ab, ohne aufzuloesen', async () => {
+ expect(await isPublicHttpUrl(new URL('ftp://example.test/'))).toBe(false);
+ expect(await isPublicHttpUrl(new URL('ldap://example.test/'))).toBe(false);
+ expect(lookupMock).not.toHaveBeenCalled();
+ });
+
+ it('lehnt localhost und .local ab, ohne aufzuloesen', async () => {
+ expect(await isPublicHttpUrl(new URL('http://localhost/'))).toBe(false);
+ expect(await isPublicHttpUrl(new URL('http://drucker.local/'))).toBe(false);
+ expect(lookupMock).not.toHaveBeenCalled();
+ });
+
+ it('prueft eine Adresse als Zahl direkt', async () => {
+ expect(await isPublicHttpUrl(new URL('http://127.0.0.1/'))).toBe(false);
+ expect(await isPublicHttpUrl(new URL('http://169.254.169.254/latest'))).toBe(false);
+ expect(await isPublicHttpUrl(new URL('http://8.8.8.8/'))).toBe(true);
+ expect(lookupMock).not.toHaveBeenCalled();
+ });
+
+ it('lehnt ab, wenn die Aufloesung scheitert oder nichts liefert', async () => {
+ lookupMock.mockRejectedValueOnce(new Error('ENOTFOUND'));
+ expect(await isPublicHttpUrl(new URL('http://gibt-es-nicht.example.test/'))).toBe(false);
+ lookupMock.mockResolvedValueOnce([]);
+ expect(await isPublicHttpUrl(new URL('http://leer.example.test/'))).toBe(false);
+ });
+});
diff --git a/apps/api/src/common/public-url-guard.ts b/apps/api/src/common/public-url-guard.ts
index 8da6223..aa864c7 100644
--- a/apps/api/src/common/public-url-guard.ts
+++ b/apps/api/src/common/public-url-guard.ts
@@ -10,6 +10,15 @@ import { isIP } from 'node:net';
* http/https ist, der Name nicht localhost/.local/0.0.0.0 ist und jede
* aufgeloeste Adresse ausserhalb privater, Loopback-, Link-Local-, CGNAT- und
* Multicast-Bereiche liegt.
+ *
+ * Gehaertet in quick-261009-ikt (Abruf fehlender Zertifikate): IPv6-Adressen werden
+ * vollstaendig in acht Gruppen zerlegt, damit versteckte Schreibweisen interner
+ * Adressen erkannt werden: IPv4-gemappt in Hex-Form (::ffff:7f00:1), IPv4-kompatibel
+ * (::7f00:1), NAT64 (64:ff9b::/96 und 64:ff9b:1::/48), 6to4 (2002::/16), Teredo
+ * (2001::/32), Dokumentation, Verwerfen (100::/64), Unique-Local, Link-Local,
+ * Site-Local, Multicast und Adressen mit Zonen-Kennung. Nicht lesbare Adressen
+ * bleiben gesperrt. Die Pruefung der aufgeloesten Adressen beim Verbindungsaufbau
+ * (gegen DNS-Rebinding) liegt in cert-manager/cert-aia.ts (`createGuardedLookup`).
*/
function isPrivateIpv4(address: string): boolean {
@@ -38,31 +47,95 @@ function isPrivateIpv4(address: string): boolean {
);
}
-function isPrivateIpv6(address: string): boolean {
- const lower = address.toLowerCase();
+/**
+ * Zerlegt eine IPv6-Adresse in acht 16-Bit-Gruppen (Zonen-Kennung wie `%eth0` entfernt, `::`
+ * aufgefuellt, eingebettete Punkt-Schreibweise des Schlusses in zwei Gruppen umgerechnet).
+ * Nicht lesbare Adressen ergeben null.
+ */
+function expandIpv6(address: string): number[] | null {
+ let text = address.trim().toLowerCase();
+ const zone = text.indexOf('%');
+ if (zone !== -1) text = text.slice(0, zone);
+ if (text === '' || /[^0-9a-f:.]/.test(text)) return null;
- if (
- lower === '::' ||
- lower === '::1' ||
- lower.startsWith('fc') ||
- lower.startsWith('fd') ||
- lower.startsWith('fe80:') ||
- lower.startsWith('ff')
- ) {
- return true;
+ const lastColon = text.lastIndexOf(':');
+ if (lastColon === -1) return null;
+ const tail = text.slice(lastColon + 1);
+ if (tail.includes('.')) {
+ const octets = tail.split('.').map((part) => (/^\d{1,3}$/.test(part) ? Number(part) : -1));
+ if (octets.length !== 4 || octets.some((o) => o < 0 || o > 255)) return null;
+ const high = ((octets[0] << 8) | octets[1]).toString(16);
+ const low = ((octets[2] << 8) | octets[3]).toString(16);
+ text = `${text.slice(0, lastColon + 1)}${high}:${low}`;
}
- // IPv4-mapped IPv6 (::ffff:) — delegate to isPrivateIpv4 to cover all
- // RFC 1918 ranges (10.x, 172.16-31.x, 192.168.x) and 169.254.x link-local
- const v4MappedMatch = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
- if (v4MappedMatch) {
- return isPrivateIpv4(v4MappedMatch[1]);
+ const halves = text.split('::');
+ if (halves.length > 2) return null;
+ const parse = (part: string): number[] | null => {
+ if (part === '') return [];
+ const groups: number[] = [];
+ for (const group of part.split(':')) {
+ if (!/^[0-9a-f]{1,4}$/.test(group)) return null;
+ groups.push(Number.parseInt(group, 16));
+ }
+ return groups;
+ };
+ const head = parse(halves[0]);
+ if (head === null) return null;
+ if (halves.length === 1) return head.length === 8 ? head : null;
+ const rest = parse(halves[1]);
+ if (rest === null || head.length + rest.length > 7) return null;
+ return [...head, ...new Array(8 - head.length - rest.length).fill(0), ...rest];
+}
+
+function embeddedIpv4(high: number, low: number): string {
+ return `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`;
+}
+
+/**
+ * IPv6-Adressen, die auf intern zeigen koennen. Gesperrt sind: unspezifiziert und Loopback,
+ * der ganze Bereich ::/96 (IPv4-kompatibel, veraltet), ::ffff:0:0/96 (IPv4-gemappt) und
+ * ::ffff:0:0:0/96 (IPv4-uebersetzt), NAT64 64:ff9b::/96 und 6to4 2002::/16 je nach
+ * eingebetteter IPv4-Adresse, das lokale NAT64-Praefix 64:ff9b:1::/48, Teredo 2001::/32,
+ * Dokumentation 2001:db8::/32 und 3fff::/20, Verwerfen 100::/64, Unique-Local fc00::/7,
+ * Link-Local fe80::/10, das veraltete Site-Local fec0::/10 und Multicast ff00::/8.
+ * Alles, was nicht lesbar ist, bleibt gesperrt.
+ */
+export function isPrivateIpv6(address: string): boolean {
+ const g = expandIpv6(address);
+ if (g === null) return true;
+
+ const zeros = (from: number, to: number): boolean => g.slice(from, to).every((x) => x === 0);
+
+ // ::/96 komplett (unspezifiziert, Loopback, IPv4-kompatibel)
+ if (zeros(0, 6)) return true;
+ // ::ffff:a.b.c.d (IPv4-gemappt, auch in der Hex-Schreibweise ::ffff:7f00:1)
+ if (zeros(0, 5) && g[5] === 0xffff) return isPrivateIpv4(embeddedIpv4(g[6], g[7]));
+ // ::ffff:0:a.b.c.d (IPv4-uebersetzt)
+ if (zeros(0, 4) && g[4] === 0xffff && g[5] === 0) return isPrivateIpv4(embeddedIpv4(g[6], g[7]));
+ // 64:ff9b::/96 (NAT64) nach eingebetteter IPv4; 64:ff9b:1::/48 (lokales NAT64) immer
+ if (g[0] === 0x64 && g[1] === 0xff9b) {
+ if (g[2] === 1) return true;
+ if (zeros(2, 6)) return isPrivateIpv4(embeddedIpv4(g[6], g[7]));
}
+ // 2002::/16 (6to4): die IPv4-Adresse steht in Gruppe 2 und 3
+ if (g[0] === 0x2002) return isPrivateIpv4(embeddedIpv4(g[1], g[2]));
+ // 2001::/32 (Teredo) und 2001:db8::/32 (Dokumentation)
+ if (g[0] === 0x2001 && (g[1] === 0 || g[1] === 0x0db8)) return true;
+ // 3fff::/20 (Dokumentation)
+ if (g[0] === 0x3fff && g[1] < 0x1000) return true;
+ // 100::/64 (Verwerfen)
+ if (g[0] === 0x100 && zeros(1, 4)) return true;
+ // fc00::/7, fe80::/10, fec0::/10, ff00::/8
+ if ((g[0] & 0xfe00) === 0xfc00) return true;
+ if ((g[0] & 0xffc0) === 0xfe80) return true;
+ if ((g[0] & 0xffc0) === 0xfec0) return true;
+ if ((g[0] & 0xff00) === 0xff00) return true;
return false;
}
-function isPrivateIpAddress(address: string): boolean {
+export function isPrivateIpAddress(address: string): boolean {
const version = isIP(address);
if (version === 4) return isPrivateIpv4(address);
diff --git a/apps/web/src/app/(portal)/modules/cert-manager/actions.ts b/apps/web/src/app/(portal)/modules/cert-manager/actions.ts
index d1df0e6..774c954 100644
--- a/apps/web/src/app/(portal)/modules/cert-manager/actions.ts
+++ b/apps/web/src/app/(portal)/modules/cert-manager/actions.ts
@@ -245,6 +245,30 @@ export async function buildOutput(input: BuildInput): Promise {
return (await response.json()) as BuildResult;
}
+/** Antwort von POST fetch-issuer: das geholte Ausstellerzertifikat (PEM), der Server und sein Name. */
+export interface FetchIssuerResult {
+ filename: string;
+ pem: string;
+ host: string;
+ cn: string;
+}
+
+/**
+ * POST /modules/cert-manager/fetch-issuer: holt das Ausstellerzertifikat zu einem Zertifikat.
+ * Nur auf Knopfdruck. Gesendet wird allein das Zertifikat; die Adresse liest der Server selbst
+ * aus dem Zertifikat, der Browser nennt nie eine Adresse.
+ */
+export async function fetchIssuer(pem: string): Promise {
+ const response = await fetch(`${API_URL}/modules/cert-manager/fetch-issuer`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ pem }),
+ credentials: 'include',
+ });
+ if (!response.ok) throw await errorFromResponse(response);
+ return (await response.json()) as FetchIssuerResult;
+}
+
/** Laedt eine Base64-Datei als Browser-Download herunter. Der Dateiname enthaelt nie ein Passwort. */
export function downloadBase64(filename: string, content: string, mimeType: string): void {
const bytes = atob(content);
diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.test.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.test.tsx
index fa03d74..ba7bdcb 100644
--- a/apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.test.tsx
+++ b/apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.test.tsx
@@ -69,6 +69,7 @@ function workspace(
errorKey: null,
addFiles: () => [],
addText: () => null,
+ addFetched: () => 'added',
setPassword: () => {},
remove: () => {},
clear: () => {},
@@ -146,6 +147,25 @@ describe('AnalyzeTab', () => {
expect(within(chainsRegion).getByText(/Zwischenzertifikat fehlt/)).toBeInTheDocument();
});
+ it('markiert ein nachgeladenes Zertifikat mit dem Server, von dem es kam', () => {
+ const fetchedEntry: WorkingEntry = {
+ ...entry('e2', 'Test_Inter.crt'),
+ origin: 'fetched',
+ host: 'pki.example.test',
+ };
+ render(
+ ,
+ );
+ // inter hat die Quelle `file: 1`, also den nachgeladenen Eintrag
+ expect(screen.getByText('nachgeladen von pki.example.test')).toBeInTheDocument();
+ });
+
it('eine Karte je Zertifikat mit Rolle, Name, Aussteller, Gueltigkeit in UTC, Namen, Schluessel', () => {
render();
const cards = screen.getAllByTestId('cert-card');
diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.tsx
index 54f93d6..45f7c63 100644
--- a/apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.tsx
+++ b/apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.tsx
@@ -46,7 +46,12 @@ export function AnalyzeTab({ workspace }: AnalyzeTabProps) {
{t('analyze.chainsTitle')}
{analysis.chains.map((chain) => (
-
+
))}
)}
diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/ChainView.test.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/ChainView.test.tsx
new file mode 100644
index 0000000..a0502c3
--- /dev/null
+++ b/apps/web/src/app/(portal)/modules/cert-manager/components/ChainView.test.tsx
@@ -0,0 +1,188 @@
+import {
+ act,
+ cleanup,
+ fireEvent,
+ render as rtlRender,
+ screen,
+ waitFor,
+} from '@testing-library/react';
+import { NextIntlClientProvider } from 'next-intl';
+import type { ReactElement } from 'react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import de from '@/messages/de.json';
+import type { CertItem, ChainInfo, FetchIssuerResult } from '../actions';
+import { CertManagerRequestError } from '../actions';
+import { ChainView } from './ChainView';
+
+const mockFetchIssuer = vi.fn();
+
+vi.mock('../actions', async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, fetchIssuer: (...args: unknown[]) => mockFetchIssuer(...args) };
+});
+
+function render(ui: ReactElement) {
+ return rtlRender(
+
+ {ui}
+ ,
+ );
+}
+
+function cert(id: string, cn: string, role: CertItem['role'], over: Partial = {}) {
+ return {
+ id,
+ kind: 'certificate',
+ role,
+ sources: [{ file: 0, path: 'a.pem' }],
+ pem: `PEM-${id}`,
+ baseName: cn,
+ cn,
+ organization: '',
+ issuerCn: 'Test Inter',
+ issuerOrganization: '',
+ notBefore: '2026-01-01T00:00:00.000Z',
+ notAfter: '2126-01-01T00:00:00.000Z',
+ isExpired: false,
+ daysLeft: 36000,
+ san: [],
+ keyType: 'RSA',
+ keyBits: 2048,
+ curve: null,
+ serialNumber: '01',
+ sha256: '',
+ sha1: '',
+ isCa: role !== 'end-entity',
+ selfSigned: false,
+ aiaIssuerUrls: [],
+ keyId: null,
+ csrIds: [],
+ ...over,
+ } satisfies CertItem;
+}
+
+const leaf = cert('c-leaf', 'www.example.test', 'end-entity');
+const inter = cert('c-inter', 'Test Inter', 'intermediate');
+
+function chain(kind: 'afterLeaf' | 'afterCa', aiaUrls: string[]): ChainInfo {
+ return {
+ headId: 'c-leaf',
+ path: kind === 'afterLeaf' ? ['c-leaf'] : ['c-leaf', 'c-inter'],
+ rootId: null,
+ complete: false,
+ alternatives: 0,
+ gap: {
+ certId: kind === 'afterLeaf' ? 'c-leaf' : 'c-inter',
+ kind,
+ missingIssuerCn: kind === 'afterLeaf' ? 'Test Inter' : 'Test Root',
+ aiaUrls,
+ },
+ };
+}
+
+const fetched: FetchIssuerResult = {
+ filename: 'Test_Inter.crt',
+ pem: '-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n',
+ host: 'pki.example.test',
+ cn: 'Test Inter',
+};
+
+beforeEach(() => {
+ mockFetchIssuer.mockReset();
+});
+afterEach(cleanup);
+
+describe('ChainView, fehlendes Zertifikat holen', () => {
+ const aia = ['http://pki.example.test/inter.cer'];
+
+ it('zeigt bei einer Luecke mit Adresse den Knopf und den Hinweis mit dem Server', () => {
+ render( 'added'} />);
+ expect(screen.getByRole('button', { name: 'Fehlendes Zertifikat holen' })).toBeEnabled();
+ expect(screen.getByText(/pki\.example\.test/)).toBeInTheDocument();
+ });
+
+ it('zeigt den Knopf auch bei einer Luecke hinter einem Zwischenzertifikat', () => {
+ render(
+ 'added'} />,
+ );
+ expect(screen.getByRole('button', { name: 'Fehlendes Zertifikat holen' })).toBeInTheDocument();
+ });
+
+ it('ohne Adresse im Zertifikat gibt es keinen Knopf, nur den Hinweis zum Herunterladen', () => {
+ render( 'added'} />);
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+ expect(screen.getByText(/keine Adresse zum Nachladen/)).toBeInTheDocument();
+ cleanup();
+ render(
+ 'added'} />,
+ );
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+ });
+
+ it('ohne onFetched zeigt die Kette keinen Knopf', () => {
+ render();
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+ });
+
+ it('holt nichts beim Anzeigen und nichts beim erneuten Anzeigen', () => {
+ const view = render(
+ 'added'} />,
+ );
+ view.rerender(
+
+ 'added'} />
+ ,
+ );
+ expect(mockFetchIssuer).not.toHaveBeenCalled();
+ });
+
+ it('ein Klick schickt genau das Zertifikat mit der Luecke, der Knopf ist waehrenddessen gesperrt', async () => {
+ let resolve: (value: FetchIssuerResult) => void = () => {};
+ mockFetchIssuer.mockReturnValue(new Promise((r) => (resolve = r)));
+ const onFetched = vi.fn(() => 'added' as const);
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Fehlendes Zertifikat holen' }));
+ const busy = await screen.findByRole('button', { name: 'Wird geholt …' });
+ expect(busy).toBeDisabled();
+ fireEvent.click(busy);
+ expect(mockFetchIssuer).toHaveBeenCalledTimes(1);
+ expect(mockFetchIssuer).toHaveBeenCalledWith('PEM-c-inter');
+
+ await act(async () => resolve(fetched));
+ expect(onFetched).toHaveBeenCalledWith(fetched);
+ expect(onFetched).toHaveBeenCalledTimes(1);
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: 'Fehlendes Zertifikat holen' })).toBeEnabled(),
+ );
+ });
+
+ it('Fehlercodes der API zeigen ihren Text', async () => {
+ mockFetchIssuer.mockRejectedValue(new CertManagerRequestError(502, 'aiaUnreachable'));
+ render( 'added'} />);
+ fireEvent.click(screen.getByRole('button', { name: 'Fehlendes Zertifikat holen' }));
+ expect(
+ await screen.findByText(/Server des Ausstellers ist nicht erreichbar/),
+ ).toBeInTheDocument();
+ });
+
+ it('ein unbekannter Fehler zeigt den allgemeinen Hinweis zum Nachladen', async () => {
+ mockFetchIssuer.mockRejectedValue(new Error('boom'));
+ render( 'added'} />);
+ fireEvent.click(screen.getByRole('button', { name: 'Fehlendes Zertifikat holen' }));
+ expect(
+ await screen.findByText(/Das Zertifikat konnte nicht geholt werden/),
+ ).toBeInTheDocument();
+ });
+
+ it('dasselbe Zertifikat noch einmal: ruhiger Hinweis statt Doppelung', async () => {
+ mockFetchIssuer.mockResolvedValue(fetched);
+ render(
+ 'duplicate'} />,
+ );
+ fireEvent.click(screen.getByRole('button', { name: 'Fehlendes Zertifikat holen' }));
+ expect(
+ await screen.findByText('Dieses Zertifikat ist schon in der Liste.'),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/ChainView.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/ChainView.tsx
index 5f60947..09d0e4d 100644
--- a/apps/web/src/app/(portal)/modules/cert-manager/components/ChainView.tsx
+++ b/apps/web/src/app/(portal)/modules/cert-manager/components/ChainView.tsx
@@ -1,19 +1,104 @@
'use client';
import { useFormatter, useTranslations } from 'next-intl';
-import type { CertItem, ChainInfo } from '../actions';
+import { useState } from 'react';
+import {
+ type CertItem,
+ type ChainGap,
+ type ChainInfo,
+ certErrorKey,
+ type FetchIssuerResult,
+ fetchIssuer,
+} from '../actions';
+import type { AddFetchedOutcome } from '../working-set';
import { ROLE_STYLES } from './FilesTab';
interface ChainViewProps {
chain: ChainInfo;
certs: CertItem[];
+ /**
+ * Haengt ein geholtes Zertifikat an den Arbeitsbereich an. Ohne diese Funktion zeigt die Kette
+ * den Knopf „Fehlendes Zertifikat holen“ nicht.
+ */
+ onFetched?: (fetched: FetchIssuerResult) => AddFetchedOutcome;
+}
+
+function hostOf(url: string | undefined): string | null {
+ if (!url) return null;
+ try {
+ return new URL(url).hostname;
+ } catch {
+ return null;
+ }
+}
+
+interface FetchMissingProps {
+ gap: ChainGap;
+ certs: CertItem[];
+ onFetched: (fetched: FetchIssuerResult) => AddFetchedOutcome;
+}
+
+/**
+ * „Fehlendes Zertifikat holen“ (D-03): geschieht nur auf Klick, nie beim Anzeigen und nie nach
+ * einer neuen Pruefung. Gesendet wird das Zertifikat, dem der Aussteller fehlt; die Adresse liest
+ * der Server selbst aus dem Zertifikat. Ein Klick holt eine Stufe; fehlt danach noch eine,
+ * erscheint der Knopf an der neuen Luecke wieder.
+ */
+function FetchMissing({ gap, certs, onFetched }: FetchMissingProps) {
+ const t = useTranslations('certManager');
+ const [busy, setBusy] = useState(false);
+ const [problem, setProblem] = useState(null);
+ const host = hostOf(gap.aiaUrls[0]);
+
+ if (gap.aiaUrls.length === 0) {
+ return {t('chain.fetchNoAddress')}
;
+ }
+
+ const click = async () => {
+ const cert = certs.find((c) => c.id === gap.certId);
+ if (!cert || busy) return;
+ setBusy(true);
+ setProblem(null);
+ try {
+ const fetched = await fetchIssuer(cert.pem);
+ const outcome = onFetched(fetched);
+ if (outcome === 'duplicate') setProblem(t('chain.fetchAlready'));
+ else if (outcome === 'tooMany') setProblem(t('chain.fetchListFull'));
+ else if (outcome === 'totalTooLarge') setProblem(t('chain.fetchListTooLarge'));
+ } catch (error) {
+ const key = certErrorKey(error);
+ setProblem(key === 'generic' ? t('chain.fetchFailed') : t(`errors.${key}`));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
{t('chain.fetchHint', { host: host ?? '' })}
+
+ {problem && (
+
+ {problem}
+
+ )}
+
+ );
}
/**
* Eine Kette in Reihenfolge: Serverzertifikat zuerst, dann jeder Aussteller. Eine duenne Linie
* verbindet die Schritte. Fehlt ein Aussteller, steht darunter, was fehlt (D-18).
*/
-export function ChainView({ chain, certs }: ChainViewProps) {
+export function ChainView({ chain, certs, onFetched }: ChainViewProps) {
const t = useTranslations('certManager');
const format = useFormatter();
const steps = chain.path
@@ -69,12 +154,18 @@ export function ChainView({ chain, certs }: ChainViewProps) {
})}
{t('chain.gapAfterLeafHint')}
+ {onFetched && }
)}
{chain.gap?.kind === 'afterCa' && (
-
- {t('chain.gapAfterCa', { name: chain.gap.missingIssuerCn || t('chain.unknownIssuer') })}
-
+
+
+ {t('chain.gapAfterCa', { name: chain.gap.missingIssuerCn || t('chain.unknownIssuer') })}
+
+ {onFetched && chain.gap.aiaUrls.length > 0 && (
+
+ )}
+
)}
);
diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.test.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.test.tsx
index a750387..67a2b9d 100644
--- a/apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.test.tsx
+++ b/apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.test.tsx
@@ -94,6 +94,7 @@ function workspace(items: AnyItem[]): CertWorkspace {
errorKey: null,
addFiles: () => [],
addText: () => null,
+ addFetched: () => 'added',
setPassword: () => {},
remove: () => {},
clear: () => {},
diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.test.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.test.tsx
index fdfb4a8..f466128 100644
--- a/apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.test.tsx
+++ b/apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.test.tsx
@@ -466,4 +466,48 @@ describe('FilesTab', () => {
expect(container.textContent).not.toContain('geheim-xyz');
});
});
+
+ describe('nachgeladene Zertifikate', () => {
+ const fetchedCert = {
+ filename: 'inter-nachgeladen.crt',
+ pem: '-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n',
+ host: 'pki.example.test',
+ };
+
+ function FetchHarness() {
+ const workspace = useCertWorkspace();
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ it('zeigt den Eintrag mit „nachgeladen von“ und sendet ihn mit; ein zweites Mal ergibt keinen zweiten Eintrag', async () => {
+ render();
+ selectFiles([makeFile('leaf.pem')]);
+ await screen.findByText('www.example.test');
+
+ fireEvent.click(screen.getByRole('button', { name: 'nachladen' }));
+ expect(await screen.findByText('inter-nachgeladen.crt')).toBeInTheDocument();
+ expect(screen.getByText('nachgeladen von pki.example.test')).toBeInTheDocument();
+ await waitFor(() => expect(mockAnalyze).toHaveBeenCalledTimes(2));
+ const lastCall = mockAnalyze.mock.calls.at(-1)?.[0] as { file: File }[];
+ expect(lastCall.map((e) => e.file.name)).toEqual(['leaf.pem', 'inter-nachgeladen.crt']);
+
+ fireEvent.click(screen.getByRole('button', { name: 'nachladen' }));
+ expect(screen.getAllByText('inter-nachgeladen.crt')).toHaveLength(1);
+ expect(mockAnalyze).toHaveBeenCalledTimes(2);
+ });
+
+ it('ein hochgeladener Eintrag traegt die Marke nicht', async () => {
+ render();
+ selectFiles([makeFile('leaf.pem')]);
+ await screen.findByText('www.example.test');
+ expect(screen.queryByText(/nachgeladen von/)).not.toBeInTheDocument();
+ });
+ });
});
diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.tsx
index 86ddd55..d7c8ada 100644
--- a/apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.tsx
+++ b/apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.tsx
@@ -351,6 +351,9 @@ export function FilesTab({ workspace }: FilesTabProps) {
{formatBytes(entry.file.size)}
{entry.origin === 'paste' && {t('files.originPaste')}}
+ {entry.origin === 'fetched' && (
+ {t('files.fetchedFrom', { host: entry.host ?? '' })}
+ )}