Move WAF whitelist to table
This commit is contained in:
@@ -8,10 +8,7 @@ use App\Models\Setting;
|
||||
use App\Services\Agent\AgentClient;
|
||||
use BackedEnum;
|
||||
use Exception;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
use Filament\Forms\Components\Repeater\TableColumn as RepeaterTableColumn;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
@@ -61,16 +58,10 @@ class Waf extends Page implements HasForms, HasTable
|
||||
public function mount(): void
|
||||
{
|
||||
$this->wafInstalled = $this->detectWaf();
|
||||
$whitelistRaw = Setting::get('waf_whitelist_rules', '[]');
|
||||
$whitelistRules = json_decode($whitelistRaw, true);
|
||||
if (! is_array($whitelistRules)) {
|
||||
$whitelistRules = [];
|
||||
}
|
||||
$this->wafFormData = [
|
||||
'enabled' => Setting::get('waf_enabled', '0') === '1',
|
||||
'paranoia' => Setting::get('waf_paranoia', '1'),
|
||||
'audit_log' => Setting::get('waf_audit_log', '1') === '1',
|
||||
'whitelist_rules' => $whitelistRules,
|
||||
];
|
||||
|
||||
$this->loadAuditLogs(false);
|
||||
@@ -123,109 +114,6 @@ class Waf extends Page implements HasForms, HasTable
|
||||
->label(__('Enable Audit Log')),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make(__('Whitelist Rules'))
|
||||
->description(__('Exclude trusted traffic from specific ModSecurity rule IDs.'))
|
||||
->headerActions([
|
||||
\Filament\Actions\Action::make('addWhitelistRule')
|
||||
->label(__('Add Custom Rule'))
|
||||
->icon('heroicon-o-plus')
|
||||
->modalHeading(__('Add Whitelist Rule'))
|
||||
->form([
|
||||
TextInput::make('label')
|
||||
->label(__('Name'))
|
||||
->placeholder(__('Example: Admin API allowlist'))
|
||||
->maxLength(80),
|
||||
Select::make('match_type')
|
||||
->label(__('Match Type'))
|
||||
->options([
|
||||
'ip' => __('IP Address or CIDR'),
|
||||
'uri_exact' => __('Exact URI'),
|
||||
'uri_prefix' => __('URI Prefix'),
|
||||
'host' => __('Host Header'),
|
||||
])
|
||||
->required(),
|
||||
TextInput::make('match_value')
|
||||
->label(__('Match Value'))
|
||||
->placeholder(__('Example: 203.0.113.10 or /wp-admin/admin-ajax.php'))
|
||||
->required(),
|
||||
TextInput::make('rule_ids')
|
||||
->label(__('Rule IDs'))
|
||||
->placeholder(__('Example: 942100,949110'))
|
||||
->helperText(__('Comma-separated ModSecurity rule IDs to disable for matches.'))
|
||||
->required(),
|
||||
])
|
||||
->action(function (array $data): void {
|
||||
$rules = $this->getWhitelistRules();
|
||||
|
||||
$rules[] = [
|
||||
'label' => $data['label'] ?? null,
|
||||
'match_type' => $data['match_type'] ?? '',
|
||||
'match_value' => $data['match_value'] ?? '',
|
||||
'rule_ids' => $data['rule_ids'] ?? '',
|
||||
];
|
||||
|
||||
Setting::set('waf_whitelist_rules', json_encode(array_values($rules), JSON_UNESCAPED_SLASHES));
|
||||
$this->wafFormData['whitelist_rules'] = $rules;
|
||||
|
||||
try {
|
||||
$agent = new AgentClient;
|
||||
$agent->wafApplySettings(
|
||||
Setting::get('waf_enabled', '0') === '1',
|
||||
(string) Setting::get('waf_paranoia', '1'),
|
||||
Setting::get('waf_audit_log', '1') === '1',
|
||||
$rules
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
Notification::make()
|
||||
->title(__('Whitelist saved, but apply failed'))
|
||||
->body($e->getMessage())
|
||||
->warning()
|
||||
->send();
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title(__('Whitelist rule added'))
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
])
|
||||
->schema([
|
||||
Repeater::make('whitelist_rules')
|
||||
->label(__('Whitelist Entries'))
|
||||
->table([
|
||||
RepeaterTableColumn::make(__('Name'))->width('22%'),
|
||||
RepeaterTableColumn::make(__('Match Type'))->width('16%'),
|
||||
RepeaterTableColumn::make(__('Match Value'))->width('32%'),
|
||||
RepeaterTableColumn::make(__('Rule IDs'))->width('30%'),
|
||||
])
|
||||
->schema([
|
||||
TextInput::make('label')
|
||||
->label(__('Name'))
|
||||
->placeholder(__('Admin API allowlist'))
|
||||
->maxLength(80),
|
||||
Select::make('match_type')
|
||||
->label(__('Match Type'))
|
||||
->options([
|
||||
'ip' => __('IP Address or CIDR'),
|
||||
'uri_exact' => __('Exact URI'),
|
||||
'uri_prefix' => __('URI Prefix'),
|
||||
'host' => __('Host Header'),
|
||||
])
|
||||
->required(),
|
||||
TextInput::make('match_value')
|
||||
->label(__('Match Value'))
|
||||
->placeholder(__('Example: 203.0.113.10 or /wp-admin/admin-ajax.php'))
|
||||
->required(),
|
||||
TextInput::make('rule_ids')
|
||||
->label(__('Rule IDs'))
|
||||
->placeholder(__('Example: 942100,949110'))
|
||||
->helperText(__('Comma-separated ModSecurity rule IDs to disable for matches.'))
|
||||
->required(),
|
||||
])
|
||||
->addActionLabel(__('Add Whitelist Rule'))
|
||||
->columns(['default' => 2, 'md' => 2]),
|
||||
])
|
||||
->columns(1),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -240,7 +128,7 @@ class Waf extends Page implements HasForms, HasTable
|
||||
Setting::set('waf_enabled', $requestedEnabled ? '1' : '0');
|
||||
Setting::set('waf_paranoia', (string) ($data['paranoia'] ?? '1'));
|
||||
Setting::set('waf_audit_log', ! empty($data['audit_log']) ? '1' : '0');
|
||||
Setting::set('waf_whitelist_rules', json_encode(array_values($data['whitelist_rules'] ?? []), JSON_UNESCAPED_SLASHES));
|
||||
$whitelistRules = $this->getWhitelistRules();
|
||||
|
||||
try {
|
||||
$agent = new AgentClient;
|
||||
@@ -248,7 +136,7 @@ class Waf extends Page implements HasForms, HasTable
|
||||
$requestedEnabled,
|
||||
(string) ($data['paranoia'] ?? '1'),
|
||||
! empty($data['audit_log']),
|
||||
$data['whitelist_rules'] ?? []
|
||||
$whitelistRules
|
||||
);
|
||||
|
||||
if (! $this->wafInstalled && ! empty($data['enabled'])) {
|
||||
@@ -306,8 +194,53 @@ class Waf extends Page implements HasForms, HasTable
|
||||
{
|
||||
$raw = Setting::get('waf_whitelist_rules', '[]');
|
||||
$rules = json_decode($raw, true);
|
||||
if (! is_array($rules)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return is_array($rules) ? $rules : [];
|
||||
$changed = false;
|
||||
|
||||
foreach ($rules as &$rule) {
|
||||
if (! is_array($rule)) {
|
||||
$rule = [];
|
||||
$changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (array_key_exists('enabled', $rule)) {
|
||||
unset($rule['enabled']);
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
$label = (string) ($rule['label'] ?? '');
|
||||
if ($label === '' || str_contains($label, '{rule}') || str_contains($label, ':rule')) {
|
||||
$rule['label'] = $this->defaultWhitelistLabel($rule);
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
$rules = array_values(array_filter($rules, fn ($rule) => is_array($rule) && ($rule !== [])));
|
||||
|
||||
if ($changed) {
|
||||
Setting::set('waf_whitelist_rules', json_encode($rules, JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
protected function defaultWhitelistLabel(array $rule): string
|
||||
{
|
||||
$ids = trim((string) ($rule['rule_ids'] ?? ''));
|
||||
if ($ids !== '') {
|
||||
return __('Rule :id', ['id' => $ids]);
|
||||
}
|
||||
|
||||
$matchValue = trim((string) ($rule['match_value'] ?? ''));
|
||||
if ($matchValue !== '') {
|
||||
return $matchValue;
|
||||
}
|
||||
|
||||
return __('Whitelist rule');
|
||||
}
|
||||
|
||||
protected function markWhitelisted(array $entries): array
|
||||
@@ -459,7 +392,7 @@ class Waf extends Page implements HasForms, HasTable
|
||||
}
|
||||
|
||||
$rules[] = [
|
||||
'label' => __('Whitelist rule :rule', ['rule' => $record['rule_id'] ?? '']),
|
||||
'label' => __('Rule :rule', ['rule' => $record['rule_id'] ?? '']),
|
||||
'match_type' => $matchType,
|
||||
'match_value' => $matchValue,
|
||||
'rule_ids' => $record['rule_id'] ?? '',
|
||||
|
||||
258
app/Livewire/Admin/WafWhitelistTable.php
Normal file
258
app/Livewire/Admin/WafWhitelistTable.php
Normal file
@@ -0,0 +1,258 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Services\Agent\AgentClient;
|
||||
use Exception;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Arr;
|
||||
use Livewire\Component;
|
||||
|
||||
class WafWhitelistTable extends Component implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.waf-whitelist-table');
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->records(fn () => $this->getWhitelistRules())
|
||||
->recordKey('id')
|
||||
->paginated([10, 25, 50])
|
||||
->defaultPaginationPageOption(10)
|
||||
->columns([
|
||||
TextColumn::make('label')
|
||||
->label(__('Name'))
|
||||
->wrap(),
|
||||
TextColumn::make('match_type')
|
||||
->label(__('Match Type'))
|
||||
->formatStateUsing(fn (string $state): string => $this->matchTypeLabel($state))
|
||||
->toggleable(),
|
||||
TextColumn::make('match_value')
|
||||
->label(__('Match Value'))
|
||||
->wrap()
|
||||
->copyable(),
|
||||
TextColumn::make('rule_ids')
|
||||
->label(__('Rule IDs'))
|
||||
->fontFamily('mono')
|
||||
->copyable(),
|
||||
])
|
||||
->headerActions([
|
||||
Action::make('add')
|
||||
->label(__('Add Custom Rule'))
|
||||
->icon('heroicon-o-plus')
|
||||
->modalHeading(__('Add Whitelist Rule'))
|
||||
->form($this->whitelistRuleForm())
|
||||
->action(function (array $data): void {
|
||||
$rules = $this->getWhitelistRules();
|
||||
$rules[] = $this->normalizeRule($data);
|
||||
$this->persistWhitelistRules($rules);
|
||||
|
||||
Notification::make()
|
||||
->title(__('Whitelist rule added'))
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
])
|
||||
->actions([
|
||||
Action::make('edit')
|
||||
->label(__('Edit'))
|
||||
->icon('heroicon-o-pencil-square')
|
||||
->form($this->whitelistRuleForm())
|
||||
->fillForm(fn (array $record) => Arr::only($record, ['label', 'match_type', 'match_value', 'rule_ids']))
|
||||
->action(function (array $data, array $record): void {
|
||||
$rules = $this->getWhitelistRules();
|
||||
|
||||
foreach ($rules as &$rule) {
|
||||
if (($rule['id'] ?? '') === ($record['id'] ?? '')) {
|
||||
$rule = array_merge($rule, $this->normalizeRule($data));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$this->persistWhitelistRules($rules);
|
||||
|
||||
Notification::make()
|
||||
->title(__('Whitelist rule updated'))
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Action::make('delete')
|
||||
->label(__('Delete'))
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->action(function (array $record): void {
|
||||
$rules = array_values(array_filter($this->getWhitelistRules(), function (array $rule) use ($record) {
|
||||
return ($rule['id'] ?? '') !== ($record['id'] ?? '');
|
||||
}));
|
||||
|
||||
$this->persistWhitelistRules($rules);
|
||||
|
||||
Notification::make()
|
||||
->title(__('Whitelist rule removed'))
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
])
|
||||
->emptyStateHeading(__('No whitelist rules'))
|
||||
->emptyStateDescription(__('Add a custom whitelist rule to allow trusted traffic.'));
|
||||
}
|
||||
|
||||
protected function whitelistRuleForm(): array
|
||||
{
|
||||
return [
|
||||
TextInput::make('label')
|
||||
->label(__('Name'))
|
||||
->placeholder(__('Rule 942100'))
|
||||
->maxLength(80),
|
||||
Select::make('match_type')
|
||||
->label(__('Match Type'))
|
||||
->options([
|
||||
'ip' => __('IP Address or CIDR'),
|
||||
'uri_exact' => __('Exact URI'),
|
||||
'uri_prefix' => __('URI Prefix'),
|
||||
'host' => __('Host Header'),
|
||||
])
|
||||
->required(),
|
||||
TextInput::make('match_value')
|
||||
->label(__('Match Value'))
|
||||
->placeholder(__('Example: 203.0.113.10 or /wp-admin/admin-ajax.php'))
|
||||
->required(),
|
||||
TextInput::make('rule_ids')
|
||||
->label(__('Rule IDs'))
|
||||
->placeholder(__('Example: 942100,949110'))
|
||||
->helperText(__('Comma-separated ModSecurity rule IDs to disable for matches.'))
|
||||
->required(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getWhitelistRules(): array
|
||||
{
|
||||
$raw = Setting::get('waf_whitelist_rules', '[]');
|
||||
$rules = json_decode($raw, true);
|
||||
if (! is_array($rules)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$changed = false;
|
||||
|
||||
foreach ($rules as &$rule) {
|
||||
if (! is_array($rule)) {
|
||||
$rule = [];
|
||||
$changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (array_key_exists('enabled', $rule)) {
|
||||
unset($rule['enabled']);
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
$rule['label'] = $this->normalizeLabel($rule);
|
||||
$rule['id'] = $this->ruleId($rule);
|
||||
}
|
||||
|
||||
$rules = array_values(array_filter($rules, fn ($rule) => is_array($rule) && ($rule !== [])));
|
||||
|
||||
if ($changed) {
|
||||
Setting::set('waf_whitelist_rules', json_encode($this->stripIds($rules), JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
protected function normalizeRule(array $data): array
|
||||
{
|
||||
$rule = [
|
||||
'label' => $data['label'] ?? null,
|
||||
'match_type' => $data['match_type'] ?? '',
|
||||
'match_value' => $data['match_value'] ?? '',
|
||||
'rule_ids' => $data['rule_ids'] ?? '',
|
||||
];
|
||||
|
||||
$rule['label'] = $this->normalizeLabel($rule);
|
||||
$rule['id'] = $this->ruleId($rule);
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
protected function normalizeLabel(array $rule): string
|
||||
{
|
||||
$label = trim((string) ($rule['label'] ?? ''));
|
||||
if ($label !== '' && !str_contains($label, '{rule}') && !str_contains($label, ':rule')) {
|
||||
return $label;
|
||||
}
|
||||
|
||||
$ids = trim((string) ($rule['rule_ids'] ?? ''));
|
||||
if ($ids !== '') {
|
||||
return __('Rule :id', ['id' => $ids]);
|
||||
}
|
||||
|
||||
$matchValue = trim((string) ($rule['match_value'] ?? ''));
|
||||
if ($matchValue !== '') {
|
||||
return $matchValue;
|
||||
}
|
||||
|
||||
return __('Whitelist rule');
|
||||
}
|
||||
|
||||
protected function ruleId(array $rule): string
|
||||
{
|
||||
return md5(json_encode(Arr::only($rule, ['label', 'match_type', 'match_value', 'rule_ids'])));
|
||||
}
|
||||
|
||||
protected function stripIds(array $rules): array
|
||||
{
|
||||
return array_map(function (array $rule): array {
|
||||
return Arr::only($rule, ['label', 'match_type', 'match_value', 'rule_ids']);
|
||||
}, $rules);
|
||||
}
|
||||
|
||||
protected function persistWhitelistRules(array $rules): void
|
||||
{
|
||||
$rules = $this->stripIds($rules);
|
||||
Setting::set('waf_whitelist_rules', json_encode(array_values($rules), JSON_UNESCAPED_SLASHES));
|
||||
|
||||
try {
|
||||
$agent = new AgentClient;
|
||||
$agent->wafApplySettings(
|
||||
Setting::get('waf_enabled', '0') === '1',
|
||||
(string) Setting::get('waf_paranoia', '1'),
|
||||
Setting::get('waf_audit_log', '1') === '1',
|
||||
$rules
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
Notification::make()
|
||||
->title(__('Whitelist saved, but apply failed'))
|
||||
->body($e->getMessage())
|
||||
->warning()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
|
||||
protected function matchTypeLabel(string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
'ip' => __('IP Address or CIDR'),
|
||||
'uri_exact' => __('Exact URI'),
|
||||
'uri_prefix' => __('URI Prefix'),
|
||||
'host' => __('Host Header'),
|
||||
default => $type,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<x-filament::section icon="heroicon-o-list-bullet" icon-color="primary">
|
||||
<x-slot name="heading">{{ __('Whitelist Rules') }}</x-slot>
|
||||
<x-slot name="description">
|
||||
{{ __('Exclude trusted traffic from specific ModSecurity rule IDs.') }}
|
||||
</x-slot>
|
||||
@livewire('admin.waf-whitelist-table')
|
||||
</x-filament::section>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<x-filament::section icon="heroicon-o-document-text" icon-color="primary">
|
||||
<x-slot name="heading">{{ __('Blocked Requests') }}</x-slot>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<div>
|
||||
{{ $this->table }}
|
||||
</div>
|
||||
|
||||
<x-filament-actions::modals />
|
||||
Reference in New Issue
Block a user