Files
KantSites/src/Ops/HttpsRedirect.php
T
kantarciandClaude Opus 5.5 8b212d5cc9 Force HTTPS from the admin, with a probe before and after
System > HTTPS and cms:https switch the redirect lines in public/.htaccess
only when the site answers over HTTPS, and take them back on a loop. HSTS
is a separate second switch. The redirect block moves before the front
controller rules, where it never reached pages, and .well-known is no
longer denied, so the origin probe works on Apache. The Git updater keeps
switched-on lines like the archive update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:22:56 +02:00

260 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Ops;
/**
* Schaltet die Umleitung auf HTTPS und HSTS in public/.htaccess ein und aus (ADR 120).
*
* Die Zeilen stehen ausgeliefert auskommentiert in der Datei: Eine Installation ohne Zertifikat
* liefe mit ihnen in eine Endlosschleife, und die Anwendung selbst erzwingt kein HTTPS, weil
* PHP hinter einem Proxy oft nur http sieht. Bisher hieß das: per FTP in die .htaccess. Dieser
* Schalter macht dasselbe aus der Verwaltung, aber nur mit Probe davor und danach:
*
* 1. Antwortet diese Installation unter https://<Adresse> mit gültigem Zertifikat? Sonst nichts.
* 2. Zeilen einschalten.
* 3. Kommt https://<Adresse> 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://<Adresse> 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<string> $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<string> $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<string> $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'];
}
}