mit gültigem Zertifikat? Sonst nichts. * 2. Zeilen einschalten. * 3. Kommt https:// weiterhin hier an? Sonst ist es eine Schleife (der Server meldet * HTTPS nicht, kein Proxy-Kopf) - die Datei wird sofort zurückgeschrieben. * 4. Leitet http:// mit 301 auf https um? Wenn nicht, liest der Server die Regel nicht * (kein mod_rewrite, AllowOverride) - die Zeilen bleiben, schaden nicht, und die Meldung sagt es. * * HSTS ist ein eigener Schritt: Ein Browser merkt sich den Kopf ein Jahr lang, ein Rückweg auf * http ist dann für jeden Besucher zu, der die Seite einmal gesehen hat. */ class HttpsRedirect { public const REDIRECT_LINES = [ 'RewriteCond %{HTTPS} !=on', 'RewriteCond %{HTTP:X-Forwarded-Proto} !=https', 'RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]', ]; public const HSTS_LINE = 'Header always set Strict-Transport-Security'; /** @var \Closure(string): array{status: int, location: string, error: string} */ private \Closure $httpProbe; /** @param \Closure(string): array{status: int, location: string, error: string}|null $httpProbe */ public function __construct(private string $projectDir, private OriginCheck $originCheck, ?\Closure $httpProbe = null) { $this->httpProbe = $httpProbe ?? self::probeHttp(...); } public function file(): string { return $this->projectDir.'/public/.htaccess'; } /** @return array{redirect: bool, hsts: bool, found: bool, writable: bool} */ public function state(): array { $content = is_file($this->file()) ? (string) file_get_contents($this->file()) : ''; return [ 'redirect' => self::active($content, self::REDIRECT_LINES), 'hsts' => self::active($content, [self::HSTS_LINE]), 'found' => self::present($content, self::REDIRECT_LINES) && self::present($content, [self::HSTS_LINE]), 'writable' => is_file($this->file()) && is_writable($this->file()), ]; } /** * Schaltet die Umleitung ein. Gibt Erfolg und eine Meldung in einem Satz oder zwei zurück; * bei Misserfolg ist die Datei so, wie sie vorher war. * * @return array{ok: bool, message: string} */ public function enableRedirect(string $origin): array { $origin = rtrim(trim($origin), '/'); if (!preg_match('#^https://[^/?\#]+$#', $origin)) { return ['ok' => false, 'message' => 'Erst die Adresse der Website mit https:// eintragen – die Umleitung braucht ein Ziel.']; } if (null !== $problem = $this->originCheck->problem($origin)) { return ['ok' => false, 'message' => $problem.' Ohne funktionierendes HTTPS bleibt die Umleitung aus.']; } $before = $this->read(); if (null === $before) { return ['ok' => false, 'message' => 'public/.htaccess fehlt oder ist nicht lesbar.']; } if (!self::present($before, self::REDIRECT_LINES)) { return ['ok' => false, 'message' => 'Die Zeilen für HTTPS stehen nicht in public/.htaccess (geändert oder sehr alte Fassung). Das nächste Update bringt sie zurück.']; } if (self::active($before, self::REDIRECT_LINES)) { return ['ok' => true, 'message' => 'Die Umleitung auf HTTPS ist schon eingeschaltet.']; } if (!$this->write(self::switchLines($before, self::REDIRECT_LINES, true))) { return ['ok' => false, 'message' => 'PHP darf public/.htaccess nicht schreiben. Die drei Zeilen unter „HTTPS erzwingen“ dort von Hand einschalten (das # davor entfernen).']; } if (null !== $problem = $this->originCheck->problem($origin)) { $this->write($before); return ['ok' => false, 'message' => 'Mit eingeschalteter Umleitung kam '.$origin.' nicht mehr an: '.rtrim($problem, '.').'. Vermutlich meldet der Server HTTPS nicht an die .htaccess (Proxy ohne X-Forwarded-Proto). Die Datei ist zurückgesetzt, nichts geändert.']; } $http = 'http://'.substr($origin, 8).OriginCheck::PATH; $answer = ($this->httpProbe)($http); $target = $origin.OriginCheck::PATH; if (in_array($answer['status'], [301, 308], true) && $answer['location'] === $target) { return ['ok' => true, 'message' => 'HTTPS wird jetzt erzwungen: http:// leitet mit 301 auf '.$origin.' um. HSTS erst einschalten, wenn das ein paar Tage gut läuft.']; } if (0 === $answer['status']) { return ['ok' => true, 'message' => 'Die Umleitung ist eingeschaltet, und HTTPS antwortet weiter. Ob http:// umleitet, ließ sich vom Server aus nicht prüfen ('.$answer['error'].') – einmal im Browser http:// aufrufen.']; } return ['ok' => true, 'message' => sprintf('Die Zeilen sind eingeschaltet, aber http:// antwortet mit %d%s statt mit 301 auf HTTPS. Der Server liest die Umleitung offenbar nicht (mod_rewrite, AllowOverride) – beim Hoster die HTTPS-Weiterleitung einschalten.', $answer['status'], '' !== $answer['location'] ? ' nach '.$answer['location'] : '')]; } /** * Schaltet die Umleitung aus - und HSTS mit, denn HSTS ohne Umleitung verspricht etwas, * das der Server nicht mehr einhält. * * @return array{ok: bool, message: string} */ public function disableRedirect(): array { $before = $this->read(); if (null === $before) { return ['ok' => false, 'message' => 'public/.htaccess fehlt oder ist nicht lesbar.']; } $hsts = self::active($before, [self::HSTS_LINE]); $after = self::switchLines(self::switchLines($before, self::REDIRECT_LINES, false), [self::HSTS_LINE], false); if ($after !== $before && !$this->write($after)) { return ['ok' => false, 'message' => 'PHP darf public/.htaccess nicht schreiben.']; } return ['ok' => true, 'message' => 'Die Umleitung auf HTTPS ist ausgeschaltet.'.($hsts ? ' HSTS ebenfalls – Browser, die den Kopf schon kennen, bleiben aber bis zu einem Jahr bei HTTPS.' : '')]; } /** @return array{ok: bool, message: string} */ public function switchHsts(bool $on, string $origin): array { $before = $this->read(); if (null === $before || !self::present($before, [self::HSTS_LINE])) { return ['ok' => false, 'message' => 'Die HSTS-Zeile steht nicht in public/.htaccess.']; } if ($on) { if (!self::active($before, self::REDIRECT_LINES)) { return ['ok' => false, 'message' => 'HSTS erst, wenn die Umleitung auf HTTPS eingeschaltet ist.']; } if (null !== $problem = $this->originCheck->problem(rtrim(trim($origin), '/'))) { return ['ok' => false, 'message' => $problem.' HSTS bleibt aus.']; } } if (!$this->write(self::switchLines($before, [self::HSTS_LINE], $on))) { return ['ok' => false, 'message' => 'PHP darf public/.htaccess nicht schreiben.']; } return ['ok' => true, 'message' => $on ? 'HSTS ist eingeschaltet: Browser rufen diese Domain ein Jahr lang nur noch über HTTPS auf, Subdomains eingeschlossen.' : 'HSTS ist ausgeschaltet. Browser, die den Kopf schon kennen, bleiben bis zu einem Jahr bei HTTPS.']; } /** * Die neue .htaccess eines Updates, mit den Zeilen eingeschaltet, die in der alten * eingeschaltet waren. Archiv- und Git-Update rufen beide hierher. */ public static function keepEnabled(string $new, string $old): string { if (self::active($old, self::REDIRECT_LINES)) { $new = self::switchLines($new, self::REDIRECT_LINES, true); } if (self::active($old, [self::HSTS_LINE])) { $new = self::switchLines($new, [self::HSTS_LINE], true); } return $new; } /** @param list $lines */ public static function active(string $content, array $lines): bool { foreach ($lines as $line) { if (1 !== preg_match('/^[ \t]*'.preg_quote($line, '/').'/m', $content)) { return false; } } return true; } /** @param list $lines */ private static function present(string $content, array $lines): bool { foreach ($lines as $line) { if (1 !== preg_match('/^[ \t]*(#[ \t]*)?'.preg_quote($line, '/').'/m', $content)) { return false; } } return true; } /** @param list $lines */ private static function switchLines(string $content, array $lines, bool $on): string { foreach ($lines as $line) { $quoted = preg_quote($line, '/'); $content = $on ? (string) preg_replace('/^([ \t]*)#[ \t]*('.$quoted.')/m', '$1$2', $content) : (string) preg_replace('/^([ \t]*)('.$quoted.')/m', '$1#$2', $content); } return $content; } private function read(): ?string { $content = is_file($this->file()) ? @file_get_contents($this->file()) : false; return false === $content ? null : $content; } private function write(string $content): bool { return is_writable($this->file()) && false !== @file_put_contents($this->file(), $content, LOCK_EX); } /** * Eine Anfrage über http, ohne Weiterleitungen zu folgen: gefragt ist nur, wohin sie zeigt. * * @return array{status: int, location: string, error: string} */ private static function probeHttp(string $url): array { if (function_exists('curl_init')) { $curl = curl_init($url); curl_setopt_array($curl, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => true, CURLOPT_NOBODY => false, CURLOPT_FOLLOWLOCATION => false, CURLOPT_PROTOCOLS => CURLPROTO_HTTP, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => 10, CURLOPT_USERAGENT => 'KantSites-HTTPS-Check', ]); $raw = curl_exec($curl); $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE); $error = curl_error($curl); curl_close($curl); $location = is_string($raw) && preg_match('/^Location:[ \t]*(\S+)/mi', $raw, $match) ? $match[1] : ''; return ['status' => $status, 'location' => $location, 'error' => '' !== $error ? $error : 'keine Antwort']; } $context = stream_context_create(['http' => ['follow_location' => 0, 'ignore_errors' => true, 'timeout' => 10, 'user_agent' => 'KantSites-HTTPS-Check']]); $http_response_header = []; $body = @file_get_contents($url, false, $context); $headers = $http_response_header; if (false === $body && [] === $headers) { return ['status' => 0, 'location' => '', 'error' => 'keine Verbindung über http']; } $status = preg_match('#^HTTP/\S+\s+(\d{3})#', (string) ($headers[0] ?? ''), $match) ? (int) $match[1] : 0; $location = ''; foreach ($headers as $header) { if (preg_match('/^Location:[ \t]*(\S+)/i', $header, $match)) { $location = $match[1]; } } return ['status' => $status, 'location' => $location, 'error' => 'keine Antwort']; } }