diff --git a/app/Filament/Admin/Pages/Waf.php b/app/Filament/Admin/Pages/Waf.php index 8eb8126..abac14c 100644 --- a/app/Filament/Admin/Pages/Waf.php +++ b/app/Filament/Admin/Pages/Waf.php @@ -8,7 +8,9 @@ use App\Models\Setting; use App\Services\Agent\AgentClient; use BackedEnum; use Exception; +use Filament\Forms\Components\Repeater; 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; @@ -47,10 +49,16 @@ class Waf extends Page implements HasForms 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, ]; } @@ -101,6 +109,44 @@ class Waf extends Page implements HasForms ->label(__('Enable Audit Log')), ]) ->columns(2), + Section::make(__('Whitelist Rules')) + ->description(__('Exclude trusted traffic from specific ModSecurity rule IDs.')) + ->schema([ + Repeater::make('whitelist_rules') + ->label(__('Whitelist Entries')) + ->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(), + Toggle::make('enabled') + ->label(__('Enabled')) + ->default(true), + ]) + ->itemLabel(fn (array $state): ?string => $state['label'] ?? $state['match_value'] ?? null) + ->addActionLabel(__('Add Whitelist Rule')) + ->collapsible() + ->columns(['default' => 2, 'md' => 2]), + ]) + ->columns(1), ]); } @@ -115,13 +161,15 @@ class Waf extends Page implements HasForms 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)); try { $agent = new AgentClient; $agent->wafApplySettings( $requestedEnabled, (string) ($data['paranoia'] ?? '1'), - ! empty($data['audit_log']) + ! empty($data['audit_log']), + $data['whitelist_rules'] ?? [] ); if (! $this->wafInstalled && ! empty($data['enabled'])) { diff --git a/app/Services/Agent/AgentClient.php b/app/Services/Agent/AgentClient.php index 7eb3315..bf119b9 100644 --- a/app/Services/Agent/AgentClient.php +++ b/app/Services/Agent/AgentClient.php @@ -1325,12 +1325,13 @@ class AgentClient } // WAF / Geo - public function wafApplySettings(bool $enabled, string $paranoia, bool $auditLog): array + public function wafApplySettings(bool $enabled, string $paranoia, bool $auditLog, array $whitelistRules = []): array { return $this->send('waf.apply', [ 'enabled' => $enabled, 'paranoia' => $paranoia, 'audit_log' => $auditLog, + 'whitelist_rules' => $whitelistRules, ]); } diff --git a/bin/jabali-agent b/bin/jabali-agent index 9c90e73..4ea112c 100755 --- a/bin/jabali-agent +++ b/bin/jabali-agent @@ -3078,6 +3078,7 @@ function wafApplySettings(array $params): array $paranoia = (int) ($params['paranoia'] ?? 1); $paranoia = max(1, min(4, $paranoia)); $auditLog = !empty($params['audit_log']); + $whitelistRules = $params['whitelist_rules'] ?? []; ensureJabaliNginxIncludeFiles(); @@ -3108,6 +3109,11 @@ function wafApplySettings(array $params): array 'SecAction "id:900110,phase:1,t:none,pass,setvar:tx.executing_paranoia_level=' . $paranoia . '"', ]; + $whitelistLines = buildWafWhitelistRules($whitelistRules); + if (!empty($whitelistLines)) { + $rules = array_merge($rules, $whitelistLines); + } + file_put_contents(JABALI_WAF_RULES, implode("\n", $rules) . "\n"); $include = [ @@ -3149,6 +3155,76 @@ function wafApplySettings(array $params): array return ['success' => true, 'enabled' => $enabled, 'paranoia' => $paranoia, 'audit_log' => $auditLog]; } +function buildWafWhitelistRules(array $rules): array +{ + $lines = []; + $ruleBaseId = 120000; + $index = 0; + + $matchMap = [ + 'ip' => ['REMOTE_ADDR', '@ipMatch'], + 'uri_exact' => ['REQUEST_URI', '@streq'], + 'uri_prefix' => ['REQUEST_URI', '@beginsWith'], + 'host' => ['REQUEST_HEADERS:Host', '@streq'], + ]; + + foreach ($rules as $rule) { + if (!is_array($rule)) { + continue; + } + + if (isset($rule['enabled']) && !$rule['enabled']) { + continue; + } + + $matchType = (string) ($rule['match_type'] ?? ''); + $matchValue = trim((string) ($rule['match_value'] ?? '')); + $idsRaw = (string) ($rule['rule_ids'] ?? ''); + + if ($matchValue === '' || $idsRaw === '') { + continue; + } + + if (!isset($matchMap[$matchType])) { + continue; + } + + $ids = preg_split('/[,\s]+/', $idsRaw, -1, PREG_SPLIT_NO_EMPTY) ?: []; + $ids = array_values(array_filter(array_map('trim', $ids), function ($id) { + return ctype_digit($id); + })); + + if (empty($ids)) { + continue; + } + + [$variable, $operator] = $matchMap[$matchType]; + $ruleId = $ruleBaseId + $index; + $index++; + + $ctlParts = []; + foreach ($ids as $id) { + $ctlParts[] = 'ctl:ruleRemoveById=' . $id; + } + + $value = str_replace('"', '\\"', $matchValue); + $lines[] = sprintf( + 'SecRule %s "%s %s" "id:%d,phase:1,pass,nolog,%s"', + $variable, + $operator, + $value, + $ruleId, + implode(',', $ctlParts) + ); + } + + if (!empty($lines)) { + array_unshift($lines, '# Whitelist rules (managed by Jabali)'); + } + + return $lines; +} + function geoUpdateDatabase(array $params): array { $accountId = trim((string) ($params['account_id'] ?? ''));