chore(audit): full code sweep — fix open-redirect, drop dead code, clean docs

Full-codebase audit + automatic cleanup (no behavioural change for users).

Security
- ServerSwitcher: post-switch redirect is now reduced to a SAME-ORIGIN
  relative path. Rejects off-host referers, non-http(s) schemes
  (javascript:/data:), different ports (https://host:444/x -> /x), and
  protocol-relative tricks (//host, /\host). Closes a Referer open-redirect
  (Codex P2).
- EditCredential: a key passphrase is stored only for key auth, never for
  password auth.

Removed dead code
- FirewallService::status/allow/deny (+ orphaned clampPort)
- Server::auditEvents(), SshCredential::scopeActive(), unused 'bell' icon

Cleanups / improvements
- Files list + editor use a dedicated 'file' icon (was the audit icon)
- Import RuntimeException/Throwable instead of FQN; fix stale docblocks
  (FirewallService, HardeningService) and the Files delete comment
- Quote base64 in the sudo command path (defensive, consistent)
- Null-safe Auth::user()?->email in Settings; single domain() read in hasTls()

Release
- Bump 0.1.0 -> 0.1.1 + CHANGELOG entry

Verified: Pint clean, Codex review clean (no actionable regressions),
R12 browser check — 9 routes HTTP 200, 0 console errors, lazy pages loaded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation v0.1.1
boban 2026-06-13 12:54:26 +02:00
parent 05f8ce49b4
commit 0f57074e6f
22 changed files with 105 additions and 87 deletions

View File

@ -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

View File

@ -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);

View File

@ -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()

View File

@ -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
{

View File

@ -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([

View File

@ -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);

View File

@ -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(),

View File

@ -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()

View File

@ -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'];

View File

@ -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.',
],

View File

@ -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);
}
}

View File

@ -35,9 +35,4 @@ class SshCredential extends Model
{
return $this->disabled_at !== null;
}
public function scopeActive($query)
{
return $query->whereNull('disabled_at');
}
}

View File

@ -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 !== '';
}
/**

View File

@ -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<int, string>}
*/
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));
}
}

View File

@ -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 = [];

View File

@ -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
}

View File

@ -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']);

View File

@ -1,5 +1,6 @@
<?php
use App\Http\Middleware\SecurityHeaders;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
@ -13,7 +14,7 @@ return Application::configure(basePath: dirname(__DIR__))
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->web(append: [
\App\Http\Middleware\SecurityHeaders::class,
SecurityHeaders::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {

View File

@ -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',

View File

@ -14,9 +14,9 @@
'cpu' => '<rect width="16" height="16" x="4" y="4" rx="2"/><rect width="6" height="6" x="9" y="9" rx="1"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/>',
'folder' => '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
'audit' => '<path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/>',
'file' => '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>',
'activity' => '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
'switcher' => '<path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/>',
'bell' => '<path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/>',
'search' => '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
'shield' => '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>',
'plus' => '<path d="M5 12h14"/><path d="M12 5v14"/>',

View File

@ -86,7 +86,7 @@
'bg-accent/10 text-accent-text' => $isDir,
'bg-raised text-ink-3' => ! $isDir,
])>
<x-icon :name="$isDir ? 'folder' : 'audit'" class="h-4 w-4" />
<x-icon :name="$isDir ? 'folder' : 'file'" class="h-4 w-4" />
</span>
@if ($isDir)

View File

@ -1,7 +1,7 @@
<div class="p-5 sm:p-6" wire:init="load">
<div class="flex items-start gap-3.5">
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-md border border-accent/30 bg-accent/10 text-accent-text">
<x-icon name="audit" class="h-5 w-5" />
<x-icon name="file" class="h-5 w-5" />
</span>
<div class="min-w-0 flex-1">
<h2 class="truncate font-display text-base font-semibold text-ink">{{ $name }}</h2>