/security-txt//security.txt haben, mit der * Basis aus den Einstellungen. Eine manipulierte Antwort der API kann die Website so nicht dazu bringen, eine * beliebige fremde Adresse abzurufen und deren Inhalt unter der eigenen Domain auszugeben. * - **Keine Anfrage nach außen beim Seitenaufruf**: Das Ziel kommt aus dem Zwischenspeicher, den der geplante Lauf * füllt. Ein Aufruf einer normalen Seite fasst nichts davon an; nur der Abruf der security.txt selbst holt die * Datei, höchstens einmal je Stunde (SecurityTxtSource). * * MTA-STS bekommt bewusst keine Weiterleitung: RFC 8461 Abschnitt 3.3 verbietet, beim Abruf der Policy einer * Weiterleitung zu folgen. Dort zeigt die Erweiterung nur den Stand und die Records, die im DNS stehen müssen. * * Diese Klasse ist bewusst dieselbe wie in der Shopware-Erweiterung. */ final class SiteHosting { /** Der Pfad, unter dem Forscher die Datei suchen (RFC 9116 Abschnitt 3). */ public const PATH = '/.well-known/security.txt'; /** So lange gilt der zwischengespeicherte Stand als frisch. */ public const FRESH_SECONDS = 3600; /** * Ab diesem Alter holt der geplante Lauf (alle 15 Minuten) den Stand neu – knapp vor Ablauf, damit er beim * nächsten Abruf der Datei nicht erst geholt werden muss. */ public const REFRESH_AFTER_SECONDS = 2700; /** * Länger wird ein Stand nicht verwendet, auch wenn DomainWarn gerade nicht erreichbar ist. Bis dahin holt die * Website die Datei vom zuletzt bekannten Ziel; ist die Auslieferung dort inzwischen aus, antwortet DomainWarn * mit 404, und es wird nichts ausgegeben. */ public const STALE_MAX_SECONDS = 86400; /** Die Zustände, die DomainWarn meldet (HostingStatus); alles andere wird zu „unknown“. */ public const STATES = ['off', 'pending', 'active', 'missing', 'outdated', 'elsewhere', 'unreachable', 'noMx']; /** * Hostname in der Form, in der DomainWarn ihn führt: klein, ohne Port und Punkt am Ende, internationalisierte * Namen als Punycode (müller.de → xn--mller-kva.de). null für alles, was kein Hostname ist. */ public static function normalizeHost(string $host): ?string { $host = strtolower(trim($host)); // Port abtrennen – aber keine IPv6-Adresse zerlegen (die hat selbst Doppelpunkte) if (preg_match('/^([^:]+):\d{1,5}$/', $host, $m) === 1) { $host = $m[1]; } $host = rtrim($host, '.'); if ($host === '') { return null; } if (preg_match('/[^\x21-\x7e]/', $host) === 1) { // ohne die intl-Erweiterung von PHP lässt sich ein Umlaut-Name nicht umrechnen: dann lieber kein Treffer if (! function_exists('idn_to_ascii')) { return null; } $ascii = idn_to_ascii($host, IDNA_NONTRANSITIONAL_TO_ASCII, INTL_IDNA_VARIANT_UTS46); if (! is_string($ascii) || $ascii === '') { return null; } $host = strtolower($ascii); } return preg_match('/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9-]{2,63}$/', $host) === 1 ? $host : null; } /** Ist der Hostname die Domain selbst oder www davor? Nur dort gehört die security.txt hin. */ public static function isMainHost(string $host, string $domain): bool { $host = self::normalizeHost($host); $domain = self::normalizeHost($domain); return $host !== null && $domain !== null && ($host === $domain || $host === 'www.'.$domain); } /** * Die Basis, unter der DomainWarn security.txt-Dateien ausliefert, mit Schrägstrich am Ende: * https://domainwarn.com/security-txt/. Eingetragen (für eine eigene Installation) oder aus der API-Adresse * abgeleitet: aus api.domainwarn.com wird domainwarn.com, eine andere API-Adresse behält ihren Host. * * Die Angabe entspricht SECURITY_TXT_BASE_URL auf dem Server: Ursprung und optional ein Pfad, darunter * /security-txt/. null, wenn daraus keine brauchbare Adresse wird – dann wird nie ausgeliefert. */ public static function securityTxtBase(string $apiBaseUrl, string $override = ''): ?string { $source = trim($override) !== '' ? trim($override) : self::webOrigin($apiBaseUrl); if ($source === null) { return null; } // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- rahmenfrei, ohne WordPress prüfbar; das Ergebnis wird unten streng geprüft. $parts = parse_url($source); if (! is_array($parts)) { return null; } $scheme = strtolower((string) ($parts['scheme'] ?? '')); // auch ein Name ohne Punkt (localhost, für eine Testinstallation) – aber nur Buchstaben, Ziffern, Punkt, Bindestrich $host = strtolower((string) ($parts['host'] ?? '')); if (! in_array($scheme, ['https', 'http'], true) || preg_match('/^[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?$/', $host) !== 1 || isset($parts['user']) || isset($parts['pass']) || isset($parts['query']) || isset($parts['fragment'])) { return null; } $path = rtrim((string) ($parts['path'] ?? ''), '/'); if ($path !== '' && preg_match('~^(?:/[A-Za-z0-9._-]+)+$~', $path) !== 1) { return null; } return $scheme.'://'.$host.(isset($parts['port']) ? ':'.(int) $parts['port'] : '').$path.'/security-txt/'; } /** * Darf die Website die Datei hier holen? Nur eine Datei unter der Basis, genau in der Form, die DomainWarn * erzeugt: <32 Zeichen Token>/security.txt – ohne Abfrage, Anker, Pfad-Tricks oder fremden Host. */ public static function isAllowedTarget(string $target, ?string $base): bool { if ($base === null || $base === '') { return false; } return preg_match('~^'.preg_quote($base, '~').'[a-z0-9]{32}/security\.txt$~D', $target) === 1; } /** Fragt die Anfrage genau nach /.well-known/security.txt? Abfrageteil zählt nicht, alles andere schon. */ public static function isSecurityTxtRequest(string $requestUri): bool { $path = explode('?', $requestUri, 2)[0]; return explode('#', $path, 2)[0] === self::PATH; } /** * Den Stand aus den Antworten der API zusammensetzen – das, was zwischengespeichert wird. * * @param array $siteHosts Hostname der Website → Kennung ihrer Domain in DomainWarn * @param array> $overviews Kennung der Domain → Antwort von GET …/hosting * @return array{fetched_at: int, domains: list>, redirects: array} */ public static function snapshot(array $siteHosts, array $overviews, ?string $base, int $now): array { $domains = []; // Hostname → Datei bei DomainWarn. Der Schlüssel heißt noch aus der Zeit der Weiterleitung so: Ein gespeicherter // Stand bleibt nach dem Update lesbar, und die Karte im Backend kennt ihn $redirects = []; foreach ($overviews as $id => $overview) { $name = self::normalizeHost(self::text($overview['domain']['name'] ?? null) ?? ''); if ($name === null) { continue; } $hosts = []; foreach ($siteHosts as $host => $domainId) { if ((string) $domainId === (string) $id) { $hosts[] = (string) $host; } } $main = array_values(array_filter($hosts, static fn (string $host) => self::isMainHost($host, $name))); $security = self::feature($overview['security_txt'] ?? null); $target = self::text($overview['security_txt']['url'] ?? null); $security['url'] = $target !== null && self::isAllowedTarget($target, $base) ? $target : null; // die Antwort nannte ein Ziel, das nicht zu DomainWarn gehört: festhalten, damit die Seite es sagen kann $security['url_rejected'] = $target !== null && $security['url'] === null; if ($security['enabled'] && $security['url'] !== null && $main !== []) { // ausgeliefert wird unter der Domain und www davor – auch wenn die Website selbst nur eine davon ist: // beide Adressen landen in derselben Installation $candidates = array_merge($main, is_array($overview['security_txt']['hosts'] ?? null) ? $overview['security_txt']['hosts'] : []); foreach ($candidates as $candidate) { $candidate = is_string($candidate) ? self::normalizeHost($candidate) : null; if ($candidate !== null && self::isMainHost($candidate, $name)) { $redirects[$candidate] = $security['url']; } } } $domains[] = [ 'id' => (string) $id, 'name' => $name, 'unicode_name' => self::text($overview['domain']['unicode_name'] ?? null) ?? $name, 'site_hosts' => $hosts, 'main' => $main !== [], 'dashboard_url' => self::url($overview['dashboard_url'] ?? null), 'security_txt' => $security, 'mta_sts' => self::feature($overview['mta_sts'] ?? null), 'bimi' => self::feature($overview['bimi'] ?? null), ]; } ksort($redirects); return ['fetched_at' => $now, 'domains' => $domains, 'redirects' => $redirects]; } /** * Adresse der Datei bei DomainWarn für diesen Hostnamen aus dem zwischengespeicherten Stand – oder null. Das Ziel wird hier * noch einmal gegen die Basis geprüft: Die Basis kann sich seit dem Speichern geändert haben. * * @param ?array $snapshot */ public static function target(?array $snapshot, string $host, ?string $base, int $now): ?string { if (! self::usable($snapshot, $now)) { return null; } $host = self::normalizeHost($host); $target = $host !== null ? ($snapshot['redirects'][$host] ?? null) : null; return is_string($target) && self::isAllowedTarget($target, $base) ? $target : null; } /** * @param ?array $snapshot */ public static function fresh(?array $snapshot, int $now, int $maxAge = self::FRESH_SECONDS): bool { return is_array($snapshot) && is_int($snapshot['fetched_at'] ?? null) && $now - $snapshot['fetched_at'] < $maxAge; } /** * Noch verwendbar, wenn auch nicht mehr frisch (siehe STALE_MAX_SECONDS). * * @param ?array $snapshot */ public static function usable(?array $snapshot, int $now): bool { return self::fresh($snapshot, $now, self::STALE_MAX_SECONDS) && is_array($snapshot['redirects'] ?? null); } /** * Eine Funktion aus der Antwort, auf das Gezeigte reduziert und geprüft: Die Werte landen auf der * Einstellungsseite, und ein Link darauf soll nie etwas anderes als http(s) sein. * * @return array{enabled: bool, state: string, records: list, dashboard_url: ?string, checked_at: ?string} */ private static function feature(mixed $raw): array { $raw = is_array($raw) ? $raw : []; $state = self::text($raw['state'] ?? null); $records = []; foreach (is_array($raw['records'] ?? null) ? $raw['records'] : [] as $record) { $type = self::text($record['type'] ?? null); $name = self::text($record['name'] ?? null); $value = self::text($record['value'] ?? null); if ($type !== null && $name !== null && $value !== null && in_array($type, ['CNAME', 'TXT'], true)) { $records[] = ['type' => $type, 'name' => $name, 'value' => $value]; } } return [ 'enabled' => ($raw['enabled'] ?? false) === true, 'state' => in_array($state, self::STATES, true) ? $state : 'unknown', 'records' => $records, 'dashboard_url' => self::url($raw['dashboard_url'] ?? null), 'checked_at' => self::text($raw['checked_at'] ?? null), ]; } /** Aus api.example.com wird https://example.com; eine andere API-Adresse behält ihren Host. */ private static function webOrigin(string $apiBaseUrl): ?string { // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- rahmenfrei, ohne WordPress prüfbar. $parts = parse_url(trim($apiBaseUrl)); if (! is_array($parts) || ! isset($parts['host'])) { return null; } $host = strtolower($parts['host']); $host = str_starts_with($host, 'api.') ? substr($host, 4) : $host; return strtolower((string) ($parts['scheme'] ?? 'https')).'://'.$host.(isset($parts['port']) ? ':'.(int) $parts['port'] : ''); } private static function url(mixed $value): ?string { $value = self::text($value); return $value !== null && preg_match('~^https?://[^\s"\'<>]+$~i', $value) === 1 ? $value : null; } private static function text(mixed $value): ?string { return is_string($value) && $value !== '' && strlen($value) <= 2048 ? $value : null; } }