diff --git a/app/Console/Commands/BindHosts.php b/app/Console/Commands/BindHosts.php index b5b85e9..585d372 100644 --- a/app/Console/Commands/BindHosts.php +++ b/app/Console/Commands/BindHosts.php @@ -53,6 +53,15 @@ class BindHosts extends Command 'FILES_HOST' => (string) $this->option('files'), ]; + foreach ($wanted as $key => $value) { + if ($value !== '' && ! $this->isHostList($value)) { + $this->error("{$key}: „{$value}“ ist kein Hostname."); + $this->line('Erwartet wird ein Name wie app.example.com, mehrere kommagetrennt.'); + + return self::FAILURE; + } + } + $missing = []; foreach ($wanted as $key => $value) { @@ -113,6 +122,43 @@ class BindHosts extends Command return self::SUCCESS; } + /** Eine Komma-Liste, in der JEDER Eintrag ein Hostname ist. */ + private function isHostList(string $value): bool + { + $names = array_map('trim', explode(',', $value)); + + if ($names === [] || in_array('', $names, true)) { + return false; + } + + foreach ($names as $name) { + if (! $this->isHost($name)) { + return false; + } + } + + return true; + } + + /** + * Dasselbe Muster, das der root-eigene Helfer in `apply-proxy-hosts` + * benutzt, bevor er einen Namen in die Proxy-Konfiguration schreibt + * (deploy/install-agent.sh). + * + * Der Anlass ist konkret: `[www.example.com](https://www.example.com)` ist + * als Hostname offensichtlich Unsinn und wurde trotzdem geschrieben, weil + * die Zeile die Form KEY=value hatte. Das Repo kennt diese Falle schon — + * RestrictConsoleNetwork::isNetwork() gibt es, weil ein Eintrag, der nichts + * trifft, sonst „stored happily and reports success". + */ + private function isHost(string $name): bool + { + return (bool) preg_match( + '/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/', + $name, + ); + } + /** * Der Wert eines Schlüssels, oder '' wenn er fehlt ODER leer ist. * diff --git a/tests/Feature/BindHostsCommandTest.php b/tests/Feature/BindHostsCommandTest.php index c460c5b..0f3d69c 100644 --- a/tests/Feature/BindHostsCommandTest.php +++ b/tests/Feature/BindHostsCommandTest.php @@ -111,20 +111,51 @@ it('sagt es, wenn schon alles steht, statt eine Sicherung anzulegen', function ( expect(glob($this->envPath.'.bak-*'))->toBeEmpty(); }); -it('schreibt einen Wert wörtlich, auch wenn er wie ein Rückverweis aussieht', function () { +it('lehnt einen Wert ab, der wie ein Rückverweis aussieht, statt ihn zu verschlucken', function () { // SITE_HOST steht schon leer da — genau das, was .env.example wirklich // ausliefert, und der Pfad, auf dem apply() eine vorhandene Zeile per // preg_replace ersetzt statt sie nur anzuhängen. file_put_contents($this->envPath, "APP_URL=https://app.example.test\nSITE_HOST=\n"); // `$1` und `\1` sind das, was preg_replace im ERSATZ als Rückverweis - // deutet und still verschluckt. Diese Datei hält jedes Geheimnis der - // Installation — was hier ankommt, muss ankommen wie eingegeben. + // deutet und still verschluckt hätte — apply() schützt genau davor. Seit + // der Hostnamen-Prüfung kommt der Wert dort aber gar nicht mehr an: `$` + // und `\` sind in keinem Hostnamen erlaubt, also weist die Prüfung ihn + // vorher zurück, statt ihn (unbeschädigt oder nicht) zu schreiben. $this->artisan('clupilot:bind-hosts', [ '--site' => 'www.example.test$1,foo\1bar', '--force' => true, - ])->assertSuccessful(); + ])->assertFailed(); expect(file_get_contents($this->envPath)) - ->toContain('SITE_HOST=www.example.test$1,foo\1bar'); + ->toBe("APP_URL=https://app.example.test\nSITE_HOST=\n") + ->and(glob($this->envPath.'.bak-*'))->toBeEmpty(); +}); + +it('lehnt ab, was kein Hostname sein kann, und schreibt nichts', function () { + file_put_contents($this->envPath, "APP_URL=https://app.example.test\n"); + + // Genau der Wert, der auf der Produktivmaschine landete. Er ist als + // Hostname offensichtlich Unsinn und wurde trotzdem geschrieben, weil + // EnvFileEditor nur prueft, ob die ZEILE die Form KEY=value hat. + $this->artisan('clupilot:bind-hosts', [ + '--site' => '[www.example.test](https://www.example.test)', + '--force' => true, + ])->assertFailed(); + + expect(file_get_contents($this->envPath))->not->toContain('SITE_HOST') + ->and(glob($this->envPath.'.bak-*'))->toBeEmpty(); +}); + +it('prüft jeden Namen einer Komma-Liste einzeln', function () { + file_put_contents($this->envPath, "APP_URL=https://app.example.test\n"); + + // Der erste Name ist gültig — geschrieben werden darf trotzdem nichts, + // sonst stünde die halbe Liste in der Datei. + $this->artisan('clupilot:bind-hosts', [ + '--site' => 'www.example.test,nicht gültig', + '--force' => true, + ])->assertFailed(); + + expect(file_get_contents($this->envPath))->not->toContain('SITE_HOST'); });