feat(cert-manager): Fehlendes Zertifikat holen, gehärteter Adressschutz
- 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 <Server>“ 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<boolean>;
|
||||
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<void> } | 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<string, unknown> }).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<CertErrorCode, `aia${string}`> };
|
||||
|
||||
/** Die Rangfolge, wenn alle Adressen scheitern: die Meldung mit dem meisten Fortschritt gewinnt. */
|
||||
const FAILURE_RANK: Record<string, number> = {
|
||||
aiaNotIssuer: 4,
|
||||
aiaTooLarge: 3,
|
||||
aiaUnreachable: 2,
|
||||
aiaInternal: 1,
|
||||
};
|
||||
|
||||
const FAILURE_STATUS: Record<string, number> = {
|
||||
aiaNotIssuer: 422,
|
||||
aiaInternal: 422,
|
||||
aiaTooLarge: 502,
|
||||
aiaUnreachable: 502,
|
||||
};
|
||||
|
||||
const FAILURE_TEXT: Record<string, string> = {
|
||||
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<FetchIssuerResult> {
|
||||
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<AttemptResult> => {
|
||||
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<AttemptResult> => {
|
||||
let current = first;
|
||||
for (let hop = 0; ; hop++) {
|
||||
if (!hasDefaultPort(current) || !(await isPublic(current))) {
|
||||
return { ok: false, code: 'aiaInternal' };
|
||||
}
|
||||
|
||||
let response: Awaited<ReturnType<typeof undiciFetch>>;
|
||||
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<string, unknown> }).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]);
|
||||
}
|
||||
Reference in New Issue
Block a user