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-rename-login.php
1122 lines
| 1 | <?php |
| 2 | |
| 3 | require_once ABSPATH . '/wp-admin/includes/plugin.php'; |
| 4 | require_once SITEGUARD_PATH . 'really-simple-captcha/siteguard-really-simple-captcha.php'; |
| 5 | |
| 6 | class SiteGuard_RenameLogin extends SiteGuard_Base { |
| 7 | private $denied_login = false; |
| 8 | |
| 9 | /** |
| 10 | * True while the login URL conflict probe is running. The URL filters below |
| 11 | * pass their input through untouched during that window so the probe can |
| 12 | * observe what the *other* plugins on the same filter do. See |
| 13 | * probe_foreign_login_url(). |
| 14 | * |
| 15 | * Static because feature_off() constructs a second instance of this class, |
| 16 | * whose filters are registered as well; a per-instance flag would leave that |
| 17 | * second instance rewriting the probe URL and the probe would report the |
| 18 | * plugin's own rewrite as a foreign one. |
| 19 | */ |
| 20 | private static $probing = false; |
| 21 | |
| 22 | /** Result of the conflict probe for this request. See get_login_url_conflict(). */ |
| 23 | private static $conflict = null; |
| 24 | private static $conflict_checked = false; |
| 25 | |
| 26 | protected static $incompatible_plugins = array( |
| 27 | 'WordPress HTTPS (SSL)' => 'wordpress-https/wordpress-https.php', |
| 28 | 'qTranslate X' => 'qtranslate-x/qtranslate.php', |
| 29 | ); |
| 30 | public static $htaccess_mark = '#==== SITEGUARD_RENAME_LOGIN_SETTINGS'; |
| 31 | |
| 32 | const STUB_WRITE_FAIL_TRANSIENT = 'siteguard_rl_stub_fail'; |
| 33 | |
| 34 | // Set while feature_on() rebuilds the .htaccess block. clear_settings() |
| 35 | // removes the block before update_settings() writes it back, and a |
| 36 | // concurrent request landing in that window would see the block missing and |
| 37 | // turn the feature off. See SiteGuard::htaccess_check(). |
| 38 | const HTACCESS_REBUILD_TRANSIENT = 'siteguard_rl_htaccess_rebuild'; |
| 39 | |
| 40 | function __construct() { |
| 41 | global $siteguard_config; |
| 42 | |
| 43 | add_filter( 'logout_url', array( $this, 'filter_logout_url' ), 10, 2 ); |
| 44 | add_action( 'admin_bar_menu', array( $this, 'rewrite_adminbar_logout' ), 999 ); |
| 45 | add_action( 'admin_notices', array( $this, 'maybe_notice_stub_failed' ) ); |
| 46 | |
| 47 | // Late priority: plugins that rewrite the login URL register their filters |
| 48 | // at various points (plugins_loaded, init, wp_loaded), and admin_init runs |
| 49 | // after all of them. |
| 50 | add_action( 'admin_init', array( $this, 'check_login_url_conflict' ), 9999 ); |
| 51 | add_action( 'admin_notices', array( $this, 'maybe_notice_login_url_conflict' ) ); |
| 52 | |
| 53 | if ( '1' === $siteguard_config->get( 'renamelogin_enable' ) ) { |
| 54 | if ( null !== $this->get_active_incompatible_plugins() ) { |
| 55 | $siteguard_config->set( 'renamelogin_enable', '0' ); |
| 56 | $siteguard_config->update(); |
| 57 | $this->feature_off(); |
| 58 | } else { |
| 59 | $this->add_filter(); |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | add_action( 'template_redirect', array( $this, 'guard_disabled_entry' ), 0 ); |
| 64 | add_action( 'template_redirect', array( $this, 'handle_siteguard_rescue' ), 0 ); |
| 65 | } |
| 66 | |
| 67 | static function get_mark() { |
| 68 | return self::$htaccess_mark; |
| 69 | } |
| 70 | |
| 71 | function init() { |
| 72 | global $siteguard_config; |
| 73 | |
| 74 | $this->denied_login = false; |
| 75 | |
| 76 | if ( '' === $siteguard_config->get( 'renamelogin_path' ) ) { |
| 77 | $siteguard_config->set( 'renamelogin_path', 'login_' . sprintf( '%05d', siteguard_rand( 1, 99999 ) ) ); |
| 78 | } |
| 79 | if ( '' === $siteguard_config->get( 'redirect_enable' ) ) { |
| 80 | $siteguard_config->set( 'redirect_enable', '0' ); |
| 81 | } |
| 82 | if ( '' === $siteguard_config->get( 'rescue_enable' ) ) { |
| 83 | $siteguard_config->set( 'rescue_enable', '1' ); |
| 84 | } |
| 85 | if ( '' === $siteguard_config->get( 'renamelogin_stub' ) ) { |
| 86 | $siteguard_config->set( 'renamelogin_stub', SITEGUARD_RENAME_MODE_HTACCESS ); // Apache=0 / Nginx=1 |
| 87 | } |
| 88 | $siteguard_config->update(); |
| 89 | |
| 90 | if ( true === siteguard_check_multisite() |
| 91 | && null === $this->get_active_incompatible_plugins() |
| 92 | ) { |
| 93 | $siteguard_config->set( 'renamelogin_enable', '1' ); |
| 94 | $siteguard_config->update(); |
| 95 | if ( ! $this->feature_on() ) { |
| 96 | $siteguard_config->set( 'renamelogin_enable', '0' ); |
| 97 | $siteguard_config->update(); |
| 98 | } |
| 99 | } else { |
| 100 | $siteguard_config->set( 'renamelogin_enable', '0' ); |
| 101 | $siteguard_config->update(); |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | function get_active_incompatible_plugins() { |
| 106 | $result = array(); |
| 107 | foreach ( self::$incompatible_plugins as $name => $path ) { |
| 108 | if ( is_plugin_active( $path ) ) { |
| 109 | $result[] = $name; |
| 110 | } |
| 111 | } |
| 112 | return empty( $result ) ? null : $result; |
| 113 | } |
| 114 | |
| 115 | function add_filter() { |
| 116 | add_filter( 'plugins_loaded', array( $this, 'handler_plugins_loaded' ), 9999 ); |
| 117 | |
| 118 | add_action( 'init', array( $this, 'guard_wp_login_direct_access' ), 0 ); |
| 119 | add_filter( 'login_init', array( $this, 'handler_login_init' ), 10, 2 ); |
| 120 | add_filter( 'site_url', array( $this, 'handler_site_url' ), 10, 2 ); |
| 121 | add_filter( 'network_site_url', array( $this, 'handler_site_url' ), 10, 2 ); |
| 122 | add_filter( 'wp_redirect', array( $this, 'handler_wp_redirect' ), 10, 2 ); |
| 123 | add_filter( 'register', array( $this, 'handler_register' ) ); |
| 124 | add_filter( 'auth_redirect_scheme', array( $this, 'handler_stop_redirect' ), 9999 ); |
| 125 | |
| 126 | remove_action( 'template_redirect', 'wp_redirect_admin_locations', 1000 ); |
| 127 | } |
| 128 | |
| 129 | public function can_use_htaccess() { |
| 130 | if ( ! isset( $_SERVER['SERVER_SOFTWARE'] ) |
| 131 | || ( false === strpos( strtolower( $_SERVER['SERVER_SOFTWARE'] ), 'apache' ) && false === strpos( strtolower( $_SERVER['SERVER_SOFTWARE'] ), 'litespeed' ) ) |
| 132 | ) { |
| 133 | return false; |
| 134 | } |
| 135 | return SiteGuard_Htaccess::is_writable_htaccess(); |
| 136 | } |
| 137 | |
| 138 | private function is_stub_mode() { |
| 139 | global $siteguard_config; |
| 140 | return SITEGUARD_RENAME_MODE_STUB === $siteguard_config->get( 'renamelogin_stub' ); |
| 141 | } |
| 142 | |
| 143 | private function slug() { |
| 144 | global $siteguard_config; |
| 145 | $slug = trim( (string) $siteguard_config->get( 'renamelogin_path' ), '/' ); |
| 146 | return $slug === '' ? 'login' : $slug; |
| 147 | } |
| 148 | |
| 149 | private function old_slug() { |
| 150 | global $siteguard_config; |
| 151 | $slug = trim( (string) $siteguard_config->get( 'oldlogin_path' ), '/' ); |
| 152 | return $slug === '' ? 'login' : $slug; |
| 153 | } |
| 154 | |
| 155 | private function stub_filename() { |
| 156 | return $this->slug() . '.php'; |
| 157 | } |
| 158 | |
| 159 | private function old_stub_filename() { |
| 160 | return $this->old_slug() . '.php'; |
| 161 | } |
| 162 | |
| 163 | public function stub_abspath() { |
| 164 | return trailingslashit( ABSPATH ) . $this->stub_filename(); |
| 165 | } |
| 166 | |
| 167 | private function old_stub_abspath() { |
| 168 | return trailingslashit( ABSPATH ) . $this->old_stub_filename(); |
| 169 | } |
| 170 | |
| 171 | private function stub_url() { |
| 172 | return rtrim( site_url(), '/' ) . '/' . $this->stub_filename(); |
| 173 | } |
| 174 | |
| 175 | private function install_stub() { |
| 176 | $file = $this->stub_abspath(); |
| 177 | $code = "<?php\n/* Generated by SiteGuard WP Plugin */\nrequire_once __DIR__ . '/wp-login.php';\n"; |
| 178 | $ok = @file_put_contents( $file, $code ); |
| 179 | if ( false === $ok ) { |
| 180 | set_transient( self::STUB_WRITE_FAIL_TRANSIENT, 1, MINUTE_IN_SECONDS * 10 ); |
| 181 | return false; |
| 182 | } |
| 183 | @chmod( $file, 0644 ); |
| 184 | return true; |
| 185 | } |
| 186 | |
| 187 | /** |
| 188 | * Make sure the stub file the current settings promise is in place, without |
| 189 | * rewriting it when it already is. Used to recover an install whose stub |
| 190 | * went missing while stub (.php) mode stayed recorded. |
| 191 | * |
| 192 | * @return bool |
| 193 | */ |
| 194 | public function ensure_stub() { |
| 195 | if ( file_exists( $this->stub_abspath() ) ) { |
| 196 | return true; |
| 197 | } |
| 198 | // This runs on every request, so a site whose root is not writable must |
| 199 | // not retry (and record the failure) each time. install_stub() keeps the |
| 200 | // transient for ten minutes, which paces the retries. |
| 201 | if ( get_transient( self::STUB_WRITE_FAIL_TRANSIENT ) ) { |
| 202 | return false; |
| 203 | } |
| 204 | return $this->install_stub(); |
| 205 | } |
| 206 | |
| 207 | private function remove_stub( $file ) { |
| 208 | if ( file_exists( $file ) ) { |
| 209 | // To prevent accidental deletion of important files, check if the file was generated by this plugin. |
| 210 | $content = file_get_contents( $file, false, null, 0, 100 ); |
| 211 | if ( false !== $content && false !== strpos( $content, '/* Generated by SiteGuard WP Plugin */' ) ) { |
| 212 | @unlink( $file ); |
| 213 | } |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | /** |
| 218 | * Check if the current request is for the login page (renamed or original). |
| 219 | * |
| 220 | * @return bool |
| 221 | */ |
| 222 | public function is_login_request() { |
| 223 | $req_path = isset( $_SERVER['REQUEST_URI'] ) ? (string) parse_url( $_SERVER['REQUEST_URI'], PHP_URL_PATH ) : ''; |
| 224 | $script_name = isset( $_SERVER['SCRIPT_NAME'] ) ? basename( $_SERVER['SCRIPT_NAME'] ) : ''; |
| 225 | return ( $script_name === 'wp-login.php' || $req_path === '/' . $this->stub_filename() || $req_path === '/' . $this->slug() ); |
| 226 | } |
| 227 | |
| 228 | /** |
| 229 | * Whether REQUEST_URI targets a default login entry point (wp-login / |
| 230 | * wp-register) rather than the configured renamed slug. |
| 231 | * |
| 232 | * Every path segment is reduced to its "stem" (lowercased, any extension |
| 233 | * dropped) and compared against wp-login / wp-register. Checking every |
| 234 | * segment — not just the basename — is required because Apache MultiViews / |
| 235 | * content negotiation serves wp-login.php for an extensionless request, and |
| 236 | * with AcceptPathInfo anything after it becomes PATH_INFO: |
| 237 | * /wp-login -> wp-login.php |
| 238 | * /wp-login/ -> wp-login.php (PATH_INFO "/") |
| 239 | * /wp-login/anything -> wp-login.php (PATH_INFO "/anything") <-- basename is "anything" |
| 240 | * /wp-login.php/x -> wp-login.php (PATH_INFO "/x") |
| 241 | * A basename-only check misses the PATH_INFO variants (the basename is the |
| 242 | * trailing segment). Scanning every segment also covers the nested forms that |
| 243 | * WordPress core canonicalizes to wp-login.php (e.g. /abc/wp-login.php, |
| 244 | * //wp-login.php) and subdirectory installs (/cms/wp-login/x). |
| 245 | * |
| 246 | * The configured slug is exempt so a site that renamed its login to a reserved |
| 247 | * word (e.g. "wp-register", which the settings screen still permits because |
| 248 | * wp-register.php does not exist) is not locked out of its own login. Renamed |
| 249 | * slugs only contain [a-zA-Z0-9_-] (no dot), so the extension stripping never |
| 250 | * collides with a slug. |
| 251 | */ |
| 252 | private function targets_default_login() { |
| 253 | $slug = strtolower( $this->slug() ); |
| 254 | $link = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_url( $_SERVER['REQUEST_URI'] ) : ''; |
| 255 | // Collapse leading/duplicate slashes before parse_url, otherwise //wp-login.php |
| 256 | // is parsed as host=wp-login.php with a NULL path and would slip through. |
| 257 | $link = preg_replace( '#^/+#', '/', $link ); |
| 258 | $path = (string) parse_url( $link, PHP_URL_PATH ); |
| 259 | $path = preg_replace( '#/+#', '/', $path ); |
| 260 | $path = urldecode( $path ); |
| 261 | foreach ( explode( '/', $path ) as $seg ) { |
| 262 | if ( '' === $seg ) { |
| 263 | continue; |
| 264 | } |
| 265 | $seg = strtolower( $seg ); |
| 266 | // Strip trailing whitespace, control characters and non-ASCII bytes so |
| 267 | // variants like "wp-login.php " or "wp-login.php%C2%A0" (NBSP) are caught |
| 268 | // after WordPress core URL normalization would treat them as wp-login.php. |
| 269 | $seg = preg_replace( '/[\s\x00-\x1f\x7f-\xff]+$/', '', $seg ); |
| 270 | $stem = preg_replace( '/\..*$/', '', $seg ); |
| 271 | if ( ( 'wp-login' === $stem || 'wp-register' === $stem ) && $stem !== $slug ) { |
| 272 | return true; |
| 273 | } |
| 274 | } |
| 275 | return false; |
| 276 | } |
| 277 | |
| 278 | /** |
| 279 | * Block direct access to the default login entry points at the init hook. |
| 280 | * |
| 281 | * Runs before login_init so that requests routed through index.php fallback |
| 282 | * (e.g. //wp-login.php on subdirectory installs) are stopped before WordPress |
| 283 | * core URL canonicalization can leak the renamed slug via wp_redirect. |
| 284 | */ |
| 285 | public function guard_wp_login_direct_access() { |
| 286 | if ( ! $this->targets_default_login() ) { |
| 287 | return; |
| 288 | } |
| 289 | // When wp-login.php is the executing script (a direct hit, or MultiViews |
| 290 | // content negotiation serving it for /wp-login, /wp-login/x, etc.), |
| 291 | // login_init fires and handler_login_init() renders the WordPress theme |
| 292 | // 404 via set_404(). Defer to it so the visitor gets the themed 404 page |
| 293 | // instead of a bare browser 404. |
| 294 | // |
| 295 | // Only the index.php fallback path is handled here with a bare 404 + exit: |
| 296 | // e.g. //wp-login.php on a subdirectory install, where wp-login.php does |
| 297 | // NOT execute (SCRIPT_NAME is index.php) and login_init never fires. That |
| 298 | // case must stop now, before WordPress core canonicalizes the URL and |
| 299 | // leaks the renamed slug via wp_redirect, and the main query is not yet |
| 300 | // set up to render a theme template safely. |
| 301 | $script = isset( $_SERVER['SCRIPT_NAME'] ) ? strtolower( basename( $_SERVER['SCRIPT_NAME'] ) ) : ''; |
| 302 | if ( 'wp-login.php' === $script ) { |
| 303 | return; |
| 304 | } |
| 305 | status_header( 404 ); |
| 306 | nocache_headers(); |
| 307 | exit; |
| 308 | } |
| 309 | |
| 310 | function handler_login_init() { |
| 311 | // See targets_default_login() for rationale, including the renamed slug |
| 312 | // exemption that keeps a "wp-login"/"wp-register" slug from 404ing the |
| 313 | // site's own login page. |
| 314 | if ( $this->targets_default_login() ) { |
| 315 | $this->set_404(); |
| 316 | } |
| 317 | } |
| 318 | |
| 319 | function convert_url( $link ) { |
| 320 | if ( self::$probing ) { |
| 321 | return $link; |
| 322 | } |
| 323 | $result = $link; |
| 324 | $repl = $this->is_stub_mode() ? $this->stub_filename() : $this->slug(); |
| 325 | |
| 326 | if ( false !== strpos( $link, 'wp-login.php?action=register' ) && $this->denied_login ) { |
| 327 | $this->set_404(); |
| 328 | } elseif ( false !== strpos( $link, 'wp-login.php' ) ) { |
| 329 | $result = str_replace( 'wp-login.php', $repl, $link ); |
| 330 | } |
| 331 | return $result; |
| 332 | } |
| 333 | |
| 334 | function handler_site_url( $link ) { |
| 335 | return $this->convert_url( $link ); } |
| 336 | function handler_register( $link ) { |
| 337 | return $this->convert_url( $link ); } |
| 338 | function handler_wp_redirect( $link, $status_code ) { |
| 339 | if ( ( ( strlen( $link ) <= 5 || 'http:' !== strtolower( substr( $link, 0, 5 ) ) ) && ( strlen( $link ) <= 6 || 'https:' !== strtolower( substr( $link, 0, 6 ) ) ) ) |
| 340 | || ( isset( $_SERVER['HTTPS'] ) && strtolower( $_SERVER['HTTPS'] ) !== 'off' && 'https' === strtolower( substr( $link, 0, strpos( $link, '://' ) ) ) ) |
| 341 | || ( ( ! isset( $_SERVER['HTTPS'] ) || strtolower( $_SERVER['HTTPS'] ) === 'off' ) && 'http' === strtolower( substr( $link, 0, strpos( $link, '://' ) ) ) ) ) { |
| 342 | return $this->convert_url( $link ); |
| 343 | } |
| 344 | return $link; |
| 345 | } |
| 346 | |
| 347 | private function htaccess_body() { |
| 348 | $slug = $this->slug(); |
| 349 | |
| 350 | $parse_url = parse_url( site_url() ); |
| 351 | $base = '/'; |
| 352 | if ( false !== $parse_url && isset( $parse_url['path'] ) && $parse_url['path'] !== '' ) { |
| 353 | $base = rtrim( $parse_url['path'], '/' ) . '/'; |
| 354 | } |
| 355 | |
| 356 | $ht = "<IfModule mod_rewrite.c>\n"; |
| 357 | $ht .= " RewriteEngine on\n"; |
| 358 | $ht .= " RewriteBase {$base}\n"; |
| 359 | $ht .= " RewriteRule ^wp-signup\\.php 404-siteguard [L]\n"; |
| 360 | $ht .= " RewriteRule ^wp-activate\\.php 404-siteguard [L]\n"; |
| 361 | $ht .= " RewriteRule ^{$slug}(.*)$ wp-login.php\$1 [L]\n"; |
| 362 | $ht .= "</IfModule>\n"; |
| 363 | |
| 364 | return $ht; |
| 365 | } |
| 366 | |
| 367 | function feature_on() { |
| 368 | // Announce the rebuild for its whole duration, so that requests arriving |
| 369 | // while the .htaccess block is momentarily absent do not act on it. |
| 370 | set_transient( self::HTACCESS_REBUILD_TRANSIENT, 1, MINUTE_IN_SECONDS ); |
| 371 | $result = $this->rebuild_feature(); |
| 372 | delete_transient( self::HTACCESS_REBUILD_TRANSIENT ); |
| 373 | return $result; |
| 374 | } |
| 375 | |
| 376 | private function rebuild_feature() { |
| 377 | global $siteguard_htaccess, $siteguard_config; |
| 378 | |
| 379 | // Remove .htaccess feature |
| 380 | SiteGuard_Htaccess::clear_settings( self::$htaccess_mark ); |
| 381 | |
| 382 | // Remove old stubs regardless of mode |
| 383 | $this->remove_stub( $this->old_stub_abspath() ); |
| 384 | if ( $this->slug() !== $this->old_slug() ) { |
| 385 | $this->remove_stub( $this->stub_abspath() ); |
| 386 | } |
| 387 | |
| 388 | // Decide between .htaccess mode and stub (.php) mode, recording the reason |
| 389 | // when .htaccess cannot be used so the settings screen can explain why. |
| 390 | $reason = $this->htaccess_unavailable_reason(); |
| 391 | if ( array() === $reason ) { |
| 392 | $data = $this->htaccess_body(); |
| 393 | $mark = self::get_mark(); |
| 394 | $ok = $siteguard_htaccess->update_settings( $mark, $data ); |
| 395 | if ( $ok ) { |
| 396 | $siteguard_config->set( 'renamelogin_stub', SITEGUARD_RENAME_MODE_HTACCESS ); |
| 397 | $siteguard_config->set( 'renamelogin_stub_reason', array() ); |
| 398 | $siteguard_config->update(); |
| 399 | return true; |
| 400 | } |
| 401 | // Writing the .htaccess block failed unexpectedly; fall back to stub. |
| 402 | $reason = array( 'code' => 'not_writable' ); |
| 403 | } |
| 404 | |
| 405 | if ( $this->install_stub() ) { |
| 406 | $siteguard_config->set( 'renamelogin_stub', SITEGUARD_RENAME_MODE_STUB ); |
| 407 | $siteguard_config->set( 'renamelogin_stub_reason', $reason ); |
| 408 | $siteguard_config->update(); |
| 409 | siteguard_error_log( 'Rename Login fell back to stub (.php) mode. Reason: ' . wp_json_encode( $reason ) ); |
| 410 | return true; |
| 411 | } |
| 412 | |
| 413 | return false; |
| 414 | } |
| 415 | |
| 416 | /** |
| 417 | * Why .htaccess mode cannot be used right now. Returns an empty array when it |
| 418 | * can be used; otherwise an array like array( 'code' => ..., 'url' => ... ) |
| 419 | * describing the cause (server software, write permission, or which stage of |
| 420 | * the .htaccess self-test failed). Used to explain stub (.php) fallback. |
| 421 | */ |
| 422 | private function htaccess_unavailable_reason() { |
| 423 | if ( ! $this->can_use_htaccess() ) { |
| 424 | $software = isset( $_SERVER['SERVER_SOFTWARE'] ) ? $_SERVER['SERVER_SOFTWARE'] : ''; |
| 425 | if ( '' !== $software && false !== stripos( $software, 'nginx' ) ) { |
| 426 | return array( 'code' => 'nginx' ); |
| 427 | } |
| 428 | if ( false === stripos( $software, 'apache' ) && false === stripos( $software, 'litespeed' ) ) { |
| 429 | return array( 'code' => 'server_software', 'detail' => $software ); |
| 430 | } |
| 431 | return array( 'code' => 'not_writable' ); |
| 432 | } |
| 433 | if ( ! SiteGuard_Htaccess::test_htaccess() ) { |
| 434 | return SiteGuard_Htaccess::$last_reason; |
| 435 | } |
| 436 | return array(); |
| 437 | } |
| 438 | |
| 439 | static function feature_off( $old_slug = null ) { |
| 440 | // Remove .htaccess feature |
| 441 | SiteGuard_Htaccess::clear_settings( self::$htaccess_mark ); |
| 442 | |
| 443 | // Remove stubs |
| 444 | $that = new self(); |
| 445 | $that->remove_stub( $that->old_stub_abspath() ); |
| 446 | if ( $that->slug() !== $that->old_slug() ) { |
| 447 | $that->remove_stub( $that->stub_abspath() ); |
| 448 | } |
| 449 | |
| 450 | // reset mode |
| 451 | global $siteguard_config; |
| 452 | $siteguard_config->set( 'renamelogin_stub', SITEGUARD_RENAME_MODE_HTACCESS ); |
| 453 | $siteguard_config->update(); |
| 454 | |
| 455 | return true; |
| 456 | } |
| 457 | |
| 458 | public function guard_disabled_entry() { |
| 459 | global $siteguard_config, $wp; |
| 460 | |
| 461 | if ( '1' === $siteguard_config->get( 'renamelogin_enable' ) ) { |
| 462 | return; |
| 463 | } |
| 464 | |
| 465 | $slug = $this->slug(); |
| 466 | $req_path = isset( $_SERVER['REQUEST_URI'] ) ? (string) parse_url( $_SERVER['REQUEST_URI'], PHP_URL_PATH ) : ''; |
| 467 | |
| 468 | $hit_slug = ( $req_path !== '' && rtrim( $req_path, '/' ) === '/' . $slug ); |
| 469 | $hit_stub = ( $req_path !== '' && rtrim( $req_path, '/' ) === '/' . $this->stub_filename() ); |
| 470 | |
| 471 | if ( $hit_slug || $hit_stub ) { |
| 472 | if ( isset( $_REQUEST['action'] ) && $_REQUEST['action'] === 'logout' ) { |
| 473 | wp_safe_redirect( wp_logout_url() ); |
| 474 | exit; |
| 475 | } |
| 476 | $this->set_404(); |
| 477 | } |
| 478 | } |
| 479 | |
| 480 | public function handle_siteguard_rescue() { |
| 481 | global $siteguard_config; |
| 482 | |
| 483 | if ( '1' !== $siteguard_config->get( 'renamelogin_enable' ) ) { |
| 484 | return; |
| 485 | } |
| 486 | |
| 487 | if ( '1' !== $siteguard_config->get( 'rescue_enable' ) ) { |
| 488 | return; |
| 489 | } |
| 490 | if ( ! isset( $_GET['siteguard_rescue'] ) || '1' !== (string) $_GET['siteguard_rescue'] ) { |
| 491 | return; |
| 492 | } |
| 493 | |
| 494 | if ( $this->current_rescue_count() >= 3 ) { |
| 495 | $this->fixed_delay(); |
| 496 | status_header( 429 ); |
| 497 | nocache_headers(); |
| 498 | $this->render_rescue_message( esc_html__( 'Request limit reached. Please try again later.', 'siteguard' ) ); |
| 499 | exit; |
| 500 | } |
| 501 | |
| 502 | if ( 'POST' === $_SERVER['REQUEST_METHOD'] ) { |
| 503 | $this->increment_rescue_counter(); |
| 504 | $this->process_rescue_post(); |
| 505 | exit; |
| 506 | } |
| 507 | $this->render_rescue_form(); |
| 508 | exit; |
| 509 | } |
| 510 | |
| 511 | private function rescue_rate_key( $ip ) { |
| 512 | return 'sg_rescue_count_' . md5( (string) $ip ); } |
| 513 | private function increment_rescue_counter() { |
| 514 | $ip = $this->get_ip(); |
| 515 | $key = $this->rescue_rate_key( $ip ); |
| 516 | $cnt = (int) get_transient( $key ); |
| 517 | ++$cnt; |
| 518 | set_transient( $key, $cnt, HOUR_IN_SECONDS ); |
| 519 | return $cnt; |
| 520 | } |
| 521 | private function current_rescue_count() { |
| 522 | $ip = $this->get_ip(); |
| 523 | $key = $this->rescue_rate_key( $ip ); |
| 524 | return (int) get_transient( $key ); |
| 525 | } |
| 526 | |
| 527 | private function render_rescue_form( $errors = array(), $email_value = '' ) { |
| 528 | // This form is what an administrator locked out of the login page has |
| 529 | // left, and it draws the same CAPTCHA image. On a server that cannot |
| 530 | // render one, generating it here would take the rescue path down with the |
| 531 | // same 500 as the login page. Show the form without the CAPTCHA instead: |
| 532 | // the rate limit of three attempts per hour per IP, the fixed delay and |
| 533 | // the identical response whether or not the address exists all still |
| 534 | // apply, so this does not turn into a usable oracle. |
| 535 | $captcha_available = SiteGuard_CAPTCHA::is_captcha_available(); |
| 536 | $prefix = ''; |
| 537 | $imgsrc = ''; |
| 538 | if ( $captcha_available ) { |
| 539 | $captcha = new SiteGuardReallySimpleCaptcha(); |
| 540 | $language = get_bloginfo( 'language' ); |
| 541 | ( strpos( $language, 'ja' ) === 0 ) ? $captcha->set_lang_mode( 'jp' ) : $captcha->set_lang_mode( 'en' ); |
| 542 | $prefix = siteguard_rand(); |
| 543 | $word = $captcha->generate_random_word(); |
| 544 | $captcha->generate_image( $prefix, $word ); |
| 545 | $imgsrc = esc_url( WP_CONTENT_URL . '/siteguard/' . $prefix . '.png' ); |
| 546 | } |
| 547 | |
| 548 | $action = esc_url( add_query_arg( 'siteguard_rescue', '1', site_url( '/' ) ) ); |
| 549 | |
| 550 | nocache_headers(); |
| 551 | echo '<!DOCTYPE html><html><head><meta charset="' . esc_attr( get_bloginfo( 'charset' ) ) . '">'; |
| 552 | echo '<meta name="robots" content="noindex,nofollow" />'; |
| 553 | echo '<title>' . esc_html__( 'Login URL Rescue', 'siteguard' ) . '</title>'; |
| 554 | echo '</head><body>'; |
| 555 | echo '<h1>' . esc_html__( 'Login URL Rescue', 'siteguard' ) . '</h1>'; |
| 556 | |
| 557 | if ( ! empty( $errors ) ) { |
| 558 | echo '<div role="alert" style="color:#b00;">'; |
| 559 | foreach ( (array) $errors as $e ) { |
| 560 | echo '<p>' . esc_html( $e ) . '</p>'; |
| 561 | } |
| 562 | echo '</div>'; |
| 563 | } |
| 564 | |
| 565 | echo '<form method="post" action="' . $action . '">'; |
| 566 | wp_nonce_field( 'siteguard_rescue', 'siteguard_rescue_nonce' ); |
| 567 | |
| 568 | echo '<p><label>' . esc_html__( 'Administrator email address', 'siteguard' ) . '<br />'; |
| 569 | echo '<input type="email" name="siteguard_rescue_email" value="' . esc_attr( $email_value ) . '" required style="min-width:280px;" />'; |
| 570 | echo '</label></p>'; |
| 571 | |
| 572 | if ( $captcha_available ) { |
| 573 | echo '<p><img src="' . $imgsrc . '" alt="CAPTCHA" /></p>'; |
| 574 | echo '<p><label>' . esc_html__( 'Enter the characters shown above', 'siteguard' ) . '<br />'; |
| 575 | echo '<input type="text" name="siteguard_captcha" value="" size="10" required />'; |
| 576 | echo '</label></p>'; |
| 577 | echo '<input type="hidden" name="siteguard_captcha_prefix" value="' . esc_attr( $prefix ) . '" />'; |
| 578 | } |
| 579 | |
| 580 | echo '<p><button type="submit">' . esc_html__( 'Send email', 'siteguard' ) . '</button></p>'; |
| 581 | echo '</form>'; |
| 582 | |
| 583 | echo '</body></html>'; |
| 584 | } |
| 585 | |
| 586 | private function uniform_delay( $start_ts_ms, $min_ms = 1800, $max_ms = 3200 ) { |
| 587 | $target = (int) wp_rand( $min_ms, $max_ms ); |
| 588 | $elapsed = (int) ( ( microtime( true ) * 1000 ) - $start_ts_ms ); |
| 589 | $remain = $target - $elapsed; |
| 590 | if ( $remain > 0 ) { |
| 591 | usleep( $remain * 1000 ); |
| 592 | } |
| 593 | } |
| 594 | private function fixed_delay( $min_ms = 1800, $max_ms = 3200 ) { |
| 595 | $target = (int) wp_rand( $min_ms, $max_ms ); |
| 596 | if ( $target > 0 ) { |
| 597 | usleep( $target * 1000 ); |
| 598 | } |
| 599 | } |
| 600 | |
| 601 | private function process_rescue_post() { |
| 602 | $start = (int) round( microtime( true ) * 1000 ); |
| 603 | |
| 604 | if ( ! isset( $_POST['siteguard_rescue_nonce'] ) || ! wp_verify_nonce( $_POST['siteguard_rescue_nonce'], 'siteguard_rescue' ) ) { |
| 605 | status_header( 400 ); |
| 606 | $this->uniform_delay( $start ); |
| 607 | $this->render_rescue_form( array( esc_html__( 'Invalid request.', 'siteguard' ) ), isset( $_POST['siteguard_rescue_email'] ) ? sanitize_email( $_POST['siteguard_rescue_email'] ) : '' ); |
| 608 | return; |
| 609 | } |
| 610 | |
| 611 | email_exists( 'dummy@example.com' ); |
| 612 | |
| 613 | $email = isset( $_POST['siteguard_rescue_email'] ) ? sanitize_email( $_POST['siteguard_rescue_email'] ) : ''; |
| 614 | $cap = isset( $_POST['siteguard_captcha'] ) ? sanitize_text_field( $_POST['siteguard_captcha'] ) : ''; |
| 615 | $pref = isset( $_POST['siteguard_captcha_prefix'] ) ? sanitize_text_field( $_POST['siteguard_captcha_prefix'] ) : ''; |
| 616 | |
| 617 | $errors = array(); |
| 618 | if ( empty( $email ) || ! is_email( $email ) ) { |
| 619 | $errors[] = esc_html__( 'Please enter a valid email address.', 'siteguard' ); |
| 620 | } |
| 621 | |
| 622 | // Verify only what the form was able to present. render_rescue_form() |
| 623 | // leaves the CAPTCHA out when the server cannot draw one, and demanding it |
| 624 | // here would reject every submission and close the rescue path for good. |
| 625 | if ( SiteGuard_CAPTCHA::is_captcha_available() ) { |
| 626 | $captcha = new SiteGuardReallySimpleCaptcha(); |
| 627 | $valid_captcha = ( $pref !== '' && $cap !== '' && $captcha->check( $pref, $cap, true ) ); |
| 628 | if ( ! $valid_captcha ) { |
| 629 | $errors[] = esc_html__( 'Invalid CAPTCHA.', 'siteguard' ); |
| 630 | } |
| 631 | } |
| 632 | |
| 633 | if ( ! empty( $errors ) ) { |
| 634 | $this->uniform_delay( $start ); |
| 635 | $this->render_rescue_form( $errors, $email ); |
| 636 | return; |
| 637 | } |
| 638 | |
| 639 | $user = get_user_by( 'email', $email ); |
| 640 | if ( $user && user_can( $user, 'manage_options' ) ) { |
| 641 | $url = $this->get_login_url(); |
| 642 | $subject = esc_html__( 'WordPress: Login URL Rescue', 'siteguard' ); |
| 643 | $body = sprintf( |
| 644 | esc_html__( "You requested the login URL.\n\nURL: %s\n\nIf you did not request this, you can ignore this email.\n\n--\nSiteGuard WP Plugin", 'siteguard' ), |
| 645 | $url |
| 646 | ); |
| 647 | @wp_mail( $email, $subject, $body ); |
| 648 | } |
| 649 | |
| 650 | nocache_headers(); |
| 651 | $this->uniform_delay( $start ); |
| 652 | $this->render_rescue_message( esc_html__( 'An email has been sent if the address exists.', 'siteguard' ) ); |
| 653 | } |
| 654 | |
| 655 | private function render_rescue_message( $message ) { |
| 656 | echo '<!DOCTYPE html><html><head><meta charset="' . esc_attr( get_bloginfo( 'charset' ) ) . '">'; |
| 657 | echo '<meta name="robots" content="noindex,nofollow" />'; |
| 658 | echo '<title>' . esc_html__( 'Login URL Rescue', 'siteguard' ) . '</title>'; |
| 659 | echo '</head><body>'; |
| 660 | echo '<h1>' . esc_html__( 'Login URL Rescue', 'siteguard' ) . '</h1>'; |
| 661 | echo '<p>' . esc_html( $message ) . '</p>'; |
| 662 | echo '</body></html>'; |
| 663 | } |
| 664 | |
| 665 | public function get_login_url() { |
| 666 | global $siteguard_config; |
| 667 | if ( '0' === $siteguard_config->get( 'renamelogin_enable' ) ) { |
| 668 | return rtrim( site_url(), '/' ) . '/wp-login.php'; |
| 669 | } |
| 670 | if ( $this->is_stub_mode() ) { |
| 671 | return $this->stub_url(); } |
| 672 | return rtrim( site_url(), '/' ) . '/' . $this->slug(); |
| 673 | } |
| 674 | |
| 675 | function set_404() { |
| 676 | global $wp_query; |
| 677 | status_header( 404 ); |
| 678 | $wp_query->set_404(); |
| 679 | if ( ( ( $template = get_404_template() ) || ( $template = get_index_template() ) ) |
| 680 | && ( $template = apply_filters( 'template_include', $template ) ) ) { |
| 681 | include $template; |
| 682 | } |
| 683 | die; |
| 684 | } |
| 685 | |
| 686 | function send_notify() { |
| 687 | $subject = esc_html__( 'WordPress: Login page URL changed', 'siteguard' ); |
| 688 | $body = sprintf( esc_html__( "Please bookmark the new login URL.\n\n%s\n\n--\nSiteGuard WP Plugin", 'siteguard' ), $this->get_login_url() ); |
| 689 | |
| 690 | $user_query = new WP_User_Query( array( 'role' => 'Administrator' ) ); |
| 691 | if ( ! empty( $user_query->results ) ) { |
| 692 | foreach ( $user_query->results as $user ) { |
| 693 | $user_email = $user->get( 'user_email' ); |
| 694 | if ( true !== @wp_mail( $user_email, $subject, $body ) ) { |
| 695 | siteguard_error_log( 'Failed send mail. To:' . $user_email . ' Subject:' . esc_html( $subject ) ); |
| 696 | } |
| 697 | } |
| 698 | } |
| 699 | } |
| 700 | |
| 701 | function handler_stop_redirect( $scheme ) { |
| 702 | global $siteguard_config; |
| 703 | $redirect_enable = $siteguard_config->get( 'redirect_enable' ); |
| 704 | if ( $redirect_enable == 1 ) { |
| 705 | if ( $user_id = wp_validate_auth_cookie( '', $scheme ) ) { |
| 706 | return $scheme; |
| 707 | } |
| 708 | wp_safe_redirect( home_url() ); |
| 709 | exit; |
| 710 | } |
| 711 | } |
| 712 | |
| 713 | function handler_plugins_loaded() { |
| 714 | if ( ! isset( $_SERVER['REQUEST_URI'] ) ) { |
| 715 | return; |
| 716 | } |
| 717 | $request = parse_url( $_SERVER['REQUEST_URI'] ); |
| 718 | $denied_slugs = array( 'wp-register', 'wp-signup', 'wp-activate' ); |
| 719 | $denied_slugs_to_regex = implode( '|', $denied_slugs ); |
| 720 | |
| 721 | $is_denied = false; |
| 722 | if ( is_array( $request ) && isset( $request['path'] ) ) { |
| 723 | $is_denied = preg_match( '#\/(' . $denied_slugs_to_regex . ')(\.php)?$#i', untrailingslashit( $request['path'] ) ); |
| 724 | } |
| 725 | if ( $is_denied && ! is_admin() ) { |
| 726 | $this->denied_login = true; |
| 727 | // In stub mode, .htaccess rules are absent; block signup/activate directly. |
| 728 | if ( $this->is_stub_mode() ) { |
| 729 | status_header( 404 ); |
| 730 | nocache_headers(); |
| 731 | exit; |
| 732 | } |
| 733 | } |
| 734 | } |
| 735 | |
| 736 | public function filter_logout_url( $logout_url, $redirect ) { |
| 737 | global $siteguard_config; |
| 738 | if ( '1' !== $siteguard_config->get( 'renamelogin_enable' ) ) { |
| 739 | return $logout_url; |
| 740 | } |
| 741 | $base = $this->get_login_url(); |
| 742 | |
| 743 | // Rebuild the logout URL from scratch to mirror WordPress core's |
| 744 | // wp_logout_url(): urlencode redirect_to, then wrap with wp_nonce_url(). |
| 745 | // |
| 746 | // We deliberately do NOT parse_str( $logout_url ) and re-emit its query: |
| 747 | // the incoming $logout_url has already been passed through wp_nonce_url(), |
| 748 | // which esc_html()-encodes it (so separators are "&"). parse_str() then |
| 749 | // splits on "&", mangling keys into "amp;redirect_to"/"amp;_wpnonce" and |
| 750 | // url-decoding the value back to its raw form. add_query_arg() re-emits that |
| 751 | // raw value, so an attacker-controlled redirect_to would break out of the |
| 752 | // href printed (without esc_url) by wp_nonce_ays( 'log-out' ) -> XSS. |
| 753 | // |
| 754 | // wp_logout_url() applies the same urlencode() contract that consumers rely |
| 755 | // on, and wp_nonce_url() re-adds the logout nonce, so building fresh keeps |
| 756 | // the URL correctly (and singly) encoded. |
| 757 | $args = array( 'action' => 'logout' ); |
| 758 | if ( ! empty( $redirect ) ) { |
| 759 | $args['redirect_to'] = urlencode( $redirect ); |
| 760 | } |
| 761 | return wp_nonce_url( add_query_arg( $args, $base ), 'log-out' ); |
| 762 | } |
| 763 | |
| 764 | public function rewrite_adminbar_logout( $wp_admin_bar ) { |
| 765 | if ( ! is_user_logged_in() ) { |
| 766 | return; } |
| 767 | if ( ! is_object( $wp_admin_bar ) ) { |
| 768 | return; } |
| 769 | foreach ( array( 'logout', 'log-out' ) as $id ) { |
| 770 | $node = $wp_admin_bar->get_node( $id ); |
| 771 | if ( ! $node || empty( $node->href ) ) { |
| 772 | continue; } |
| 773 | $new = $this->filter_logout_url( $node->href, '' ); |
| 774 | if ( $new && $new !== $node->href ) { |
| 775 | $node->href = $new; |
| 776 | $wp_admin_bar->add_node( $node ); |
| 777 | } |
| 778 | } |
| 779 | } |
| 780 | |
| 781 | /** |
| 782 | * Result of the login URL conflict probe for this request, or null when no |
| 783 | * other plugin was found to be changing the login page URL. |
| 784 | * |
| 785 | * Keys: 'url' (the login URL the other plugin produces), 'plugin' (its name, |
| 786 | * or '' when it could not be resolved), 'same_url' (bool) and 'fatal_risk' |
| 787 | * (bool). Populated by check_login_url_conflict() on admin_init. |
| 788 | * |
| 789 | * @return array|null |
| 790 | */ |
| 791 | public static function get_login_url_conflict() { |
| 792 | return self::$conflict; |
| 793 | } |
| 794 | |
| 795 | /** |
| 796 | * Run the login URL conflict probe once per admin request. |
| 797 | * |
| 798 | * Only admin screens are checked: the result is only ever shown to an |
| 799 | * administrator, and admin_init is the earliest hook that runs after every |
| 800 | * other plugin has registered its URL filters. admin-ajax.php also fires |
| 801 | * admin_init, so AJAX (and cron / REST) is excluded to keep the probe off |
| 802 | * background requests. |
| 803 | */ |
| 804 | public function check_login_url_conflict() { |
| 805 | global $siteguard_config; |
| 806 | |
| 807 | if ( self::$conflict_checked ) { |
| 808 | return; |
| 809 | } |
| 810 | if ( ! is_admin() || wp_doing_ajax() || wp_doing_cron() ) { |
| 811 | return; |
| 812 | } |
| 813 | if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { |
| 814 | return; |
| 815 | } |
| 816 | if ( ! current_user_can( 'manage_options' ) ) { |
| 817 | return; |
| 818 | } |
| 819 | if ( '1' !== $siteguard_config->get( 'renamelogin_enable' ) ) { |
| 820 | return; |
| 821 | } |
| 822 | |
| 823 | self::$conflict_checked = true; |
| 824 | self::$conflict = $this->probe_foreign_login_url(); |
| 825 | } |
| 826 | |
| 827 | /** |
| 828 | * Detect whether another plugin is also changing the login page URL. |
| 829 | * |
| 830 | * Rather than checking for known plugins by name, this observes the symptom |
| 831 | * itself: every plugin that renames the login page has to rewrite the URLs |
| 832 | * WordPress generates for wp-login.php, and does so through the 'site_url' |
| 833 | * or 'login_url' filters. So an unfiltered wp-login.php URL is pushed |
| 834 | * through those filters with our own rewrite suspended (self::$probing); if |
| 835 | * "wp-login.php" is gone from the result, somebody else is rewriting it too. |
| 836 | * |
| 837 | * Requiring "wp-login.php" to disappear (rather than any change at all) is |
| 838 | * what keeps plugins that merely decorate site_url — multilingual plugins |
| 839 | * adding a language prefix, for instance — from being reported. |
| 840 | * |
| 841 | * @return array|null Conflict details, or null when nothing was detected. |
| 842 | */ |
| 843 | private function probe_foreign_login_url() { |
| 844 | // Build the URL without site_url() so the filters under test are applied |
| 845 | // exactly once, by us, below. |
| 846 | $raw = rtrim( (string) get_option( 'siteurl' ), '/' ) . '/wp-login.php'; |
| 847 | |
| 848 | $probes = array( |
| 849 | 'site_url' => array( 'wp-login.php', null, null ), |
| 850 | 'login_url' => array( '', false ), |
| 851 | ); |
| 852 | |
| 853 | // The probe runs other plugins' filter callbacks. A failure in one of |
| 854 | // them has to stay inside that one probe: it must not take the admin |
| 855 | // screen down, and it must not skip the remaining hook — a plugin that |
| 856 | // only filters login_url is exactly the kind this feature looks for. |
| 857 | // self::$probing is cleared either way, or the URL rewriting would stay |
| 858 | // disabled for the rest of the request. |
| 859 | $found = null; |
| 860 | self::$probing = true; |
| 861 | foreach ( $probes as $hook => $args ) { |
| 862 | $params = array_merge( array( $raw ), $args ); |
| 863 | try { |
| 864 | $probed = call_user_func_array( 'apply_filters', array_merge( array( $hook ), $params ) ); |
| 865 | } catch ( Exception $e ) { |
| 866 | siteguard_error_log( 'Login URL conflict probe failed on ' . $hook . ': ' . $e->getMessage() ); |
| 867 | continue; |
| 868 | } catch ( Throwable $e ) { |
| 869 | siteguard_error_log( 'Login URL conflict probe failed on ' . $hook . ': ' . $e->getMessage() ); |
| 870 | continue; |
| 871 | } |
| 872 | if ( ! is_string( $probed ) || '' === $probed || false !== strpos( $probed, 'wp-login.php' ) ) { |
| 873 | continue; |
| 874 | } |
| 875 | // The conflict itself is established at this point. Failing to name |
| 876 | // the plugin responsible must not discard it. |
| 877 | $plugin = ''; |
| 878 | try { |
| 879 | $plugin = $this->conflicting_plugin_names( $hook, $raw, $args ); |
| 880 | } catch ( Exception $e ) { |
| 881 | siteguard_error_log( 'Naming the conflicting plugin failed: ' . $e->getMessage() ); |
| 882 | } catch ( Throwable $e ) { |
| 883 | siteguard_error_log( 'Naming the conflicting plugin failed: ' . $e->getMessage() ); |
| 884 | } |
| 885 | $found = array( |
| 886 | 'url' => $probed, |
| 887 | 'plugin' => $plugin, |
| 888 | ); |
| 889 | break; |
| 890 | } |
| 891 | self::$probing = false; |
| 892 | |
| 893 | if ( null === $found ) { |
| 894 | return null; |
| 895 | } |
| 896 | |
| 897 | $same_url = $this->is_same_login_url( $this->get_login_url(), $found['url'] ); |
| 898 | |
| 899 | return array( |
| 900 | 'url' => $found['url'], |
| 901 | 'plugin' => $found['plugin'], |
| 902 | 'same_url' => $same_url, |
| 903 | // The fatal error only happens when our .htaccess rewrite makes the |
| 904 | // core wp-login.php execute (declaring login_header() and friends) |
| 905 | // while the other plugin, matching the very same URL, loads its own |
| 906 | // copy of the login page on top of it. In stub (.php) mode our URL |
| 907 | // ends in ".php", which no other plugin's slug matches. |
| 908 | 'fatal_risk' => ( $same_url && ! $this->is_stub_mode() ), |
| 909 | ); |
| 910 | } |
| 911 | |
| 912 | /** |
| 913 | * Names of the active plugins whose callbacks on $hook_name remove |
| 914 | * wp-login.php from the URL. Each foreign callback is applied on its own so |
| 915 | * that a plugin which merely happens to filter the same hook is not blamed |
| 916 | * for another plugin's rewrite. |
| 917 | * |
| 918 | * @param string $hook_name Filter to inspect. |
| 919 | * @param string $raw Unfiltered wp-login.php URL. |
| 920 | * @param array $args Remaining filter arguments. |
| 921 | * @return string Comma separated plugin names, or '' when none could be resolved. |
| 922 | */ |
| 923 | private function conflicting_plugin_names( $hook_name, $raw, $args ) { |
| 924 | global $wp_filter; |
| 925 | |
| 926 | if ( empty( $wp_filter[ $hook_name ] ) ) { |
| 927 | return ''; |
| 928 | } |
| 929 | |
| 930 | $self_dir = wp_normalize_path( SITEGUARD_PATH ); |
| 931 | $names = array(); |
| 932 | |
| 933 | foreach ( $wp_filter[ $hook_name ] as $callbacks ) { |
| 934 | if ( ! is_array( $callbacks ) ) { |
| 935 | continue; |
| 936 | } |
| 937 | foreach ( $callbacks as $callback ) { |
| 938 | if ( ! isset( $callback['function'] ) || ! is_callable( $callback['function'] ) ) { |
| 939 | continue; |
| 940 | } |
| 941 | $file = $this->callback_file( $callback['function'] ); |
| 942 | if ( '' === $file || 0 === strpos( wp_normalize_path( $file ), $self_dir ) ) { |
| 943 | continue; |
| 944 | } |
| 945 | $name = $this->plugin_name_by_file( $file ); |
| 946 | if ( '' === $name || in_array( $name, $names, true ) ) { |
| 947 | continue; |
| 948 | } |
| 949 | |
| 950 | $accepted = isset( $callback['accepted_args'] ) ? (int) $callback['accepted_args'] : 1; |
| 951 | $params = array_slice( array_merge( array( $raw ), $args ), 0, max( 1, $accepted ) ); |
| 952 | try { |
| 953 | $result = call_user_func_array( $callback['function'], $params ); |
| 954 | } catch ( Exception $e ) { |
| 955 | continue; |
| 956 | } catch ( Throwable $e ) { |
| 957 | continue; |
| 958 | } |
| 959 | if ( is_string( $result ) && false === strpos( $result, 'wp-login.php' ) ) { |
| 960 | $names[] = $name; |
| 961 | } |
| 962 | } |
| 963 | } |
| 964 | |
| 965 | return implode( ', ', $names ); |
| 966 | } |
| 967 | |
| 968 | /** |
| 969 | * Source file a filter callback is defined in, or '' when it cannot be |
| 970 | * resolved (internal functions, or anything Reflection refuses). |
| 971 | * |
| 972 | * @param mixed $callback Callback as stored in $wp_filter. |
| 973 | * @return string |
| 974 | */ |
| 975 | private function callback_file( $callback ) { |
| 976 | try { |
| 977 | if ( is_array( $callback ) && 2 === count( $callback ) ) { |
| 978 | $class = is_object( $callback[0] ) ? get_class( $callback[0] ) : $callback[0]; |
| 979 | $ref = new ReflectionMethod( $class, $callback[1] ); |
| 980 | } elseif ( is_string( $callback ) && false !== strpos( $callback, '::' ) ) { |
| 981 | $ref = new ReflectionMethod( $callback ); |
| 982 | } elseif ( is_object( $callback ) && ! ( $callback instanceof Closure ) ) { |
| 983 | $ref = new ReflectionMethod( $callback, '__invoke' ); |
| 984 | } else { |
| 985 | $ref = new ReflectionFunction( $callback ); |
| 986 | } |
| 987 | } catch ( Exception $e ) { |
| 988 | return ''; |
| 989 | } |
| 990 | |
| 991 | $file = $ref->getFileName(); |
| 992 | return is_string( $file ) ? $file : ''; |
| 993 | } |
| 994 | |
| 995 | /** |
| 996 | * Name of the active plugin that owns $file, or '' when the file does not |
| 997 | * belong to one (a theme, a must-use plugin, or WordPress itself). |
| 998 | * |
| 999 | * @param string $file Absolute path. |
| 1000 | * @return string |
| 1001 | */ |
| 1002 | private function plugin_name_by_file( $file ) { |
| 1003 | $plugin_dir = trailingslashit( wp_normalize_path( WP_PLUGIN_DIR ) ); |
| 1004 | $file = wp_normalize_path( $file ); |
| 1005 | if ( 0 !== strpos( $file, $plugin_dir ) ) { |
| 1006 | return ''; |
| 1007 | } |
| 1008 | |
| 1009 | $relative = substr( $file, strlen( $plugin_dir ) ); |
| 1010 | $slug = ( false !== strpos( $relative, '/' ) ) ? substr( $relative, 0, strpos( $relative, '/' ) ) : $relative; |
| 1011 | if ( '' === $slug ) { |
| 1012 | return ''; |
| 1013 | } |
| 1014 | |
| 1015 | foreach ( get_plugins() as $plugin_file => $data ) { |
| 1016 | if ( $plugin_file !== $slug && 0 !== strpos( $plugin_file, $slug . '/' ) ) { |
| 1017 | continue; |
| 1018 | } |
| 1019 | if ( is_plugin_active( $plugin_file ) && ! empty( $data['Name'] ) ) { |
| 1020 | return $data['Name']; |
| 1021 | } |
| 1022 | } |
| 1023 | return ''; |
| 1024 | } |
| 1025 | |
| 1026 | /** |
| 1027 | * Whether two login URLs would be requested by the same path. Trailing |
| 1028 | * slashes are ignored (some plugins hand out a trailing slashed URL); a |
| 1029 | * query string has to match as well, because a plugin that puts its slug in |
| 1030 | * the query (e.g. /?secret) is not hit by a request for our path. |
| 1031 | * |
| 1032 | * @param string $a URL. |
| 1033 | * @param string $b URL. |
| 1034 | * @return bool |
| 1035 | */ |
| 1036 | private function is_same_login_url( $a, $b ) { |
| 1037 | $path_a = untrailingslashit( (string) wp_parse_url( $a, PHP_URL_PATH ) ); |
| 1038 | $path_b = untrailingslashit( (string) wp_parse_url( $b, PHP_URL_PATH ) ); |
| 1039 | $query_a = (string) wp_parse_url( $a, PHP_URL_QUERY ); |
| 1040 | $query_b = (string) wp_parse_url( $b, PHP_URL_QUERY ); |
| 1041 | |
| 1042 | return ( $path_a === $path_b && $query_a === $query_b ); |
| 1043 | } |
| 1044 | |
| 1045 | /** |
| 1046 | * Warning text for a detected conflict. Returns pre-escaped HTML. |
| 1047 | * |
| 1048 | * @param array $conflict As returned by get_login_url_conflict(). |
| 1049 | * @return string |
| 1050 | */ |
| 1051 | public static function conflict_message( $conflict ) { |
| 1052 | $url = '<code>' . esc_html( $conflict['url'] ) . '</code>'; |
| 1053 | $plugin = '' !== $conflict['plugin'] ? '<strong>' . esc_html( $conflict['plugin'] ) . '</strong>' : ''; |
| 1054 | |
| 1055 | if ( ! empty( $conflict['fatal_risk'] ) ) { |
| 1056 | if ( '' !== $plugin ) { |
| 1057 | return sprintf( |
| 1058 | /* translators: 1: plugin name, 2: login URL */ |
| 1059 | esc_html__( '%1$s is also changing the login page URL, to the same URL as SiteGuard (%2$s). In this state the login page can stop working with a PHP fatal error. Please turn off the login page URL change feature in one of the two plugins.', 'siteguard' ), |
| 1060 | $plugin, |
| 1061 | $url |
| 1062 | ); |
| 1063 | } |
| 1064 | return sprintf( |
| 1065 | /* translators: %s: login URL */ |
| 1066 | esc_html__( 'Another active plugin is also changing the login page URL, to the same URL as SiteGuard (%s). In this state the login page can stop working with a PHP fatal error. Please turn off the login page URL change feature in one of the two plugins.', 'siteguard' ), |
| 1067 | $url |
| 1068 | ); |
| 1069 | } |
| 1070 | |
| 1071 | if ( '' !== $plugin ) { |
| 1072 | return sprintf( |
| 1073 | /* translators: 1: plugin name, 2: login URL */ |
| 1074 | esc_html__( '%1$s is also changing the login page URL (%2$s). Two different login URLs stay available, which weakens this feature, and the two can conflict later. Please use the login page URL change feature in only one of the two plugins.', 'siteguard' ), |
| 1075 | $plugin, |
| 1076 | $url |
| 1077 | ); |
| 1078 | } |
| 1079 | return sprintf( |
| 1080 | /* translators: %s: login URL */ |
| 1081 | esc_html__( 'Another active plugin is also changing the login page URL (%s). Two different login URLs stay available, which weakens this feature, and the two can conflict later. Please use the login page URL change feature in only one of the two plugins.', 'siteguard' ), |
| 1082 | $url |
| 1083 | ); |
| 1084 | } |
| 1085 | |
| 1086 | /** |
| 1087 | * Admin notice for the case that actually breaks the site: both plugins |
| 1088 | * pointing at the same URL while we serve it through the .htaccess rewrite. |
| 1089 | * The milder cases are reported on the Rename Login screen only, so that a |
| 1090 | * situation which is not currently breaking anything does not follow the |
| 1091 | * administrator around the dashboard. |
| 1092 | */ |
| 1093 | public function maybe_notice_login_url_conflict() { |
| 1094 | if ( ! current_user_can( 'manage_options' ) ) { |
| 1095 | return; |
| 1096 | } |
| 1097 | // The Rename Login screen prints its own, always-visible block. |
| 1098 | // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- reading which admin screen is being rendered; no state is changed. |
| 1099 | $screen = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : ''; |
| 1100 | if ( 'siteguard_rename_login' === $screen ) { |
| 1101 | return; |
| 1102 | } |
| 1103 | $conflict = self::get_login_url_conflict(); |
| 1104 | if ( null === $conflict || empty( $conflict['fatal_risk'] ) ) { |
| 1105 | return; |
| 1106 | } |
| 1107 | |
| 1108 | echo '<div class="notice notice-error is-dismissible"><p>' . wp_kses_post( self::conflict_message( $conflict ) ) . '</p></div>'; |
| 1109 | } |
| 1110 | |
| 1111 | public function maybe_notice_stub_failed() { |
| 1112 | if ( ! current_user_can( 'manage_options' ) ) { |
| 1113 | return; } |
| 1114 | if ( get_transient( self::STUB_WRITE_FAIL_TRANSIENT ) ) { |
| 1115 | echo '<div class="notice notice-warning"><p>'; |
| 1116 | echo esc_html__( 'SiteGuard: Could not create the required login file. Please check file permissions or contact your hosting provider.', 'siteguard' ); |
| 1117 | echo '</p></div>'; |
| 1118 | delete_transient( self::STUB_WRITE_FAIL_TRANSIENT ); |
| 1119 | } |
| 1120 | } |
| 1121 | } |
| 1122 |