feat(api): Favoriten — Symbol trotz Zertifikatsfehler holen, Reihenfolge per PUT /favorites/order speichern

- icon-discovery.service.ts: undicis eigenes fetch mit Modul-Singleton
  LENIENT_TLS_AGENT (Agent({ connect: { rejectUnauthorized: false } }))
  als dispatcher in fetchWithRedirectGuard, der einzigen Ausgangsstelle
  fuer HTML-Ermittlung und Icon-Byte-Holen; SSRF-Schutz unveraendert
- undici 7.28.0 (bereits im Lockfile aufgeloest) als direkte Abhaengigkeit
  von @tessera/api via pnpm add --offline
- PUT /favorites/order (ReorderFavoritesDto) vor den :id-Routen;
  FavoritesService.reorder() setzt position=index fuer die Favoriten
  eines Widgets in EINER withTenantTransaction, userId+widgetId in jeder
  Bedingung (zweites Netz), eine BadRequestException fuer alle
  Abweichungen (T-JDD-06)
- getIcon: X-Content-Type-Options nosniff + restriktive CSP (T-JDD-02)
- 10 neue Tests (3 Dispatcher, 7 reorder); volle API-Suite 68 Dateien/
  1101 Tests und type-check gruen

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 14:36:03 +02:00
parent e7633e15de
commit 2a562d0b14
8 changed files with 373 additions and 11 deletions
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { lookup } from 'dns/promises';
import { isIP } from 'net';
import { Agent, fetch as undiciFetch, type Response as UndiciResponse } from 'undici';
/**
* Server-side favicon / icon discovery with SSRF protection (T-08-05).
@@ -12,6 +13,9 @@ import { isIP } from 'net';
* - 4000 ms AbortController timeout per request
* - 200 000 character HTML cap to prevent memory exhaustion (T-08-09)
* - Blocked hostnames: localhost, .local, 0.0.0.0
* - 260917-jdd: Zertifikatsfehler des Zielhosts werden toleriert (siehe
* LENIENT_TLS_AGENT unten) — DNS-Pruefung, Redirect-Limit, Timeout und
* Groessendeckel bleiben davon unberuehrt.
*/
const FALLBACK_ICON_PATH = '/favicon.ico';
@@ -21,6 +25,27 @@ const MAX_REDIRECTS = 2;
const MAX_HTML_CHARS = 200000;
const MAX_ICON_BYTES = 1_000_000;
/**
* 260917-jdd — Ziel ist ein Bildchen, kein Geheimnis: selbstsignierte,
* abgelaufene oder falsch benannte Zertifikate sollen das Symbol eines
* Favoriten nicht verhindern. Dieser Dispatcher gilt AUSSCHLIESSLICH fuer
* die beiden Aufrufe in dieser Datei (Dispatcher pro Aufruf, keine
* prozessweite Abschaltung der Zertifikatspruefung — insbesondere NICHT
* ueber die Node-Umgebungsvariable, die mit NODE_TLS_ beginnt).
*
* Der Dispatcher wirkt nur zusammen mit undicis EIGENEM `fetch` — Nodes
* globales `fetch` ignoriert einen Agent aus dem npm-Paket (andere Klasse,
* Node 24 buendelt intern undici 7.25.0). Gemessen 2026-09-17 gegen
* self-signed.badssl.com: `undiciFetch(url, { dispatcher: new Agent(...) })`
* -> Status 200; `globalThis.fetch` derselben URL -> DEPTH_ZERO_SELF_SIGNED_CERT.
* Deshalb der Modulimport oben statt des globalen `fetch`.
*
* DNS-Pruefung (isPublicHttpUrl), Redirect-Limit (MAX_REDIRECTS), Timeout
* und Groessendeckel (MAX_ICON_BYTES/MAX_HTML_CHARS) bleiben davon
* unberuehrt (T-JDD-01).
*/
const LENIENT_TLS_AGENT = new Agent({ connect: { rejectUnauthorized: false } });
type FetchHtmlResult = {
html: string;
finalUrl: string;
@@ -243,7 +268,7 @@ function extractIconFromHtml(html: string, baseUrl: string): string | null {
async function fetchWithRedirectGuard(
pageUrl: URL,
options: { accept: string; timeoutMs: number; userAgent?: string },
): Promise<{ response: Response; finalUrl: URL } | null> {
): Promise<{ response: UndiciResponse; finalUrl: URL } | null> {
let currentUrl = pageUrl;
for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount++) {
@@ -255,7 +280,8 @@ async function fetchWithRedirectGuard(
const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
try {
const response = await fetch(currentUrl.toString(), {
const response = await undiciFetch(currentUrl.toString(), {
dispatcher: LENIENT_TLS_AGENT, // 260917-jdd: siehe Kommentar an der Konstante
redirect: 'manual', // SSRF: follow manually so each hop is re-validated
signal: controller.signal,
headers: {