/.well-known/security.txt abruft? * * Abgerufen wird wie von außen, ohne einer Weiterleitung zu folgen. Die Website liefert die Datei von DomainWarn * selbst aus; geprüft wird deshalb, ob unter ihrer Adresse genau die Datei liegt, die DomainWarn ausliefert (Contact * und Expires sind nach RFC 9116 Pflicht). Eine Weiterleitung auf DomainWarn (eine Regel im Webserver) zählt * ebenfalls – dann kommt die Datei nur nicht von der Erweiterung. * * Und es geht um Konflikte: Liegt schon eine Datei im Webverzeichnis, oder beantwortet ein anderes Plugin die Adresse, * soll die Erweiterung das nicht still überdecken. Dafür gibt es einen zweiten Abruf mit einem Prüfschlüssel, bei dem * die eigene Auslieferung aussetzt: Antwortet dann jemand anderes, gibt es einen Konflikt. * * Diese Klasse ist bewusst dieselbe wie in der Shopware-Erweiterung. */ final readonly class SecurityTxtCheck { /** Name des Abfrageparameters, mit dem die eigene Auslieferung für einen Prüfabruf aussetzt. */ public const PROBE_PARAMETER = 'domainwarn_probe'; /** Zustände, bei denen eine andere security.txt neben der von DomainWarn existiert. */ public const CONFLICTS = ['served', 'shadowed']; public function __construct(private HttpTransport $transport) {} /** * @param ?string $expected Datei bei DomainWarn, die die Website ausliefern sollte (null: sie soll nicht) * @param ?string $probeToken Prüfschlüssel, mit dem die eigene Auslieferung aussetzt (null: kein zweiter Abruf) * @return array{host: string, state: string, location: ?string, conflict: bool, message: Message} */ public function run(string $host, ?string $expected, ?string $probeToken = null): array { $url = 'https://'.$host.SiteHosting::PATH; try { $first = $this->transport->request('GET', $url, ['Accept' => 'text/plain']); } catch (TransportException $e) { return self::result($host, 'unreachable', null, Message::of('security_txt.check_unreachable', ['detail' => $e->getMessage()])); } $location = self::location($first, $url); if ($location !== null && $expected !== null && $location === $expected) { // eine Weiterleitung genau auf die Datei bei DomainWarn: eine Regel im Webserver, nicht die Erweiterung (die // liefert selbst aus) – auch das bringt Forscher an die richtige Datei return $this->origin($expected) !== null ? self::result($host, 'redirected', $location, Message::of('security_txt.check_redirected')) : self::result($host, 'target_invalid', $location, Message::of('security_txt.check_target_invalid')); } if ($location !== null) { return self::result($host, 'elsewhere', $location, Message::of('security_txt.check_elsewhere', ['location' => $location])); } if (! self::isSecurityTxt($first)) { return self::result($host, 'missing', null, Message::of('security_txt.check_missing')); } if ($expected === null) { // eine Datei, aber nicht über uns: Datei im Webverzeichnis oder ein anderes Plugin return self::result($host, 'served', null, Message::of('security_txt.check_served')); } // eine Datei – von der Erweiterung oder von jemand anderem? Mit Prüfschlüssel setzt die eigene Auslieferung aus; // antwortet dann noch jemand, überdecken wir ihn (oder er uns: eine Datei im Webverzeichnis kommt vor WordPress) if ($probeToken !== null && $this->answeredWithout($url, $probeToken)) { return self::result($host, 'shadowed', null, Message::of('security_txt.check_shadowed')); } $origin = $this->origin($expected); if ($origin === null) { return self::result($host, 'target_invalid', null, Message::of('security_txt.check_target_invalid')); } // byte-genau dieselbe Datei – sonst hält ein Zwischenspeicher noch eine ältere Fassung return $origin === $first->body ? self::result($host, 'ok', null, Message::of('security_txt.check_ok')) : self::result($host, 'outdated', null, Message::of('security_txt.check_outdated')); } /** Beantwortet jemand anderes die Adresse, wenn die eigene Auslieferung aussetzt? */ private function answeredWithout(string $url, string $probeToken): bool { try { $response = $this->transport->request('GET', $url.'?'.self::PROBE_PARAMETER.'='.rawurlencode($probeToken), ['Accept' => 'text/plain']); } catch (TransportException) { return false; } return self::location($response, $url) !== null || self::isSecurityTxt($response); } /** Die Datei, die DomainWarn unter dem Ziel ausliefert, oder null, wenn dort keine gültige liegt. */ private function origin(string $target): ?string { try { $response = $this->transport->request('GET', $target, ['Accept' => 'text/plain']); } catch (TransportException) { return null; } return self::isSecurityTxt($response) ? $response->body : null; } /** * Eine gültige security.txt im Sinn dieser Prüfung: 200, Text statt HTML, und die Pflichtfelder Contact und * Expires. Eine 200-Seite mit HTML (manche Themes beantworten jede Adresse) ist keine. */ public static function isSecurityTxt(TransportResponse $response): bool { $type = strtolower((string) $response->header('content-type')); if ($response->status !== 200 || ($type !== '' && ! str_starts_with($type, 'text/plain'))) { return false; } return preg_match('/^contact:\s*\S/mi', $response->body) === 1 && preg_match('/^expires:\s*\S/mi', $response->body) === 1; } /** Ziel einer Weiterleitung als absolute Adresse, sonst null. */ private static function location(TransportResponse $response, string $url): ?string { $location = $response->header('location'); if (! in_array($response->status, [301, 302, 303, 307, 308], true) || $location === null || trim($location) === '') { return null; } $location = trim($location); if (str_starts_with($location, '//')) { return 'https:'.$location; } if (str_starts_with($location, '/')) { // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- rahmenfrei, ohne WordPress prüfbar. return 'https://'.parse_url($url, PHP_URL_HOST).$location; } return $location; } /** * @return array{host: string, state: string, location: ?string, conflict: bool, message: Message} */ private static function result(string $host, string $state, ?string $location, Message $message): array { return [ 'host' => $host, 'state' => $state, 'location' => $location, 'conflict' => in_array($state, self::CONFLICTS, true), 'message' => $message, ]; } }