Sicherheitsdurchsicht: Passwort-Logins am Host zu, und die Konsole sagt selbst, wo sie offensteht

DER FUND. Auf einem uebernommenen Host blieb die Anmeldung als root MIT PASSWORT
erlaubt. Port 22 steht waehrend der ganzen Uebernahme offen im Internet — die
Host-Firewall macht ihn erst als vorletzter von sechzehn Schritten zu. Dazwischen
lag ein Fenster von Stunden, in dem jede Maschine der Welt Root-Passwoerter
durchprobieren durfte. Und wer spaeter das Notfallskript benutzt, reisst es
wieder auf.

EstablishSshTrust schreibt jetzt /etc/ssh/sshd_config.d/99-clupilot.conf und
verbietet Passwort-Anmeldung. Der Zeitpunkt ist genau richtig gewaehlt: eine
Zeile darueber hat sich `keyLogin()` erfolgreich MIT DEM SCHLUESSEL angemeldet —
wir wissen also, dass der Weg hinein steht, bevor wir den anderen zumachen.
`reload` statt `restart`, und `sshd -t` davor. Ein Fehlschlag bricht die
Uebernahme NICHT ab: eine Haertung, die einen ganzen Aufbau scheitern laesst,
wird beim naechsten Mal weggelassen.

DIE KONSOLE SAGT ES JETZT SELBST. Neue Pruefgruppe „Sicherheit" auf der
Bereitschaftsseite, drei Punkte, alle drei aus dieser Durchsicht:

- Ist die Konsole ueberhaupt eingeschraenkt? (blockierend)
- Steht in TRUSTED_RANGES nur, was dort hingehoert? Alles andere wurde von Hand
  in die .env geschrieben und erscheint in der Oberflaeche als „nicht
  entfernbar" — beim naechsten Anschlusswechsel ein Aussperren.
- Haengen APP_PORT/REVERB_HOST_PORT auf der Schleife? Docker traegt
  veroeffentlichte Ports VOR der Firewall ein: ein Dienst auf 0.0.0.0 ist von
  aussen erreichbar, auch wenn ufw zu aussieht — und wer ihn direkt anspricht,
  geht am Reverse Proxy vorbei, an dessen Zugangsliste und an TLS.

Diese Entwicklungsmaschine meldet prompt zwei davon. Genau dafuer ist die Gruppe
da: eine fehlende Einrichtung faellt beim ersten Versuch auf, eine offene Tuer
nie — bis sie jemand benutzt.

WAS DIE DURCHSICHT SONST ERGAB, und was in Ordnung ist: TrustProxies traut nur
privaten Bereichen und ausdruecklich NICHT X-Forwarded-Host, eine gefaelschte
Herkunftsadresse greift also nicht. Zwei-Faktor ist erzwungen, nicht optional.
Die Anmeldung bremst nach fuenf Versuchen. Geheimnisse liegen mit eigenem
Schluessel verschluesselt. Die Host-Firewall laesst 22 und 8006 nur aus dem
Tunnel. Der Terminal-Pfad umgeht die Netzsperre bewusst — sein Riegel ist das
Einmal-Ticket, dreissig Sekunden, an Host und Betreiber gebunden.

2523 Tests gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/versandtakt
nexxo 2026-08-03 10:28:56 +02:00
parent 7921f5b0bf
commit c99911ca55
8 changed files with 202 additions and 0 deletions

View File

@ -180,6 +180,9 @@ class Readiness extends Component
// Company details AND the invoice series both live on
// Admin\Finance — there is no dedicated "company" page.
'company' => route('admin.finance'),
// Die Zugangsliste der Konsole liegt auf der Einstellungsseite, nicht
// bei den Integrationen — ein `?tab=security` dorthin liefe ins Leere.
'security' => route('admin.settings'),
default => route('admin.integrations'),
};
}

View File

@ -46,6 +46,57 @@ class EstablishSshTrust extends HostStep
$this->keyLogin($this->shell, $host);
$run->forgetContext('root_password');
$this->refusePasswordLogins();
return StepResult::advance();
}
/**
* Ab hier kommt man auf diese Maschine nur noch mit dem Schlüssel.
*
* Bis hierher blieb die Anmeldung als root MIT PASSWORT erlaubt und Port
* 22 steht während der ganzen Übernahme offen im Internet, denn die
* Host-Firewall macht ihn erst als vorletzter Schritt zu. Zwischen diesem
* Schritt und jenem lag also ein Fenster von Stunden, in dem jede Maschine
* der Welt Root-Passwörter durchprobieren durfte. Und wer später einmal das
* Notfallskript benutzt, reisst dieses Fenster wieder auf.
*
* Der richtige Zeitpunkt ist GENAU HIER: eine Zeile weiter oben hat sich
* `keyLogin()` erfolgreich mit dem Schlüssel angemeldet. Wir wissen also,
* dass der Weg hinein steht, bevor wir den anderen zumachen.
*
* `reload`, nicht `restart`: bestehende Sitzungen bleiben, auch die, über
* die wir hier gerade reden. Und `sshd -t` davor eine Konfiguration, die
* sshd nicht laden kann, dürfte er nie zu sehen bekommen.
*
* Fehlschläge sind ausdrücklich KEIN Grund, die Übernahme abzubrechen: der
* Schlüssel liegt dann trotzdem, die Maschine ist benutzbar, und die
* Firewall macht Port 22 weiter unten ohnehin zu. Eine Härtung, die einen
* ganzen Aufbau scheitern lässt, wird beim nächsten Mal weggelassen.
*/
private function refusePasswordLogins(): void
{
// Eigene Datei unter sshd_config.d, nicht die Hauptdatei bearbeiten:
// Debians sshd_config zieht dieses Verzeichnis in der ERSTEN Zeile
// herein, und bei sshd gewinnt der erste Treffer. Was hier steht,
// schlägt damit alles, was weiter unten in der Hauptdatei steht — ohne
// dass wir eine fremde Datei umschreiben, die das nächste Systemupdate
// wieder ersetzt.
$this->shell->run('install -d -m 755 /etc/ssh/sshd_config.d');
$this->shell->putFile('/etc/ssh/sshd_config.d/99-clupilot.conf', <<<'SSHD'
# Geschrieben von CluPilot (EstablishSshTrust). Nicht von Hand ändern —
# der nächste Lauf dieses Schrittes überschreibt die Datei.
#
# Auf diese Maschine kommt man mit dem Flottenschlüssel. Ein Passwort ist ab
# hier keine Eintrittskarte mehr, auch nicht für root. Der Weg zurück, wenn der
# Schlüssel je verlorengeht, ist die Konsole des Anbieters — nicht ein
# Passwort, das jemand raten kann.
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin prohibit-password
SSHD);
$this->shell->run('sshd -t && { systemctl reload ssh || systemctl reload sshd; }');
}
}

View File

@ -7,6 +7,7 @@ use App\Support\Readiness\Check;
use App\Support\Readiness\DeliveryChecks;
use App\Support\Readiness\OnboardingChecks;
use App\Support\Readiness\OperationChecks;
use App\Support\Readiness\SecurityChecks;
use App\Support\Readiness\ProvisioningChecks;
/**
@ -38,6 +39,7 @@ final class Readiness
...ProvisioningChecks::all(),
...DeliveryChecks::all(),
...OperationChecks::all(),
...SecurityChecks::all(),
];
}

View File

@ -0,0 +1,98 @@
<?php
namespace App\Support\Readiness;
use App\Http\Middleware\RestrictConsoleNetwork;
/**
* Was von aussen offensteht und was offenstehen soll.
*
* Die anderen Gruppen in dieser Datei fragen, ob etwas EINGERICHTET ist. Diese
* fragt, ob etwas ZU ist. Der Unterschied ist wichtig: eine fehlende
* Einrichtung faellt beim ersten Versuch auf, eine offene Tuer nie bis sie
* jemand benutzt.
*
* Alle drei Punkte hier sind aus einer Durchsicht am 2026-08-03 entstanden, und
* zwei davon standen auf der echten Anlage wirklich so.
*/
final class SecurityChecks
{
public const GROUP = 'security';
/**
* @return list<Check>
*/
public static function all(): array
{
return [
new Check(
key: 'security.console_restricted',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.security.console_restricted'),
breaks: __('readiness.security.console_restricted_breaks'),
tab: 'security',
satisfied: RestrictConsoleNetwork::isRestricted(),
),
new Check(
key: 'security.trusted_ranges_clean',
group: self::GROUP,
severity: Check::SEVERITY_WARNING,
label: __('readiness.security.trusted_ranges'),
breaks: __('readiness.security.trusted_ranges_breaks'),
tab: 'security',
// Alles, was ueber das Management-Netz und die eigene Maschine
// hinausgeht, ist von Hand in die .env geschrieben worden — und
// steht damit an einer Stelle, die der Betreiber in der Konsole
// nicht aendern kann. Genau so stand eine Bueroadresse dort:
// beim naechsten Anschlusswechsel waere das ein Aussperren
// gewesen. Sie gehoert in die Liste, die man pflegen kann.
satisfied: array_diff(
(array) config('admin_access.trusted_ranges', []),
(array) config('admin_access.structural_ranges', []),
) === [],
),
new Check(
key: 'security.ports_loopback',
group: self::GROUP,
severity: Check::SEVERITY_WARNING,
label: __('readiness.security.ports_loopback'),
breaks: __('readiness.security.ports_loopback_breaks'),
tab: 'env',
satisfied: self::onlyLoopback(),
),
];
}
/**
* Sind die Anwendungs-Ports nur auf der eigenen Maschine erreichbar?
*
* Docker traegt veroeffentlichte Ports VOR der Firewall ein. Ein Dienst auf
* `0.0.0.0` ist damit aus dem Internet erreichbar, auch wenn ufw geschlossen
* aussieht und wer ihn direkt anspricht, geht am Reverse Proxy vorbei und
* damit an dessen Zugangsliste und an TLS.
*
* Ohne Angabe gilt in docker-compose.yml die Schleife (`127.0.0.1:8080`),
* dann ist alles in Ordnung. Ein blosser Port ohne Adresse `APP_PORT=80`
* bedeutet bei Docker ausdruecklich ALLE Adressen; das ist der Fall, den
* diese Pruefung sucht.
*/
private static function onlyLoopback(): bool
{
foreach (['APP_PORT', 'REVERB_HOST_PORT'] as $name) {
$wert = trim((string) env($name, ''));
if ($wert === '') {
continue; // Vorgabe aus docker-compose.yml: Schleife.
}
if (! str_starts_with($wert, '127.0.0.1:') && ! str_starts_with($wert, 'localhost:')) {
return false;
}
}
return true;
}
}

View File

@ -18,6 +18,7 @@ return [
'provisioning' => 'Bereitstellung',
'delivery' => 'Zustellung',
'operation' => 'Betrieb',
'security' => 'Sicherheit',
],
'status' => [
@ -126,4 +127,13 @@ return [
'zone_list_unclear' => 'Hetzner hat die Zonenliste nicht ausgeliefert (HTTP :status). Über die Zonen dieses Kontos sagt das nichts — der Token kann trotzdem richtig sein.',
'zone_list_unreadable' => 'Die Antwort kam mit HTTP :status, war aber keine Zonenliste. Da hat etwas anderes geantwortet — ein Portal, ein Filter, ein Proxy. Anfang der Antwort: :body',
'write_failed' => 'Der Schreibversuch wurde mit HTTP :status abgelehnt. Über die Berechtigung des Tokens sagt das nichts — der wäre mit 401 oder 403 abgewiesen worden. Hetzner sagt dazu: :detail',
'security' => [
'console_restricted' => 'Die Konsole ist nicht für jeden erreichbar',
'console_restricted_breaks' => 'Ohne Einschränkung kann jede Adresse der Welt die Anmeldeseite der Konsole sehen und Passwörter durchprobieren. Mit Einschränkung bekommt sie eine 404 und erfährt nicht einmal, dass es sie gibt.',
'trusted_ranges' => 'In TRUSTED_RANGES steht nur, was dort hingehört',
'trusted_ranges_breaks' => 'Was darüber hinaus dort steht, wurde von Hand in die .env geschrieben — und die Konsole zeigt es als „nicht entfernbar" an. Beim nächsten Wechsel des Anschlusses lässt es sich dann nicht ändern. Solche Adressen gehören in die Liste „Zusätzlich erlaubt", wo du sie selbst pflegst.',
'ports_loopback' => 'Die Anwendung hängt nicht direkt am Internet',
'ports_loopback_breaks' => 'Docker trägt veröffentlichte Ports VOR der Firewall ein: ein Dienst auf 0.0.0.0 ist von außen erreichbar, auch wenn ufw zu aussieht. Wer ihn direkt anspricht, geht am Reverse Proxy vorbei — und damit an dessen Zugangsliste und an der Verschlüsselung. APP_PORT und REVERB_HOST_PORT gehören auf 127.0.0.1.',
],
];

View File

@ -18,6 +18,7 @@ return [
'provisioning' => 'Provisioning',
'delivery' => 'Delivery',
'operation' => 'Operation',
'security' => 'Security',
],
'status' => [
@ -121,4 +122,13 @@ return [
'zone_list_unclear' => 'Hetzner did not return the zone list (HTTP :status). That says nothing about this account\'s zones — the token may still be correct.',
'zone_list_unreadable' => 'The response came back with HTTP :status but was not a zone list. Something else answered — a portal, a filter, a proxy. Start of the response: :body',
'write_failed' => 'The write attempt was rejected with HTTP :status. That says nothing about the token\'s permissions — those come back as 401 or 403. Hetzner says: :detail',
'security' => [
'console_restricted' => 'The console is not reachable by everyone',
'console_restricted_breaks' => 'Without the restriction, any address in the world can see the console sign-in page and try passwords. With it, they get a 404 and never learn it exists.',
'trusted_ranges' => 'TRUSTED_RANGES holds only what belongs there',
'trusted_ranges_breaks' => 'Anything beyond that was written into the .env by hand — and the console shows it as "not removable". At the next change of connection it cannot be changed. Such addresses belong in the "Additionally allowed" list, which you maintain yourself.',
'ports_loopback' => 'The application is not exposed to the internet directly',
'ports_loopback_breaks' => 'Docker inserts published ports BEFORE the firewall: a service on 0.0.0.0 is reachable from outside even when ufw looks closed. Reaching it directly skips the reverse proxy — and with it that proxy\'s access list and TLS. APP_PORT and REVERB_HOST_PORT belong on 127.0.0.1.',
],
];

View File

@ -153,6 +153,31 @@ it('establishes ssh trust, pins the host key and scrubs the password', function
->and($s['shell']->connectionsWith('key'))->toHaveCount(1);
});
it('macht die Anmeldung mit Passwort zu, sobald der Schluessel nachweislich traegt', function () {
// Bis hierher blieb root-mit-Passwort erlaubt, und Port 22 steht waehrend
// der ganzen Uebernahme offen im Internet — die Host-Firewall macht ihn erst
// als vorletzter Schritt zu. Dazwischen lag ein Fenster von Stunden, in dem
// jede Maschine der Welt Root-Passwoerter durchprobieren durfte.
$s = fakeServices();
$host = Host::factory()->create(['public_ip' => '203.0.113.5']);
$run = hostRun($host, ['root_password' => Crypt::encryptString('rootpw')]);
app(EstablishSshTrust::class)->execute($run);
$conf = $s['shell']->files()['/etc/ssh/sshd_config.d/99-clupilot.conf'] ?? '';
expect($conf)->toContain('PasswordAuthentication no')
->and($conf)->toContain('PermitRootLogin prohibit-password');
// Geprueft, bevor sshd sie zu sehen bekommt — eine Konfiguration, die er
// nicht laden kann, darf er nie geladen bekommen. Und `reload`, nicht
// `restart`: bestehende Sitzungen bleiben, auch die, ueber die dieser
// Schritt gerade selbst spricht.
expect($s['shell']->ran('sshd -t'))->toBeTrue()
->and($s['shell']->ran('systemctl reload ssh'))->toBeTrue()
->and($s['shell']->ran('systemctl restart ssh'))->toBeFalse();
});
it('skips ssh trust once the host key is already pinned', function () {
$s = fakeServices();
$host = Host::factory()->create(['ssh_host_key' => 'SHA256:pinned']);

View File

@ -140,6 +140,9 @@ it('sends every check to the tab or page where its field actually lives', functi
'delivery.mailbox' => 'mail',
'delivery.mail_templates' => 'templates',
'delivery.inbound_mail_password' => 'services',
'security.console_restricted' => 'security',
'security.trusted_ranges_clean' => 'security',
'security.ports_loopback' => 'env',
'operation.scheduler' => 'env',
'operation.queue_provisioning' => 'env',
];