fix(vpn): an open modal stops serving the config once access is revoked

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 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 22:39:30 +02:00
parent db7b8a6ee3
commit 04ba9dd2a9
2 changed files with 43 additions and 0 deletions

View File

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

View File

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