PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.9.7
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.9.7
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… 2.9.7, at includes/class-htaccess-protection.php

659 lines 27.8 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 public function apply_rules() {
82 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
83
84 $manager = Vigilante_Htaccess_Manager::get_instance();
85
86 if ( ! $manager->is_apache() ) {
87 return new WP_Error( 'not_apache', __( 'Server is not Apache/LiteSpeed', 'vigilante' ) );
88 }
89
90 if ( ! $manager->is_writable() ) {
91 return new WP_Error( 'not_writable', __( '.htaccess is not writable', 'vigilante' ) );
92 }
93
94 // Clean old plugin rules first
95 $this->remove_old_rules();
96
97 $rules = $this->generate_rules_content();
98
99 $result = $manager->add_block( self::MARKER_START, self::MARKER_END, $rules, 'before_wordpress' );
100
101 // Regenerate critical file baseline so the integrity scan does not
102 // flag our own modifications as unauthorized changes.
103 if ( true === $result ) {
104 /** This action is documented in class-wpconfig-security.php */
105 do_action( 'vigilante_critical_file_written', '.htaccess' );
106 }
107
108 return $result;
109 }
110
111 /**
112 * Remove .htaccess rules
113 *
114 * @return bool|WP_Error
115 */
116 public function remove_rules() {
117 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
118
119 $manager = Vigilante_Htaccess_Manager::get_instance();
120
121 $result = $manager->remove_block( self::MARKER_START, self::MARKER_END );
122
123 if ( true === $result ) {
124 /** This action is documented in class-wpconfig-security.php */
125 do_action( 'vigilante_critical_file_written', '.htaccess' );
126 }
127
128 return $result;
129 }
130
131 /**
132 * Remove old plugin rules
133 *
134 * @return bool
135 */
136 public function remove_old_rules() {
137 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
138
139 $manager = Vigilante_Htaccess_Manager::get_instance();
140
141 foreach ( $this->old_markers as $markers ) {
142 $manager->remove_block( $markers[0], $markers[1] );
143 }
144
145 return true;
146 }
147
148 /**
149 * Check if rules are active
150 *
151 * @return bool
152 */
153 public function are_rules_active() {
154 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
155
156 $manager = Vigilante_Htaccess_Manager::get_instance();
157
158 return $manager->block_exists( self::MARKER_START );
159 }
160
161 /**
162 * Check if server is Apache/LiteSpeed
163 *
164 * @return bool
165 */
166 public function is_apache() {
167 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
168 return Vigilante_Htaccess_Manager::get_instance()->is_apache();
169 }
170
171 /**
172 * Check if .htaccess is writable
173 *
174 * @return bool
175 */
176 public function is_htaccess_writable() {
177 require_once VIGILANTE_INCLUDES_DIR . 'class-htaccess-manager.php';
178 return Vigilante_Htaccess_Manager::get_instance()->is_writable();
179 }
180
181 /**
182 * Generate firewall rules content (without markers)
183 *
184 * OPTION MAPPING (UI checkbox -> setting key -> htaccess rule):
185 *
186 * Section "File Protection (.htaccess)":
187 * - "Directory Browsing" -> disable_directory_browsing -> Options -Indexes
188 * - "Server Signature" -> hide_server_signature -> ServerSignature Off
189 * - "Protect wp-config.php" -> protect_wp_config -> Files wp-config.php
190 * - "Protect wp-cron.php" -> protect_wp_cron -> Files wp-cron.php (opt-in)
191 * - "Protect wp-includes" -> protect_wp_includes -> RewriteRule wp-includes
192 * - "PHP in Uploads" -> protect_uploads_php -> RewriteRule uploads/*.php
193 * - "Sensitive Files" -> protect_sensitive_files -> FilesMatch extensions
194 * - "Limit HTTP Methods" -> limit_http_methods -> RewriteCond REQUEST_METHOD
195 *
196 * Section "Firewall Protection" (htaccess portion):
197 * - "Block Bad Bots" -> block_bad_bots -> RewriteCond USER_AGENT
198 * - "Block Bad Query Strings" -> block_bad_query_strings -> RewriteCond QUERY_STRING
199 *
200 * @return string
201 */
202 private function generate_rules_content() {
203 $rules = array();
204
205 $rules[] = '# Vigilante for WordPress - Firewall v' . VIGILANTE_VERSION;
206 $rules[] = '# Generated: ' . gmdate( 'Y-m-d H:i:s' ) . ' UTC';
207 $rules[] = '# https://servicios.ayudawp.com';
208 $rules[] = '';
209
210 // =====================================================================
211 // SECTION: Basic Server Configuration
212 // =====================================================================
213
214 // Option: hide_server_signature
215 // UI: "Server Signature" checkbox
216 if ( ! empty( $this->options['hide_server_signature'] ) ) {
217 $rules[] = '# Hide server signature';
218 $rules[] = 'ServerSignature Off';
219 $rules[] = '';
220 }
221
222 // Option: disable_directory_browsing
223 // UI: "Directory Browsing" checkbox
224 if ( ! empty( $this->options['disable_directory_browsing'] ) ) {
225 $rules[] = '# Disable directory listing';
226 $rules[] = 'Options -Indexes';
227 $rules[] = '';
228 }
229
230 // =====================================================================
231 // SECTION: Bot and Request Filtering (htaccess-based)
232 // =====================================================================
233
234 // Negated exception conditions shared by every blocking rule below.
235 $whitelist_exceptions = $this->generate_whitelist_exceptions();
236
237 // Option: block_bad_bots
238 // UI: "Block Bad Bots" checkbox
239 if ( ! empty( $this->options['block_bad_bots'] ) ) {
240 $rules[] = '# Block malicious bots and crawlers';
241 $rules[] = '# Exception: WooCommerce IPN callbacks (payment gateways use various User-Agents)';
242 $rules[] = '<IfModule mod_rewrite.c>';
243 $rules[] = ' RewriteEngine On';
244 $rules = array_merge( $rules, $whitelist_exceptions );
245 $rules[] = ' RewriteCond %{QUERY_STRING} !wc-api= [NC]';
246 // Tokens must be specific bot names. The pattern matches as a bare
247 // substring anywhere in the UA ([NC], no word anchors), so a short
248 // generic token 403s legitimate clients: "rma" used to match inside
249 // "Performance" and blocked WP Rocket's page fetch (fixed in 2.9.3
250 // together with custo/disco/library/loader/extract/miner/scan/titan).
251 $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]';
252 $rules[] = ' RewriteRule .* - [F,L]';
253 $rules[] = '</IfModule>';
254 $rules[] = '';
255 }
256
257 // Option: block_bad_query_strings
258 // UI: "Block Bad Query Strings" checkbox
259 if ( ! empty( $this->options['block_bad_query_strings'] ) ) {
260 $rules[] = '# Block malicious query strings';
261 $rules[] = '<IfModule mod_rewrite.c>';
262 $rules[] = ' RewriteEngine On';
263 $rules = array_merge( $rules, $whitelist_exceptions );
264 $rules[] = ' # SQL injection patterns';
265 $rules[] = ' RewriteCond %{QUERY_STRING} (union.*select) [NC,OR]';
266 $rules[] = ' RewriteCond %{QUERY_STRING} (concat\(.*\)) [NC,OR]';
267 $rules[] = ' # Script injection';
268 $rules[] = ' RewriteCond %{QUERY_STRING} (<script) [NC,OR]';
269 $rules[] = ' RewriteCond %{QUERY_STRING} (javascript:) [NC,OR]';
270 $rules[] = ' # Path traversal';
271 $rules[] = ' RewriteCond %{QUERY_STRING} (\.\.\/) [NC,OR]';
272 $rules[] = ' # Sensitive files access';
273 $rules[] = ' RewriteCond %{QUERY_STRING} (etc\/passwd) [NC,OR]';
274 $rules[] = ' RewriteCond %{QUERY_STRING} (boot\.ini) [NC,OR]';
275 $rules[] = ' # PHP exploits';
276 $rules[] = ' RewriteCond %{QUERY_STRING} (base64_encode) [NC,OR]';
277 $rules[] = ' RewriteCond %{QUERY_STRING} (base64_decode) [NC,OR]';
278 $rules[] = ' RewriteCond %{QUERY_STRING} (GLOBALS=) [NC,OR]';
279 $rules[] = ' RewriteCond %{QUERY_STRING} (_REQUEST=) [NC,OR]';
280 $rules[] = ' # Command injection';
281 $rules[] = ' RewriteCond %{QUERY_STRING} (proc\/self) [NC,OR]';
282 $rules[] = ' # Null bytes';
283 $rules[] = ' RewriteCond %{QUERY_STRING} (%00) [NC]';
284 $rules[] = ' RewriteRule .* - [F,L]';
285 $rules[] = '</IfModule>';
286 $rules[] = '';
287 }
288
289 // Option: limit_http_methods
290 // UI: "Limit HTTP Methods" checkbox
291 // Note: REST API excluded - needs PUT, PATCH, DELETE for plugins like SiteGround Optimizer
292 if ( ! empty( $this->options['limit_http_methods'] ) ) {
293 $rules[] = '# Block suspicious HTTP methods (allow only GET, POST, HEAD)';
294 $rules[] = '# Exception: REST API endpoints need PUT, PATCH, DELETE';
295 $rules[] = '<IfModule mod_rewrite.c>';
296 $rules[] = ' RewriteEngine On';
297 $rules = array_merge( $rules, $whitelist_exceptions );
298 $rules[] = ' RewriteCond %{REQUEST_URI} !^/wp-json/ [NC]';
299 $rules[] = ' RewriteCond %{REQUEST_METHOD} ^(connect|debug|move|trace|track) [NC]';
300 $rules[] = ' RewriteRule .* - [F,L]';
301 $rules[] = '</IfModule>';
302 $rules[] = '';
303 }
304
305 // =====================================================================
306 // SECTION: File Protection
307 // =====================================================================
308
309 // Option: protect_wp_config
310 // UI: "Protect wp-config.php" checkbox
311 // This is SEPARATE from protect_sensitive_files
312 if ( ! empty( $this->options['protect_wp_config'] ) ) {
313 $rules[] = '# Block direct access to wp-config.php';
314 $rules[] = '<Files "wp-config.php">';
315 $rules[] = ' <IfModule mod_authz_core.c>';
316 $rules[] = ' Require all denied';
317 $rules[] = ' </IfModule>';
318 $rules[] = ' <IfModule !mod_authz_core.c>';
319 $rules[] = ' Order Allow,Deny';
320 $rules[] = ' Deny from all';
321 $rules[] = ' </IfModule>';
322 $rules[] = '</Files>';
323 $rules[] = '';
324 }
325
326 // Option: protect_wp_cron
327 // UI: "Protect wp-cron.php" checkbox (off by default — opt-in only)
328 // Blocks direct HTTP access to wp-cron.php to prevent cron-spam DoS abuse.
329 // ONLY safe when the host has a real server-side cron job calling wp-cron.php;
330 // otherwise scheduled WP tasks stop running. Pairs with the wp-config
331 // DISABLE_WP_CRON constant in WP Hardening for full coverage.
332 if ( ! empty( $this->options['protect_wp_cron'] ) ) {
333 $rules[] = '# Block direct HTTP access to wp-cron.php (host-side cron required)';
334 $rules[] = '<Files "wp-cron.php">';
335 $rules[] = ' <IfModule mod_authz_core.c>';
336 $rules[] = ' Require all denied';
337 $rules[] = ' </IfModule>';
338 $rules[] = ' <IfModule !mod_authz_core.c>';
339 $rules[] = ' Order Allow,Deny';
340 $rules[] = ' Deny from all';
341 $rules[] = ' </IfModule>';
342 $rules[] = '</Files>';
343 $rules[] = '';
344 }
345
346 // Option: protect_sensitive_files
347 // UI: "Sensitive Files" checkbox - blocks .sql, .bak, .log, .ini, etc.
348 if ( ! empty( $this->options['protect_sensitive_files'] ) ) {
349 $rules[] = '# Block access to sensitive file types (.sql, .bak, .log, .ini, etc.)';
350 $rules[] = '<FilesMatch "\.(sql|bak|old|tmp|swp|save|backup|log|ini|htpasswd)$">';
351 $rules[] = ' <IfModule mod_authz_core.c>';
352 $rules[] = ' Require all denied';
353 $rules[] = ' </IfModule>';
354 $rules[] = ' <IfModule !mod_authz_core.c>';
355 $rules[] = ' Order Allow,Deny';
356 $rules[] = ' Deny from all';
357 $rules[] = ' </IfModule>';
358 $rules[] = '</FilesMatch>';
359 $rules[] = '';
360
361 // Also block common WordPress sensitive files
362 $rules[] = '# Block access to WordPress sensitive files';
363 $rules[] = '<FilesMatch "^(readme\.html|license\.txt|licencia\.txt|debug\.log|error_log|php_error\.log|\.htaccess)$">';
364 $rules[] = ' <IfModule mod_authz_core.c>';
365 $rules[] = ' Require all denied';
366 $rules[] = ' </IfModule>';
367 $rules[] = ' <IfModule !mod_authz_core.c>';
368 $rules[] = ' Order Allow,Deny';
369 $rules[] = ' Deny from all';
370 $rules[] = ' </IfModule>';
371 $rules[] = '</FilesMatch>';
372 $rules[] = '';
373 }
374
375 // Option: protect_uploads_php
376 // UI: "PHP in Uploads" checkbox
377 if ( ! empty( $this->options['protect_uploads_php'] ) ) {
378 $rules[] = '# Block PHP execution in uploads directory';
379 $rules[] = '<IfModule mod_rewrite.c>';
380 $rules[] = ' RewriteEngine On';
381 $rules[] = ' RewriteRule ^wp-content/uploads/.*\.ph(p[345]?|t|tml|ar)$ - [F,L]';
382 $rules[] = '</IfModule>';
383 $rules[] = '';
384 }
385
386 // Option: protect_wp_includes
387 // UI: "Protect wp-includes" checkbox
388 if ( ! empty( $this->options['protect_wp_includes'] ) ) {
389 $rules[] = '# Block direct access to WordPress includes directory';
390 $rules[] = '<IfModule mod_rewrite.c>';
391 $rules[] = ' RewriteEngine On';
392 $rules[] = ' RewriteBase /';
393 $rules[] = ' RewriteRule ^wp-admin/includes/ - [F,L]';
394 $rules[] = ' RewriteRule !^wp-includes/ - [S=3]';
395 $rules[] = ' RewriteRule ^wp-includes/[^/]+\.php$ - [F,L]';
396 $rules[] = ' RewriteRule ^wp-includes/js/tinymce/langs/.+\.php - [F,L]';
397 $rules[] = ' RewriteRule ^wp-includes/theme-compat/ - [F,L]';
398 $rules[] = '</IfModule>';
399 $rules[] = '';
400 }
401
402 // Option: block_php_in_plugins (if enabled in settings, default false)
403 if ( ! empty( $this->options['block_php_in_plugins'] ) ) {
404 $rules[] = '# Block direct PHP access in plugins';
405 $rules[] = '<IfModule mod_rewrite.c>';
406 $rules[] = ' RewriteEngine On';
407 $rules[] = ' RewriteRule ^wp-content/plugins/.*\.php$ - [F,L]';
408 $rules[] = '</IfModule>';
409 $rules[] = '';
410 }
411
412 // Option: block_php_in_themes (if enabled in settings, default false)
413 if ( ! empty( $this->options['block_php_in_themes'] ) ) {
414 $rules[] = '# Block direct PHP access in themes (except main templates)';
415 $rules[] = '<IfModule mod_rewrite.c>';
416 $rules[] = ' RewriteEngine On';
417 $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]';
418 $rules[] = ' RewriteRule ^wp-content/themes/[^/]+/.*\.php$ - [F,L]';
419 $rules[] = '</IfModule>';
420 $rules[] = '';
421 }
422
423 // =====================================================================
424 // SECTION: Fingerprinting Prevention
425 // =====================================================================
426
427 // Option: remove_fingerprinting_headers
428 // UI: "Remove Fingerprinting Headers" checkbox
429 if ( ! empty( $this->options['remove_fingerprinting_headers'] ) ) {
430 $rules[] = '# Remove server fingerprinting headers';
431 $rules[] = '<IfModule mod_headers.c>';
432 $rules[] = ' Header always unset X-Powered-By';
433 $rules[] = ' Header always unset Server';
434 $rules[] = '</IfModule>';
435 }
436
437 return implode( "\n", $rules );
438 }
439
440 /**
441 * Generate rules for display/preview
442 *
443 * @return string
444 */
445 public function generate_rules() {
446 return self::MARKER_START . "\n" . $this->generate_rules_content() . "\n" . self::MARKER_END;
447 }
448
449 /**
450 * Get rules preview for admin
451 *
452 * @return array
453 */
454 public function get_rules_preview() {
455 $preview = array();
456
457 if ( ! empty( $this->options['hide_server_signature'] ) ) {
458 $preview[] = __( 'Hide server signature', 'vigilante' );
459 }
460
461 if ( ! empty( $this->options['disable_directory_browsing'] ) ) {
462 $preview[] = __( 'Disable directory listing', 'vigilante' );
463 }
464
465 if ( ! empty( $this->options['remove_fingerprinting_headers'] ) ) {
466 $preview[] = __( 'Remove fingerprinting headers (X-Powered-By, Server)', 'vigilante' );
467 }
468
469 if ( ! empty( $this->options['block_bad_bots'] ) ) {
470 $preview[] = __( 'Block malicious bots and crawlers', 'vigilante' );
471 }
472
473 if ( ! empty( $this->options['block_bad_query_strings'] ) ) {
474 $preview[] = __( 'Block malicious query strings', 'vigilante' );
475 }
476
477 if ( ! empty( $this->options['limit_http_methods'] ) ) {
478 $preview[] = __( 'Block suspicious HTTP methods', 'vigilante' );
479 }
480
481 if ( ! empty( $this->options['protect_wp_config'] ) ) {
482 $preview[] = __( 'Block direct access to wp-config.php', 'vigilante' );
483 }
484
485 if ( ! empty( $this->options['protect_sensitive_files'] ) ) {
486 $preview[] = __( 'Block access to sensitive files (.sql, .bak, .log, etc.)', 'vigilante' );
487 }
488
489 if ( ! empty( $this->options['protect_uploads_php'] ) ) {
490 $preview[] = __( 'Block PHP execution in uploads', 'vigilante' );
491 }
492
493 if ( ! empty( $this->options['protect_wp_includes'] ) ) {
494 $preview[] = __( 'Protect wp-includes directory', 'vigilante' );
495 }
496
497 return $preview;
498 }
499
500 /**
501 * Build negated RewriteCond exception lines from the firewall whitelists.
502 *
503 * The PHP firewall exempts whitelisted IPs and User-Agents from every
504 * check, but the rules this class writes run inside Apache before PHP
505 * even starts, so the same exemptions must be emitted as negated
506 * conditions ahead of each blocking rule. RewriteCond lines are AND-ed
507 * with a following [OR] chain, so a whitelisted visitor short-circuits
508 * the block while everyone else still hits the filters.
509 *
510 * @since 2.9.3
511 * @return array Lines to insert right after "RewriteEngine On".
512 */
513 private function generate_whitelist_exceptions() {
514 $lines = array();
515
516 // The connection address is always checked. When the site declared a
517 // trusted proxy header (Firewall visitor IP detection, v2.7.0), the
518 // real visitor IP travels in that header, so it is checked too.
519 $ip_variables = array( '%{REMOTE_ADDR}' => false );
520
521 $proxy_variables = array(
522 'cf-connecting-ip' => array( '%{HTTP:CF-Connecting-IP}', false ),
523 'x-real-ip' => array( '%{HTTP:X-Real-IP}', false ),
524 // X-Forwarded-For may carry a comma-separated proxy chain; the
525 // client is the first hop, so a trailing list is allowed.
526 'x-forwarded-for' => array( '%{HTTP:X-Forwarded-For}', true ),
527 );
528
529 $trusted = isset( $this->options['trusted_proxy_header'] ) ? (string) $this->options['trusted_proxy_header'] : '';
530
531 if ( isset( $proxy_variables[ $trusted ] ) ) {
532 $ip_variables[ $proxy_variables[ $trusted ][0] ] = $proxy_variables[ $trusted ][1];
533 }
534
535 $ip_list = isset( $this->options['ip_whitelist'] ) ? (array) $this->options['ip_whitelist'] : array();
536
537 foreach ( $ip_list as $entry ) {
538 $pattern = $this->ip_entry_to_pattern( trim( (string) $entry ) );
539
540 if ( null === $pattern ) {
541 // Not expressible as a literal match (off-octet CIDR, IPv6
542 // CIDR). The PHP firewall layer still honours the entry.
543 continue;
544 }
545
546 foreach ( $ip_variables as $variable => $is_chain ) {
547 if ( 'exact' === $pattern['type'] && ! $is_chain ) {
548 $lines[] = ' RewriteCond ' . $variable . ' "!=' . $pattern['ip'] . '" [NC]';
549 } elseif ( 'exact' === $pattern['type'] ) {
550 $lines[] = ' RewriteCond ' . $variable . ' "!^' . $pattern['regex'] . '(,|$)" [NC]';
551 } else {
552 $lines[] = ' RewriteCond ' . $variable . ' "!^' . $pattern['regex'] . '" [NC]';
553 }
554 }
555 }
556
557 $ua_list = isset( $this->options['ua_whitelist'] ) ? (array) $this->options['ua_whitelist'] : array();
558
559 foreach ( $ua_list as $ua ) {
560 $ua = trim( (string) $ua );
561
562 // A double quote would break the directive syntax (500 on the
563 // whole site), and "%" is expanded by mod_rewrite. Skip those
564 // entries; the PHP firewall layer still honours them.
565 if ( '' === $ua || false !== strpos( $ua, '"' ) || false !== strpos( $ua, '%' ) || preg_match( '/[^\x20-\x7e]/', $ua ) ) {
566 continue;
567 }
568
569 $lines[] = ' RewriteCond %{HTTP_USER_AGENT} "!' . preg_quote( $ua ) . '" [NC]';
570 }
571
572 if ( ! empty( $lines ) ) {
573 array_unshift( $lines, ' # Exceptions: firewall IP / User-Agent whitelist entries bypass these filters' );
574 }
575
576 return $lines;
577 }
578
579 /**
580 * Translate one firewall IP whitelist entry into a literal form that
581 * mod_rewrite can match in .htaccess context.
582 *
583 * Supported: exact IPv4/IPv6, IPv4 wildcards (203.0.113.*), IPv6
584 * wildcards (2a02:c207:*) and IPv4 CIDR blocks on octet boundaries
585 * (/8, /16, /24, /32). Anything else returns null: .htaccess-level
586 * mod_rewrite has no portable CIDR matching, so those entries are
587 * covered by the PHP firewall layer only.
588 *
589 * @since 2.9.3
590 * @param string $entry Whitelist entry as stored.
591 * @return array|null Array with keys type (exact|prefix), regex and,
592 * for exact matches, ip. Null when unsupported.
593 */
594 private function ip_entry_to_pattern( $entry ) {
595 if ( '' === $entry ) {
596 return null;
597 }
598
599 // Exact address. IPv6 is normalized to its compressed lowercase
600 // form, which is how Apache reports REMOTE_ADDR.
601 if ( filter_var( $entry, FILTER_VALIDATE_IP ) ) {
602 $ip = $entry;
603
604 if ( false !== strpos( $entry, ':' ) ) {
605 $packed = inet_pton( $entry );
606
607 if ( false === $packed ) {
608 return null;
609 }
610
611 $ip = inet_ntop( $packed );
612 }
613
614 return array(
615 'type' => 'exact',
616 'ip' => $ip,
617 'regex' => preg_quote( $ip ),
618 );
619 }
620
621 // IPv4 wildcard: 203.0.113.* or 203.0.*
622 if ( preg_match( '/^((?:\d{1,3}\.){1,3})\*$/', $entry, $m ) ) {
623 return array(
624 'type' => 'prefix',
625 'regex' => preg_quote( $m[1] ),
626 );
627 }
628
629 // IPv6 wildcard: 2a02:c207:*
630 if ( preg_match( '/^([0-9a-f]{1,4}(?::[0-9a-f]{1,4})*:)\*$/i', $entry, $m ) ) {
631 return array(
632 'type' => 'prefix',
633 'regex' => preg_quote( strtolower( $m[1] ) ),
634 );
635 }
636
637 // IPv4 CIDR on an octet boundary.
638 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 ) ) {
639 if ( '32' === $m[2] ) {
640 return array(
641 'type' => 'exact',
642 'ip' => $m[1],
643 'regex' => preg_quote( $m[1] ),
644 );
645 }
646
647 $octets = explode( '.', $m[1] );
648 $keep = (int) $m[2] / 8;
649 $prefix = implode( '.', array_slice( $octets, 0, $keep ) ) . '.';
650
651 return array(
652 'type' => 'prefix',
653 'regex' => preg_quote( $prefix ),
654 );
655 }
656
657 return null;
658 }
659 }