PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.0
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.0
3.0.0 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 All 88 releases
vigilante / includes / class-htaccess-protection.php

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

781 lines 34.1 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, but only
524 // from a peer that is one of the declared proxies (see below).
525 $proxy_variables = array(
526 'cf-connecting-ip' => array( '%{HTTP:CF-Connecting-IP}', false ),
527 'x-real-ip' => array( '%{HTTP:X-Real-IP}', false ),
528 // X-Forwarded-For may carry a comma-separated chain, and only its
529 // last entry was written by the proxy: see
530 // Vigilante_IP_Utils::client_from_chain(). Until 2.11.8 this
531 // matched the first entry, which the visitor writes.
532 'x-forwarded-for' => array( '%{HTTP:X-Forwarded-For}', true ),
533 );
534
535 $trusted = isset( $this->options['trusted_proxy_header'] ) ? (string) $this->options['trusted_proxy_header'] : '';
536 $header_variable = null;
537 $header_is_chain = false;
538
539 if ( isset( $proxy_variables[ $trusted ] ) ) {
540 $header_variable = $proxy_variables[ $trusted ][0];
541 $header_is_chain = $proxy_variables[ $trusted ][1];
542 }
543
544 /*
545 * The header only counts when the connection comes from a proxy the site
546 * declared, exactly the policy Vigilante_IP_Utils::peer_is_trusted_proxy()
547 * applies in PHP. Until 2.11.10 this half emitted the header condition on
548 * its own, so anyone reaching the origin directly and sending
549 * X-Forwarded-For with a whitelisted address skipped the Apache filters
550 * for SQL injection, XSS, traversal, methods and bad bots. It is the same
551 * spoofing wp.org reported as 6.1 against 2.11.8, in the half that fix did
552 * not reach. Found by the file-by-file review of 2.11.10.
553 *
554 * Apache cannot express "trusted peer" inside a negated chain, so the
555 * peer is resolved once into an environment variable and every header
556 * condition is OR-ed with it. With no trusted proxies declared the header
557 * is not honoured here at all, which fails safe: the PHP layer still
558 * exempts the visitor, so a whitelisted address loses nothing except the
559 * exemption from filters it was never going to trip.
560 */
561 $proxy_patterns = array();
562
563 foreach ( (array) ( isset( $this->options['trusted_proxies'] ) ? $this->options['trusted_proxies'] : array() ) as $proxy_entry ) {
564 $proxy_entry = trim( (string) $proxy_entry );
565
566 /*
567 * The same filter the PHP side applies, and it has to be the same
568 * one: Vigilante_IP_Utils::in_list_ip_or_cidr() rejects wildcards and
569 * ranges too wide to name a proxy, while ip_entry_to_pattern()
570 * accepts both. Without this, an entry like 10.* that reached the
571 * option through an import (which does not pass
572 * split_list_ip_or_cidr()) made Apache trust a peer that PHP does
573 * not, which is the trust-everyone footgun this list exists to
574 * avoid. Found by the cross review of 2.11.10.
575 *
576 * "The same one" is now literally true and was not when it was
577 * written: the two sides ran different code that happened to agree
578 * on most inputs. They share proxy_prefix_is_sane() and
579 * same_address() (class-ip-utils.php), so a disagreement has to be
580 * introduced on purpose. The second cross review of 2.11.10 found
581 * the two that were left, an IPv6 written in another case and a /1.
582 */
583 if ( ! Vigilante_IP_Utils::is_valid_proxy( $proxy_entry ) ) {
584 continue;
585 }
586
587 $proxy_pattern = $this->ip_entry_to_pattern( $proxy_entry );
588
589 if ( null === $proxy_pattern ) {
590 continue;
591 }
592
593 $proxy_patterns[] = ( 'exact' === $proxy_pattern['type'] )
594 ? '^' . $proxy_pattern['regex'] . '$'
595 : '^' . $proxy_pattern['regex'];
596 }
597
598 $ip_list = isset( $this->options['ip_whitelist'] ) ? (array) $this->options['ip_whitelist'] : array();
599
600 // Nothing to exempt means nothing to emit: without a whitelist the
601 // SetEnvIf served no purpose and was written three times anyway, once per
602 // block that uses these exceptions.
603 if ( empty( $proxy_patterns ) || empty( $ip_list ) ) {
604 $header_variable = null;
605 }
606
607 if ( null !== $header_variable ) {
608 /*
609 * Inside its own IfModule, and not inside the mod_rewrite one this
610 * block lives in. Measured on Apache: an unknown directive inside
611 * <IfModule mod_rewrite.c> returns 500 for the whole tree, so a
612 * server without mod_setenvif would be taken down by the file
613 * Vigilant writes to the document root. Found by the cross review of
614 * 2.11.10. Without the variable set, the condition below reads it as
615 * empty and the header simply never exempts, which is the safe side.
616 */
617 $lines[] = ' <IfModule mod_setenvif.c>';
618 $lines[] = ' SetEnvIf Remote_Addr "' . implode( '|', $proxy_patterns ) . '" VIGILANTE_TRUSTED_PROXY=1';
619 $lines[] = ' </IfModule>';
620 }
621
622 foreach ( $ip_list as $entry ) {
623 $pattern = $this->ip_entry_to_pattern( trim( (string) $entry ) );
624
625 if ( null === $pattern ) {
626 // Not expressible as a literal match (off-octet CIDR, IPv6
627 // CIDR). The PHP firewall layer still honours the entry.
628 continue;
629 }
630
631 // The connection address is always checked, on its own.
632 if ( 'exact' === $pattern['type'] ) {
633 $lines[] = ' RewriteCond %{REMOTE_ADDR} "!=' . $pattern['ip'] . '" [NC]';
634 } else {
635 $lines[] = ' RewriteCond %{REMOTE_ADDR} "!^' . $pattern['regex'] . '" [NC]';
636 }
637
638 if ( null === $header_variable ) {
639 continue;
640 }
641
642 /*
643 * And the header, but only when the peer is one of the declared
644 * proxies: the [OR] ties the two, so the exemption needs a trusted
645 * peer AND a matching header.
646 *
647 * In a chain the whitelisted address has to be the LAST entry. The
648 * PHP layer also passes over private addresses at the end, which a
649 * regular expression here cannot do without spelling out every
650 * private range; so behind a proxy of the site's own network these
651 * rules exempt less than PHP does, never more.
652 */
653 $lines[] = ' RewriteCond %{ENV:VIGILANTE_TRUSTED_PROXY} "!=1" [OR]';
654
655 if ( 'exact' === $pattern['type'] && ! $header_is_chain ) {
656 $lines[] = ' RewriteCond ' . $header_variable . ' "!=' . $pattern['ip'] . '" [NC]';
657 } elseif ( 'exact' === $pattern['type'] ) {
658 $lines[] = ' RewriteCond ' . $header_variable . ' "!(^|, *)' . $pattern['regex'] . ' *$" [NC]';
659 } elseif ( $header_is_chain ) {
660 $lines[] = ' RewriteCond ' . $header_variable . ' "!(^|, *)' . $pattern['regex'] . '[^,]*$" [NC]';
661 } else {
662 $lines[] = ' RewriteCond ' . $header_variable . ' "!^' . $pattern['regex'] . '" [NC]';
663 }
664 }
665
666 $ua_list = isset( $this->options['ua_whitelist'] ) ? (array) $this->options['ua_whitelist'] : array();
667
668 foreach ( $ua_list as $ua ) {
669 $ua = trim( (string) $ua );
670
671 /*
672 * A double quote would break the directive syntax (500 on the whole
673 * site), "%" is expanded by mod_rewrite, and a backslash is an
674 * escape character twice over: once for Apache inside a quoted
675 * argument and once for the regex engine. An entry ending in one
676 * escaped the closing quote and answered 500 for every request
677 * (reproduced from the settings screen on 25 aug 2026). None of the
678 * three can be expressed here safely, so the entry is skipped; the
679 * PHP firewall layer still honours it, which is where the matching
680 * actually has to be right.
681 */
682 if ( '' === $ua
683 || false !== strpos( $ua, '"' )
684 || false !== strpos( $ua, '%' )
685 || false !== strpos( $ua, '\\' )
686 || preg_match( '/[^\x20-\x7e]/', $ua )
687 ) {
688 continue;
689 }
690
691 $lines[] = ' RewriteCond %{HTTP_USER_AGENT} "!' . preg_quote( $ua ) . '" [NC]';
692 }
693
694 if ( ! empty( $lines ) ) {
695 array_unshift( $lines, ' # Exceptions: firewall IP / User-Agent whitelist entries bypass these filters' );
696 }
697
698 return $lines;
699 }
700
701 /**
702 * Translate one firewall IP whitelist entry into a literal form that
703 * mod_rewrite can match in .htaccess context.
704 *
705 * Supported: exact IPv4/IPv6, IPv4 wildcards (203.0.113.*), IPv6
706 * wildcards (2a02:c207:*) and IPv4 CIDR blocks on octet boundaries
707 * (/8, /16, /24, /32). Anything else returns null: .htaccess-level
708 * mod_rewrite has no portable CIDR matching, so those entries are
709 * covered by the PHP firewall layer only.
710 *
711 * @since 2.9.3
712 * @param string $entry Whitelist entry as stored.
713 * @return array|null Array with keys type (exact|prefix), regex and,
714 * for exact matches, ip. Null when unsupported.
715 */
716 private function ip_entry_to_pattern( $entry ) {
717 if ( '' === $entry ) {
718 return null;
719 }
720
721 // Exact address. IPv6 is normalized to its compressed lowercase
722 // form, which is how Apache reports REMOTE_ADDR.
723 if ( filter_var( $entry, FILTER_VALIDATE_IP ) ) {
724 $ip = $entry;
725
726 if ( false !== strpos( $entry, ':' ) ) {
727 $packed = inet_pton( $entry );
728
729 if ( false === $packed ) {
730 return null;
731 }
732
733 $ip = inet_ntop( $packed );
734 }
735
736 return array(
737 'type' => 'exact',
738 'ip' => $ip,
739 'regex' => preg_quote( $ip ),
740 );
741 }
742
743 // IPv4 wildcard: 203.0.113.* or 203.0.*
744 if ( preg_match( '/^((?:\d{1,3}\.){1,3})\*$/', $entry, $m ) ) {
745 return array(
746 'type' => 'prefix',
747 'regex' => preg_quote( $m[1] ),
748 );
749 }
750
751 // IPv6 wildcard: 2a02:c207:*
752 if ( preg_match( '/^([0-9a-f]{1,4}(?::[0-9a-f]{1,4})*:)\*$/i', $entry, $m ) ) {
753 return array(
754 'type' => 'prefix',
755 'regex' => preg_quote( strtolower( $m[1] ) ),
756 );
757 }
758
759 // IPv4 CIDR on an octet boundary.
760 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 ) ) {
761 if ( '32' === $m[2] ) {
762 return array(
763 'type' => 'exact',
764 'ip' => $m[1],
765 'regex' => preg_quote( $m[1] ),
766 );
767 }
768
769 $octets = explode( '.', $m[1] );
770 $keep = (int) $m[2] / 8;
771 $prefix = implode( '.', array_slice( $octets, 0, $keep ) ) . '.';
772
773 return array(
774 'type' => 'prefix',
775 'regex' => preg_quote( $prefix ),
776 );
777 }
778
779 return null;
780 }
781 }