/**
 * Schutz vor Server-Side Request Forgery (SSRF) bei User-kontrollierten
 * Hosts/URLs in Endpunkten wie test-connection, test-mail-access.
 *
 * Wir blockieren bewusst NICHT die komplette private IP-Range (127.0.0.0/8,
 * 10.0.0.0/8 etc.), weil legitime On-Premise-Setups häufig Plesk/Dovecot/
 * Postfix auf 127.0.0.1 oder im internen Netz laufen lassen. Stattdessen
 * blockieren wir nur:
 *   - Cloud-Metadata-Endpoints (169.254.169.254, fd00:ec2::254)
 *   - 169.254.0.0/16 Link-Local (deckt Cloud-Metadata + APIPA ab)
 *   - 0.0.0.0/8 (ungültiger Source/Routing-Range)
 *   - Multicast / Reserved Ranges (224.0.0.0/4, 240.0.0.0/4)
 *
 * Für Defense-in-Depth gegen DNS-Rebinding wäre eine vollständige DNS-
 * Resolution + IP-Vergleich nötig – das überlassen wir v1.1, weil es
 * legitimes Caching/CDN-Verhalten brechen kann.
 */

const BLOCKED_PATTERNS: RegExp[] = [
  /^169\.254\./,                  // Link-Local (AWS/GCP/Azure Metadata, APIPA)
  /^0\./,                         // 0.0.0.0/8 reserved
  /^22[4-9]\./,                   // 224-229 Multicast
  /^23[0-9]\./,                   // 230-239 Multicast
  /^24[0-9]\./,                   // 240-249 reserved
  /^25[0-5]\./,                   // 250-255 reserved
  // Pentest 51.2 (LOW, 2026-06-01): 100.64.0.0/10 CGNAT (RFC 6598)
  // wird teils von Cloud-Providern für interne Pfade genutzt; 100.100.x.x
  // ist konkret Alibaba Cloud Metadata.
  /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
  /^fd00:ec2::/i,                 // AWS IPv6 Metadata
  /^fe80:/i,                      // IPv6 Link-Local
  // Pentest 51.1: lang ausgeschriebene fe80-Form abdecken
  /^fe80:0*:/i,
  /^ff/i,                         // IPv6 Multicast
];

// Opt-in für Cloud-Deployments: ALLE privaten IP-Ranges blocken, nicht
// nur Cloud-Metadata. On-Prem-Default ist `false`, weil On-Prem-Setups
// häufig Plesk/Dovecot/Postfix auf 127.0.0.1 oder im internen Netz
// laufen lassen. (Pentest 2026-05-20 INFO 30.14.) Aktivieren mit
// `SSRF_BLOCK_PRIVATE_IPS=true` in der Umgebung.
const BLOCK_PRIVATE_IPS = (process.env.SSRF_BLOCK_PRIVATE_IPS || '').toLowerCase() === 'true';

const PRIVATE_IP_PATTERNS: RegExp[] = [
  /^127\./,                       // 127.0.0.0/8 Loopback
  /^10\./,                        // 10.0.0.0/8
  /^192\.168\./,                  // 192.168.0.0/16
  /^172\.(1[6-9]|2\d|3[01])\./,   // 172.16.0.0/12
  // IPv6 Loopback in allen Schreibweisen, die DNS/URL-Parser liefern können
  /^::1$/,                        // kompakte Form
  /^0:0:0:0:0:0:0:1$/,            // voll ausgeschrieben
  /^::ffff:127\./i,               // IPv4-mapped Loopback (kompakt)
  /^0:0:0:0:0:ffff:127\./i,       // IPv4-mapped Loopback (ausgeschrieben)
  /^::ffff:10\./i,                // IPv4-mapped 10/8
  /^0:0:0:0:0:ffff:10\./i,
  /^::ffff:192\.168\./i,          // IPv4-mapped 192.168/16
  /^0:0:0:0:0:ffff:192\.168\./i,
  /^::ffff:172\.(1[6-9]|2\d|3[01])\./i,
  /^0:0:0:0:0:ffff:172\.(1[6-9]|2\d|3[01])\./i,
  // Pentest 51.1 (MEDIUM, 2026-06-01): fc00::/7 deckt fc00..fdff ab
  // (Unique-Local + Site-Local). Das alte `/^f[cd]/i` greift nur am
  // Anfang einer einzelnen Hex-Stelle; lang ausgeschriebene Formen
  // fingen wir nicht zuverlässig. Jetzt explizit auf das erste Group-
  // Hex-Block-Prefix `fc` oder `fd` (gefolgt von 2 Hex + ':').
  /^f[cd][0-9a-f]{2}:/i,
];

const PRIVATE_HOSTNAMES = new Set([
  'localhost',
  'ip6-localhost',
  'ip6-loopback',
]);

const BLOCKED_HOSTNAMES = new Set([
  'metadata.google.internal',
  'metadata.goog',
  'metadata',
  '169.254.169.254',
  // Pentest 51.2 (LOW, 2026-06-01): Alibaba Cloud Metadata
  '100.100.100.200',
  // Vollständig ausgeschriebene IPv6-Loopback und gängige Cloud-Provider-
  // Hostnamen, die DNS-Auflösung in geblockten Ranges liefern würden.
  '0:0:0:0:0:0:0:1',
  '[::1]',
]);

/**
 * Pentest 59.4 (HIGH, 2026-06-01): Node's URL-Parser normalisiert
 * IPv4-mapped IPv6 zur **Hex-Form**:
 *   `::ffff:127.0.0.1` → `::ffff:7f00:1`
 *   `::ffff:169.254.169.254` → `::ffff:a9fe:a9fe` (GCP/AWS Metadata!)
 *   `::ffff:10.0.0.1` → `::ffff:a00:1`
 *
 * Die alten Patterns (`::ffff:127\.`, `::ffff:10\.` etc.) griffen nur
 * auf die Dotted-Form – Angreifer konnten via URL-Brackets die Hex-Form
 * an der Blocklist vorbeischleusen, weil `new URL()` umnormalisiert.
 *
 * Lösung: aus IPv4-mapped IPv6 extrahieren wir den IPv4-Anteil und
 * lassen ihn durch die IPv4-Patterns laufen. Das deckt beide Schreib-
 * weisen + ausgeschriebene Long-Form ab.
 */
function extractMappedIPv4(addr: string): string | null {
  // Compact dotted: ::ffff:127.0.0.1
  let m = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(addr);
  if (m) return m[1];
  // Compact hex: ::ffff:7f00:1
  m = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(addr);
  if (m) {
    const h1 = parseInt(m[1], 16);
    const h2 = parseInt(m[2], 16);
    if (!Number.isFinite(h1) || !Number.isFinite(h2) || h1 > 0xffff || h2 > 0xffff) return null;
    return `${(h1 >> 8) & 0xff}.${h1 & 0xff}.${(h2 >> 8) & 0xff}.${h2 & 0xff}`;
  }
  // Expanded dotted: 0:0:0:0:0:ffff:127.0.0.1
  m = /^0:0:0:0:0:ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(addr);
  if (m) return m[1];
  // Expanded hex: 0:0:0:0:0:ffff:7f00:1
  m = /^0:0:0:0:0:ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(addr);
  if (m) {
    const h1 = parseInt(m[1], 16);
    const h2 = parseInt(m[2], 16);
    if (!Number.isFinite(h1) || !Number.isFinite(h2) || h1 > 0xffff || h2 > 0xffff) return null;
    return `${(h1 >> 8) & 0xff}.${h1 & 0xff}.${(h2 >> 8) & 0xff}.${h2 & 0xff}`;
  }
  return null;
}

function checkIPv4(ipv4: string, includePrivate: boolean): boolean {
  if (BLOCKED_HOSTNAMES.has(ipv4)) return true;
  for (const pattern of BLOCKED_PATTERNS) if (pattern.test(ipv4)) return true;
  if (includePrivate) {
    if (PRIVATE_HOSTNAMES.has(ipv4)) return true;
    for (const pattern of PRIVATE_IP_PATTERNS) if (pattern.test(ipv4)) return true;
  }
  return false;
}

export function isBlockedSsrfHost(host: string | null | undefined): boolean {
  if (!host) return false;
  const h = host.trim().toLowerCase();
  if (!h) return false;
  if (BLOCKED_HOSTNAMES.has(h)) return true;
  for (const pattern of BLOCKED_PATTERNS) {
    if (pattern.test(h)) return true;
  }
  // 59.4: IPv4-mapped IPv6 entpacken und die IPv4 separat prüfen –
  // egal ob Hex- oder Dotted-Form, egal ob compact oder expanded.
  const mappedV4 = extractMappedIPv4(h);
  if (mappedV4 && checkIPv4(mappedV4, BLOCK_PRIVATE_IPS)) return true;
  if (BLOCK_PRIVATE_IPS) {
    if (PRIVATE_HOSTNAMES.has(h)) return true;
    for (const pattern of PRIVATE_IP_PATTERNS) {
      if (pattern.test(h)) return true;
    }
  }
  return false;
}

/**
 * Strikter Check: blockt private/loopback IP-Ranges UNABHÄNGIG von
 * `SSRF_BLOCK_PRIVATE_IPS`. Für Use Cases, in denen ein privater Host
 * NIE legitim sein kann – z.B. eine URL, die an Endkunden per Mail
 * geht (der Kunde kann eh nicht auf 192.168.x.x routen). Pentest
 * 2026-05-28 Runde 35.
 *
 * Liefert true auch für die regulären Block-Patterns (Cloud-Metadata
 * etc.), sodass Caller nur eine Funktion aufrufen müssen.
 */
export function isPrivateOrBlockedHost(host: string | null | undefined): boolean {
  if (!host) return false;
  const h = host.trim().toLowerCase();
  if (!h) return false;
  if (BLOCKED_HOSTNAMES.has(h)) return true;
  if (PRIVATE_HOSTNAMES.has(h)) return true;
  for (const pattern of BLOCKED_PATTERNS) {
    if (pattern.test(h)) return true;
  }
  for (const pattern of PRIVATE_IP_PATTERNS) {
    if (pattern.test(h)) return true;
  }
  // 59.4: IPv4-mapped IPv6 strikt prüfen (Hex- + Dotted-Form,
  // compact + expanded). Pentester konnte ::ffff:7f00:1 statt
  // ::ffff:127.0.0.1 nutzen, weil URL-Parser umnormalisiert.
  const mappedV4 = extractMappedIPv4(h);
  if (mappedV4 && checkIPv4(mappedV4, true)) return true;
  return false;
}

/**
 * Wirft einen Fehler, wenn der Host für ausgehende Verbindungen blockiert ist.
 * Caller sollte den Fehler in 400er Response umsetzen.
 *
 * `strict=true` (Pentest 50.1, 2026-06-01): private/Loopback-Ranges werden
 * UNABHÄNGIG von `SSRF_BLOCK_PRIVATE_IPS` immer geblockt. Für Endpunkte mit
 * besonders kritischer Angriffsfläche (test-connection, test-mail-access),
 * die im Cloud-Deployment sonst Metadata-/Internal-Service-Probes erlauben
 * würden.
 */
export function assertAllowedHost(host: string | null | undefined, label = 'Host', opts: { strict?: boolean } = {}): void {
  const blocked = opts.strict ? isPrivateOrBlockedHost(host) : isBlockedSsrfHost(host);
  if (blocked) {
    throw new Error(`${label} verweist auf eine geblockte Adresse (Cloud-Metadata / Link-Local / Reserved / privater Host).`);
  }
}

import { promises as dns } from 'dns';
import net from 'net';

/**
 * DNS-Rebinding-Schutz: löst den Hostname zu allen IPs auf und prüft jede
 * gegen die Block-Liste. Wirft wenn IRGENDEINE IP geblockt ist.
 *
 * Das Resultat enthält die erste (geprüfte) IP plus den Original-Hostname
 * als `servername` für TLS-SNI / Cert-Validation. Der Caller muss die
 * Connection mit `host=ip` und `tls.servername=hostname` aufbauen, damit
 * ein zweiter DNS-Lookup keine andere (geblockte) IP liefern kann.
 *
 * Wenn der Host bereits eine IP-Literal ist, wird er direkt geprüft.
 */
export async function safeResolveHost(
  host: string | null | undefined,
  label = 'Host',
  opts: { strict?: boolean } = {},
): Promise<{ ip: string; servername: string }> {
  if (!host || !host.trim()) {
    throw new Error(`${label} fehlt`);
  }
  // URL.hostname liefert IPv6-Hosts mit eckigen Klammern (`[::1]`).
  // Damit `net.isIP` und die Regex-Pattern korrekt matchen, hier strippen.
  // Pentest 51.1 (2026-06-01): ohne dieses Stripping fiel `::1` durch
  // ins DNS-Branch und die Block-Patterns liefen ins Leere.
  const trimmed = host.trim().replace(/^\[|\]$/g, '');
  const check = opts.strict ? isPrivateOrBlockedHost : isBlockedSsrfHost;

  // IP-Literal? Direkt prüfen, kein DNS nötig.
  if (net.isIP(trimmed)) {
    assertAllowedHost(trimmed, label, opts);
    return { ip: trimmed, servername: trimmed };
  }

  // Pentest 50.1 Defense-in-Depth: bereits vor DNS prüfen, ob der
  // Hostname selbst auf der Blocklist steht (z.B. "metadata",
  // "metadata.google.internal", "localhost"). DNS könnte sonst je nach
  // Resolver legitime IPs liefern und so die Hostname-Blocklist umgehen.
  assertAllowedHost(trimmed, label, opts);

  // Hostname → resolve to IPv4 + IPv6
  let ips: string[] = [];
  try {
    const v4 = await dns.resolve4(trimmed).catch(() => [] as string[]);
    const v6 = await dns.resolve6(trimmed).catch(() => [] as string[]);
    ips = [...v4, ...v6];
  } catch {
    throw new Error(`${label}: DNS-Auflösung fehlgeschlagen für ${trimmed}`);
  }

  if (ips.length === 0) {
    throw new Error(`${label}: keine IP-Adresse für ${trimmed} gefunden`);
  }

  // Alle aufgelösten IPs prüfen – schon eine geblockte reicht für Ablehnung.
  for (const ip of ips) {
    if (check(ip)) {
      throw new Error(`${label} ${trimmed} löst auf geblockte Adresse ${ip} auf`);
    }
  }

  return { ip: ips[0], servername: trimmed };
}
