siteguard-admin-filter.php
3 months ago
siteguard-base.php
3 weeks ago
siteguard-captcha.php
1 week ago
siteguard-config.php
1 year ago
siteguard-disable-author-query.php
2 months ago
siteguard-disable-pingback.php
3 months ago
siteguard-disable-xmlrpc.php
3 months ago
siteguard-htaccess.php
3 weeks ago
siteguard-login-alert.php
3 months ago
siteguard-login-history.php
2 months ago
siteguard-login-lock.php
2 months ago
siteguard-rename-login.php
1 week ago
siteguard-updates-notify.php
1 week ago
siteguard-waf-exclude-rule.php
3 months ago
siteguard-htaccess.php
463 lines
| 1 | <?php |
| 2 | |
| 3 | class SiteGuard_Htaccess extends SiteGuard_Base { |
| 4 | const HTACCESS_PERMISSION = 0604; |
| 5 | const HTACCESS_MARK_START = '#SITEGUARD_PLUGIN_SETTINGS_START'; |
| 6 | const HTACCESS_MARK_END = '#SITEGUARD_PLUGIN_SETTINGS_END'; |
| 7 | |
| 8 | // Temporary directory used by test_htaccess(), and how long one may live |
| 9 | // before it is considered abandoned. The lifetime has to stay well above |
| 10 | // the two wp_remote_get timeouts below, so that a directory belonging to a |
| 11 | // self-test that is still running is never swept away by another run. |
| 12 | const TEST_DIR_PREFIX = 'siteguard-test-'; |
| 13 | const TEST_DIR_MAX_AGE = 300; |
| 14 | |
| 15 | function __construct() { |
| 16 | } |
| 17 | static function get_htaccess_file() { |
| 18 | return ABSPATH . '.htaccess'; |
| 19 | } |
| 20 | static function get_tmp_dir() { |
| 21 | return SITEGUARD_PATH . 'tmp/'; |
| 22 | } |
| 23 | static function is_writable_htaccess() { |
| 24 | if ( isset( $_SERVER['SERVER_SOFTWARE'] ) && false !== stripos( $_SERVER['SERVER_SOFTWARE'], 'nginx' ) ) { |
| 25 | return false; |
| 26 | } |
| 27 | if ( file_exists( self::get_htaccess_file() ) ) { |
| 28 | return is_writable( self::get_htaccess_file() ); |
| 29 | } |
| 30 | return is_writable( ABSPATH ); |
| 31 | } |
| 32 | |
| 33 | // Diagnostic reason for the most recent test_htaccess() failure, as an array |
| 34 | // like array( 'code' => 'http_status', 'url' => ..., 'status' => 403 ). An empty |
| 35 | // array means success (or not yet run). SiteGuard_RenameLogin reads this to |
| 36 | // record why it fell back to stub (.php) mode, so administrators can see the |
| 37 | // cause on the settings screen. |
| 38 | public static $last_reason = array(); |
| 39 | |
| 40 | static function test_htaccess() { |
| 41 | self::$last_reason = array(); |
| 42 | if ( ! self::is_writable_htaccess() ) { |
| 43 | $is_nginx = isset( $_SERVER['SERVER_SOFTWARE'] ) && false !== stripos( $_SERVER['SERVER_SOFTWARE'], 'nginx' ); |
| 44 | self::$last_reason = array( 'code' => $is_nginx ? 'nginx' : 'not_writable' ); |
| 45 | return false; |
| 46 | } |
| 47 | |
| 48 | // Sweep orphaned test directories left behind by previous runs whose |
| 49 | // wp_remote_get timed out before cleanup could execute. |
| 50 | self::cleanup_orphaned_test_dirs(); |
| 51 | |
| 52 | |
| 53 | $test_dir_name = self::TEST_DIR_PREFIX . uniqid(); |
| 54 | $test_dir_path = ABSPATH . $test_dir_name; |
| 55 | $htaccess_path = $test_dir_path . '/.htaccess'; |
| 56 | $php_file_path = $test_dir_path . '/test.php'; |
| 57 | // The test directory is created under ABSPATH, which is served at the |
| 58 | // WordPress Address (siteurl), NOT necessarily the Site Address (home). |
| 59 | // On "WordPress in its own directory" installs (e.g. core in /wp, site at |
| 60 | // root) these differ, so home_url() would build a URL that does not map to |
| 61 | // the test directory and the self-test would always 404. Use the raw |
| 62 | // siteurl (get_option avoids the rename-login site_url filter) so the URL |
| 63 | // matches ABSPATH. For ordinary installs siteurl == home, so no change. |
| 64 | $base_url = rtrim( get_option( 'siteurl' ), '/' ); |
| 65 | $test_url = $base_url . '/' . $test_dir_name . '/test.html'; |
| 66 | $php_content = '<?php echo "SUCCESS";'; |
| 67 | $htaccess_content = "RewriteEngine On\nRewriteRule ^test\\.html$ test.php [L]"; |
| 68 | |
| 69 | $cleanup = function () use ( $htaccess_path, $php_file_path, $test_dir_path ) { |
| 70 | if ( file_exists( $htaccess_path ) ) { |
| 71 | @unlink( $htaccess_path ); |
| 72 | } |
| 73 | if ( file_exists( $php_file_path ) ) { |
| 74 | @unlink( $php_file_path ); |
| 75 | } |
| 76 | if ( is_dir( $test_dir_path ) ) { |
| 77 | @rmdir( $test_dir_path ); |
| 78 | } |
| 79 | }; |
| 80 | |
| 81 | if ( ! @mkdir( $test_dir_path, 0755 ) ) { |
| 82 | self::$last_reason = array( 'code' => 'mkdir' ); |
| 83 | return false; |
| 84 | } |
| 85 | |
| 86 | if ( false === @file_put_contents( $php_file_path, $php_content ) || false === @file_put_contents( $htaccess_path, $htaccess_content ) ) { |
| 87 | $cleanup(); |
| 88 | self::$last_reason = array( 'code' => 'write' ); |
| 89 | return false; |
| 90 | } |
| 91 | |
| 92 | $args = array( |
| 93 | 'timeout' => 10, |
| 94 | 'sslverify' => apply_filters( 'https_local_ssl_verify', false ), |
| 95 | ); |
| 96 | $response = wp_remote_get( $test_url, $args ); |
| 97 | |
| 98 | // On success the .htaccess rewrite turned test.html into test.php (SUCCESS). |
| 99 | if ( ! is_wp_error( $response ) && 200 === wp_remote_retrieve_response_code( $response ) && 'SUCCESS' === wp_remote_retrieve_body( $response ) ) { |
| 100 | $cleanup(); |
| 101 | return true; |
| 102 | } |
| 103 | |
| 104 | // The rewrite test failed. Probe test.php directly (before cleanup) to tell |
| 105 | // apart "the .htaccess was ignored" from "the test files were unreachable": |
| 106 | // if test.php itself returns SUCCESS, the directory and PHP are reachable |
| 107 | // and only the RewriteRule had no effect (AllowOverride None / mod_rewrite |
| 108 | // off). If test.php is also unreachable, the URL did not map to the test |
| 109 | // directory at all (subdirectory install, routing, or access restriction). |
| 110 | $php_url = $base_url . '/' . $test_dir_name . '/test.php'; |
| 111 | $php_probe = wp_remote_get( $php_url, $args ); |
| 112 | $probe_ok = ! is_wp_error( $php_probe ) && 200 === wp_remote_retrieve_response_code( $php_probe ) && 'SUCCESS' === wp_remote_retrieve_body( $php_probe ); |
| 113 | |
| 114 | $cleanup(); |
| 115 | |
| 116 | if ( is_wp_error( $response ) ) { |
| 117 | self::$last_reason = array( |
| 118 | 'code' => 'wp_error', |
| 119 | 'url' => $test_url, |
| 120 | 'detail' => $response->get_error_message(), |
| 121 | ); |
| 122 | return false; |
| 123 | } |
| 124 | if ( $probe_ok ) { |
| 125 | self::$last_reason = array( |
| 126 | 'code' => 'htaccess_ignored', |
| 127 | 'url' => $test_url, |
| 128 | ); |
| 129 | return false; |
| 130 | } |
| 131 | $status = wp_remote_retrieve_response_code( $response ); |
| 132 | if ( 200 === $status ) { |
| 133 | self::$last_reason = array( |
| 134 | 'code' => 'bad_body', |
| 135 | 'url' => $test_url, |
| 136 | ); |
| 137 | return false; |
| 138 | } |
| 139 | self::$last_reason = array( |
| 140 | 'code' => 'http_status', |
| 141 | 'url' => $test_url, |
| 142 | 'status' => $status, |
| 143 | ); |
| 144 | return false; |
| 145 | } |
| 146 | private static function cleanup_orphaned_test_dirs() { |
| 147 | $orphans = glob( ABSPATH . self::TEST_DIR_PREFIX . '*', GLOB_ONLYDIR ); |
| 148 | if ( empty( $orphans ) ) { |
| 149 | return; |
| 150 | } |
| 151 | // Only sweep directories old enough that no self-test can still be |
| 152 | // waiting on them. Several self-tests can be in flight at once (the |
| 153 | // upgrade path calls feature_on() on every request until the version is |
| 154 | // recorded), and deleting a directory that another run has just created |
| 155 | // makes that run's request 404 — which it would read as "the .htaccess |
| 156 | // rewrite does not work here" and fall back to stub (.php) mode even |
| 157 | // though .htaccess is perfectly usable. |
| 158 | $threshold = time() - self::TEST_DIR_MAX_AGE; |
| 159 | foreach ( $orphans as $dir ) { |
| 160 | $mtime = @filemtime( $dir ); |
| 161 | if ( false !== $mtime && $mtime > $threshold ) { |
| 162 | continue; |
| 163 | } |
| 164 | $entries = @scandir( $dir ); |
| 165 | if ( is_array( $entries ) ) { |
| 166 | foreach ( $entries as $entry ) { |
| 167 | if ( '.' === $entry || '..' === $entry ) { |
| 168 | continue; |
| 169 | } |
| 170 | $path = $dir . DIRECTORY_SEPARATOR . $entry; |
| 171 | if ( is_file( $path ) ) { |
| 172 | @unlink( $path ); |
| 173 | } |
| 174 | } |
| 175 | } |
| 176 | @rmdir( $dir ); |
| 177 | } |
| 178 | } |
| 179 | static function get_htaccess_new_file() { |
| 180 | return tempnam( self::get_tmp_dir(), 'htaccess_' ); |
| 181 | } |
| 182 | static function make_tmp_dir() { |
| 183 | $dir = self::get_tmp_dir(); |
| 184 | if ( ! wp_mkdir_p( $dir ) ) { |
| 185 | siteguard_error_log( "make tempdir failed: $dir" ); |
| 186 | return false; |
| 187 | } |
| 188 | // Defense-in-depth against directory listing if .htaccess is ignored |
| 189 | // (e.g. Apache configured with AllowOverride None). Do not chmod the |
| 190 | // file — making it read-only blocks WordPress plugin overwrite/upgrade. |
| 191 | $index_file = $dir . 'index.html'; |
| 192 | if ( ! file_exists( $index_file ) ) { |
| 193 | @file_put_contents( $index_file, '' ); |
| 194 | } |
| 195 | $htaccess_file = $dir . '.htaccess'; |
| 196 | |
| 197 | if ( file_exists( $htaccess_file ) ) { |
| 198 | $lines = file( $htaccess_file ); |
| 199 | $res = preg_grep( '/IfModule authz_core_module/', $lines ); |
| 200 | if ( ! empty( $res ) ) { |
| 201 | return true; |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | if ( $handle = @fopen( $htaccess_file, 'w' ) ) { |
| 206 | fwrite( $handle, '<IfModule authz_core_module>' . "\n" ); |
| 207 | fwrite( $handle, ' Require all denied' . "\n" ); |
| 208 | fwrite( $handle, '</IfModule>' . "\n" ); |
| 209 | fwrite( $handle, '<IfModule !authz_core_module>' . "\n" ); |
| 210 | fwrite( $handle, ' Order deny,allow' . "\n" ); |
| 211 | fwrite( $handle, ' Deny from all' . "\n" ); |
| 212 | fwrite( $handle, '</IfModule>' . "\n" ); |
| 213 | fclose( $handle ); |
| 214 | } |
| 215 | |
| 216 | return true; |
| 217 | } |
| 218 | static function is_exists_setting( $mark ) { |
| 219 | $result = false; |
| 220 | if ( '' === $mark ) { |
| 221 | $mark_start = self::HTACCESS_MARK_START; |
| 222 | $mark_end = self::HTACCESS_MARK_END; |
| 223 | } else { |
| 224 | $mark_start = $mark . '_START'; |
| 225 | $mark_end = $mark . '_END'; |
| 226 | } |
| 227 | $current_file = self::get_htaccess_file(); |
| 228 | if ( ! file_exists( $current_file ) ) { |
| 229 | return $result; |
| 230 | } |
| 231 | $fr = @fopen( $current_file, 'r' ); |
| 232 | if ( null === $fr ) { |
| 233 | return $result; |
| 234 | } |
| 235 | $line_num = 0; |
| 236 | $start_line = 0; |
| 237 | $end_line = 0; |
| 238 | while ( ! feof( $fr ) ) { |
| 239 | $line = fgets( $fr, 4096 ); |
| 240 | ++$line_num; |
| 241 | if ( false !== strpos( $line, $mark_start ) ) { |
| 242 | $start_line = $line_num; |
| 243 | } |
| 244 | if ( false !== strpos( $line, $mark_end ) ) { |
| 245 | $end_line = $line_num; |
| 246 | if ( $start_line > 0 && ( $end_line - $start_line ) > 1 ) { |
| 247 | $result = true; |
| 248 | } |
| 249 | break; |
| 250 | } |
| 251 | } |
| 252 | @fclose( $fr ); |
| 253 | |
| 254 | return $result; |
| 255 | } |
| 256 | static function check_permission( $flag_create = true ) { |
| 257 | $file = self::get_htaccess_file(); |
| 258 | if ( true === $flag_create ) { |
| 259 | self::get_apply_permission( $file ); |
| 260 | } |
| 261 | if ( ! is_readable( $file ) ) { |
| 262 | siteguard_error_log( "file not readable: $file" ); |
| 263 | return false; |
| 264 | } |
| 265 | if ( ! is_writable( $file ) ) { |
| 266 | siteguard_error_log( "file not writable: $file" ); |
| 267 | return false; |
| 268 | } |
| 269 | $path = pathinfo( $file, PATHINFO_DIRNAME ); |
| 270 | if ( ! is_writable( $path ) ) { |
| 271 | siteguard_error_log( 'directory not writable: ' . $path ); |
| 272 | return false; |
| 273 | } |
| 274 | return true; |
| 275 | } |
| 276 | static function get_apply_permission_itr( $file ) { |
| 277 | clearstatcache(); |
| 278 | $perm = intval( substr( sprintf( '%o', fileperms( $file ) ), -4 ), 8 ); |
| 279 | return $perm; |
| 280 | } |
| 281 | static function get_apply_permission( $file ) { |
| 282 | $perm = self::HTACCESS_PERMISSION; |
| 283 | if ( file_exists( $file ) ) { |
| 284 | $perm = self::get_apply_permission_itr( $file ); |
| 285 | } else { |
| 286 | @touch( $file ); |
| 287 | } |
| 288 | @chmod( $file, $perm ); |
| 289 | return $perm; |
| 290 | } |
| 291 | static function clear_settings( $mark ) { |
| 292 | // On Nginx (or any environment where .htaccess is not in use), the |
| 293 | // rebuild is a no-op. Skipping here avoids creating the in-plugin |
| 294 | // tmp/ directory and any tempnam fragments that would not be |
| 295 | // protected from web access. |
| 296 | if ( ! self::is_writable_htaccess() ) { |
| 297 | return true; |
| 298 | } |
| 299 | if ( ! self::make_tmp_dir() ) { |
| 300 | return false; |
| 301 | } |
| 302 | if ( '' === $mark ) { |
| 303 | $mark_start = self::HTACCESS_MARK_START; |
| 304 | $mark_end = self::HTACCESS_MARK_END; |
| 305 | } else { |
| 306 | $mark_start = $mark . '_START'; |
| 307 | $mark_end = $mark . '_END'; |
| 308 | } |
| 309 | $flag_settings = false; |
| 310 | $current_file = self::get_htaccess_file(); |
| 311 | if ( ! file_exists( $current_file ) ) { |
| 312 | return false; |
| 313 | } |
| 314 | $perm = self::get_apply_permission( $current_file ); |
| 315 | |
| 316 | if ( ! self::check_permission( false ) ) { |
| 317 | return false; |
| 318 | } |
| 319 | $fr = @fopen( $current_file, 'r' ); |
| 320 | if ( null === $fr ) { |
| 321 | siteguard_error_log( "fopen failed: $current_file" ); |
| 322 | return false; |
| 323 | } |
| 324 | $new_file = self::get_htaccess_new_file(); |
| 325 | $fw = @fopen( $new_file, 'w' ); |
| 326 | if ( null === $fw ) { |
| 327 | siteguard_error_log( "fopen failed: $new_file" ); |
| 328 | @unlink( $new_file ); |
| 329 | fclose( $fr ); |
| 330 | return false; |
| 331 | } |
| 332 | while ( ! feof( $fr ) ) { |
| 333 | $line = fgets( $fr, 4096 ); |
| 334 | if ( false !== strpos( $line, $mark_start ) ) { |
| 335 | $flag_settings = true; |
| 336 | } |
| 337 | if ( false === $flag_settings ) { |
| 338 | fputs( $fw, $line, 4096 ); |
| 339 | } |
| 340 | if ( true == $flag_settings && false !== strpos( $line, $mark_end ) ) { |
| 341 | $flag_settings = false; |
| 342 | } |
| 343 | } |
| 344 | fclose( $fr ); |
| 345 | fclose( $fw ); |
| 346 | @chmod( $new_file, $perm ); |
| 347 | if ( ! rename( $new_file, $current_file ) ) { |
| 348 | siteguard_error_log( "rename failed: $new_file $current_file" ); |
| 349 | @unlink( $new_file ); |
| 350 | return false; |
| 351 | } |
| 352 | return true; |
| 353 | } |
| 354 | function update_settings( $mark, $data ) { |
| 355 | // See note in clear_settings(): skip on Nginx where .htaccess is unused. |
| 356 | if ( ! self::is_writable_htaccess() ) { |
| 357 | return true; |
| 358 | } |
| 359 | if ( ! self::make_tmp_dir() ) { |
| 360 | return false; |
| 361 | } |
| 362 | $flag_write = false; |
| 363 | $flag_through = true; |
| 364 | $flag_wp = false; |
| 365 | $flag_wp_set = false; |
| 366 | $wp_settings = ''; |
| 367 | $mark_start = $mark . '_START'; |
| 368 | $mark_end = $mark . '_END'; |
| 369 | $mark_wp_start = '# BEGIN WordPress'; |
| 370 | $mark_wp_end = '# END WordPress'; |
| 371 | $current_file = self::get_htaccess_file(); |
| 372 | $perm = self::get_apply_permission( $current_file ); |
| 373 | if ( ! self::check_permission( false ) ) { |
| 374 | return false; |
| 375 | } |
| 376 | $fr = @fopen( $current_file, 'r' ); |
| 377 | if ( null === $fr ) { |
| 378 | siteguard_error_log( "fopen failed: $current_file" ); |
| 379 | return false; |
| 380 | } |
| 381 | $new_file = self::get_htaccess_new_file(); |
| 382 | if ( ! is_writable( $new_file ) ) { |
| 383 | siteguard_error_log( "file not writable: $new_file" ); |
| 384 | @unlink( $new_file ); |
| 385 | fclose( $fr ); |
| 386 | return false; |
| 387 | } |
| 388 | $fw = @fopen( $new_file, 'w' ); |
| 389 | if ( null === $fw ) { |
| 390 | siteguard_error_log( "fopen failed: $new_file" ); |
| 391 | @unlink( $new_file ); |
| 392 | fclose( $fr ); |
| 393 | return false; |
| 394 | } |
| 395 | while ( ! feof( $fr ) ) { |
| 396 | $line = fgets( $fr, 4096 ); |
| 397 | |
| 398 | // Save WordPress settings. |
| 399 | // WordPress settings has to be written after SiteGuard settings. |
| 400 | if ( false === $flag_write && false == $flag_wp_set && false !== strpos( $line, $mark_wp_start ) ) { |
| 401 | $flag_wp = true; |
| 402 | $flag_wp_set = true; |
| 403 | } |
| 404 | if ( $flag_wp_set ) { |
| 405 | $wp_settings .= $line; |
| 406 | if ( false !== strpos( $line, $mark_wp_end ) ) { |
| 407 | $flag_wp_set = false; |
| 408 | } |
| 409 | continue; |
| 410 | } |
| 411 | |
| 412 | if ( false === $flag_write && false !== strpos( $line, $mark_start ) ) { |
| 413 | fwrite( $fw, $line, strlen( $line ) ); |
| 414 | fwrite( $fw, $data, strlen( $data ) ); |
| 415 | $flag_write = true; |
| 416 | $flag_through = false; |
| 417 | // continue; |
| 418 | } |
| 419 | if ( false === $flag_write && false !== strpos( $line, self::HTACCESS_MARK_END ) ) { |
| 420 | fwrite( $fw, $mark_start . "\n", strlen( $mark_start ) + 1 ); |
| 421 | fwrite( $fw, $data, strlen( $data ) ); |
| 422 | fwrite( $fw, $mark_end . "\n", strlen( $mark_end ) + 1 ); |
| 423 | $flag_write = true; |
| 424 | } |
| 425 | if ( false === $flag_through && false !== strpos( $line, $mark_end ) ) { |
| 426 | $flag_through = true; |
| 427 | } |
| 428 | if ( $flag_through ) { |
| 429 | fwrite( $fw, $line, strlen( $line ) ); |
| 430 | if ( false === $flag_wp && false !== strpos( $line, $mark_wp_start ) ) { |
| 431 | $flag_wp = true; |
| 432 | } |
| 433 | } |
| 434 | } |
| 435 | if ( false === $flag_write ) { |
| 436 | fwrite( $fw, "\n" . self::HTACCESS_MARK_START . "\n", strlen( self::HTACCESS_MARK_START ) + 2 ); |
| 437 | fwrite( $fw, $mark_start . "\n", strlen( $mark_start ) + 1 ); |
| 438 | fwrite( $fw, $data, strlen( $data ) ); |
| 439 | fwrite( $fw, $mark_end . "\n", strlen( $mark_end ) + 1 ); |
| 440 | fwrite( $fw, self::HTACCESS_MARK_END . "\n", strlen( self::HTACCESS_MARK_END ) + 1 ); |
| 441 | } |
| 442 | if ( '' != $wp_settings ) { // Write saved WordPress Settings |
| 443 | fwrite( $fw, "\n", 1 ); |
| 444 | fwrite( $fw, $wp_settings, strlen( $wp_settings ) ); |
| 445 | fwrite( $fw, "\n", 1 ); |
| 446 | } elseif ( false === $flag_wp ) { // Write empty WordPress Settings |
| 447 | fwrite( $fw, "\n", 1 ); |
| 448 | fwrite( $fw, $mark_wp_start . "\n", strlen( $mark_wp_start ) + 1 ); |
| 449 | fwrite( $fw, $mark_wp_end . "\n", strlen( $mark_wp_end ) + 1 ); |
| 450 | fwrite( $fw, "\n", 1 ); |
| 451 | } |
| 452 | fclose( $fr ); |
| 453 | fclose( $fw ); |
| 454 | @chmod( $new_file, $perm ); |
| 455 | if ( ! rename( $new_file, $current_file ) ) { |
| 456 | siteguard_error_log( "rename failed: $new_file $current_file" ); |
| 457 | @unlink( $new_file ); |
| 458 | return false; |
| 459 | } |
| 460 | return true; |
| 461 | } |
| 462 | } |
| 463 |