🚀 Enhance authorization and exception handling across Livewire components and SecurityMonitor

- **SecurityMonitor:** Added logic to record and prevent logging of locked-property exceptions, while ensuring non-security exceptions are properly forwarded.
- **Livewire `Members/Admin`:** Centralized authorization logic in private methods, enforced access control on actions, and moved allowed pubkeys to class constant for maintainability.
- **Livewire `News`:** Enforced authorization for editing and deleting news with guard methods and ensured unauthorized users can't access data.
- **Bootstrap exceptions:** Implemented custom exception handling to record Livewire-related security issues while preventing redundant logs.
- Updated tests with new behavior verification covering access control and exception responses.
This commit is contained in:
HolgerHatGarKeineNode
2026-06-02 19:23:51 +02:00
parent 59bc440a59
commit 5f28bfedd4
6 changed files with 251 additions and 34 deletions
+16 -4
View File
@@ -20,10 +20,22 @@ return Application::configure(basePath: dirname(__DIR__))
ThrottleRequests::class.':api', ThrottleRequests::class.':api',
]); ]);
}) })
->withExceptions(function (Exceptions $exceptions) { ->withExceptions(function (Exceptions $exceptions): void {
Integration::handles($exceptions); // Record Livewire tampering exceptions, then return false to stop them
// reaching Sentry/Nightwatch/log. Must run before Integration::handles()
// (callbacks fire in order; false short-circuits the rest). dontReport()
// is unusable here — it short-circuits before the recording would run.
$exceptions->report(function (Throwable $e): bool {
$monitor = app(SecurityMonitor::class);
$exceptions->report(function (Throwable $e) { if ($monitor->shouldRecord($e)) {
app(SecurityMonitor::class)->recordFromException($e); $monitor->recordFromException($e);
return false;
}
return true;
}); });
Integration::handles($exceptions);
})->create(); })->create();
@@ -12,6 +12,23 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
new class extends Component new class extends Component
{ {
/**
* Pubkeys permitted to manage members. Authorization is re-checked
* server-side on every sensitive action gating the view on $isAllowed
* is cosmetic only, because Livewire exposes every public method as a
* directly callable endpoint regardless of what the view renders.
*
* @var array<int, string>
*/
private const ALLOWED_PUBKEYS = [
'0adf67475ccc5ca456fd3022e46f5d526eb0af6284bf85494c0dd7847f3e5033',
'430169631f2f0682c60cebb4f902d68f0c71c498fd1711fd982f052cf1fd4279',
'7acf30cf60b85c62b8f654556cc21e4016df8f5604b3b6892794f88bb80d7a1d',
'f240be2b684f85cc81566f2081386af81d7427ea86250c8bde6b7a8500c761ba',
'19e358b8011f5f4fc653c565c6d4c2f33f32661f4f90982c9eedc292a8774ec3',
'acbcec475a1a4f9481939ecfbd1c3d111f5b5a474a39ae039bbc720fdd305bec',
];
#[Locked] #[Locked]
public bool $isAllowed = false; public bool $isAllowed = false;
@@ -25,10 +42,13 @@ new class extends Component
public string $sortDirection = 'desc'; public string $sortDirection = 'desc';
#[Locked]
public ?int $selectedPlebId = null; public ?int $selectedPlebId = null;
#[Locked]
public ?int $confirmAcceptId = null; public ?int $confirmAcceptId = null;
#[Locked]
public ?int $confirmDeleteId = null; public ?int $confirmDeleteId = null;
public string $search = ''; public string $search = '';
@@ -39,6 +59,8 @@ new class extends Component
public function updatedSearch(): void public function updatedSearch(): void
{ {
$this->ensureAuthorized();
$this->plebs = $this->loadPlebs(); $this->plebs = $this->loadPlebs();
} }
@@ -51,42 +73,18 @@ new class extends Component
{ {
if (NostrAuth::check()) { if (NostrAuth::check()) {
$this->currentPubkey = NostrAuth::pubkey(); $this->currentPubkey = NostrAuth::pubkey();
$this->currentPleb = \App\Models\EinundzwanzigPleb::query() $this->currentPleb = NostrAuth::user()?->getPleb();
->where('pubkey', $this->currentPubkey)->first();
$allowedPubkeys = [
'0adf67475ccc5ca456fd3022e46f5d526eb0af6284bf85494c0dd7847f3e5033',
'430169631f2f0682c60cebb4f902d68f0c71c498fd1711fd982f052cf1fd4279',
'7acf30cf60b85c62b8f654556cc21e4016df8f5604b3b6892794f88bb80d7a1d',
'f240be2b684f85cc81566f2081386af81d7427ea86250c8bde6b7a8500c761ba',
'19e358b8011f5f4fc653c565c6d4c2f33f32661f4f90982c9eedc292a8774ec3',
'acbcec475a1a4f9481939ecfbd1c3d111f5b5a474a39ae039bbc720fdd305bec',
];
if (in_array($this->currentPubkey, $allowedPubkeys, true)) {
$this->isAllowed = true;
}
} }
$this->plebs = $this->loadPlebs(); $this->refreshAccess();
} }
public function handleNostrLoggedIn($signedEvent = null): void public function handleNostrLoggedIn($signedEvent = null): void
{ {
$pubkey = NostrAuth::loginWithSignedEvent($signedEvent); $this->currentPubkey = NostrAuth::loginWithSignedEvent($signedEvent);
$this->currentPleb = NostrAuth::user()?->getPleb();
$this->currentPubkey = $pubkey; $this->refreshAccess();
$this->currentPleb = EinundzwanzigPleb::query()
->where('pubkey', $pubkey)->first();
$allowedPubkeys = [
'0adf67475ccc5ca456fd3022e46f5d526eb0af6284bf85494c0dd7847f3e5033',
'430169631f2f0682c60cebb4f902d68f0c71c498fd1711fd982f052cf1fd4279',
'7acf30cf60b85c62b8f654556cc21e4016df8f5604b3b6892794f88bb80d7a1d',
'f240be2b684f85cc81566f2081386af81d7427ea86250c8bde6b7a8500c761ba',
'19e358b8011f5f4fc653c565c6d4c2f33f32661f4f90982c9eedc292a8774ec3',
'acbcec475a1a4f9481939ecfbd1c3d111f5b5a474a39ae039bbc720fdd305bec',
];
$this->isAllowed = in_array($pubkey, $allowedPubkeys, true);
} }
public function handleNostrLoggedOut(): void public function handleNostrLoggedOut(): void
@@ -94,6 +92,27 @@ new class extends Component
$this->currentPubkey = null; $this->currentPubkey = null;
$this->currentPleb = null; $this->currentPleb = null;
$this->isAllowed = false; $this->isAllowed = false;
$this->plebs = [];
}
private function isAuthorized(): bool
{
return NostrAuth::check()
&& in_array(NostrAuth::pubkey(), self::ALLOWED_PUBKEYS, true);
}
private function ensureAuthorized(): void
{
abort_unless($this->isAuthorized(), 403);
}
private function refreshAccess(): void
{
$this->isAllowed = $this->isAuthorized();
if ($this->isAllowed) {
$this->plebs = $this->loadPlebs();
}
} }
private function loadPlebs() private function loadPlebs()
@@ -127,6 +146,8 @@ new class extends Component
public function sort(string $column): void public function sort(string $column): void
{ {
$this->ensureAuthorized();
if ($this->sortBy === $column) { if ($this->sortBy === $column) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc'; $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else { } else {
@@ -139,30 +160,40 @@ new class extends Component
public function togglePaidFilter(): void public function togglePaidFilter(): void
{ {
$this->ensureAuthorized();
$this->showPaidOnly = !$this->showPaidOnly; $this->showPaidOnly = !$this->showPaidOnly;
$this->plebs = $this->loadPlebs(); $this->plebs = $this->loadPlebs();
} }
public function openPaymentModal(int $plebId): void public function openPaymentModal(int $plebId): void
{ {
$this->ensureAuthorized();
$this->selectedPlebId = $plebId; $this->selectedPlebId = $plebId;
Flux::modal('payment-details')->show(); Flux::modal('payment-details')->show();
} }
public function accept($rowId): void public function accept($rowId): void
{ {
$this->ensureAuthorized();
$this->confirmAcceptId = $rowId; $this->confirmAcceptId = $rowId;
Flux::modal('confirm-accept-pleb')->show(); Flux::modal('confirm-accept-pleb')->show();
} }
public function delete($rowId): void public function delete($rowId): void
{ {
$this->ensureAuthorized();
$this->confirmDeleteId = $rowId; $this->confirmDeleteId = $rowId;
Flux::modal('confirm-delete-pleb')->show(); Flux::modal('confirm-delete-pleb')->show();
} }
public function acceptPleb(): void public function acceptPleb(): void
{ {
$this->ensureAuthorized();
if ($this->confirmAcceptId) { if ($this->confirmAcceptId) {
$pleb = EinundzwanzigPleb::query()->findOrFail($this->confirmAcceptId); $pleb = EinundzwanzigPleb::query()->findOrFail($this->confirmAcceptId);
$for = $pleb->application_for; $for = $pleb->application_for;
@@ -180,6 +211,8 @@ new class extends Component
public function deletePleb(): void public function deletePleb(): void
{ {
$this->ensureAuthorized();
if ($this->confirmDeleteId) { if ($this->confirmDeleteId) {
$pleb = EinundzwanzigPleb::query()->findOrFail($this->confirmDeleteId); $pleb = EinundzwanzigPleb::query()->findOrFail($this->confirmDeleteId);
$pleb->application_for = null; $pleb->application_for = null;
@@ -199,6 +232,8 @@ new class extends Component
public function exportCsv(): StreamedResponse public function exportCsv(): StreamedResponse
{ {
$this->ensureAuthorized();
$currentYear = (int) date('Y'); $currentYear = (int) date('Y');
$years = PaymentEvent::query() $years = PaymentEvent::query()
->where('year', '>=', 2025) ->where('year', '>=', 2025)
@@ -257,6 +292,10 @@ new class extends Component
#[Computed] #[Computed]
public function selectedPleb(): ?EinundzwanzigPleb public function selectedPleb(): ?EinundzwanzigPleb
{ {
if (! $this->isAuthorized()) {
return null;
}
return EinundzwanzigPleb::with(['paymentEvents'])->find($this->selectedPlebId); return EinundzwanzigPleb::with(['paymentEvents'])->find($this->selectedPlebId);
} }
}; };
@@ -41,6 +41,7 @@ class extends Component {
#[Locked] #[Locked]
public ?\App\Models\EinundzwanzigPleb $currentPleb = null; public ?\App\Models\EinundzwanzigPleb $currentPleb = null;
#[Locked]
public ?int $confirmDeleteId = null; public ?int $confirmDeleteId = null;
public function mount(): void public function mount(): void
@@ -58,8 +59,10 @@ class extends Component {
$this->isAllowed = true; $this->isAllowed = true;
} }
if ($this->isAllowed) {
$this->loadNews(); $this->loadNews();
} }
}
#[Computed] #[Computed]
public function filteredNews(): Collection|array public function filteredNews(): Collection|array
@@ -93,6 +96,8 @@ class extends Component {
public function save(): void public function save(): void
{ {
$this->ensureCanEdit();
$this->validate([ $this->validate([
'file' => 'required|file|mimes:pdf', 'file' => 'required|file|mimes:pdf',
'form.category' => 'required|string|in:'.implode(',', NewsCategory::values()), 'form.category' => 'required|string|in:'.implode(',', NewsCategory::values()),
@@ -119,11 +124,15 @@ class extends Component {
public function confirmDelete(int $id): void public function confirmDelete(int $id): void
{ {
$this->ensureCanEdit();
$this->confirmDeleteId = $id; $this->confirmDeleteId = $id;
} }
public function delete(): void public function delete(): void
{ {
$this->ensureCanEdit();
$news = Notification::query()->findOrFail($this->confirmDeleteId); $news = Notification::query()->findOrFail($this->confirmDeleteId);
$news->delete(); $news->delete();
$this->loadNews(); $this->loadNews();
@@ -131,9 +140,21 @@ class extends Component {
public function removeFile(): void public function removeFile(): void
{ {
$this->ensureCanEdit();
$this->file->delete(); $this->file->delete();
$this->file = null; $this->file = null;
} }
private function canEditNews(): bool
{
return NostrAuth::user()?->isBoardMember() ?? false;
}
private function ensureCanEdit(): void
{
abort_unless($this->canEditNews(), 403);
}
}; };
?> ?>
@@ -4,6 +4,8 @@ use App\Models\EinundzwanzigPleb;
use App\Support\NostrAuth; use App\Support\NostrAuth;
use Livewire\Livewire; use Livewire\Livewire;
const ALLOWED_ADMIN_PUBKEY = '0adf67475ccc5ca456fd3022e46f5d526eb0af6284bf85494c0dd7847f3e5033';
it('denies access to unauthorized users', function () { it('denies access to unauthorized users', function () {
$pleb = EinundzwanzigPleb::factory()->create(); $pleb = EinundzwanzigPleb::factory()->create();
@@ -73,3 +75,60 @@ it('displays einundzwanzig pleb table when authorized', function () {
->assertSet('isAllowed', true) ->assertSet('isAllowed', true)
->assertSee('einundzwanzig-pleb-table'); ->assertSee('einundzwanzig-pleb-table');
}); });
it('does not load the member list for unauthorized visitors', function () {
EinundzwanzigPleb::factory()->count(3)->create();
Livewire::test('association.members.admin')
->assertSet('isAllowed', false)
->assertSet('plebs', []);
});
it('forbids guests from exporting the member CSV', function () {
Livewire::test('association.members.admin')
->call('exportCsv')
->assertForbidden();
});
it('forbids unauthorized members from exporting the member CSV', function () {
$pleb = EinundzwanzigPleb::factory()->create();
NostrAuth::login($pleb->pubkey);
Livewire::test('association.members.admin')
->call('exportCsv')
->assertForbidden();
});
it('forbids unauthorized members from accepting an application', function () {
$pleb = EinundzwanzigPleb::factory()->create();
NostrAuth::login($pleb->pubkey);
Livewire::test('association.members.admin')
->call('acceptPleb')
->assertForbidden();
});
it('forbids unauthorized members from rejecting an application', function () {
$pleb = EinundzwanzigPleb::factory()->create();
NostrAuth::login($pleb->pubkey);
Livewire::test('association.members.admin')
->call('deletePleb')
->assertForbidden();
});
it('lets an authorized member pass the authorization guard', function () {
$pleb = EinundzwanzigPleb::factory()->create([
'pubkey' => ALLOWED_ADMIN_PUBKEY,
]);
NostrAuth::login($pleb->pubkey);
Livewire::test('association.members.admin')
->call('acceptPleb')
->assertStatus(200)
->assertHasNoErrors();
});
@@ -112,6 +112,58 @@ it('can delete news entry', function () {
expect(Notification::find($news->id))->toBeNull(); expect(Notification::find($news->id))->toBeNull();
}); });
it('forbids guests from deleting news', function () {
$author = EinundzwanzigPleb::factory()->create();
$news = Notification::factory()->create([
'einundzwanzig_pleb_id' => $author->id,
]);
Livewire::test('association.news')
->call('delete')
->assertForbidden();
expect(Notification::find($news->id))->not->toBeNull();
});
it('forbids non-board members from deleting news', function () {
$author = EinundzwanzigPleb::factory()->create();
$news = Notification::factory()->create([
'einundzwanzig_pleb_id' => $author->id,
]);
$pleb = EinundzwanzigPleb::factory()->active()->withPaidCurrentYear()->create();
NostrAuth::login($pleb->pubkey);
Livewire::test('association.news')
->call('delete')
->assertForbidden();
expect(Notification::find($news->id))->not->toBeNull();
});
it('forbids non-board members from creating news', function () {
$pleb = EinundzwanzigPleb::factory()->active()->withPaidCurrentYear()->create();
NostrAuth::login($pleb->pubkey);
Livewire::test('association.news')
->call('save')
->assertForbidden();
expect(Notification::count())->toBe(0);
});
it('does not load news for unauthorized visitors', function () {
$author = EinundzwanzigPleb::factory()->create();
Notification::factory()->count(2)->create([
'einundzwanzig_pleb_id' => $author->id,
]);
Livewire::test('association.news')
->assertSet('isAllowed', false)
->assertSet('news', []);
});
it('displays news list', function () { it('displays news list', function () {
$pleb = EinundzwanzigPleb::factory()->active()->withPaidCurrentYear()->create(); $pleb = EinundzwanzigPleb::factory()->active()->withPaidCurrentYear()->create();
$news1 = Notification::factory()->create(); $news1 = Notification::factory()->create();
+34
View File
@@ -2,7 +2,9 @@
use App\Models\SecurityAttempt; use App\Models\SecurityAttempt;
use App\Services\SecurityMonitor; use App\Services\SecurityMonitor;
use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException; use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
beforeEach(function () { beforeEach(function () {
@@ -145,6 +147,38 @@ it('truncates long values', function () {
expect(strlen($attempt->user_agent))->toBeLessThanOrEqual(500); expect(strlen($attempt->user_agent))->toBeLessThanOrEqual(500);
}); });
it('records a security attempt when a locked-property exception is reported through the handler', function () {
$exception = new CannotUpdateLockedPropertyException('isLoggedIn');
app(ExceptionHandler::class)->report($exception);
expect(SecurityAttempt::count())->toBe(1)
->and(SecurityAttempt::first()->target_property)->toBe('isLoggedIn');
});
it('does not forward locked-property exceptions to the default log stack', function () {
Log::spy();
app(ExceptionHandler::class)->report(new CannotUpdateLockedPropertyException('isLoggedIn'));
expect(SecurityAttempt::count())->toBe(1);
Log::shouldNotHaveReceived('log');
Log::shouldNotHaveReceived('error');
Log::shouldNotHaveReceived('critical');
Log::shouldNotHaveReceived('warning');
});
it('still forwards non-security exceptions to the default log stack', function () {
Log::spy();
app(ExceptionHandler::class)->report(new RuntimeException('boom'));
expect(SecurityAttempt::count())->toBe(0);
Log::shouldHaveReceived('error');
});
it('handles X-Forwarded-For header', function () { it('handles X-Forwarded-For header', function () {
$exception = new CannotUpdateLockedPropertyException('test'); $exception = new CannotUpdateLockedPropertyException('test');