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>
This commit is contained in:
1 parent
daad5a1740
commit
8b212d5cc9
15 files changed
+614
-24
No files matched your search
@@ -566,15 +566,7 @@ final class ArchiveUpdate
|
||||
*/
|
||||
private function mergeHtaccess(string $neu, string $alt): void
|
||||
{
|
||||
$vorher = (string) file_get_contents($alt);
|
||||
$inhalt = (string) file_get_contents($neu);
|
||||
foreach (['RewriteCond %{HTTPS} !=on', 'RewriteCond %{HTTP:X-Forwarded-Proto} !=https', 'RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]', 'Header always set Strict-Transport-Security'] as $zeile) {
|
||||
$aktiv = 1 === preg_match('/^\s*'.preg_quote($zeile, '/').'/m', $vorher);
|
||||
if ($aktiv) {
|
||||
$inhalt = (string) preg_replace('/^(\s*)#\s*('.preg_quote($zeile, '/').')/m', '$1$2', $inhalt);
|
||||
}
|
||||
}
|
||||
file_put_contents($alt, $inhalt);
|
||||
file_put_contents($alt, HttpsRedirect::keepEnabled((string) file_get_contents($neu), (string) file_get_contents($alt)));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
<?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'];
|
||||
}
|
||||
}
|
||||
+20
-3
@@ -96,7 +96,7 @@ final class Updater
|
||||
'git' => $git,
|
||||
// Der Weg ueber Git steht nur einer Arbeitskopie offen.
|
||||
'worktree' => $arbeitskopie,
|
||||
'clean' => null === $status ? null : ('' === $status['output'] && $status['ok']),
|
||||
'clean' => null === $status ? null : ([] === self::dirty($status['output']) && $status['ok']),
|
||||
'log' => $this->log(),
|
||||
];
|
||||
}
|
||||
@@ -138,10 +138,10 @@ final class Updater
|
||||
}
|
||||
|
||||
$status = $this->git(['status', '--porcelain']);
|
||||
$dirty = array_values(array_filter(explode("\n", $status['output'])));
|
||||
$dirty = self::dirty($status['output']);
|
||||
$add($checks, 'Keine lokalen Änderungen', [] === $dirty, [] === $dirty
|
||||
? 'Die Arbeitskopie ist unverändert.'
|
||||
: sprintf('%d geänderte Datei(en), zum Beispiel %s. Ein Checkout würde sie verwerfen. Erst sichern oder zurücknehmen.', count($dirty), trim(explode("\n", $status['output'])[0])));
|
||||
: sprintf('%d geänderte Datei(en), zum Beispiel %s. Ein Checkout würde sie verwerfen. Erst sichern oder zurücknehmen.', count($dirty), trim($dirty[0])));
|
||||
|
||||
$remote = $this->git(['remote', 'get-url', 'origin']);
|
||||
$add($checks, 'Herkunft', $remote['ok'], $remote['ok']
|
||||
@@ -299,8 +299,13 @@ final class Updater
|
||||
throw new \RuntimeException('Die alte Git-Konfiguration konnte nicht für den Updatewechsel vorbereitet werden.');
|
||||
}
|
||||
}
|
||||
$htaccess = $this->root.'/public/.htaccess';
|
||||
$htaccessBefore = is_file($htaccess) ? (string) file_get_contents($htaccess) : null;
|
||||
$checkout = $this->git(['checkout', '--force', $target, '--']);
|
||||
$this->restoreEnvironment();
|
||||
if (null !== $htaccessBefore && is_file($htaccess)) {
|
||||
@file_put_contents($htaccess, HttpsRedirect::keepEnabled((string) file_get_contents($htaccess), $htaccessBefore), LOCK_EX);
|
||||
}
|
||||
if (!$step($steps, 'Code wechseln', $checkout['ok'], $checkout['ok'] ? 'Auf '.$target.' gewechselt.' : $checkout['output'])) {
|
||||
throw new \RuntimeException('checkout');
|
||||
}
|
||||
@@ -444,6 +449,18 @@ final class Updater
|
||||
}
|
||||
|
||||
/** Legacy tags tracked .env; preserve the installation even when checking out those tags. */
|
||||
/**
|
||||
* Die geänderten Dateien laut `git status --porcelain` - ohne public/.htaccess: Eine dort
|
||||
* eingeschaltete HTTPS-Umleitung nimmt apply() in die neue Fassung mit (ADR 120), sie ist
|
||||
* kein Grund, das Update zu verweigern.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function dirty(string $porcelain): array
|
||||
{
|
||||
return array_values(array_filter(explode("\n", $porcelain), static fn (string $line): bool => '' !== trim($line) && 1 !== preg_match('#^\s*M{1,2}\s+public/\.htaccess$#', rtrim($line))));
|
||||
}
|
||||
|
||||
private function restoreEnvironment(): void
|
||||
{
|
||||
foreach ($this->environmentBefore as $file => $content) {
|
||||
|
||||
Reference in new issue
Block a user