fix(rbac,honeypot): gate audit-retention + email sendTest; honeytoken scans JSON body
Re-review (completeness sweep + Codex) found: Audit\Index::saveRetention wrote the global
audit_retention_days policy ungated (a non-admin could shrink retention and prune audit evidence)
-> manage-panel guard + @can-hidden control; Settings\Email::sendTest lacked the role guard that
mount/save already had -> manage-panel; DetectHoneytoken read only form-encoded bodies via post(),
missing a JSON {"api_key":"<canary>"} replay -> now scans json() too (still body-only, no query).
feat/v1-foundation
parent
e602320c9d
commit
72de7b9a22
|
|
@ -45,13 +45,15 @@ class DetectHoneytoken
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A small, bounded set of candidate values — request BODY only (post()), no query string,
|
// A small, bounded set of candidate values — request BODY only (form-encoded via post() AND a
|
||||||
// no recursion into nested arrays.
|
// JSON body via json()), never the query string (which a reflected link could carry to ban a
|
||||||
|
// victim), no recursion into nested arrays.
|
||||||
$candidates = [];
|
$candidates = [];
|
||||||
foreach (self::CANDIDATE_KEYS as $key) {
|
foreach (self::CANDIDATE_KEYS as $key) {
|
||||||
$value = $request->post($key);
|
foreach ([$request->post($key), $request->json($key)] as $value) {
|
||||||
if (is_string($value) && $value !== '') {
|
if (is_string($value) && $value !== '') {
|
||||||
$candidates[] = $value;
|
$candidates[] = $value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (($bearer = $request->bearerToken()) !== null && $bearer !== '') {
|
if (($bearer = $request->bearerToken()) !== null && $bearer !== '') {
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,10 @@ class Index extends Component
|
||||||
/** Persist the retention policy, audit the change and notify the operator. */
|
/** Persist the retention policy, audit the change and notify the operator. */
|
||||||
public function saveRetention(): void
|
public function saveRetention(): void
|
||||||
{
|
{
|
||||||
|
// Panel-wide policy mutation (a short retention prunes audit history) — admin only. Viewing
|
||||||
|
// the audit log stays open to everyone; only changing the retention window is gated.
|
||||||
|
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||||
|
|
||||||
$validated = $this->validate([
|
$validated = $this->validate([
|
||||||
'retentionDays' => ['nullable', 'integer', 'min:1', 'max:3650'],
|
'retentionDays' => ['nullable', 'integer', 'min:1', 'max:3650'],
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,8 @@ class Email extends Component
|
||||||
*/
|
*/
|
||||||
public function sendTest(): void
|
public function sendTest(): void
|
||||||
{
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manage-panel'), 403);
|
||||||
|
|
||||||
if (! $this->configured()) {
|
if (! $this->configured()) {
|
||||||
$this->dispatch('notify', message: __('mail.notify_test_failed', ['error' => __('mail.not_configured')]));
|
$this->dispatch('notify', message: __('mail.notify_test_failed', ['error' => __('mail.not_configured')]));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@
|
||||||
<x-status-pill status="online">{{ __('audit.event_count', ['count' => $events->total()]) }}</x-status-pill>
|
<x-status-pill status="online">{{ __('audit.event_count', ['count' => $events->total()]) }}</x-status-pill>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Aufbewahrung (Retention) --}}
|
{{-- Aufbewahrung (Retention) — admin-only policy control --}}
|
||||||
|
@can('manage-panel')
|
||||||
<x-panel :title="__('audit.retention_title')" :subtitle="__('audit.retention_subtitle')">
|
<x-panel :title="__('audit.retention_title')" :subtitle="__('audit.retention_subtitle')">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{{-- Aktuelle Richtlinie --}}
|
{{-- Aktuelle Richtlinie --}}
|
||||||
|
|
@ -47,6 +48,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</x-panel>
|
</x-panel>
|
||||||
|
@endcan
|
||||||
|
|
||||||
{{-- Ereignisliste --}}
|
{{-- Ereignisliste --}}
|
||||||
<x-panel :title="__('audit.panel_title')" :subtitle="__('audit.panel_subtitle')" :padded="false">
|
<x-panel :title="__('audit.panel_title')" :subtitle="__('audit.panel_subtitle')" :padded="false">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Livewire\Audit\Index;
|
||||||
|
use App\Livewire\Settings\Email;
|
||||||
|
use App\Models\AuditEvent;
|
||||||
|
use App\Models\BannedIp;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class RbacReviewFixTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_non_admin_cannot_change_audit_retention(): void
|
||||||
|
{
|
||||||
|
foreach (['operator', 'viewer'] as $role) {
|
||||||
|
Livewire::actingAs(User::factory()->{$role}()->create())
|
||||||
|
->test(Index::class)
|
||||||
|
->set('retentionDays', '1')
|
||||||
|
->call('saveRetention')
|
||||||
|
->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertFalse(Setting::where('key', 'audit_retention_days')->exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_change_audit_retention(): void
|
||||||
|
{
|
||||||
|
Livewire::actingAs(User::factory()->create()) // default factory role = admin
|
||||||
|
->test(Index::class)
|
||||||
|
->set('retentionDays', '30')
|
||||||
|
->call('saveRetention');
|
||||||
|
|
||||||
|
$this->assertSame('30', Setting::where('key', 'audit_retention_days')->value('value'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_non_admin_cannot_send_test_email(): void
|
||||||
|
{
|
||||||
|
Mail::fake();
|
||||||
|
|
||||||
|
// Email::mount() is itself gated, so a non-admin cannot even mount the component; the sendTest
|
||||||
|
// guard is the defense-in-depth belt behind it.
|
||||||
|
foreach (['operator', 'viewer'] as $role) {
|
||||||
|
Livewire::actingAs(User::factory()->{$role}()->create())
|
||||||
|
->test(Email::class)
|
||||||
|
->assertForbidden();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_passes_the_send_test_email_gate(): void
|
||||||
|
{
|
||||||
|
Mail::fake();
|
||||||
|
|
||||||
|
// Admin passes the role gate; unconfigured SMTP short-circuits with a notify (proves no 403).
|
||||||
|
Livewire::actingAs(User::factory()->create())
|
||||||
|
->test(Email::class)
|
||||||
|
->call('sendTest')
|
||||||
|
->assertDispatched('notify');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_honeytoken_trips_on_a_json_body_from_a_non_exempt_ip(): void
|
||||||
|
{
|
||||||
|
config(['clusev.honeypot.enabled' => true, 'clusev.honeypot.canaries' => ['api_key' => 'C4n4ryJs0nV4lu3XYZ']]);
|
||||||
|
|
||||||
|
$res = $this->call(
|
||||||
|
'POST',
|
||||||
|
'/broadcasting/auth',
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
['REMOTE_ADDR' => '203.0.113.50', 'CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT' => 'application/json'],
|
||||||
|
(string) json_encode(['api_key' => 'C4n4ryJs0nV4lu3XYZ'])
|
||||||
|
);
|
||||||
|
|
||||||
|
$res->assertStatus(403);
|
||||||
|
$this->assertTrue(BannedIp::where('ip', '203.0.113.50')->exists());
|
||||||
|
$this->assertTrue(AuditEvent::where('action', 'security.honeytoken_used')->exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_honeytoken_does_not_trip_without_a_canary(): void
|
||||||
|
{
|
||||||
|
config(['clusev.honeypot.enabled' => true, 'clusev.honeypot.canaries' => ['api_key' => 'C4n4ryJs0nV4lu3XYZ']]);
|
||||||
|
|
||||||
|
$this->call(
|
||||||
|
'POST',
|
||||||
|
'/broadcasting/auth',
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
['REMOTE_ADDR' => '203.0.113.51', 'CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT' => 'application/json'],
|
||||||
|
(string) json_encode(['api_key' => 'just-a-normal-value'])
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertFalse(BannedIp::where('ip', '203.0.113.51')->exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue