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
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Ops\Download;
|
||||
use App\Ops\HttpsRedirect;
|
||||
use App\Ops\OriginCheck;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* HTTPS erzwingen aus der Verwaltung (ADR 120): eingeschaltet wird nur, wenn HTTPS vorher und
|
||||
* nachher hier ankommt, und eine Schleife wird zurückgenommen, bevor sie jemanden aussperrt.
|
||||
*/
|
||||
final class HttpsRedirectTest extends TestCase
|
||||
{
|
||||
private string $root;
|
||||
private string $shipped;
|
||||
/** @var list<?string> */
|
||||
private array $answers = [];
|
||||
/** @var array{status: int, location: string, error: string} */
|
||||
private array $http = ['status' => 301, 'location' => 'https://www.example.de/.well-known/kantsites-origin', 'error' => ''];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->root = sys_get_temp_dir().'/kant-https-'.bin2hex(random_bytes(4));
|
||||
mkdir($this->root.'/public', 0o777, true);
|
||||
$this->shipped = (string) file_get_contents(dirname(__DIR__).'/public/.htaccess');
|
||||
file_put_contents($this->root.'/public/.htaccess', $this->shipped);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
@unlink($this->root.'/public/.htaccess');
|
||||
@rmdir($this->root.'/public');
|
||||
@rmdir($this->root);
|
||||
}
|
||||
|
||||
public function testTheShippedFileHasTheRedirectBeforeTheFrontController(): void
|
||||
{
|
||||
// Hinter "RewriteRule ^index\.php$ - [L]" kam die Umleitung für Seiten nie an die Reihe:
|
||||
// Nach dem Umschreiben auf index.php beginnt die Runde neu und endet an dieser Regel.
|
||||
$redirect = strpos($this->shipped, '#'.HttpsRedirect::REDIRECT_LINES[0]);
|
||||
$front = strpos($this->shipped, 'RewriteRule ^index\.php$ - [L]');
|
||||
self::assertNotFalse($redirect);
|
||||
self::assertNotFalse($front);
|
||||
self::assertLessThan($front, $redirect);
|
||||
self::assertSame(['redirect' => false, 'hsts' => false, 'found' => true, 'writable' => true], $this->redirect()->state());
|
||||
}
|
||||
|
||||
public function testEnablingProbesBeforeAndAfterAndSwitchesOnlyTheThreeLines(): void
|
||||
{
|
||||
$this->answers = ['ok', 'ok'];
|
||||
$result = $this->redirect()->enableRedirect('https://www.example.de/');
|
||||
self::assertTrue($result['ok'], $result['message']);
|
||||
self::assertStringContainsString('leitet mit 301', $result['message']);
|
||||
$now = (string) file_get_contents($this->root.'/public/.htaccess');
|
||||
foreach (HttpsRedirect::REDIRECT_LINES as $line) {
|
||||
self::assertMatchesRegularExpression('/^'.preg_quote($line, '/').'$/m', $now);
|
||||
}
|
||||
self::assertStringContainsString('#'.HttpsRedirect::HSTS_LINE, $now, 'HSTS bleibt ein eigener Schritt.');
|
||||
self::assertSame([], $this->answers, 'Vorher und nachher geprüft.');
|
||||
|
||||
$off = $this->redirect()->disableRedirect();
|
||||
self::assertTrue($off['ok']);
|
||||
self::assertSame($this->shipped, (string) file_get_contents($this->root.'/public/.htaccess'), 'Aus heißt: wieder wie ausgeliefert.');
|
||||
}
|
||||
|
||||
public function testNothingChangesWithoutWorkingHttps(): void
|
||||
{
|
||||
$this->answers = [null];
|
||||
$result = $this->redirect()->enableRedirect('https://www.example.de');
|
||||
self::assertFalse($result['ok']);
|
||||
self::assertStringContainsString('bleibt die Umleitung aus', $result['message']);
|
||||
self::assertSame($this->shipped, (string) file_get_contents($this->root.'/public/.htaccess'));
|
||||
|
||||
$result = $this->redirect()->enableRedirect('http://www.example.de');
|
||||
self::assertFalse($result['ok']);
|
||||
self::assertStringContainsString('https://', $result['message']);
|
||||
}
|
||||
|
||||
public function testALoopIsTakenBackAtOnce(): void
|
||||
{
|
||||
// Der Server meldet HTTPS nicht an die .htaccess: mit den Zeilen leitet auch https://
|
||||
// auf sich selbst um, die Probe danach kommt nicht mehr an.
|
||||
$this->answers = ['ok', null];
|
||||
$result = $this->redirect()->enableRedirect('https://www.example.de');
|
||||
self::assertFalse($result['ok']);
|
||||
self::assertStringContainsString('zurückgesetzt', $result['message']);
|
||||
self::assertSame($this->shipped, (string) file_get_contents($this->root.'/public/.htaccess'));
|
||||
}
|
||||
|
||||
public function testAServerThatIgnoresTheRuleIsNamed(): void
|
||||
{
|
||||
$this->answers = ['ok', 'ok'];
|
||||
$this->http = ['status' => 200, 'location' => '', 'error' => ''];
|
||||
$result = $this->redirect()->enableRedirect('https://www.example.de');
|
||||
self::assertTrue($result['ok']);
|
||||
self::assertStringContainsString('antwortet mit 200', $result['message']);
|
||||
}
|
||||
|
||||
public function testHstsOnlyAfterTheRedirect(): void
|
||||
{
|
||||
$this->answers = ['ok'];
|
||||
$result = $this->redirect()->switchHsts(true, 'https://www.example.de');
|
||||
self::assertFalse($result['ok']);
|
||||
self::assertStringContainsString('erst, wenn die Umleitung', $result['message']);
|
||||
|
||||
$this->answers = ['ok', 'ok', 'ok'];
|
||||
$this->redirect()->enableRedirect('https://www.example.de');
|
||||
self::assertTrue($this->redirect()->switchHsts(true, 'https://www.example.de')['ok']);
|
||||
self::assertSame(['redirect' => true, 'hsts' => true, 'found' => true, 'writable' => true], $this->redirect()->state());
|
||||
|
||||
// Die Umleitung aus nimmt HSTS mit.
|
||||
$this->redirect()->disableRedirect();
|
||||
self::assertSame($this->shipped, (string) file_get_contents($this->root.'/public/.htaccess'));
|
||||
}
|
||||
|
||||
public function testAnUpdateKeepsWhatWasSwitchedOn(): void
|
||||
{
|
||||
$this->answers = ['ok', 'ok', 'ok'];
|
||||
$this->redirect()->enableRedirect('https://www.example.de');
|
||||
$this->redirect()->switchHsts(true, 'https://www.example.de');
|
||||
$merged = HttpsRedirect::keepEnabled($this->shipped, (string) file_get_contents($this->root.'/public/.htaccess'));
|
||||
self::assertTrue(HttpsRedirect::active($merged, HttpsRedirect::REDIRECT_LINES));
|
||||
self::assertTrue(HttpsRedirect::active($merged, [HttpsRedirect::HSTS_LINE]));
|
||||
self::assertSame($this->shipped, HttpsRedirect::keepEnabled($this->shipped, $this->shipped));
|
||||
}
|
||||
|
||||
public function testTheGitUpdaterDoesNotCallTheSwitchedFileALocalChange(): void
|
||||
{
|
||||
$dirty = new \ReflectionMethod(\App\Ops\Updater::class, 'dirty');
|
||||
self::assertSame([], $dirty->invoke(null, " M public/.htaccess\n"));
|
||||
self::assertSame([], $dirty->invoke(null, 'M public/.htaccess'), 'Auch wenn die Ausgabe vorn gekürzt ist.');
|
||||
self::assertSame([' M src/Kernel.php'], $dirty->invoke(null, " M public/.htaccess\n M src/Kernel.php\n"));
|
||||
self::assertSame(['?? public/.htaccess.bak'], $dirty->invoke(null, '?? public/.htaccess.bak'));
|
||||
}
|
||||
|
||||
private function redirect(): HttpsRedirect
|
||||
{
|
||||
$test = $this;
|
||||
$download = new class($test) extends Download {
|
||||
public function __construct(private HttpsRedirectTest $test)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function get(string $url): ?string
|
||||
{
|
||||
return $this->test->nextAnswer();
|
||||
}
|
||||
};
|
||||
$check = new OriginCheck('geheim', $download);
|
||||
$this->token = $check->token();
|
||||
|
||||
return new HttpsRedirect($this->root, $check, fn (string $url): array => $this->http);
|
||||
}
|
||||
|
||||
private string $token = '';
|
||||
|
||||
public function nextAnswer(): ?string
|
||||
{
|
||||
self::assertNotSame([], $this->answers, 'Mehr Proben als erwartet.');
|
||||
$answer = array_shift($this->answers);
|
||||
|
||||
return null === $answer ? null : $this->token;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user