From 04ba9dd2a9e60043a9387c7675929571c6a2130f Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 25 Jul 2026 22:39:30 +0200 Subject: [PATCH] fix(vpn): an open modal stops serving the config once access is revoked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once revealed, the plaintext sits in the handoff cache for ten minutes, and the modal read it from the token alone. A former owner could keep pulling the key out of an open modal after revocation — the exact window purgeSecret() exists to close. Every request now re-checks the policy and drops the handoff when it no longer holds. Co-Authored-By: Claude Opus 4.8 --- app/Livewire/Admin/VpnConfigAccess.php | 18 ++++++++++++++++++ tests/Feature/Admin/VpnTest.php | 25 +++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/app/Livewire/Admin/VpnConfigAccess.php b/app/Livewire/Admin/VpnConfigAccess.php index ca871b1..67c833b 100644 --- a/app/Livewire/Admin/VpnConfigAccess.php +++ b/app/Livewire/Admin/VpnConfigAccess.php @@ -6,6 +6,7 @@ use App\Models\VpnPeer; use App\Services\Wireguard\ConfigHandoff; use App\Services\Wireguard\ConfigVault; use App\Services\Wireguard\QrCode; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\RateLimiter; @@ -42,6 +43,23 @@ class VpnConfigAccess extends ModalComponent $this->name = $peer->name; } + /** + * Every later request re-checks: once revealed, the plaintext sits in the + * handoff cache for ten minutes, and an open modal would otherwise keep + * handing it out after the access was revoked or reassigned — exactly the + * window purgeSecret() exists to close. + */ + public function hydrate(): void + { + $peer = VpnPeer::withTrashed()->where('uuid', $this->uuid)->first(); + + if ($peer === null || Gate::denies('downloadConfig', $peer)) { + ConfigHandoff::forget($this->token); + $this->token = null; + abort(403); + } + } + public function reveal(): void { // A retry must not still be showing the previous attempt's complaint. diff --git a/tests/Feature/Admin/VpnTest.php b/tests/Feature/Admin/VpnTest.php index 3b1a8ae..fa943e2 100644 --- a/tests/Feature/Admin/VpnTest.php +++ b/tests/Feature/Admin/VpnTest.php @@ -667,3 +667,28 @@ it('removes a revoked colleague from the hub only after the rows are committed', expect($hub->peerIps())->toBe([]) ->and(VpnPeer::withTrashed()->count())->toBe(0); // tombstone cleared by the removal }); + +it('stops serving a revealed config once the access is revoked', function () { + vpnHub(); + Queue::fake(); + $support = operator('Support'); + $support->forceFill(['password' => Hash::make('geheim-1234')])->save(); + $peer = VpnPeer::factory()->ownedBy($support)->create([ + 'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"), + ]); + + $modal = Livewire::actingAs($support)->test(VpnConfigAccess::class, ['uuid' => $peer->uuid]) + ->set('password', 'geheim-1234') + ->call('reveal'); + $token = $modal->get('token'); + expect(App\Services\Wireguard\ConfigHandoff::get($token))->toContain('PrivateKey = XYZ'); + + // Revoked while the modal is still open on someone's screen. + Livewire::actingAs(operator('Owner')) + ->test(ConfirmDeleteVpnPeer::class, ['uuid' => $peer->uuid]) + ->call('delete'); + + // The open modal must not keep handing the key out of the cache. + $modal->call('toggleQr')->assertForbidden(); + expect(App\Services\Wireguard\ConfigHandoff::get($token))->toBeNull(); +});