From bc2f2b527f7686c528ef028b4e7484be05fc3420 Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 5 Jul 2026 16:05:16 +0200 Subject: [PATCH] fix(security): close file-read RBAC gap + fail2ban arg-injection + update/HMAC hardening Confirmed-security quick-wins from a full re-audit (6-agent subsystem fan-out, Codex-reviewed). Each item ships with a regression test proven to fail pre-fix. - files: gate file CONTENT reads (download / edit / FileEditor::load) behind the `operate` ability so a read-only viewer keeps LISTING/browse access but can no longer pull file bytes over the server credential (root -> /etc/shadow, keys, .env). Hide the download/edit/delete controls from viewers in the blade, and basename() Files::open() for parity with the other path builders. - fail2ban: reject a leading dash/dot in validJail(). A jail name like "-h" or "--help" was parsed by fail2ban-client as an OPTION (argument-injection) rather than a positional jail; internal dashes ("nginx-http-auth") still pass. - bruteforce: release the atomically-claimed ban-audit dedup slot when the AuditEvent write throws, so a transient DB failure no longer suppresses the auth.ip_banned audit for the whole 60s window (the ban itself is unaffected). - update path: verify_update_request now FAILS CLOSED when UPDATE_HMAC_KEY is unset (was fail-open -> any ./run marker could drive a root update). Pin the self-update `git pull` to the recorded CLUSEV_BUILD_BRANCH and refuse a diverged checkout, so a `git checkout` in the tree cannot redirect the next root update. - wireguard: normalise the DNS input to a single comma list and QUOTE the WG_DNS assignment in wg.env; an unquoted space previously split the assignment and broke the next `. "$WG_ENV"`. 13 new regression tests. 614 tests, Pint, shellcheck, Codex review all green. Co-Authored-By: Claude Fable 5 --- app/Livewire/Files/Index.php | 15 ++- app/Livewire/Modals/FileEditor.php | 5 + app/Services/BruteforceGuard.php | 27 +++-- app/Services/Fail2banService.php | 7 +- docker/restart-sentinel/watch.sh | 6 +- docker/wg/clusev-wg.sh | 11 ++- .../views/livewire/files/index.blade.php | 37 ++++--- tests/Feature/BruteforceGuardBanTest.php | 46 +++++++++ tests/Feature/Fail2banJailValidationTest.php | 99 +++++++++++++++++++ tests/Feature/RbacOperateGateTest.php | 89 +++++++++++++++++ tests/Feature/RbacUiHidingTest.php | 42 ++++++++ update.sh | 18 +++- 12 files changed, 370 insertions(+), 32 deletions(-) create mode 100644 tests/Feature/Fail2banJailValidationTest.php diff --git a/app/Livewire/Files/Index.php b/app/Livewire/Files/Index.php index fda2e35..8980319 100644 --- a/app/Livewire/Files/Index.php +++ b/app/Livewire/Files/Index.php @@ -60,7 +60,11 @@ class Index extends Component return; } - $this->path = rtrim($this->path, '/').'/'.$name; + // basename() for parity with download()/edit()/confirmDelete(): a listing entry + // name is a single path segment, never a traversal fragment. $entries is a public, + // non-#[Locked] property, so a crafted /livewire/update could otherwise set name to + // ../.. and walk outside the browsed tree. + $this->path = rtrim($this->path, '/').'/'.basename($name); $this->load(); } @@ -102,6 +106,11 @@ class Index extends Component /** Stream a remote file to the browser as a download (SFTP get). */ public function download(int $index, FleetService $fleet) { + // Reading a file's BYTES over the server credential (often root) discloses secrets a + // viewer must not reach (/etc/shadow, keys, .env). Browsing the LISTING stays open; + // pulling content requires operate, like every other file operation. + abort_unless(auth()->user()?->can('operate'), 403); + $name = $this->entries[$index]['name'] ?? null; $active = $this->activeServer(); if ($name === null || ! $active || ! $active->credential_exists) { @@ -122,6 +131,10 @@ class Index extends Component /** Open the view/edit modal for a file. */ public function edit(int $index): void { + // The editor reads (and can write) file content — an operate action. Gate here so a + // viewer never even opens the modal; FileEditor::load() re-checks on the read itself. + abort_unless(auth()->user()?->can('operate'), 403); + $name = $this->entries[$index]['name'] ?? null; $active = $this->activeServer(); if ($name === null || ! $active) { diff --git a/app/Livewire/Modals/FileEditor.php b/app/Livewire/Modals/FileEditor.php index c22cee3..8118a4e 100644 --- a/app/Livewire/Modals/FileEditor.php +++ b/app/Livewire/Modals/FileEditor.php @@ -78,6 +78,11 @@ class FileEditor extends ModalComponent public function load(FleetService $fleet, Sftp $sftp): void { + // The actual content read (SFTP get / readFile). Gate it directly — not just the + // Files::edit entry point — so file bytes the server credential can reach (root → + // /etc/shadow, keys, .env) are never disclosed to a read-only viewer. + abort_unless(auth()->user()?->can('operate'), 403); + $server = Server::find($this->serverId); if (! $server) { $this->error = __('common.server_not_found'); diff --git a/app/Services/BruteforceGuard.php b/app/Services/BruteforceGuard.php index de2a539..67c8ff7 100644 --- a/app/Services/BruteforceGuard.php +++ b/app/Services/BruteforceGuard.php @@ -158,15 +158,24 @@ class BruteforceGuard // honeypot ban) is still audited. A short 60s TTL (matching record()'s ban dedup) collapses the // rapid scanner flood WITHOUT masking a legitimate later re-ban — e.g. after an operator unban, // which is minutes of human time later, well past this window (so it is not tied to unban()). - if (Cache::add('bruteforce:banaudit:'.$canonical.':'.$reason, 1, 60)) { - AuditEvent::create([ - 'user_id' => null, - 'actor' => 'system', - 'action' => 'auth.ip_banned', - 'target' => $canonical, - 'ip' => $canonical, - 'meta' => ['reason' => $reason], - ]); + $auditKey = 'bruteforce:banaudit:'.$canonical.':'.$reason; + if (Cache::add($auditKey, 1, 60)) { + try { + AuditEvent::create([ + 'user_id' => null, + 'actor' => 'system', + 'action' => 'auth.ip_banned', + 'target' => $canonical, + 'ip' => $canonical, + 'meta' => ['reason' => $reason], + ]); + } catch (\Throwable $e) { + // The Cache::add above atomically CLAIMED the dedup slot before the write. If the + // write throws (a transient DB blip), release the slot so the 60s window is not + // burned with no audit row — the next attempt re-claims and re-audits this ban. + Cache::forget($auditKey); + throw $e; + } } return true; diff --git a/app/Services/Fail2banService.php b/app/Services/Fail2banService.php index 21fca9f..363fa13 100644 --- a/app/Services/Fail2banService.php +++ b/app/Services/Fail2banService.php @@ -398,7 +398,12 @@ class Fail2banService private function validJail(string $jail): bool { - return (bool) preg_match('/^[A-Za-z0-9._-]+$/', $jail); + // Must START with an alphanumeric/underscore: a leading '-' would be parsed by + // fail2ban-client as an OPTION (e.g. "-h", "--help") rather than a jail argument, + // and a leading '.' has no legitimate jail use. Internal '.-_' stay allowed so real + // names like "nginx-http-auth" pass. (Shell metacharacters are already inert via the + // base64 transport in runPrivileged; this closes the argument-injection confusion.) + return (bool) preg_match('/^[A-Za-z0-9_][A-Za-z0-9._-]*$/', $jail); } private function validIpOrCidr(string $v): bool diff --git a/docker/restart-sentinel/watch.sh b/docker/restart-sentinel/watch.sh index 4de6faf..d04f74f 100755 --- a/docker/restart-sentinel/watch.sh +++ b/docker/restart-sentinel/watch.sh @@ -66,7 +66,11 @@ write_update_error() { verify_update_request() { local f="$1" key ts mac expect key="$(grep -m1 '^UPDATE_HMAC_KEY=' "${CLUSEV_DIR}/.env" 2>/dev/null | cut -d= -f2-)" - [ -n "$key" ] || { log "UPDATE_HMAC_KEY unset — skipping request authentication"; return 0; } + # Fail CLOSED: with no key the marker cannot be authenticated, so refuse rather than run a + # ROOT update on an unauthenticated request. install.sh always backfills UPDATE_HMAC_KEY + # (set_kv), so a real install has one; a missing key means a broken/hand-edited .env — the + # operator re-runs install.sh (or sets the key) to restore dashboard-triggered updates. + [ -n "$key" ] || { log "UPDATE_HMAC_KEY unset — REFUSING update request (re-run install.sh to backfill the key)"; return 1; } ts="$(sed -n '1p' "$f" 2>/dev/null)" mac="$(sed -n '2p' "$f" 2>/dev/null)" [ -n "$ts" ] && [ -n "$mac" ] || { log "update request unsigned/malformed — refusing"; return 1; } diff --git a/docker/wg/clusev-wg.sh b/docker/wg/clusev-wg.sh index 3b62d57..61097f6 100755 --- a/docker/wg/clusev-wg.sh +++ b/docker/wg/clusev-wg.sh @@ -509,14 +509,19 @@ _set_endpoint() { _set_dns() { local dns="$1"; require_setup - # One or more IPv4 addresses, comma/space separated (e.g. "10.0.0.1" or "1.1.1.1, 1.0.0.1"). + # One or more IPv4 addresses, comma/space separated on INPUT (e.g. "10.0.0.1" or "1.1.1.1, 1.0.0.1"). # Only affects NEW peer configs (the DNS line is baked into each client config at creation). case "$dns" in *[!0-9.,\ ]*|'') echo "bad dns" >&2; return 1 ;; esac printf '%s' "$dns" | grep -qE '^[0-9]{1,3}(\.[0-9]{1,3}){3}([,[:space:]]+[0-9]{1,3}(\.[0-9]{1,3}){3})*$' || { echo "bad dns" >&2; return 1; } + # Normalise every comma/space run to a single comma before STORING: WireGuard's peer `DNS =` + # line wants a comma list, and — critically — an unquoted space in wg.env would split the + # WG_DNS assignment so the next `. "$WG_ENV"` runs the second address as a command. Store as a + # single comma-separated token and QUOTE the assignment (defence-in-depth) so sourcing is safe. + dns="$(printf '%s' "$dns" | tr -s ',[:space:]' ',' | sed -e 's/^,//' -e 's/,$//')" if grep -q '^WG_DNS=' "$WG_ENV" 2>/dev/null; then - sed -i "s#^WG_DNS=.*#WG_DNS=${dns}#" "$WG_ENV" + sed -i "s#^WG_DNS=.*#WG_DNS=\"${dns}\"#" "$WG_ENV" else - printf 'WG_DNS=%s\n' "$dns" >> "$WG_ENV" # older wg.env (pre-DNS) has no line yet + printf 'WG_DNS="%s"\n' "$dns" >> "$WG_ENV" # older wg.env (pre-DNS) has no line yet fi } diff --git a/resources/views/livewire/files/index.blade.php b/resources/views/livewire/files/index.blade.php index 8459934..a5f5a7c 100644 --- a/resources/views/livewire/files/index.blade.php +++ b/resources/views/livewire/files/index.blade.php @@ -101,16 +101,22 @@ {{ $e['name'] }}/ @else - {{-- Filename opens the editor modal too — inline modalTrigger keeps the - link layout but adds the pending state + timeout error toast. --}} - + @can('operate') + {{-- Filename opens the editor modal too — inline modalTrigger keeps the + link layout but adds the pending state + timeout error toast. + operate-gated: reading content is not a viewer capability. --}} + + @else + {{-- Viewer: read-only listing — the filename is not a content-read trigger. --}} + {{ $e['name'] }} + @endcan @endif @@ -128,13 +134,14 @@ {{ $e['modified'] }}

- {{-- Row actions --}} + {{-- Row actions — content read (download/edit) and delete all require operate; + a viewer only browses the listing, so it sees no row actions. --}}
- @unless ($isDir) - {{ __('files.download') }} - {{ __('common.edit') }} - @endunless @can('operate') + @unless ($isDir) + {{ __('files.download') }} + {{ __('common.edit') }} + @endunless {{ __('common.delete') }} @endcan
diff --git a/tests/Feature/BruteforceGuardBanTest.php b/tests/Feature/BruteforceGuardBanTest.php index afad66a..87f9197 100644 --- a/tests/Feature/BruteforceGuardBanTest.php +++ b/tests/Feature/BruteforceGuardBanTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature; +use App\Models\AuditEvent; use App\Models\BannedIp; use App\Models\Setting; use App\Services\BruteforceGuard; @@ -81,4 +82,49 @@ class BruteforceGuardBanTest extends TestCase $g->record('203.0.113.5', 'login'); // ban via plain IPv4 $this->assertTrue($g->isBanned('::ffff:203.0.113.5')); // mapped form is the same ban } + + // ── banNow (honeypot first-hit ban) audit dedup ───────────────────────────── + + public function test_bannow_audits_once_per_reason_within_the_window(): void + { + $g = $this->guard(); + + $g->banNow('203.0.113.8', 'honeypot'); + $g->banNow('203.0.113.8', 'honeypot'); // same ip+reason inside 60s → the flood is deduped + $this->assertDatabaseCount('audit_events', 1); + + $g->banNow('203.0.113.8', 'honeytoken'); // a DISTINCT reason is still audited + $this->assertDatabaseCount('audit_events', 2); + } + + public function test_bannow_releases_the_audit_slot_when_the_write_fails(): void + { + $g = $this->guard(); + + // Make the FIRST audit insert throw (a transient DB blip), then heal. + $fail = true; + AuditEvent::creating(function () use (&$fail) { + if ($fail) { + $fail = false; + throw new \RuntimeException('db blip'); + } + }); + + // The ban row must still be written; the audit fails and re-throws (the honeypot + // caller swallows it). The dedup slot must NOT stay claimed after a failed write. + try { + $g->banNow('203.0.113.7', 'honeypot'); + } catch (\Throwable) { + // expected — banNow re-throws the audit failure + } + + $this->assertDatabaseHas('banned_ips', ['ip' => '203.0.113.7']); // ban persisted despite audit failure + $this->assertDatabaseCount('audit_events', 0); // audit did not land + $this->assertFalse(Cache::has('bruteforce:banaudit:203.0.113.7:honeypot')); // slot released, not burned + + // A retry inside the same 60s window now re-audits — the failure did not suppress it. + $g->banNow('203.0.113.7', 'honeypot'); + $this->assertDatabaseHas('audit_events', ['action' => 'auth.ip_banned', 'ip' => '203.0.113.7']); + $this->assertDatabaseCount('audit_events', 1); + } } diff --git a/tests/Feature/Fail2banJailValidationTest.php b/tests/Feature/Fail2banJailValidationTest.php new file mode 100644 index 0000000..b8554d4 --- /dev/null +++ b/tests/Feature/Fail2banJailValidationTest.php @@ -0,0 +1,99 @@ + banip ` is built by interpolating the jail name. The + * base64 transport in runPrivileged() neutralises SHELL metacharacters, but a jail name + * beginning with `-` (e.g. "-h", "--help") is still parsed by fail2ban-client as an + * OPTION rather than a positional jail argument. The jail reaches this sink from a + * client-controlled source — Fail2banBan::$jail (a wire:model property) and + * Fail2banBans::unbanIp($jail) (a method argument) — so validJail() must reject a + * leading dash (and dot) while still accepting real jail names that contain an internal + * dash, like "nginx-http-auth". + */ +class Fail2banJailValidationTest extends TestCase +{ + use RefreshDatabase; + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + private function server(): Server + { + return Server::create(['name' => 'box', 'ip' => '10.0.0.2', 'ssh_port' => 22, 'status' => 'online']); + } + + /** FleetService whose privileged exec must NEVER run (guard fires first). */ + private function fleetNeverRuns(): FleetService + { + $fleet = Mockery::mock(FleetService::class); + $fleet->shouldReceive('runPrivileged')->never(); + + return $fleet; + } + + private function service(FleetService $fleet, ?OsDetector $detector = null): Fail2banService + { + return new Fail2banService($fleet, $detector ?? Mockery::mock(OsDetector::class)); + } + + public function test_ban_rejects_a_jail_beginning_with_a_dash(): void + { + $res = $this->service($this->fleetNeverRuns())->ban($this->server(), '-h', '1.2.3.4'); + + $this->assertFalse($res['ok']); + $this->assertSame(__('backend.invalid_input'), $res['output']); + } + + public function test_unban_rejects_a_double_dash_option_jail(): void + { + $res = $this->service($this->fleetNeverRuns())->unban($this->server(), '--all', '1.2.3.4'); + + $this->assertFalse($res['ok']); + $this->assertSame(__('backend.invalid_input'), $res['output']); + } + + public function test_ban_rejects_a_jail_beginning_with_a_dot(): void + { + $res = $this->service($this->fleetNeverRuns())->ban($this->server(), '.evil', '1.2.3.4'); + + $this->assertFalse($res['ok']); + $this->assertSame(__('backend.invalid_input'), $res['output']); + } + + /** + * A real jail with an INTERNAL dash must still pass validation — proves the tightened + * regex is not over-broad. It gets PAST validJail and reaches support(), which we stub + * to report "unsupported"; the distinguishing signal is that the output is the support + * reason, NOT invalid_input (which would mean validJail wrongly rejected it). + */ + public function test_valid_jail_with_internal_dash_passes_validation(): void + { + // A real profile where fail2ban is unsupported (apk/openrc), so support() returns a reason. + $profile = new OsProfile('alpine', 'alpine', 'Alpine Linux', 'apk', 'none', 'openrc', []); + $reason = $profile->supports('fail2ban'); + $this->assertNotNull($reason); // sanity: this profile really reports unsupported + + $detector = Mockery::mock(OsDetector::class); + $detector->shouldReceive('detect')->andReturn($profile); + + $res = $this->service($this->fleetNeverRuns(), $detector) + ->unban($this->server(), 'nginx-http-auth', '1.2.3.4'); + + $this->assertFalse($res['ok']); + $this->assertSame($reason, $res['output']); // reached support(), not the validJail reject + } +} diff --git a/tests/Feature/RbacOperateGateTest.php b/tests/Feature/RbacOperateGateTest.php index f082fb7..c6f6693 100644 --- a/tests/Feature/RbacOperateGateTest.php +++ b/tests/Feature/RbacOperateGateTest.php @@ -238,6 +238,95 @@ class RbacOperateGateTest extends TestCase ->assertOk(); } + // ── operate: file CONTENT reads (download + editor view) ──────────────────── + // Browsing the LISTING (names/perms/size) stays open to a viewer — see the browse + // test below. But pulling a file's BYTES (SFTP get) discloses secrets the server's + // credential can read (/etc/shadow, keys, .env) when the credential is root, which is + // beyond "read-only". So the content reads — Files::download, Files::edit (opens the + // editor) and FileEditor::load (the actual read) — require operate, like the writes. + + public function test_viewer_cannot_download_a_file(): void + { + $this->actingAs($this->viewer()); + $this->activeServer(); + + $fleet = Mockery::mock(FleetService::class); + $fleet->shouldReceive('files')->andReturn([]); + $fleet->shouldReceive('getFile')->never(); // guard fires before the SFTP get + app()->instance(FleetService::class, $fleet); + + Livewire::test(Files::class) + ->set('entries', [['name' => 'shadow', 'type' => 'file', 'size' => 1, 'perms' => '-rw-------', 'owner' => 'root', 'modified' => 'now']]) + ->call('download', 0) + ->assertForbidden(); + } + + public function test_operator_can_download_a_file(): void + { + $this->actingAs($this->operator()); + $this->activeServer(); + + $fleet = Mockery::mock(FleetService::class); + $fleet->shouldReceive('files')->andReturn([]); + $fleet->shouldReceive('getFile')->once() + ->with(Mockery::type(Server::class), '/shadow') + ->andReturn('root:x:...'); + app()->instance(FleetService::class, $fleet); + + Livewire::test(Files::class) + ->set('entries', [['name' => 'shadow', 'type' => 'file', 'size' => 1, 'perms' => '-rw-------', 'owner' => 'root', 'modified' => 'now']]) + ->call('download', 0) + ->assertOk(); // allowed through the guard, SFTP mocked away + } + + public function test_viewer_cannot_open_the_file_editor(): void + { + $this->actingAs($this->viewer()); + $this->activeServer(); + + $fleet = Mockery::mock(FleetService::class); + $fleet->shouldReceive('files')->andReturn([]); + app()->instance(FleetService::class, $fleet); + + Livewire::test(Files::class) + ->set('entries', [['name' => 'x.conf', 'type' => 'file', 'size' => 1, 'perms' => '-rw-r--r--', 'owner' => 'root', 'modified' => 'now']]) + ->call('edit', 0) + ->assertForbidden(); + } + + public function test_viewer_cannot_read_file_content_in_the_editor(): void + { + $this->actingAs($this->viewer()); + $server = Server::create(['name' => 's', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']); + + $fleet = Mockery::mock(FleetService::class); + $fleet->shouldReceive('readFile')->never(); // guard fires before the remote read + $this->app->instance(FleetService::class, $fleet); + $this->app->instance(Sftp::class, Mockery::mock(Sftp::class)); + + Livewire::test(FileEditor::class, ['serverId' => $server->id, 'path' => '/etc/x.conf', 'name' => 'x.conf']) + ->call('load') + ->assertForbidden(); + } + + public function test_operator_can_read_file_content_in_the_editor(): void + { + $this->actingAs($this->operator()); + $server = Server::create(['name' => 's', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']); + + $fleet = Mockery::mock(FleetService::class); + $fleet->shouldReceive('readFile')->once() + ->with(Mockery::type(Server::class), '/etc/x.conf') + ->andReturn(['content' => 'edited = wert', 'binary' => false, 'tooBig' => false]); + $this->app->instance(FleetService::class, $fleet); + $this->app->instance(Sftp::class, Mockery::mock(Sftp::class)); + + Livewire::test(FileEditor::class, ['serverId' => $server->id, 'path' => '/etc/x.conf', 'name' => 'x.conf']) + ->call('load') + ->assertOk() // allowed through the guard, SFTP mocked away + ->assertSet('content', 'edited = wert'); + } + // ── read-only: a viewer keeps READ access (proves read-only, not locked-out) ─ public function test_viewer_can_load_the_services_list(): void diff --git a/tests/Feature/RbacUiHidingTest.php b/tests/Feature/RbacUiHidingTest.php index f3f6401..593f6a7 100644 --- a/tests/Feature/RbacUiHidingTest.php +++ b/tests/Feature/RbacUiHidingTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature; +use App\Livewire\Files\Index as Files; use App\Livewire\Servers\Index as Servers; use App\Livewire\Services\Index as Services; use App\Models\Server; @@ -116,6 +117,47 @@ class RbacUiHidingTest extends TestCase ->assertDontSee(__('services.restart')); // but the action buttons are gone } + // ── operate: Files\Index content controls (download / edit / delete) ──────── + // The listing (browse) is open to a viewer; the content-read + write controls are not. + + /** Stubs the SFTP directory read so the browser renders with one file row. */ + private function stubFiles(): void + { + $fleet = Mockery::mock(FleetService::class); + $fleet->shouldReceive('files')->andReturn([ + ['name' => 'secrets.env', 'type' => 'file', 'size' => 12, 'perms' => '-rw-------', 'owner' => 'root', 'modified' => 'now'], + ]); + app()->instance(FleetService::class, $fleet); + } + + public function test_operator_sees_the_file_content_controls(): void + { + $this->actingAs($this->operator()); + $this->activeServer(); + $this->stubFiles(); + + Livewire::test(Files::class) + ->call('load') + ->assertOk() + ->assertSee(__('files.download')) // download button present + ->assertSee(__('common.edit')); // edit trigger present + } + + public function test_viewer_does_not_see_the_file_content_controls(): void + { + $this->actingAs($this->viewer()); + $this->activeServer(); + $this->stubFiles(); + + Livewire::test(Files::class) + ->call('load') + ->assertOk() + ->assertSee('secrets.env') // the listing is visible (browse stays open) + ->assertDontSee(__('files.download')) // but no download button + ->assertDontSee(__('common.edit')) // no edit trigger + ->assertDontSee(__('common.delete')); // no delete trigger + } + // ── manage-network: sidebar WireGuard nav item ────────────────────────────── public function test_admin_sees_the_wireguard_nav(): void diff --git a/update.sh b/update.sh index 543cac5..92f2e70 100755 --- a/update.sh +++ b/update.sh @@ -64,10 +64,24 @@ if [ "${CLUSEV_UPDATE_REEXEC:-0}" != 1 ]; then # 1. let root operate on the clusev-owned working tree git config --global --add safe.directory "$REPO_DIR" 2>/dev/null || true - # 2. fast-forward pull only — never auto-merge or throw away local edits + # 2. fast-forward pull only — never auto-merge or throw away local edits. + # PIN the pull to the branch recorded at install time (install.sh writes CLUSEV_BUILD_BRANCH + # into .env from the then-current HEAD) instead of implicitly following whatever branch is + # checked out now — otherwise anyone able to `git checkout` in this tree (the unprivileged + # clusev user) could silently redirect the next ROOT update to an arbitrary branch/fork. + # Refuse when the checkout diverges from it; fall back to a plain pull only when no branch + # was recorded (an install predating this field). + pin_branch="$(grep -m1 '^CLUSEV_BUILD_BRANCH=' .env 2>/dev/null | cut -d= -f2-)" + pull_args=(--ff-only) + if [ -n "$pin_branch" ]; then + current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo '')" + [ "$current_branch" = "$pin_branch" ] \ + || die "Ausgecheckter Branch '${current_branch}' weicht vom Installations-Branch '${pin_branch}' ab — Update abgebrochen. Mit 'git checkout ${pin_branch}' zuruecksetzen und erneut versuchen." + pull_args=(--ff-only origin "$pin_branch") + fi before_hash="$(sha256sum -- "$0" | cut -d' ' -f1)" info "Hole Aktualisierungen (git pull --ff-only) ..." - timeout 300 git pull --ff-only \ + timeout 300 git pull "${pull_args[@]}" \ || die "git pull fehlgeschlagen — Timeout, fehlende Zugangsdaten zum privaten Repo, lokale Aenderungen oder divergierter Branch. Mit 'git status' und dem Repo-Zugriff (Token im origin-Remote) pruefen und erneut versuchen." after_hash="$(sha256sum -- "$0" | cut -d' ' -f1)"