diff --git a/CHANGELOG.md b/CHANGELOG.md index afb4834..e96ce52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,28 @@ getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Bui _Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._ +## [0.1.1] - 2026-06-13 + +Wartungs-Release: vollständige Code-Auditierung, Entfernung von totem Code und ein behobenes Open-Redirect. Keine funktionalen Änderungen für Nutzer. + +### Behoben +- Open-Redirect im Server-Switcher: ein manipulierter `Referer` konnte die + Weiterleitung nach dem Server-Wechsel auf eine fremde Adresse lenken. Die + Weiterleitung wird jetzt auf einen **gleich-origin** relativen Pfad reduziert + (fremde Schemata wie `javascript:`/`data:`, andere Hosts/Ports sowie + protokoll-relative Tricks `//host` / `/\host` werden abgewiesen). +- SSH-Zugang: eine Passphrase wurde unabhängig vom Auth-Typ gespeichert; sie wird + jetzt nur noch bei Schlüssel-Authentifizierung hinterlegt. + +### Geändert +- Datei-Manager + Editor verwenden ein eigenes `file`-Symbol statt des + Audit-Symbols (semantisch korrekt). +- Code-Hygiene: toten Code entfernt (ungenutzte `FirewallService`-Methoden + `status/allow/deny`, `Server::auditEvents()`, `SshCredential::scopeActive()`, + ungenutztes `bell`-Icon), veraltete Doc-Blöcke/Kommentare korrigiert, + Exceptions sauber importiert, base64-Argument im Sudo-Pfad defensiv gequotet, + Null-sichere Nutzerabfrage in den Einstellungen. Pint + Codex-Review sauber. + ## [0.1.0] - 2026-06-13 Erste geschnittene Version — die komplette v1-Basis inklusive Server- und Panel-Härtung als ein Release. @@ -57,5 +79,6 @@ Erste geschnittene Version — die komplette v1-Basis inklusive Server- und Pane - Release-/Versionierungsmodell: echte semantische Versionen + Git-Tags, Changelog pro Version, Kanäle `stable`/`beta` (kein nutzer-seitiges `dev`). -[Unreleased]: https://git.bave.dev/boban/clusev/compare/v0.1.0...HEAD +[Unreleased]: https://git.bave.dev/boban/clusev/compare/v0.1.1...HEAD +[0.1.1]: https://git.bave.dev/boban/clusev/compare/v0.1.0...v0.1.1 [0.1.0]: https://git.bave.dev/boban/clusev/releases/tag/v0.1.0 diff --git a/app/Livewire/Auth/TwoFactorChallenge.php b/app/Livewire/Auth/TwoFactorChallenge.php index 584dd8e..df8e314 100644 --- a/app/Livewire/Auth/TwoFactorChallenge.php +++ b/app/Livewire/Auth/TwoFactorChallenge.php @@ -45,7 +45,7 @@ class TwoFactorChallenge extends Component throw ValidationException::withMessages(['code' => 'Sitzung abgelaufen. Bitte erneut anmelden.']); } - $valid = (new Google2FA())->verifyKey($user->two_factor_secret, preg_replace('/\s+/', '', $this->code)); + $valid = (new Google2FA)->verifyKey($user->two_factor_secret, preg_replace('/\s+/', '', $this->code)); if (! $valid) { RateLimiter::hit($key, 60); diff --git a/app/Livewire/Auth/TwoFactorSetup.php b/app/Livewire/Auth/TwoFactorSetup.php index affea45..2befb40 100644 --- a/app/Livewire/Auth/TwoFactorSetup.php +++ b/app/Livewire/Auth/TwoFactorSetup.php @@ -25,14 +25,14 @@ class TwoFactorSetup extends Component return $this->redirect(route('dashboard'), navigate: true); } - $this->secret = (new Google2FA())->generateSecretKey(); + $this->secret = (new Google2FA)->generateSecretKey(); } public function confirm() { $this->validate(); - if (! (new Google2FA())->verifyKey($this->secret, preg_replace('/\s+/', '', $this->code))) { + if (! (new Google2FA)->verifyKey($this->secret, preg_replace('/\s+/', '', $this->code))) { throw ValidationException::withMessages(['code' => 'Code stimmt nicht — prüfe die Uhrzeit der Authenticator-App.']); } @@ -46,7 +46,7 @@ class TwoFactorSetup extends Component public function qrCode(): string { - return (new Google2FA())->getQRCodeInline('Clusev', Auth::user()->email, $this->secret); + return (new Google2FA)->getQRCodeInline('Clusev', Auth::user()->email, $this->secret); } public function render() diff --git a/app/Livewire/Files/Index.php b/app/Livewire/Files/Index.php index 3f90389..f6b55e3 100644 --- a/app/Livewire/Files/Index.php +++ b/app/Livewire/Files/Index.php @@ -114,7 +114,7 @@ class Index extends Component return null; } - return response()->streamDownload(fn () => print($content), basename($name)); + return response()->streamDownload(fn () => print ($content), basename($name)); } /** Open the view/edit modal for a file. */ @@ -162,7 +162,8 @@ class Index extends Component /** * Delete a file (R5): opens the confirm modal, which writes the AuditEvent - * and re-dispatches `fileConfirmed`. SFTP unlink is wired in behind this later. + * and re-dispatches `fileConfirmed` — handled by deleteEntry(), which performs + * the SFTP unlink over the active server's connection. */ public function confirmDelete(int $index): void { diff --git a/app/Livewire/Modals/EditCredential.php b/app/Livewire/Modals/EditCredential.php index 2c8a12a..2978e4e 100644 --- a/app/Livewire/Modals/EditCredential.php +++ b/app/Livewire/Modals/EditCredential.php @@ -70,7 +70,8 @@ class EditCredential extends ModalComponent 'username' => trim($this->username), 'auth_type' => $this->authType === 'key' ? 'key' : 'password', 'secret' => $this->secret, - 'passphrase' => $this->passphrase !== '' ? $this->passphrase : null, + // A passphrase only applies to a private key; never store one for password auth. + 'passphrase' => ($this->authType === 'key' && $this->passphrase !== '') ? $this->passphrase : null, ]); AuditEvent::create([ diff --git a/app/Livewire/Modals/FileEditor.php b/app/Livewire/Modals/FileEditor.php index 995a34d..fd8a789 100644 --- a/app/Livewire/Modals/FileEditor.php +++ b/app/Livewire/Modals/FileEditor.php @@ -38,6 +38,11 @@ class FileEditor extends ModalComponent $this->name = $name; } + public static function modalMaxWidth(): string + { + return 'lg'; + } + public function load(FleetService $fleet): void { $server = Server::find($this->serverId); diff --git a/app/Livewire/Modals/HardeningAction.php b/app/Livewire/Modals/HardeningAction.php index 076d0cd..3175605 100644 --- a/app/Livewire/Modals/HardeningAction.php +++ b/app/Livewire/Modals/HardeningAction.php @@ -6,6 +6,7 @@ use App\Models\AuditEvent; use App\Models\Server; use App\Services\HardeningService; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Str; use LivewireUI\Modal\ModalComponent; use Throwable; @@ -91,7 +92,7 @@ class HardeningAction extends ModalComponent $this->done = true; $this->ok = $result['ok']; // Clean result only — no raw command output on success; a short reason on failure. - $this->output = $this->ok ? '' : \Illuminate\Support\Str::limit(trim($result['output']) ?: 'Unbekannter Fehler.', 200); + $this->output = $this->ok ? '' : Str::limit(trim($result['output']) ?: 'Unbekannter Fehler.', 200); AuditEvent::create([ 'user_id' => Auth::id(), diff --git a/app/Livewire/ServerSwitcher.php b/app/Livewire/ServerSwitcher.php index 0226e17..fd29a30 100644 --- a/app/Livewire/ServerSwitcher.php +++ b/app/Livewire/ServerSwitcher.php @@ -18,7 +18,46 @@ class ServerSwitcher extends Component session(['active_server_id' => $server->id]); - $this->redirect(request()->header('Referer') ?: route('dashboard'), navigate: true); + $this->redirect($this->safeReturnUrl(), navigate: true); + } + + /** + * Return a SAME-ORIGIN relative target derived from the Referer, or the + * dashboard. An attacker-supplied Referer must never turn a server switch + * into an open redirect, so we: + * 1. reject any Referer whose host differs or whose scheme is not http(s) + * (blocks javascript:/data: and off-site hosts), then + * 2. keep ONLY the path + query and redirect to that rooted relative URL — + * so the result can never carry a foreign scheme, host, or port (e.g. + * `https://host:444/x` collapses to `/x` on the current origin), and + * 3. reject protocol-relative (`//host`, `/\host`) and non-rooted values. + */ + private function safeReturnUrl(): string + { + $referer = request()->header('Referer'); + $fallback = route('dashboard'); + + if (! is_string($referer) || $referer === '') { + return $fallback; + } + + $parts = parse_url($referer); + if ($parts === false + || ! isset($parts['host']) + || strtolower($parts['host']) !== strtolower(request()->getHost()) + || (isset($parts['scheme']) && ! in_array(strtolower($parts['scheme']), ['http', 'https'], true))) { + return $fallback; + } + + $target = ($parts['path'] ?? '/').(isset($parts['query']) ? '?'.$parts['query'] : ''); + + // Must be a rooted, host-relative path; reject protocol-relative tricks. + if ($target === '' || $target[0] !== '/' + || (isset($target[1]) && ($target[1] === '/' || $target[1] === '\\'))) { + return $fallback; + } + + return $target; } public function render() diff --git a/app/Livewire/Services/Index.php b/app/Livewire/Services/Index.php index aa9849f..e70103c 100644 --- a/app/Livewire/Services/Index.php +++ b/app/Livewire/Services/Index.php @@ -4,6 +4,7 @@ namespace App\Livewire\Services; use App\Livewire\Concerns\WithFleetContext; use App\Services\FleetService; +use Illuminate\Support\Str; use Livewire\Attributes\Layout; use Livewire\Attributes\On; use Livewire\Attributes\Title; @@ -111,7 +112,7 @@ class Index extends Component $this->dispatch('notify', message: $res['ok'] ? "{$name}: {$op} ausgeführt." - : "{$name}: fehlgeschlagen — ".\Illuminate\Support\Str::limit($res['output'] ?: 'keine Rechte', 90)); + : "{$name}: fehlgeschlagen — ".Str::limit($res['output'] ?: 'keine Rechte', 90)); try { $this->services = $fleet->systemd($active)['services']; diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php index 7de5a1e..df5dcf3 100644 --- a/app/Livewire/Settings/Index.php +++ b/app/Livewire/Settings/Index.php @@ -69,7 +69,7 @@ class Index extends Component 'danger' => true, 'icon' => 'shield', 'auditAction' => 'two_factor.disable', - 'auditTarget' => Auth::user()->email, + 'auditTarget' => Auth::user()?->email, 'event' => 'twoFactorDisabled', 'notify' => '2FA deaktiviert.', ], diff --git a/app/Models/Server.php b/app/Models/Server.php index 4a8771e..ed2aa6e 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -3,7 +3,6 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Support\Str; @@ -31,9 +30,4 @@ class Server extends Model { return $this->hasOne(SshCredential::class); } - - public function auditEvents(): HasMany - { - return $this->hasMany(AuditEvent::class); - } } diff --git a/app/Models/SshCredential.php b/app/Models/SshCredential.php index 0d528a1..350d2ae 100644 --- a/app/Models/SshCredential.php +++ b/app/Models/SshCredential.php @@ -35,9 +35,4 @@ class SshCredential extends Model { return $this->disabled_at !== null; } - - public function scopeActive($query) - { - return $query->whereNull('disabled_at'); - } } diff --git a/app/Services/DeploymentService.php b/app/Services/DeploymentService.php index 1e03334..e6f44c4 100644 --- a/app/Services/DeploymentService.php +++ b/app/Services/DeploymentService.php @@ -52,7 +52,9 @@ class DeploymentService /** True once a domain is configured — i.e. TLS via Let's Encrypt is in effect. */ public function hasTls(): bool { - return $this->domain() !== null && $this->domain() !== ''; + $domain = $this->domain(); + + return $domain !== null && $domain !== ''; } /** diff --git a/app/Services/FirewallService.php b/app/Services/FirewallService.php index 66582ab..80a46aa 100644 --- a/app/Services/FirewallService.php +++ b/app/Services/FirewallService.php @@ -11,37 +11,12 @@ use App\Models\Server; * Port (detected from sshd_config, default 22) plus 80/443 BEFORE turning the * firewall on — so enabling UFW can never sever the operator's own SSH session. * - * status()/preview() are read-only command builders; the mutating methods all - * return array{ok: bool, output: string, preview: string}. + * Both mutating methods return array{ok: bool, output: string}. */ class FirewallService { public function __construct(private FleetService $fleet) {} - /** - * Parse `ufw status verbose` into a structured snapshot. - * - * @return array{active: bool, raw: string, rules: array} - */ - public function status(Server $server): array - { - $res = $this->fleet->runPrivileged($server, 'ufw status verbose 2>&1'); - $raw = $res['output']; - - $active = (bool) preg_match('/Status:\s*active/i', $raw); - - $rules = []; - foreach (preg_split('/\R/', $raw) as $line) { - $line = trim($line); - // rule lines carry an action token (ALLOW/DENY/REJECT/LIMIT) - if ($line !== '' && preg_match('/\b(ALLOW|DENY|REJECT|LIMIT)\b/i', $line)) { - $rules[] = $line; - } - } - - return ['active' => $active, 'raw' => $raw, 'rules' => $rules]; - } - /** * GUARD: allow the real sshd Port + 80/443 first, THEN enable UFW. Never * enable a firewall that would drop the current SSH session. Installs ufw if missing. @@ -64,22 +39,6 @@ class FirewallService return ['ok' => $res['ok'], 'output' => $res['output']]; } - /** Allow a TCP port through the firewall. @return array{ok: bool, output: string} */ - public function allow(Server $server, int $port): array - { - $res = $this->fleet->runPrivileged($server, 'ufw allow '.$this->clampPort($port).'/tcp'); - - return ['ok' => $res['ok'], 'output' => $res['output']]; - } - - /** Deny a TCP port through the firewall. @return array{ok: bool, output: string} */ - public function deny(Server $server, int $port): array - { - $res = $this->fleet->runPrivileged($server, 'ufw deny '.$this->clampPort($port).'/tcp'); - - return ['ok' => $res['ok'], 'output' => $res['output']]; - } - /** Build the guarded enable script: install ufw if missing, open ssh+80+443, then enable. */ private function enableScript(int $port): string { @@ -103,10 +62,4 @@ class FirewallService return ($res['ok'] && $port >= 1 && $port <= 65535) ? $port : 22; } - - /** Keep a port in the valid TCP range. */ - private function clampPort(int $port): int - { - return max(1, min(65535, $port)); - } } diff --git a/app/Services/FleetService.php b/app/Services/FleetService.php index 1cf6120..04d9b40 100644 --- a/app/Services/FleetService.php +++ b/app/Services/FleetService.php @@ -47,7 +47,7 @@ class FleetService if ($password !== '') { $b64 = base64_encode($password."\n"); - $branch = 'echo '.$b64.' | base64 -d | sudo -S -p \'\' "$@"'; + $branch = 'echo \''.$b64.'\' | base64 -d | sudo -S -p \'\' "$@"'; } else { $branch = 'sudo -n "$@"'; } @@ -70,7 +70,7 @@ class FleetService try { [$out, $code] = $ssh->run( 'export LC_ALL=C; '.$this->sudoFn($server). - 'priv sh -c "$(printf %s '.$b64.' | base64 -d)" 2>&1' + 'priv sh -c "$(printf %s \''.$b64.'\' | base64 -d)" 2>&1' ); return ['ok' => $code === 0, 'output' => trim($out)]; @@ -90,7 +90,7 @@ class FleetService $ssh = $this->client($server, $timeout); try { - [$out, $code] = $ssh->run('export LC_ALL=C; sh -c "$(printf %s '.$b64.' | base64 -d)" 2>&1'); + [$out, $code] = $ssh->run('export LC_ALL=C; sh -c "$(printf %s \''.$b64.'\' | base64 -d)" 2>&1'); return ['ok' => $code === 0, 'output' => trim($out)]; } finally { @@ -668,7 +668,6 @@ class FleetService return $out; } - private function parseKeys(string $body): array { $out = []; diff --git a/app/Services/HardeningService.php b/app/Services/HardeningService.php index 2051023..afd0d99 100644 --- a/app/Services/HardeningService.php +++ b/app/Services/HardeningService.php @@ -4,15 +4,17 @@ namespace App\Services; use App\Models\Server; use InvalidArgumentException; +use RuntimeException; +use Throwable; /** * Applies the dashboard server-hardening toggles over SSH (as root). Every item is * BIDIRECTIONAL — apply($server, $action, $enable) toggles the underlying feature * on/off; package items (fail2ban, unattended-upgrades, UFW) install on demand. * - * preview() returns the EXACT command apply() runs (single source via commandFor()), - * so the operator always confirms precisely what executes. Disabling SSH password - * auth is GUARDED so the operator can never lock themselves out. + * apply() runs the exact command built by commandFor() (the single source of truth); + * title()/description() supply the German confirm-modal copy for each direction. + * Disabling SSH password auth is GUARDED so the operator can never lock themselves out. */ class HardeningService { @@ -45,7 +47,7 @@ class HardeningService $res = $this->fleet->runPrivileged($server, $cmd); if (! $res['ok']) { - throw new \RuntimeException('Härtungs-Status konnte nicht gelesen werden.'); + throw new RuntimeException('Härtungs-Status konnte nicht gelesen werden.'); } return $this->parseState($res['output']); @@ -184,7 +186,7 @@ class HardeningService if (count($this->fleet->sshKeys($server)) > 0) { return true; } - } catch (\Throwable) { + } catch (Throwable) { // fall through to the raw check } diff --git a/app/Services/MaintenanceService.php b/app/Services/MaintenanceService.php index d8eeb10..3b8f9e0 100644 --- a/app/Services/MaintenanceService.php +++ b/app/Services/MaintenanceService.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Models\Server; +use RuntimeException; /** * Debian/Ubuntu (apt) maintenance helpers: counts pending package updates, @@ -85,7 +86,7 @@ class MaintenanceService $res = $this->fleet->runPrivileged($server, $cmd); if (! str_contains($res['output'], 'CLUSEV_READ_OK')) { - throw new \RuntimeException('fail2ban-Konfiguration konnte nicht gelesen werden.'); + throw new RuntimeException('fail2ban-Konfiguration konnte nicht gelesen werden.'); } return $this->parseFail2banDefaults($res['output']); diff --git a/bootstrap/app.php b/bootstrap/app.php index ec84274..775adfd 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,6 @@ withMiddleware(function (Middleware $middleware): void { $middleware->web(append: [ - \App\Http\Middleware\SecurityHeaders::class, + SecurityHeaders::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/config/clusev.php b/config/clusev.php index b53750a..c2f3618 100644 --- a/config/clusev.php +++ b/config/clusev.php @@ -3,7 +3,7 @@ return [ // First tagged release is v0.1.0 (semantic, not -dev). The live build hash // is resolved from .git at runtime (see App\Livewire\Versions\Index). - 'version' => '0.1.0', + 'version' => '0.1.1', // Default user channel. Only 'stable' and 'beta' are ever offered to users. 'channel' => 'stable', diff --git a/resources/views/components/icon.blade.php b/resources/views/components/icon.blade.php index 129d9b0..e311e6d 100644 --- a/resources/views/components/icon.blade.php +++ b/resources/views/components/icon.blade.php @@ -14,9 +14,9 @@ 'cpu' => '', 'folder' => '', 'audit' => '', + 'file' => '', 'activity' => '', 'switcher' => '', - 'bell' => '', 'search' => '', 'shield' => '', 'plus' => '', diff --git a/resources/views/livewire/files/index.blade.php b/resources/views/livewire/files/index.blade.php index 3d69a8e..991dc9e 100644 --- a/resources/views/livewire/files/index.blade.php +++ b/resources/views/livewire/files/index.blade.php @@ -86,7 +86,7 @@ 'bg-accent/10 text-accent-text' => $isDir, 'bg-raised text-ink-3' => ! $isDir, ])> - + @if ($isDir) diff --git a/resources/views/livewire/modals/file-editor.blade.php b/resources/views/livewire/modals/file-editor.blade.php index de44e2f..a5b1a3a 100644 --- a/resources/views/livewire/modals/file-editor.blade.php +++ b/resources/views/livewire/modals/file-editor.blade.php @@ -1,7 +1,7 @@
- +

{{ $name }}