PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.8
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.8
2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 2.9.4 All 87 releases
vigilante / includes / class-htaccess-protection.php

class-htaccess-protection.php in Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… 2.11.8, at includes/class-htaccess-protection.php

688 lines 29.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * HTAccess Protection Class
4 *
5 * Manages firewall rules via .htaccess
6 *
7 * IMPORTANT: Each option in this class corresponds EXACTLY to a checkbox in the admin UI.
8 * The option names match those in class-settings.php firewall section.
9 *
10 * @package Vigilante
11 */
12
13 // Prevent direct access
14 if ( ! defined( 'ABSPATH' ) ) {
15 exit;
16 }
17
18 /**
19 * Class Vigilante_Htaccess_Protection
20 *
21 * Applies firewall rules to .htaccess for Apache/LiteSpeed servers
22 */
23 class Vigilante_Htaccess_Protection {
24
25 /**
26 * Settings instance
27 *
28 * @var Vigilante_Settings
29 */
30 private $settings;
31
32 /**
33 * Firewall options
34 *
35 * @var array
36 */
37 private $options;
38
39 /**
40 * Block markers
41 */
42 const MARKER_START = '# BEGIN Vigilante Protection';
43 const MARKER_END = '# END Vigilante Protection';
44
45 /**
46 * Old plugin markers to clean
47 */
48 private $old_markers = array(
49 array( '# BEGIN SECURITY HEADERS', '# END SECURITY HEADERS' ),
50 array( '# BEGIN 8G FIREWALL', '# END 8G FIREWALL' ),
51 array( '# BEGIN ADDITIONAL PROTECTIONS', '# END ADDITIONAL PROTECTIONS' ),
52 array( '# BEGIN AyudaWP Security', '# END AyudaWP Security' ),
53 );
54
55 /**
56 * Constructor
57 *
58 * @param Vigilante_Settings $settings Settings instance.
59 */
60 public function __construct( $settings ) {
61 $this->settings = $settings;
62
63 $firewall = $settings->get_section( 'firewall' );
64 $security_headers = $settings->get_section( 'security_headers' );
65
66 // Server Protection keys moved from firewall to security_headers in v2.0.0.
67 $this->options = array_merge(
68 $firewall,
69 array(
70 'hide_server_signature' => ! empty( $security_headers['hide_server_signature'] ),
71 'remove_fingerprinting_headers' => ! empty( $security_headers['remove_fingerprinting_headers'] ),
72 )
73 );
74 }
75
76 /**
77 * Apply all .htaccess rules
78 *
79 * @return bool|WP_Error
80 */
81 /**
82 * @param bool $automatic True when Vigilant is refreshing the block by
83 * itself rather than because someone pressed Save.
84 * See Vigilante_Htaccess_Manager::add_block().
85 */
86 public function apply_rules( $automatic = false ) {
87 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
88
89 $manager = Vigilante_Htaccess_Manager::get_instance();
90
91 if ( ! $manager->is_apache() ) {
92 return new WP_Error( 'not_apache', __( 'Server is not Apache/LiteSpeed', 'vigilante' ) );
93 }
94
95 if ( ! $manager->is_writable() ) {
96 return new WP_Error( 'not_writable', __( '.htaccess is not writable', 'vigilante' ) );
97 }
98
99 // Clean old plugin rules first
100 $this->remove_old_rules();
101
102 $rules = $this->generate_rules_content();
103
104 $result = $manager->add_block( self::MARKER_START, self::MARKER_END, $rules, 'before_wordpress', $automatic );
105
106 // Regenerate critical file baseline so the integrity scan does not
107 // flag our own modifications as unauthorized changes.
108 if ( true === $result ) {
109 /** This action is documented in class-wpconfig-security.php */
110 do_action( 'vigilante_critical_file_written', '.htaccess' );
111 }
112
113 return $result;
114 }
115
116 /**
117 * Remove .htaccess rules
118 *
119 * @return bool|WP_Error
120 */
121 public function remove_rules() {
122 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
123
124 $manager = Vigilante_Htaccess_Manager::get_instance();
125
126 $result = $manager->remove_block( self::MARKER_START, self::MARKER_END );
127
128 if ( true === $result ) {
129 /** This action is documented in class-wpconfig-security.php */
130 do_action( 'vigilante_critical_file_written', '.htaccess' );
131 }
132
133 return $result;
134 }
135
136 /**
137 * Remove old plugin rules
138 *
139 * @return bool
140 */
141 public function remove_old_rules() {
142 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
143
144 $manager = Vigilante_Htaccess_Manager::get_instance();
145
146 foreach ( $this->old_markers as $markers ) {
147 $manager->remove_block( $markers[0], $markers[1] );
148 }
149
150 return true;
151 }
152
153 /**
154 * Check if rules are active
155 *
156 * @return bool
157 */
158 public function are_rules_active() {
159 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
160
161 $manager = Vigilante_Htaccess_Manager::get_instance();
162
163 return $manager->block_exists( self::MARKER_START );
164 }
165
166 /**
167 * Check if server is Apache/LiteSpeed
168 *
169 * @return bool
170 */
171 public function is_apache() {
172 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
173 return Vigilante_Htaccess_Manager::get_instance()->is_apache();
174 }
175
176 /**
177 * Check if .htaccess is writable
178 *
179 * @return bool
180 */
181 public function is_htaccess_writable() {
182 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
183 return Vigilante_Htaccess_Manager::get_instance()->is_writable();
184 }
185
186 /**
187 * Generate firewall rules content (without markers)
188 *
189 * OPTION MAPPING (UI checkbox -> setting key -> htaccess rule):
190 *
191 * Section "File Protection (.htaccess)":
192 * - "Directory Browsing" -> disable_directory_browsing -> Options -Indexes
193 * - "Server Signature" -> hide_server_signature -> ServerSignature Off
194 * - "Protect wp-config.php" -> protect_wp_config -> Files wp-config.php
195 * - "Protect wp-cron.php" -> protect_wp_cron -> Files wp-cron.php (opt-in)
196 * - "Protect wp-includes" -> protect_wp_includes -> RewriteRule wp-includes
197 * - "PHP in Uploads" -> protect_uploads_php -> RewriteRule uploads/*.php
198 * - "Sensitive Files" -> protect_sensitive_files -> FilesMatch extensions
199 * - "Limit HTTP Methods" -> limit_http_methods -> RewriteCond REQUEST_METHOD
200 *
201 * Section "Firewall Protection" (htaccess portion):
202 * - "Block Bad Bots" -> block_bad_bots -> RewriteCond USER_AGENT
203 * - "Block Bad Query Strings" -> block_bad_query_strings -> RewriteCond QUERY_STRING
204 *
205 * @return string
206 */
207 private function generate_rules_content() {
208 $rules = array();
209
210 $rules[] = '# Vigilante for WordPress - Firewall v' . VIGILANTE_VERSION;
211 $rules[] = '# Generated: ' . gmdate( 'Y-m-d H:i:s' ) . ' UTC';
212 $rules[] = '# https://servicios.ayudawp.com';
213 $rules[] = '';
214
215 // =====================================================================
216 // SECTION: Basic Server Configuration
217 // =====================================================================
218
219 // Option: hide_server_signature
220 // UI: "Server Signature" checkbox
221 if ( ! empty( $this->options['hide_server_signature'] ) ) {
222 $rules[] = '# Hide server signature';
223 $rules[] = 'ServerSignature Off';
224 $rules[] = '';
225 }
226
227 // Option: disable_directory_browsing
228 // UI: "Directory Browsing" checkbox
229 if ( ! empty( $this->options['disable_directory_browsing'] ) ) {
230 $rules[] = '# Disable directory listing';
231 $rules[] = 'Options -Indexes';
232 $rules[] = '';
233 }
234
235 // =====================================================================
236 // SECTION: Bot and Request Filtering (htaccess-based)
237 // =====================================================================
238
239 // Negated exception conditions shared by every blocking rule below.
240 $whitelist_exceptions = $this->generate_whitelist_exceptions();
241
242 // Option: block_bad_bots
243 // UI: "Block Bad Bots" checkbox
244 if ( ! empty( $this->options['block_bad_bots'] ) ) {
245 $rules[] = '# Block malicious bots and crawlers';
246 $rules[] = '# Exception: WooCommerce IPN callbacks (payment gateways use various User-Agents)';
247 $rules[] = '<IfModule mod_rewrite.c>';
248 $rules[] = ' RewriteEngine On';
249 $rules = array_merge( $rules, $whitelist_exceptions );
250 $rules[] = ' RewriteCond %{QUERY_STRING} !wc-api= [NC]';
251 // Tokens must be specific bot names. The pattern matches as a bare
252 // substring anywhere in the UA ([NC], no word anchors), so a short
253 // generic token 403s legitimate clients: "rma" used to match inside
254 // "Performance" and blocked WP Rocket's page fetch (fixed in 2.9.3
255 // together with custo/disco/library/loader/extract/miner/scan/titan).
256 $rules[] = ' RewriteCond %{HTTP_USER_AGENT} (ahrefs|alexibot|backlink|bandit|black.hole|blackwidow|blekkobot|blowfish|botalot|buddy|builtbottough|bullseye|bunnyslippers|ccbot|cheesebot|cherrypicker|chinaclaw|collector|copier|copyrightcheck|cosmos|crescent|demon|discobot|dittospyder|dotbot|dragonfly|drip|easydl|ebingbong|ecatch|eirgrabber|emailcollector|emailsiphon|emailwolf|eyenetie|flashget|foobot|frontpage|getright|getweb|go.ahead.got.it|gotit|grabnet|grafula|gsa-crawler|harvest|hloader|hmview|httplib|httrack|humanlinks|id-search|ilsebot|indy.library|infotekies|interget|intraformant|iron33|jennybot|jetbot|jetcar|joc|jorgee|kenjin|keyword|larbin|leechftp|lexibot|libweb|libwww|linkextractorpro|linkscan|linkwalker|lwp-trivial|mag-net|magnet|markwatch|mass.downloader|masscan|majestic|mj12bot|morfeus|moget|msiecrawler|navroad|nearsite|netants|netmechanic|netspider|nicerspro|npbot|nutch|octopus|offline.explorer|offline.navigator|openfind|outfoxbot|pagegrabber|papa|pavuk|pcbrowser|pockey|propowerbot|prowebwalker|psbot|pump|queryn|radiation|realdownload|reget|retriever|rogerbot|screaming|semalt|semrush|serpstat|siclab|sistrix|siteexplorer|sitelock|sitesucker|skygrid|smartdownload|snoopy|sogou|sosospider|spankbot|spbot|sqlmap|stackrambler|stripper|sucker|superbot|superhttp|surfbot|surveybot|suzuran|swiftbot|takeout|teleport|telesoft|thenomad|tighttwatbot|tocrawl|true_robot|turingos|turnitinbot|ufoseek|urlspiderpro|vacuum|voidbot|voideye|webauto|webbandit|webcollector|webcopier|webcopy|webfetch|webgo|webleacher|webmasterworldforum|webpictures|webreaper|webripper|websauger|webspider|webster|webstripper|webwhacker|webzip|wget|widow|wisenutbot|wotbox|wwwoffle|xaldon|xenu|zade|zeus|zmeu|zune|zyborg) [NC]';
257 $rules[] = ' RewriteRule .* - [F,L]';
258 $rules[] = '</IfModule>';
259 $rules[] = '';
260 }
261
262 // Option: block_bad_query_strings
263 // UI: "Block Bad Query Strings" checkbox
264 if ( ! empty( $this->options['block_bad_query_strings'] ) ) {
265 $rules[] = '# Block malicious query strings';
266 $rules[] = '<IfModule mod_rewrite.c>';
267 $rules[] = ' RewriteEngine On';
268 $rules = array_merge( $rules, $whitelist_exceptions );
269 $rules[] = ' # SQL injection patterns';
270 $rules[] = ' RewriteCond %{QUERY_STRING} (union.*select) [NC,OR]';
271 $rules[] = ' RewriteCond %{QUERY_STRING} (concat\(.*\)) [NC,OR]';
272 $rules[] = ' # Script injection';
273 $rules[] = ' RewriteCond %{QUERY_STRING} (<script) [NC,OR]';
274 $rules[] = ' RewriteCond %{QUERY_STRING} (javascript:) [NC,OR]';
275 $rules[] = ' # Path traversal';
276 $rules[] = ' RewriteCond %{QUERY_STRING} (\.\.\/) [NC,OR]';
277 $rules[] = ' # Sensitive files access';
278 $rules[] = ' RewriteCond %{QUERY_STRING} (etc\/passwd) [NC,OR]';
279 $rules[] = ' RewriteCond %{QUERY_STRING} (boot\.ini) [NC,OR]';
280 $rules[] = ' # PHP exploits';
281 $rules[] = ' RewriteCond %{QUERY_STRING} (base64_encode) [NC,OR]';
282 $rules[] = ' RewriteCond %{QUERY_STRING} (base64_decode) [NC,OR]';
283 $rules[] = ' RewriteCond %{QUERY_STRING} (GLOBALS=) [NC,OR]';
284 $rules[] = ' RewriteCond %{QUERY_STRING} (_REQUEST=) [NC,OR]';
285 $rules[] = ' # Command injection';
286 $rules[] = ' RewriteCond %{QUERY_STRING} (proc\/self) [NC,OR]';
287 $rules[] = ' # Null bytes';
288 $rules[] = ' RewriteCond %{QUERY_STRING} (%00) [NC]';
289 $rules[] = ' RewriteRule .* - [F,L]';
290 $rules[] = '</IfModule>';
291 $rules[] = '';
292 }
293
294 // Option: limit_http_methods
295 // UI: "Limit HTTP Methods" checkbox
296 // Note: REST API excluded - needs PUT, PATCH, DELETE for plugins like SiteGround Optimizer
297 if ( ! empty( $this->options['limit_http_methods'] ) ) {
298 $rules[] = '# Block suspicious HTTP methods (allow only GET, POST, HEAD)';
299 $rules[] = '# Exception: REST API endpoints need PUT, PATCH, DELETE';
300 $rules[] = '<IfModule mod_rewrite.c>';
301 $rules[] = ' RewriteEngine On';
302 $rules = array_merge( $rules, $whitelist_exceptions );
303 $rules[] = ' RewriteCond %{REQUEST_URI} !^/wp-json/ [NC]';
304 $rules[] = ' RewriteCond %{REQUEST_METHOD} ^(connect|debug|move|trace|track) [NC]';
305 $rules[] = ' RewriteRule .* - [F,L]';
306 $rules[] = '</IfModule>';
307 $rules[] = '';
308 }
309
310 // =====================================================================
311 // SECTION: File Protection
312 // =====================================================================
313
314 // Option: protect_wp_config
315 // UI: "Protect wp-config.php" checkbox
316 // This is SEPARATE from protect_sensitive_files
317 if ( ! empty( $this->options['protect_wp_config'] ) ) {
318 $rules[] = '# Block direct access to wp-config.php';
319 $rules[] = '<Files "wp-config.php">';
320 $rules[] = ' <IfModule mod_authz_core.c>';
321 $rules[] = ' Require all denied';
322 $rules[] = ' </IfModule>';
323 $rules[] = ' <IfModule !mod_authz_core.c>';
324 $rules[] = ' Order Allow,Deny';
325 $rules[] = ' Deny from all';
326 $rules[] = ' </IfModule>';
327 $rules[] = '</Files>';
328 $rules[] = '';
329 }
330
331 // Option: protect_wp_cron
332 // UI: "Protect wp-cron.php" checkbox (off by default — opt-in only)
333 // Blocks direct HTTP access to wp-cron.php to prevent cron-spam DoS abuse.
334 // ONLY safe when the host has a real server-side cron job calling wp-cron.php;
335 // otherwise scheduled WP tasks stop running. Pairs with the wp-config
336 // DISABLE_WP_CRON constant in WP Hardening for full coverage.
337 if ( ! empty( $this->options['protect_wp_cron'] ) ) {
338 $rules[] = '# Block direct HTTP access to wp-cron.php (host-side cron required)';
339 $rules[] = '<Files "wp-cron.php">';
340 $rules[] = ' <IfModule mod_authz_core.c>';
341 $rules[] = ' Require all denied';
342 $rules[] = ' </IfModule>';
343 $rules[] = ' <IfModule !mod_authz_core.c>';
344 $rules[] = ' Order Allow,Deny';
345 $rules[] = ' Deny from all';
346 $rules[] = ' </IfModule>';
347 $rules[] = '</Files>';
348 $rules[] = '';
349 }
350
351 // Option: protect_sensitive_files
352 // UI: "Sensitive Files" checkbox - blocks .sql, .bak, .log, .ini, etc.
353 if ( ! empty( $this->options['protect_sensitive_files'] ) ) {
354 $rules[] = '# Block access to sensitive file types (.sql, .bak, .log, .ini, etc.)';
355 $rules[] = '<FilesMatch "\.(sql|bak|old|tmp|swp|save|backup|log|ini|htpasswd)$">';
356 $rules[] = ' <IfModule mod_authz_core.c>';
357 $rules[] = ' Require all denied';
358 $rules[] = ' </IfModule>';
359 $rules[] = ' <IfModule !mod_authz_core.c>';
360 $rules[] = ' Order Allow,Deny';
361 $rules[] = ' Deny from all';
362 $rules[] = ' </IfModule>';
363 $rules[] = '</FilesMatch>';
364 $rules[] = '';
365
366 // Also block common WordPress sensitive files
367 $rules[] = '# Block access to WordPress sensitive files';
368 $rules[] = '<FilesMatch "^(readme\.html|license\.txt|licencia\.txt|debug\.log|error_log|php_error\.log|\.htaccess)$">';
369 $rules[] = ' <IfModule mod_authz_core.c>';
370 $rules[] = ' Require all denied';
371 $rules[] = ' </IfModule>';
372 $rules[] = ' <IfModule !mod_authz_core.c>';
373 $rules[] = ' Order Allow,Deny';
374 $rules[] = ' Deny from all';
375 $rules[] = ' </IfModule>';
376 $rules[] = '</FilesMatch>';
377 $rules[] = '';
378 }
379
380 // Option: protect_uploads_php
381 // UI: "PHP in Uploads" checkbox
382 if ( ! empty( $this->options['protect_uploads_php'] ) ) {
383 $rules[] = '# Block PHP execution in uploads directory';
384 $rules[] = '<IfModule mod_rewrite.c>';
385 $rules[] = ' RewriteEngine On';
386 $rules[] = ' RewriteRule ^wp-content/uploads/.*\.ph(p[345]?|t|tml|ar)$ - [F,L]';
387 $rules[] = '</IfModule>';
388 $rules[] = '';
389 }
390
391 // Option: protect_wp_includes
392 // UI: "Protect wp-includes" checkbox
393 if ( ! empty( $this->options['protect_wp_includes'] ) ) {
394 $rules[] = '# Block direct access to WordPress includes directory';
395 $rules[] = '<IfModule mod_rewrite.c>';
396 $rules[] = ' RewriteEngine On';
397 $rules[] = ' RewriteBase /';
398 $rules[] = ' RewriteRule ^wp-admin/includes/ - [F,L]';
399 $rules[] = ' RewriteRule !^wp-includes/ - [S=3]';
400 $rules[] = ' RewriteRule ^wp-includes/[^/]+\.php$ - [F,L]';
401 $rules[] = ' RewriteRule ^wp-includes/js/tinymce/langs/.+\.php - [F,L]';
402 $rules[] = ' RewriteRule ^wp-includes/theme-compat/ - [F,L]';
403 $rules[] = '</IfModule>';
404 $rules[] = '';
405 }
406
407 // Option: block_php_in_plugins (if enabled in settings, default false)
408 if ( ! empty( $this->options['block_php_in_plugins'] ) ) {
409 $rules[] = '# Block direct PHP access in plugins';
410 $rules[] = '<IfModule mod_rewrite.c>';
411 $rules[] = ' RewriteEngine On';
412 $rules[] = ' RewriteRule ^wp-content/plugins/.*\.php$ - [F,L]';
413 $rules[] = '</IfModule>';
414 $rules[] = '';
415 }
416
417 // Option: block_php_in_themes (if enabled in settings, default false)
418 if ( ! empty( $this->options['block_php_in_themes'] ) ) {
419 $rules[] = '# Block direct PHP access in themes (except main templates)';
420 $rules[] = '<IfModule mod_rewrite.c>';
421 $rules[] = ' RewriteEngine On';
422 $rules[] = ' RewriteCond %{REQUEST_URI} !^/wp-content/themes/[^/]+/(functions|single|page|index|archive|category|tag|taxonomy|author|search|404|comments|header|footer|sidebar|front-page|home|template-[^/]+|woocommerce[^/]*)\.php$ [NC]';
423 $rules[] = ' RewriteRule ^wp-content/themes/[^/]+/.*\.php$ - [F,L]';
424 $rules[] = '</IfModule>';
425 $rules[] = '';
426 }
427
428 // =====================================================================
429 // SECTION: Fingerprinting Prevention
430 // =====================================================================
431
432 // Option: remove_fingerprinting_headers
433 // UI: "Remove Fingerprinting Headers" checkbox
434 if ( ! empty( $this->options['remove_fingerprinting_headers'] ) ) {
435 $rules[] = '# Remove server fingerprinting headers';
436 $rules[] = '<IfModule mod_headers.c>';
437 $rules[] = ' Header always unset X-Powered-By';
438 $rules[] = ' Header always unset Server';
439 $rules[] = '</IfModule>';
440 }
441
442 return implode( "\n", $rules );
443 }
444
445 /**
446 * Generate rules for display/preview
447 *
448 * @return string
449 */
450 public function generate_rules() {
451 return self::MARKER_START . "\n" . $this->generate_rules_content() . "\n" . self::MARKER_END;
452 }
453
454 /**
455 * Get rules preview for admin
456 *
457 * @return array
458 */
459 public function get_rules_preview() {
460 $preview = array();
461
462 if ( ! empty( $this->options['hide_server_signature'] ) ) {
463 $preview[] = __( 'Hide server signature', 'vigilante' );
464 }
465
466 if ( ! empty( $this->options['disable_directory_browsing'] ) ) {
467 $preview[] = __( 'Disable directory listing', 'vigilante' );
468 }
469
470 if ( ! empty( $this->options['remove_fingerprinting_headers'] ) ) {
471 $preview[] = __( 'Remove fingerprinting headers (X-Powered-By, Server)', 'vigilante' );
472 }
473
474 if ( ! empty( $this->options['block_bad_bots'] ) ) {
475 $preview[] = __( 'Block malicious bots and crawlers', 'vigilante' );
476 }
477
478 if ( ! empty( $this->options['block_bad_query_strings'] ) ) {
479 $preview[] = __( 'Block malicious query strings', 'vigilante' );
480 }
481
482 if ( ! empty( $this->options['limit_http_methods'] ) ) {
483 $preview[] = __( 'Block suspicious HTTP methods', 'vigilante' );
484 }
485
486 if ( ! empty( $this->options['protect_wp_config'] ) ) {
487 $preview[] = __( 'Block direct access to wp-config.php', 'vigilante' );
488 }
489
490 if ( ! empty( $this->options['protect_sensitive_files'] ) ) {
491 $preview[] = __( 'Block access to sensitive files (.sql, .bak, .log, etc.)', 'vigilante' );
492 }
493
494 if ( ! empty( $this->options['protect_uploads_php'] ) ) {
495 $preview[] = __( 'Block PHP execution in uploads', 'vigilante' );
496 }
497
498 if ( ! empty( $this->options['protect_wp_includes'] ) ) {
499 $preview[] = __( 'Protect wp-includes directory', 'vigilante' );
500 }
501
502 return $preview;
503 }
504
505 /**
506 * Build negated RewriteCond exception lines from the firewall whitelists.
507 *
508 * The PHP firewall exempts whitelisted IPs and User-Agents from every
509 * check, but the rules this class writes run inside Apache before PHP
510 * even starts, so the same exemptions must be emitted as negated
511 * conditions ahead of each blocking rule. RewriteCond lines are AND-ed
512 * with a following [OR] chain, so a whitelisted visitor short-circuits
513 * the block while everyone else still hits the filters.
514 *
515 * @since 2.9.3
516 * @return array Lines to insert right after "RewriteEngine On".
517 */
518 private function generate_whitelist_exceptions() {
519 $lines = array();
520
521 // The connection address is always checked. When the site declared a
522 // trusted proxy header (Firewall visitor IP detection, v2.7.0), the
523 // real visitor IP travels in that header, so it is checked too.
524 $ip_variables = array( '%{REMOTE_ADDR}' => false );
525
526 $proxy_variables = array(
527 'cf-connecting-ip' => array( '%{HTTP:CF-Connecting-IP}', false ),
528 'x-real-ip' => array( '%{HTTP:X-Real-IP}', false ),
529 // X-Forwarded-For may carry a comma-separated chain, and only its
530 // last entry was written by the proxy: see
531 // Vigilante_IP_Utils::client_from_chain(). Until 2.11.8 this
532 // matched the first entry, which the visitor writes.
533 'x-forwarded-for' => array( '%{HTTP:X-Forwarded-For}', true ),
534 );
535
536 $trusted = isset( $this->options['trusted_proxy_header'] ) ? (string) $this->options['trusted_proxy_header'] : '';
537
538 if ( isset( $proxy_variables[ $trusted ] ) ) {
539 $ip_variables[ $proxy_variables[ $trusted ][0] ] = $proxy_variables[ $trusted ][1];
540 }
541
542 $ip_list = isset( $this->options['ip_whitelist'] ) ? (array) $this->options['ip_whitelist'] : array();
543
544 foreach ( $ip_list as $entry ) {
545 $pattern = $this->ip_entry_to_pattern( trim( (string) $entry ) );
546
547 if ( null === $pattern ) {
548 // Not expressible as a literal match (off-octet CIDR, IPv6
549 // CIDR). The PHP firewall layer still honours the entry.
550 continue;
551 }
552
553 foreach ( $ip_variables as $variable => $is_chain ) {
554 /*
555 * In a chain, the whitelisted address has to be the LAST entry.
556 * The PHP layer also passes over private addresses at the end,
557 * which a regular expression here cannot do without spelling
558 * out every private range; so behind a proxy of the site's own
559 * network these rules exempt less than PHP does, never more.
560 */
561 if ( 'exact' === $pattern['type'] && ! $is_chain ) {
562 $lines[] = ' RewriteCond ' . $variable . ' "!=' . $pattern['ip'] . '" [NC]';
563 } elseif ( 'exact' === $pattern['type'] ) {
564 $lines[] = ' RewriteCond ' . $variable . ' "!(^|, *)' . $pattern['regex'] . ' *$" [NC]';
565 } elseif ( $is_chain ) {
566 $lines[] = ' RewriteCond ' . $variable . ' "!(^|, *)' . $pattern['regex'] . '[^,]*$" [NC]';
567 } else {
568 $lines[] = ' RewriteCond ' . $variable . ' "!^' . $pattern['regex'] . '" [NC]';
569 }
570 }
571 }
572
573 $ua_list = isset( $this->options['ua_whitelist'] ) ? (array) $this->options['ua_whitelist'] : array();
574
575 foreach ( $ua_list as $ua ) {
576 $ua = trim( (string) $ua );
577
578 /*
579 * A double quote would break the directive syntax (500 on the whole
580 * site), "%" is expanded by mod_rewrite, and a backslash is an
581 * escape character twice over: once for Apache inside a quoted
582 * argument and once for the regex engine. An entry ending in one
583 * escaped the closing quote and answered 500 for every request
584 * (reproduced from the settings screen on 25 aug 2026). None of the
585 * three can be expressed here safely, so the entry is skipped; the
586 * PHP firewall layer still honours it, which is where the matching
587 * actually has to be right.
588 */
589 if ( '' === $ua
590 || false !== strpos( $ua, '"' )
591 || false !== strpos( $ua, '%' )
592 || false !== strpos( $ua, '\\' )
593 || preg_match( '/[^\x20-\x7e]/', $ua )
594 ) {
595 continue;
596 }
597
598 $lines[] = ' RewriteCond %{HTTP_USER_AGENT} "!' . preg_quote( $ua ) . '" [NC]';
599 }
600
601 if ( ! empty( $lines ) ) {
602 array_unshift( $lines, ' # Exceptions: firewall IP / User-Agent whitelist entries bypass these filters' );
603 }
604
605 return $lines;
606 }
607
608 /**
609 * Translate one firewall IP whitelist entry into a literal form that
610 * mod_rewrite can match in .htaccess context.
611 *
612 * Supported: exact IPv4/IPv6, IPv4 wildcards (203.0.113.*), IPv6
613 * wildcards (2a02:c207:*) and IPv4 CIDR blocks on octet boundaries
614 * (/8, /16, /24, /32). Anything else returns null: .htaccess-level
615 * mod_rewrite has no portable CIDR matching, so those entries are
616 * covered by the PHP firewall layer only.
617 *
618 * @since 2.9.3
619 * @param string $entry Whitelist entry as stored.
620 * @return array|null Array with keys type (exact|prefix), regex and,
621 * for exact matches, ip. Null when unsupported.
622 */
623 private function ip_entry_to_pattern( $entry ) {
624 if ( '' === $entry ) {
625 return null;
626 }
627
628 // Exact address. IPv6 is normalized to its compressed lowercase
629 // form, which is how Apache reports REMOTE_ADDR.
630 if ( filter_var( $entry, FILTER_VALIDATE_IP ) ) {
631 $ip = $entry;
632
633 if ( false !== strpos( $entry, ':' ) ) {
634 $packed = inet_pton( $entry );
635
636 if ( false === $packed ) {
637 return null;
638 }
639
640 $ip = inet_ntop( $packed );
641 }
642
643 return array(
644 'type' => 'exact',
645 'ip' => $ip,
646 'regex' => preg_quote( $ip ),
647 );
648 }
649
650 // IPv4 wildcard: 203.0.113.* or 203.0.*
651 if ( preg_match( '/^((?:\d{1,3}\.){1,3})\*$/', $entry, $m ) ) {
652 return array(
653 'type' => 'prefix',
654 'regex' => preg_quote( $m[1] ),
655 );
656 }
657
658 // IPv6 wildcard: 2a02:c207:*
659 if ( preg_match( '/^([0-9a-f]{1,4}(?::[0-9a-f]{1,4})*:)\*$/i', $entry, $m ) ) {
660 return array(
661 'type' => 'prefix',
662 'regex' => preg_quote( strtolower( $m[1] ) ),
663 );
664 }
665
666 // IPv4 CIDR on an octet boundary.
667 if ( preg_match( '/^(\d{1,3}(?:\.\d{1,3}){3})\/(8|16|24|32)$/', $entry, $m ) && filter_var( $m[1], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
668 if ( '32' === $m[2] ) {
669 return array(
670 'type' => 'exact',
671 'ip' => $m[1],
672 'regex' => preg_quote( $m[1] ),
673 );
674 }
675
676 $octets = explode( '.', $m[1] );
677 $keep = (int) $m[2] / 8;
678 $prefix = implode( '.', array_slice( $octets, 0, $keep ) ) . '.';
679
680 return array(
681 'type' => 'prefix',
682 'regex' => preg_quote( $prefix ),
683 );
684 }
685
686 return null;
687 }
688 }