source = new SecurityTxtSource(new WpHttpTransport(self::FETCH_TIMEOUT), new WpSecurityTxtStore); } public function register(): void { add_action('init', [$this, 'handle'], 0); } public function handle(): void { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- verglichen wird nur mit einem festen Pfad; ausgegeben wird nichts davon. $uri = isset($_SERVER['REQUEST_URI']) ? (string) wp_unslash($_SERVER['REQUEST_URI']) : ''; if (! SiteHosting::isSecurityTxtRequest($uri)) { return; } $method = isset($_SERVER['REQUEST_METHOD']) ? strtoupper(sanitize_key(wp_unslash($_SERVER['REQUEST_METHOD']))) : 'GET'; if (! in_array($method, ['GET', 'HEAD'], true)) { return; } $target = $this->target(); $body = $target !== null ? $this->source->body($target, time()) : null; if ($body === null) { return; } status_header(200); header('Content-Type: text/plain; charset=utf-8'); // eine Stunde, so lange wie der eigene Zwischenspeicher: Wird die Auslieferung abgeschaltet, soll die Datei // nicht länger aus einem Cache kommen header('Cache-Control: public, max-age=3600'); header('X-Content-Type-Options: nosniff'); if ($method !== 'HEAD') { // byte-genau, was DomainWarn geliefert hat: als text/plain mit nosniff ausgegeben, und SecurityTxtFile hat // nur Text ohne HTML angenommen – maskiert würde die Datei verfälscht (und eine Signatur ungültig) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- text/plain, siehe oben. echo $body; } exit; } /** Die Datei bei DomainWarn, die diese Anfrage beantwortet, oder null. */ private function target(): ?string { $settings = $this->site->settings(); if (! $settings->securityTxt || ! $settings->complete() || ! self::atDomainRoot() || $this->probing() || $this->conflict()) { return null; } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nur Schlüssel für den Zwischenspeicher; SiteHosting::normalizeHost lässt nichts anderes als einen Hostnamen durch. $host = isset($_SERVER['HTTP_HOST']) ? (string) wp_unslash($_SERVER['HTTP_HOST']) : (string) wp_parse_url(home_url(), PHP_URL_HOST); $now = time(); $snapshot = $this->site->hostingSnapshot(); if (! SiteHosting::fresh($snapshot, $now) && get_transient(self::RETRY_TRANSIENT) === false) { // der geplante Lauf hätte ihn erneuert – WP-Cron steht also, oder es ist der erste Abruf nach dem Einschalten set_transient(self::RETRY_TRANSIENT, 1, self::RETRY_SECONDS); $snapshot = $this->refresh() ?? $snapshot; } return SiteHosting::target($snapshot, $host, $settings->securityTxtBase(), $now); } /** * Stand bei DomainWarn neu holen und speichern. * * @return ?array */ public function refresh(): ?array { $settings = $this->site->settings(); if (! $settings->complete()) { return null; } try { $snapshot = (new HostingRefresh($this->factory->create($settings)))->run( DomainCollector::hostnames($this->site->siteUrls()), $settings->securityTxtBase(), time(), ); } catch (TransportException) { return null; } $this->site->saveHostingSnapshot($snapshot); return $snapshot; } /** * Der Knopf „Prüfen“: Stand neu holen, dann jede Adresse abrufen, die ausliefern soll (oder die Website selbst, * wenn keine soll). Der gespeicherte Konflikt und der Zwischenspeicher der Datei werden vorher gelöst – geprüft * wird, was passiert, wenn die Erweiterung die aktuelle Datei ausliefert; der Abruf mit Prüfschlüssel zeigt, ob sie * dabei jemanden überdeckt. * * @return array{checked_at: int, conflict: bool, results: list}>} */ public function check(): array { $settings = $this->site->settings(); $snapshot = $this->refresh() ?? $this->site->hostingSnapshot(); $hosts = []; foreach (is_array($snapshot['redirects'] ?? null) ? array_keys($snapshot['redirects']) : [] as $host) { $hosts[] = (string) $host; } if ($hosts === []) { $home = DomainCollector::host((string) home_url()); $hosts = $home !== null ? [$home] : []; } delete_option(self::CHECK_OPTION); $this->source->forget(); $probe = bin2hex(random_bytes(16)); set_transient(self::PROBE_TRANSIENT, $probe, 120); $check = new SecurityTxtCheck(new WpHttpTransport); $results = []; $conflict = false; foreach (array_slice($hosts, 0, 5) as $host) { $expected = $settings->securityTxt && self::atDomainRoot() ? SiteHosting::target($snapshot, $host, $settings->securityTxtBase(), time()) : null; $result = $check->run($host, $expected, $expected !== null ? $probe : null); $conflict = $conflict || $result['conflict']; $results[] = [ 'host' => $result['host'], 'state' => $result['state'], 'location' => $result['location'], 'key' => $result['message']->key, 'params' => $result['message']->params, ]; } delete_transient(self::PROBE_TRANSIENT); $stored = ['checked_at' => time(), 'conflict' => $conflict, 'results' => $results]; update_option(self::CHECK_OPTION, $stored, false); return $stored; } /** * @return ?array{checked_at: int, conflict: bool, results: list>} */ public static function lastCheck(): ?array { $stored = get_option(self::CHECK_OPTION, null); return is_array($stored) && isset($stored['checked_at'], $stored['conflict'], $stored['results']) ? $stored : null; } /** * Liegt eine echte Datei im Webverzeichnis? Dann liefert der Webserver sie aus, bevor WordPress überhaupt * startet – die Erweiterung käme nie zum Zug. */ public static function physicalFile(): ?string { $roots = [ABSPATH]; if (function_exists('get_home_path')) { $roots[] = get_home_path(); } // ein Pfad des eigenen Servers, nur für file_exists $documentRoot = isset($_SERVER['DOCUMENT_ROOT']) ? sanitize_text_field(wp_unslash($_SERVER['DOCUMENT_ROOT'])) : ''; if ($documentRoot !== '') { $roots[] = $documentRoot; } foreach (array_unique(array_map(static fn (string $root) => trailingslashit($root), $roots)) as $root) { if (file_exists($root.'.well-known/security.txt')) { return $root.'.well-known/security.txt'; } } return null; } /** Liegt die Website an der Wurzel ihrer Domain (kunde.de/ und nicht kunde.de/blog/)? */ public static function atDomainRoot(): bool { $path = wp_parse_url(home_url(), PHP_URL_PATH); return ! is_string($path) || trim($path, '/') === ''; } /** Ein Prüfabruf aus check(): Die eigene Auslieferung setzt aus, damit sichtbar wird, wer sonst antwortet. */ private function probing(): bool { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- kein Formular: ein zufälliger Schlüssel, der nur während der Prüfung gilt. $given = isset($_GET[SecurityTxtCheck::PROBE_PARAMETER]) ? sanitize_text_field(wp_unslash($_GET[SecurityTxtCheck::PROBE_PARAMETER])) : ''; if ($given === '') { return false; } $expected = get_transient(self::PROBE_TRANSIENT); return is_string($expected) && $expected !== '' && hash_equals($expected, $given); } private function conflict(): bool { return (self::lastCheck()['conflict'] ?? false) === true; } }