| 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 |
} |