PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.12
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.12
2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 2.9.4 All 87 releases
vigilante / includes / class-login-security.php

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

1,734 lines 63.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Login Security Class
4 *
5 * Handles login protection, brute force prevention and lockouts
6 *
7 * @package Vigilante
8 */
9
10 // Prevent direct access
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 /**
16 * Class Vigilante_Login_Security
17 *
18 * Manages login security features
19 */
20 class Vigilante_Login_Security {
21
22 /**
23 * Settings instance
24 *
25 * @var Vigilante_Settings
26 */
27 private $settings;
28
29 /**
30 * Database instance
31 *
32 * @var Vigilante_Database
33 */
34 private $database;
35
36 /**
37 * Activity log instance
38 *
39 * @var Vigilante_Activity_Log
40 */
41 private $activity_log;
42
43 /**
44 * Login security options
45 *
46 * @var array
47 */
48 private $options;
49
50 /**
51 * Custom login slug
52 *
53 * @var string
54 */
55 private $custom_login_slug = '';
56
57 /**
58 * Whether the current login error carries a Vigilant-specific code
59 * that must bypass the generic-message mask in hide_login_errors().
60 * Set by detect_specific_login_error() (hooked to wp_login_errors).
61 *
62 * @var bool
63 */
64 private $show_specific_login_error = false;
65
66 /**
67 * Whether we are rendering the login action specifically.
68 *
69 * The login_errors filter that hide_login_errors() masks is fired by
70 * login_header() on every wp-login.php screen (login, register,
71 * lostpassword, resetpass). Only the login action also fires
72 * wp_login_errors, so detect_specific_login_error() runs solely there
73 * and flips this flag. When it stays false the generic mask is skipped,
74 * so registration / lost-password / reset-password keep their real
75 * validation errors instead of "Invalid username or password".
76 *
77 * @var bool
78 */
79 private $in_login_context = false;
80
81 /**
82 * Constructor
83 *
84 * @param Vigilante_Settings $settings Settings instance.
85 * @param Vigilante_Database $database Database instance.
86 * @param Vigilante_Activity_Log $activity_log Activity log instance.
87 */
88 public function __construct( $settings, $database, $activity_log ) {
89 $this->settings = $settings;
90 $this->database = $database;
91 $this->activity_log = $activity_log;
92 $this->options = $settings->get_section( 'login_security' );
93
94 $this->init_hooks();
95 }
96
97 /**
98 * Initialize hooks
99 */
100 private function init_hooks() {
101 // Check lockout before authentication
102 add_filter( 'authenticate', array( $this, 'check_lockout' ), 30, 3 );
103
104 // Track login attempts
105 add_action( 'wp_login_failed', array( $this, 'handle_failed_login' ), 10, 2 );
106 add_action( 'wp_login', array( $this, 'handle_successful_login' ), 10, 2 );
107
108 // Hide login errors
109 if ( ! empty( $this->options['hide_login_errors'] ) ) {
110 add_filter( 'wp_login_errors', array( $this, 'detect_specific_login_error' ), 10, 2 );
111 add_filter( 'login_errors', array( $this, 'hide_login_errors' ) );
112 add_filter( 'shake_error_codes', array( $this, 'remove_shake_errors' ) );
113 }
114
115 // Disable application passwords
116 if ( ! empty( $this->options['disable_application_passwords'] ) ) {
117 add_filter( 'wp_is_application_passwords_available', '__return_false' );
118 }
119
120 // Notify on admin login
121 if ( ! empty( $this->options['notify_on_admin_login'] ) ) {
122 add_action( 'wp_login', array( $this, 'notify_admin_login' ), 10, 2 );
123 }
124
125 // Add lockout info to login form
126 add_action( 'login_form', array( $this, 'show_remaining_attempts' ) );
127
128 // Custom login URL
129 if ( ! empty( $this->options['custom_login_url'] ) ) {
130 $this->init_custom_login();
131 }
132 }
133
134 /**
135 * Initialize custom login URL functionality
136 *
137 * Uses request interception instead of rewrite rules for reliability
138 */
139 private function init_custom_login() {
140 $custom_url = sanitize_title( $this->options['custom_login_url'] );
141
142 if ( empty( $custom_url ) ) {
143 return;
144 }
145
146 // Store custom URL for use in other methods
147 $this->custom_login_slug = $custom_url;
148
149 // Intercept requests early - this is the key hook
150 add_action( 'wp_loaded', array( $this, 'wp_loaded_handler' ) );
151
152 // Filter login URL
153 add_filter( 'login_url', array( $this, 'filter_login_url' ), 10, 3 );
154 add_filter( 'logout_url', array( $this, 'filter_logout_url' ), 10, 2 );
155 add_filter( 'lostpassword_url', array( $this, 'filter_lostpassword_url' ), 10, 2 );
156 add_filter( 'register_url', array( $this, 'filter_register_url' ) );
157
158 // After requesting "lost password", core redirects to wp-login.php?checkemail=confirm
159 // which is blocked by block_wp_login_access (no whitelisted action) and yields a 404.
160 // Send the user to the custom login URL instead so the confirmation message renders.
161 add_filter( 'lostpassword_redirect', array( $this, 'filter_lostpassword_redirect' ) );
162
163 // Block direct wp-login.php access (always when custom URL is set)
164 add_action( 'login_init', array( $this, 'block_wp_login_access' ), 1 );
165
166 // Site URL filter for login form action
167 add_filter( 'site_url', array( $this, 'filter_site_url' ), 10, 4 );
168
169 // Redirect to home after logout instead of wp-login.php
170 add_filter( 'logout_redirect', array( $this, 'filter_logout_redirect' ), 10, 3 );
171
172 // Block wp-admin access for non-logged users - execute immediately
173 $this->block_wp_admin_access();
174
175 // Intercept redirects to wp-login.php from wp-admin and show 404 instead
176 add_filter( 'wp_redirect', array( $this, 'intercept_admin_redirect' ), 1, 2 );
177
178 // Block core's /login and /wp-login.php pretty-URL shortcuts.
179 // Priority 1: must win over redirect_canonical() (10) and
180 // wp_redirect_admin_locations() (1000), which would 302 the
181 // shortcut to wp_login_url() — the hidden URL — leaking the slug.
182 add_action( 'template_redirect', array( $this, 'block_login_shortcuts' ), 1 );
183 }
184
185 /**
186 * Intercept redirects to wp-login.php from wp-admin
187 * Shows 404 instead of redirecting to login
188 *
189 * @param string $location The redirect location.
190 * @param int $status The redirect status code.
191 * @return string
192 */
193 public function intercept_admin_redirect( $location, $status ) {
194
195 // Only intercept if custom login URL is set
196 if ( empty( $this->options['custom_login_url'] ) ) {
197 return $location;
198 }
199
200 // Check if this is a redirect to the login screen. Besides literal
201 // wp-login.php, auth_redirect() targets wp_login_url(), which the
202 // login_url filter has already rewritten to the hidden slug — so a
203 // redirect to the custom login URL must be caught too, or an
204 // anonymous POST to /wp-admin would leak the slug in the Location
205 // header (the login_url filter runs for every wp_login_url() call).
206 if ( strpos( $location, 'wp-login.php' ) === false && ! $this->is_hidden_login_url( $location ) ) {
207 return $location;
208 }
209
210 // Check if the redirect is coming from wp-admin area
211 $request = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
212
213
214 // If accessing wp-admin and being redirected to login, show 404 instead
215 if ( self::is_wp_admin_request() ) {
216 // Don't intercept admin-ajax.php or admin-post.php
217 if ( self::is_open_admin_endpoint() ) {
218 return $location;
219 }
220
221 // Allow whitelisted IPs (e.g. remote managers like MainWP/ManageWP).
222 if ( $this->is_ip_exempt_from_hiding() ) {
223 return $location;
224 }
225
226
227 // Log the attempt
228 if ( $this->activity_log ) {
229 $this->activity_log->log(
230 'login',
231 'hidden_admin_access',
232 __( 'Attempt to access hidden wp-admin', 'vigilante' ),
233 array( 'request_uri' => $request ),
234 'warning'
235 );
236 }
237
238 $this->serve_404();
239 }
240
241 return $location;
242 }
243
244 /**
245 * Whether a URL points at the hidden custom login screen.
246 *
247 * Matches the exact URL, with or without trailing slash, and with a
248 * query string. Prefix-matching the bare slug is deliberately avoided
249 * so a slug like "acceso" does not match "accesorios".
250 *
251 * @since 2.9.3
252 * @param string $url URL to test.
253 * @return bool
254 */
255 private function is_hidden_login_url( $url ) {
256 if ( empty( $this->custom_login_slug ) ) {
257 return false;
258 }
259
260 $hidden = home_url( $this->custom_login_slug . '/' );
261 $bare = untrailingslashit( $hidden );
262
263 return 0 === strpos( $url, $hidden )
264 || $url === $bare
265 || 0 === strpos( $url, $bare . '?' );
266 }
267
268 /**
269 * The request path as sent by the client, query string stripped.
270 *
271 * Unlike get_request_path(), the result is NOT made relative to
272 * home_url(): the admin area can hang from a different path than the
273 * site itself (WP_SITEURL vs WP_HOME), so wp-admin matching needs the
274 * raw path.
275 *
276 * @since 2.9.4
277 * @return string
278 */
279 private static function get_request_uri_path() {
280 $request = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
281
282 if ( '' === $request ) {
283 return '';
284 }
285
286 if ( false !== strpos( $request, '?' ) ) {
287 $request = strstr( $request, '?', true );
288 }
289
290 return untrailingslashit( $request );
291 }
292
293 /**
294 * Whether the current request targets the real wp-admin area.
295 *
296 * Replaces a strpos() for "/wp-admin" over the whole REQUEST_URI. That
297 * test also matched the query string, so /?redirect_to=/wp-admin/ turned
298 * the home page into a 404 and an un-encoded redirect_to broke the hidden
299 * login screen itself; it matched any front-end path merely starting with
300 * those characters (/wp-admin-tips/); and it dragged scanner hits on
301 * non-existent subdirectories (/blog/wp-admin/) into the blocking path,
302 * where they were answered from 'init' instead of by WordPress' own 404.
303 *
304 * Two independent signals, ORed so neither is a single point of failure:
305 * is_admin(), set by the wp-admin bootstrap and immune to filters, and a
306 * path comparison against admin_url() for the rare setup that serves the
307 * admin directory through the front controller. Prefix matching requires
308 * a following slash, the same precaution is_hidden_login_url() takes.
309 *
310 * @since 2.9.4
311 * @return bool
312 */
313 private static function is_wp_admin_request() {
314 if ( is_admin() ) {
315 return true;
316 }
317
318 $path = self::get_request_uri_path();
319
320 if ( '' === $path ) {
321 return false;
322 }
323
324 $admin_path = wp_parse_url( admin_url(), PHP_URL_PATH );
325
326 if ( empty( $admin_path ) ) {
327 return false;
328 }
329
330 $admin_path = untrailingslashit( $admin_path );
331
332 return $path === $admin_path || 0 === strpos( $path, $admin_path . '/' );
333 }
334
335 /**
336 * Whether the request targets an admin entry point that must stay open.
337 *
338 * admin-ajax.php and admin-post.php are used by logged-out visitors on
339 * the front end (WooCommerce fragments, form handlers), so hiding
340 * wp-admin must never touch them. Matched as the exact admin path plus
341 * the file name: the old check ran over the whole REQUEST_URI, so
342 * /wp-admin/edit.php?x=admin-ajax.php slipped past the block, and a bare
343 * basename() would do the same for a PATH_INFO style request such as
344 * /wp-admin/options-general.php/admin-ajax.php. Falls back to the file
345 * name only when admin_url() gives nothing to compare against, so a
346 * broken filter can never take AJAX down for logged-out visitors.
347 *
348 * @since 2.9.4
349 * @return bool
350 */
351 private static function is_open_admin_endpoint() {
352 $path = self::get_request_uri_path();
353
354 if ( '' === $path ) {
355 return false;
356 }
357
358 $endpoints = array( 'admin-ajax.php', 'admin-post.php' );
359 $admin_path = wp_parse_url( admin_url(), PHP_URL_PATH );
360
361 if ( empty( $admin_path ) ) {
362 return in_array( basename( $path ), $endpoints, true );
363 }
364
365 $admin_path = untrailingslashit( $admin_path );
366
367 foreach ( $endpoints as $endpoint ) {
368 if ( $path === $admin_path . '/' . $endpoint ) {
369 return true;
370 }
371 }
372
373 return false;
374 }
375
376 /**
377 * Block access to wp-admin for non-logged users
378 * Shows 404 instead of redirecting to login
379 */
380 public function block_wp_admin_access() {
381 // Only if custom login URL is set
382 if ( empty( $this->options['custom_login_url'] ) ) {
383 return;
384 }
385
386 // Get the request URI
387 $request = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
388
389
390 // Check if accessing wp-admin
391 if ( ! self::is_wp_admin_request() ) {
392 return;
393 }
394
395 // Allow admin-ajax.php and admin-post.php
396 if ( self::is_open_admin_endpoint() ) {
397 return;
398 }
399
400 // Allow if user is logged in
401 if ( is_user_logged_in() ) {
402 return;
403 }
404
405 // Allow POST requests
406 $request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '';
407 if ( 'POST' === $request_method ) {
408 return;
409 }
410
411 // Allow whitelisted IPs (e.g. remote managers like MainWP/ManageWP).
412 if ( $this->is_ip_exempt_from_hiding() ) {
413 return;
414 }
415
416
417 // Log the attempt
418 if ( $this->activity_log ) {
419 $this->activity_log->log(
420 'login',
421 'hidden_admin_access',
422 __( 'Attempt to access hidden wp-admin', 'vigilante' ),
423 array( 'request_uri' => $request ),
424 'warning'
425 );
426 }
427
428 $this->serve_404();
429 }
430
431 /**
432 * Handle requests on wp_loaded
433 * This intercepts requests to our custom login URL
434 */
435 public function wp_loaded_handler() {
436 global $pagenow;
437
438 // Get the request path
439 $request = $this->get_request_path();
440
441
442 // Check if accessing our custom login URL
443 if ( $this->is_custom_login_request( $request ) ) {
444
445 // Set flag that we're coming from custom login
446 if ( ! defined( 'VIGILANTE_CUSTOM_LOGIN' ) ) {
447 define( 'VIGILANTE_CUSTOM_LOGIN', true );
448 }
449
450 // Set pagenow to wp-login.php for compatibility
451 $pagenow = 'wp-login.php';
452
453 // Initialize global variables expected by wp-login.php (PHP 8.x strict)
454 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- Required by WordPress core wp-login.php
455 global $user_login, $error;
456 $user_login = '';
457 $error = '';
458
459 // Load the login page
460 require_once ABSPATH . 'wp-login.php';
461 exit;
462 }
463 }
464
465 /**
466 * Block direct access to wp-login.php
467 * Uses login_init hook which fires inside wp-login.php
468 */
469 public function block_wp_login_access() {
470
471 // If we came from our custom login URL, allow access
472 if ( defined( 'VIGILANTE_CUSTOM_LOGIN' ) && VIGILANTE_CUSTOM_LOGIN ) {
473 return;
474 }
475
476 /*
477 * Everything from here down reaches wp-login.php WITHOUT having gone
478 * through the secret address, so nothing rendered from here may carry
479 * it. The filters this class registers rewrite the form action and
480 * every login link to the slug, and wp-login.php prints them on each
481 * page it serves, so any request let through below handed the hidden
482 * address to whoever asked for it.
483 *
484 * Measured on 12 sep 2026 with the slug configured: a plain anonymous
485 * GET of ?action=lostpassword or ?action=retrievepassword returned it
486 * three times, ?password=changed twice and ?checkemail=confirm once.
487 * The exemptions those requests use (the allowed actions and the
488 * informational query strings) have been there since hiding the login
489 * existed, so the address was never actually hidden from anyone who
490 * asked for a password reset page.
491 *
492 * The first attempt at this fix dropped the filters only for POST, and
493 * only helped the POST with no action: the cross review of 2.11.10
494 * found the four GETs and the POST to ?action=lostpassword, which still
495 * leaked through lostpassword_redirect. The rule is now one rule, not a
496 * list of shapes: came in by the slug, or the address is not emitted.
497 *
498 * A visitor with a session is the single exception, and on purpose:
499 * they already have access, and logging out has to land on the hidden
500 * address or core's redirect to ?loggedout=true would 404.
501 */
502 if ( ! is_user_logged_in() ) {
503 $this->stop_emitting_custom_login_url();
504 }
505
506 /*
507 * A POST is let through so a remote manager such as MainWP or ManageWP
508 * can authenticate, which is what this exemption has always existed for.
509 * Authentication is unaffected by the lines above, because a successful
510 * login redirects to the destination and never renders this form.
511 */
512 $request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '';
513 if ( 'POST' === $request_method ) {
514 return;
515 }
516
517 // Allow AJAX requests
518 if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) {
519 return;
520 }
521
522 // Check for specific allowed actions that need wp-login.php
523 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
524 $action = isset( $_GET['action'] ) ? sanitize_key( $_GET['action'] ) : '';
525 $allowed_actions = array( 'postpass', 'logout', 'rp', 'resetpass', 'confirmaction', 'lostpassword', 'retrievepassword' );
526
527
528 if ( in_array( $action, $allowed_actions, true ) ) {
529 return;
530 }
531
532 // Allow informational query strings that core appends without an action,
533 // e.g. ?checkemail=confirm after a lost-password request and ?password=changed
534 // after a successful reset. These render the corresponding success message
535 // inside wp-login.php and would otherwise 404.
536 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
537 if ( isset( $_GET['checkemail'] ) || isset( $_GET['password'] ) ) {
538 return;
539 }
540
541 /*
542 * A visitor in the middle of a second factor verification. Every redirect
543 * of that flow goes to wp_login_url(), and with the address filters
544 * dropped that is the plain wp-login.php, so a wrong code, an expired
545 * nonce or running out of attempts landed on a 404 with no way back: a
546 * login started by POST straight at wp-login.php could be begun but never
547 * finished. Found by the third cross review of 2.11.10, which measured
548 * 2.11.9 returning the visitor to the login form and trunk returning 404.
549 *
550 * The proof asked for is the pending session itself, not the cookie:
551 * a made-up token finds no transient and gets the 404 like anybody else,
552 * and the real one is only issued after the right password. Same key as
553 * the trait (trait-two-factor-session.php:91 and :201).
554 */
555 if ( isset( $_COOKIE['vigilante_2fa_token'] ) ) {
556 $pending = get_transient( 'vigilante_2fa_pending_' . sanitize_text_field( wp_unslash( $_COOKIE['vigilante_2fa_token'] ) ) );
557
558 if ( is_array( $pending ) && ! empty( $pending['user_id'] ) ) {
559 return;
560 }
561 }
562
563 // Check if user already logged in - redirect to admin
564 if ( is_user_logged_in() ) {
565 wp_safe_redirect( admin_url() );
566 exit;
567 }
568
569 // Log the attempt
570 if ( $this->activity_log ) {
571 $request = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
572 $this->activity_log->log(
573 'login',
574 'hidden_login_access',
575 __( 'Attempt to access hidden wp-login.php', 'vigilante' ),
576 array( 'request_uri' => $request ),
577 'warning'
578 );
579 }
580
581 // Return 404
582 $this->serve_404();
583 }
584
585 /**
586 * Block WordPress' pretty-URL login shortcuts (/login, /wp-login.php).
587 *
588 * Core's wp_redirect_admin_locations() (template_redirect, priority
589 * 1000) turns a 404 on those paths into wp_redirect( wp_login_url() ).
590 * With a custom login URL active wp_login_url() IS the hidden slug, so
591 * that 302 would hand the secret to anyone typing /login, while /admin
592 * correctly ends in a 404. Runs only when the request is already a 404:
593 * if a real page named "login" exists, core does not redirect either
594 * and this must not interfere.
595 *
596 * @since 2.9.3
597 */
598 public function block_login_shortcuts() {
599 if ( ! is_404() ) {
600 return;
601 }
602
603 $request = strtolower( $this->get_request_path() );
604
605 if ( ! in_array( $request, array( 'login', 'wp-login.php' ), true ) ) {
606 return;
607 }
608
609 if ( $this->activity_log ) {
610 $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
611 $this->activity_log->log(
612 'login',
613 'hidden_login_access',
614 __( 'Attempt to access hidden wp-login.php', 'vigilante' ),
615 array( 'request_uri' => $request_uri ),
616 'warning'
617 );
618 }
619
620 $this->serve_404();
621 }
622
623 /**
624 * Get the request path without query string
625 *
626 * @return string
627 */
628 private function get_request_path() {
629 $request = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
630
631 // Remove query string
632 if ( false !== strpos( $request, '?' ) ) {
633 $request = strstr( $request, '?', true );
634 }
635
636 // Get path relative to home URL
637 $home_path = wp_parse_url( home_url(), PHP_URL_PATH );
638 if ( ! empty( $home_path ) ) {
639 $request = str_replace( $home_path, '', $request );
640 }
641
642 // Clean up the path
643 $request = ltrim( $request, '/' );
644 $request = rtrim( $request, '/' );
645
646 return $request;
647 }
648
649 /**
650 * Check if this is a request to our custom login URL
651 *
652 * @param string $request The request path.
653 * @return bool
654 */
655 private function is_custom_login_request( $request ) {
656 return $request === $this->custom_login_slug;
657 }
658
659 /**
660 * Filter the login URL
661 *
662 * @param string $login_url The login URL.
663 * @param string $redirect The redirect URL.
664 * @param bool $force_reauth Whether to force reauth.
665 * @return string
666 */
667 public function filter_login_url( $login_url, $redirect = '', $force_reauth = false ) {
668 $login_url = home_url( $this->custom_login_slug . '/' );
669
670 if ( ! empty( $redirect ) ) {
671 $login_url = add_query_arg( 'redirect_to', rawurlencode( $redirect ), $login_url );
672 }
673
674 if ( $force_reauth ) {
675 $login_url = add_query_arg( 'reauth', '1', $login_url );
676 }
677
678 return $login_url;
679 }
680
681 /**
682 * Filter logout URL
683 *
684 * @param string $logout_url The logout URL.
685 * @param string $redirect The redirect URL.
686 * @return string
687 */
688 public function filter_logout_url( $logout_url, $redirect = '' ) {
689 $args = array( 'action' => 'logout' );
690
691 if ( ! empty( $redirect ) ) {
692 $args['redirect_to'] = rawurlencode( $redirect );
693 }
694
695 $logout_url = add_query_arg( $args, home_url( $this->custom_login_slug . '/' ) );
696 $logout_url = wp_nonce_url( $logout_url, 'log-out' );
697
698 return $logout_url;
699 }
700
701 /**
702 * Filter lost password URL
703 *
704 * @param string $lostpassword_url The lost password URL.
705 * @param string $redirect The redirect URL.
706 * @return string
707 */
708 public function filter_lostpassword_url( $lostpassword_url, $redirect = '' ) {
709 $args = array( 'action' => 'lostpassword' );
710
711 if ( ! empty( $redirect ) ) {
712 $args['redirect_to'] = rawurlencode( $redirect );
713 }
714
715 return add_query_arg( $args, home_url( $this->custom_login_slug . '/' ) );
716 }
717
718 /**
719 * Filter register URL
720 *
721 * @param string $register_url The register URL.
722 * @return string
723 */
724 public function filter_register_url( $register_url ) {
725 /*
726 * Not from wp-signup.php. On a single site the core answers that file
727 * with wp_redirect( wp_registration_url() ) and dies (wp-signup.php:39-41),
728 * so this filter put the secret address in the Location header of a plain
729 * anonymous request, outside wp-login.php and therefore out of reach of
730 * block_wp_login_access(), which only runs on login_init. Measured by the
731 * third cross review of 2.11.10; present since hiding the login existed.
732 *
733 * Left unfiltered, that redirect lands on wp-login.php?action=register,
734 * which the blocker answers with the same 404 as any other direct visit,
735 * which is what hiding the login is for. The registration link served on
736 * the login page itself is rendered under the slug, where this filter goes
737 * on doing its job.
738 */
739 if ( $this->request_is_signup() ) {
740 return $register_url;
741 }
742
743 return add_query_arg( 'action', 'register', home_url( $this->custom_login_slug . '/' ) );
744 }
745
746 /**
747 * Whether this request is being served by wp-signup.php or wp-activate.php
748 *
749 * @since 2.11.10
750 *
751 * @return bool
752 */
753 private function request_is_signup() {
754 foreach ( array( 'SCRIPT_NAME', 'PHP_SELF', 'SCRIPT_FILENAME' ) as $key ) {
755 if ( empty( $_SERVER[ $key ] ) ) {
756 continue;
757 }
758
759 $file = basename( sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ) );
760
761 if ( 'wp-signup.php' === $file || 'wp-activate.php' === $file ) {
762 return true;
763 }
764 }
765
766 return false;
767 }
768
769 /**
770 * Redirect after a successful lost-password request to the custom login URL
771 *
772 * Without this filter, core sends the user to wp-login.php?checkemail=confirm,
773 * which 404s when the custom login URL is enabled (block_wp_login_access only
774 * whitelists requests with a known action= parameter). Sending the user back
775 * to the custom login URL with the same query string lets wp-login.php render
776 * the "Check your email" confirmation correctly.
777 *
778 * @param string $redirect_to The default redirect URL.
779 * @return string
780 */
781 public function filter_lostpassword_redirect( $redirect_to ) {
782 return add_query_arg( 'checkemail', 'confirm', home_url( $this->custom_login_slug . '/' ) );
783 }
784
785 /**
786 * Filter site_url to replace wp-login.php in login form action
787 *
788 * @param string $url The complete site URL.
789 * @param string $path Path relative to the site URL.
790 * @param string|null $scheme Scheme to give the site URL context.
791 * @param int|null $blog_id Site ID, or null for the current site.
792 * @return string
793 */
794 public function filter_site_url( $url, $path, $scheme, $blog_id ) {
795 if ( 'login_post' === $scheme || 'login' === $scheme ) {
796 if ( strpos( $path, 'wp-login.php' ) !== false ) {
797 $url = str_replace( 'wp-login.php', $this->custom_login_slug . '/', $url );
798 }
799 }
800 return $url;
801 }
802
803 /**
804 * Filter logout redirect to go to home instead of wp-login.php
805 *
806 * @param string $redirect_to The redirect destination URL.
807 * @param string $requested_redirect_to The requested redirect destination URL.
808 * @param WP_User $user The WP_User object for the logged out user.
809 * @return string
810 */
811 public function filter_logout_redirect( $redirect_to, $requested_redirect_to, $user ) {
812 // If no specific redirect requested, go to home page
813 if ( empty( $requested_redirect_to ) || strpos( $redirect_to, 'wp-login.php' ) !== false ) {
814 return home_url( '/' );
815 }
816 return $redirect_to;
817 }
818
819 /**
820 * Show 404 page (full version with theme template)
821 * Use this only when WordPress is fully loaded (login_init, template_redirect, etc.)
822 */
823 private function show_404() {
824
825 global $wp_query;
826
827 // Set 404 status
828 status_header( 404 );
829 nocache_headers();
830
831 // $wp_query always exists by now: wp-settings.php creates it before
832 // 'init' fires, and serve_404() only routes here once 'wp_loaded' has
833 // passed. Earlier versions called wp() when it was missing, a branch
834 // that was never reachable and that misled a performance analysis into
835 // blaming the main query for the cost of the render.
836 if ( isset( $wp_query ) && is_object( $wp_query ) ) {
837 $wp_query->set_404();
838 }
839
840 // Try to get the theme's 404 template
841 $template = get_query_template( '404' );
842
843
844 if ( $template && file_exists( $template ) ) {
845 include $template;
846 exit;
847 }
848
849 // Block themes have no 404.php; resolve their 404 template the same
850 // way core's template-loader does, so those sites also get the
851 // theme's 404 instead of the plain fallback page.
852 if ( function_exists( 'locate_block_template' ) ) {
853 $template = locate_block_template( '', '404', array( '404' ) );
854
855 if ( $template && file_exists( $template ) ) {
856 include $template;
857 exit;
858 }
859 }
860
861 // Fallback to simple 404
862 $this->show_404_simple();
863 }
864
865 /**
866 * Serve the hidden-URL 404 through a single decision point.
867 *
868 * All blocking paths call this helper so the response never diverges by
869 * accident. Rendering a theme template is only safe once 'wp_loaded' has
870 * fired: that is the point the rest of the stack assumes has passed
871 * before any template runs, and WooCommerce for one does not set up the
872 * cart until then. block_wp_login_access() runs at 'login_init' and
873 * block_login_shortcuts() at 'template_redirect', both after 'wp_loaded',
874 * so those keep the themed 404; block_wp_admin_access() runs inside
875 * 'init' and gets the simple page.
876 *
877 * 2.9.3 gated this on 'after_setup_theme', which has already fired by
878 * 'init'. The wp-admin path therefore included the theme's 404.php from
879 * inside 'init' on every blocked request, filling debug.log with
880 * _doing_it_wrong notices and costing a full page render per rejection.
881 *
882 * @since 2.9.3
883 */
884 private function serve_404() {
885 if ( did_action( 'wp_loaded' ) && ! is_admin() ) {
886 $this->show_404();
887 }
888
889 $this->show_404_simple();
890 }
891
892 /**
893 * Show simple 404 page (for early execution before WordPress is fully loaded)
894 * Use this when intercepting requests very early (plugins_loaded, admin init, etc.)
895 */
896 private function show_404_simple() {
897 status_header( 404 );
898 nocache_headers();
899
900 // Use wp_die which is the WordPress standard for early termination
901 wp_die(
902 sprintf(
903 '<h1>%s</h1><p>%s</p><p><a href="%s">%s</a></p>',
904 esc_html__( 'Page not found', 'vigilante' ),
905 esc_html__( 'The page you are looking for does not exist.', 'vigilante' ),
906 esc_url( home_url( '/' ) ),
907 esc_html__( 'Go to homepage', 'vigilante' )
908 ),
909 esc_html__( '404 Not Found', 'vigilante' ),
910 array(
911 'response' => 404,
912 'back_link' => false,
913 )
914 );
915 }
916
917 /**
918 * Check if user is locked out
919 *
920 * @param WP_User|WP_Error|null $user User object or error.
921 * @param string $username Username.
922 * @param string $password Password.
923 * @return WP_User|WP_Error
924 */
925 public function check_lockout( $user, $username, $password ) {
926 // Skip if already error or empty credentials
927 if ( empty( $username ) || empty( $password ) ) {
928 return $user;
929 }
930
931 $ip = $this->database->get_client_ip();
932
933 // Check IP whitelist
934 if ( $this->is_ip_whitelisted( $ip ) ) {
935 return $user;
936 }
937
938 // Check if locked out
939 $lockout = $this->database->is_locked_out( $ip );
940
941 if ( $lockout ) {
942 $remaining = strtotime( $lockout['lockout_until'] ) - time();
943 $minutes = ceil( $remaining / 60 );
944
945 // Log the blocked attempt
946 if ( $this->activity_log ) {
947 $this->activity_log->log(
948 'login',
949 'lockout_blocked',
950 sprintf(
951 /* translators: %s: Username */
952 __( 'Login attempt blocked due to lockout: %s', 'vigilante' ),
953 $username
954 ),
955 array(
956 'ip' => $ip,
957 'username' => $username,
958 'lockout_until' => $lockout['lockout_until'],
959 ),
960 'warning'
961 );
962 }
963
964 // This rejection is ours, not a wrong password. wp_authenticate()
965 // still fires wp_login_failed for it, and until 2.11.0 that counted
966 // the blocked attempt as one more failure, which rewrote the row's
967 // status and produced a fresh lockout, with its critical entry and
968 // its email, on every POST made during the lockout (S8). The error
969 // code below is what handle_failed_login() reads to leave it alone.
970 return new WP_Error(
971 'vigilante_lockout',
972 sprintf(
973 /* translators: %d: Minutes remaining */
974 __( '<strong>Error</strong>: Too many failed login attempts. Please try again in %d minutes.', 'vigilante' ),
975 $minutes
976 )
977 );
978 }
979
980 return $user;
981 }
982
983 /**
984 * Error codes of the refusals Vigilant issues itself
985 *
986 * WordPress treats every WP_Error out of the authenticate chain as a failed
987 * login and fires wp_login_failed for it (wp-includes/pluggable.php,
988 * wp_authenticate()). These seven are not wrong passwords: the credentials
989 * were right and Vigilant stopped the login for a reason of its own, so none
990 * of them counts towards the brute force lockout.
991 *
992 * The rejection identifies itself by the error code it carries. Until
993 * 2.11.12 each one instead added a filter that stayed registered for the
994 * rest of the request, which meant that one controlled rejection stopped
995 * every later failed login of the same request from being counted: measured
996 * on 17 Sep 2026, three wrong passwords for a different account, sent in the
997 * same request, none of them recorded. A single XML-RPC system.multicall is
998 * enough to make that one request.
999 *
1000 * @since 2.11.12
1001 *
1002 * @var string[]
1003 */
1004 const CONTROLLED_REJECTIONS = array(
1005 'vigilante_2fa_required', // Two factor asked for, by app or by email.
1006 'vigilante_lockout', // This address is already locked out.
1007 'vigilante_force_reset', // An administrator forced a password reset.
1008 'vigilante_password_expired', // Password expiry policy, over XML-RPC.
1009 'pending_approval', // Registration awaiting approval.
1010 'session_limit_exceeded', // Too many sessions already open.
1011 'email_not_verified', // Email address not verified yet.
1012 );
1013
1014 /**
1015 * Handle failed login attempt
1016 *
1017 * @since 2.11.12 Receives the WP_Error, so a refusal of Vigilant's own is told
1018 * apart from a wrong password by what it is and not by a flag
1019 * left behind for the rest of the request.
1020 *
1021 * @param string $username Username that failed.
1022 * @param WP_Error|null $error The error WordPress is reporting, if any.
1023 */
1024 public function handle_failed_login( $username, $error = null ) {
1025 // A refusal of ours, not a wrong password.
1026 if ( $error instanceof WP_Error && array_intersect( $error->get_error_codes(), self::CONTROLLED_REJECTIONS ) ) {
1027 return;
1028 }
1029
1030 // Kept for anything outside the plugin that marks its own controlled
1031 // rejection. Nothing inside Vigilant sets it any more.
1032 if ( apply_filters( 'vigilante_skip_failed_login_count', false ) ) {
1033 return;
1034 }
1035
1036 $ip = $this->database->get_client_ip();
1037
1038 // Skip whitelisted IPs
1039 if ( $this->is_ip_whitelisted( $ip ) ) {
1040 return;
1041 }
1042
1043 // Record the attempt
1044 $this->database->record_login_attempt( $ip, $username, 'failed' );
1045
1046 // Log the attempt
1047 if ( $this->activity_log ) {
1048 $this->activity_log->log(
1049 'login',
1050 'failed',
1051 sprintf(
1052 /* translators: %s: Username */
1053 __( 'Failed login attempt for username: %s', 'vigilante' ),
1054 $username
1055 ),
1056 array(
1057 'ip' => $ip,
1058 'username' => $username,
1059 ),
1060 'warning'
1061 );
1062 }
1063
1064 // Check if should be locked out
1065 $this->maybe_lockout( $ip, $username );
1066 }
1067
1068 /**
1069 * Check if IP should be locked out
1070 *
1071 * @param string $ip IP address.
1072 * @param string $username Username.
1073 */
1074 public function maybe_lockout( $ip, $username ) {
1075 $max_attempts = absint( $this->options['max_attempts'] ?? 5 );
1076 $lockout_duration = absint( $this->options['lockout_duration'] ?? 1800 );
1077
1078 // Get failed attempts in the last hour
1079 $failed_count = $this->database->get_failed_attempt_count( $ip, 60 );
1080
1081 if ( $failed_count >= $max_attempts ) {
1082 // Already locked out: every further POST during the lockout used to
1083 // write another critical entry and send another email (S8). The
1084 // lockout itself is what check_lockout() enforces; nothing to add.
1085 if ( $this->database->is_locked_out( $ip ) ) {
1086 return;
1087 }
1088
1089 // Calculate lockout duration with increment
1090 if ( ! empty( $this->options['lockout_increment'] ) ) {
1091 $previous_lockouts = $this->get_previous_lockout_count( $ip );
1092 $lockout_duration = min(
1093 $lockout_duration * pow( 2, $previous_lockouts ),
1094 absint( $this->options['max_lockout_duration'] ?? 86400 )
1095 );
1096 }
1097
1098 // Set lockout
1099 $this->database->set_lockout( $ip, $lockout_duration );
1100
1101 // Log the lockout
1102 if ( $this->activity_log ) {
1103 $this->activity_log->log(
1104 'login',
1105 'lockout',
1106 sprintf(
1107 /* translators: 1: IP address, 2: Duration in minutes */
1108 __( 'IP %1$s locked out for %2$d minutes', 'vigilante' ),
1109 $ip,
1110 ceil( $lockout_duration / 60 )
1111 ),
1112 array(
1113 'ip' => $ip,
1114 'username' => $username,
1115 'attempts' => $failed_count,
1116 'duration' => $lockout_duration,
1117 ),
1118 'critical'
1119 );
1120 }
1121
1122 // Send notification if enabled
1123 if ( ! empty( $this->options['notify_on_lockout'] ) ) {
1124 $this->send_lockout_notification( $ip, $username, $failed_count, $lockout_duration );
1125 }
1126 }
1127 }
1128
1129 /**
1130 * Record a failed login attempt (public wrapper)
1131 *
1132 * Use this method from external modules (like 2FA) to integrate with the lockout system.
1133 *
1134 * @param string $username Username or identifier.
1135 * @param string $context Context for logging (e.g., 'password', '2fa').
1136 */
1137 public function record_failed_attempt( $username, $context = 'password' ) {
1138 $ip = $this->database->get_client_ip();
1139
1140 // Skip whitelisted IPs
1141 if ( $this->is_ip_whitelisted( $ip ) ) {
1142 return;
1143 }
1144
1145 // Record the attempt
1146 $this->database->record_login_attempt( $ip, $username, 'failed' );
1147
1148 // Log the attempt
1149 if ( $this->activity_log ) {
1150 $this->activity_log->log(
1151 'login',
1152 'failed',
1153 sprintf(
1154 /* translators: 1: Username, 2: Context (password/2fa) */
1155 __( 'Failed login attempt for %1$s (%2$s verification)', 'vigilante' ),
1156 $username,
1157 $context
1158 ),
1159 array(
1160 'ip' => $ip,
1161 'username' => $username,
1162 'context' => $context,
1163 ),
1164 'warning'
1165 );
1166 }
1167
1168 // Check if should be locked out
1169 $this->maybe_lockout( $ip, $username );
1170 }
1171
1172 /**
1173 * Get remaining attempts before lockout
1174 *
1175 * @return int Remaining attempts, or -1 if whitelisted
1176 */
1177 public function get_remaining_attempts() {
1178 $ip = $this->database->get_client_ip();
1179
1180 if ( $this->is_ip_whitelisted( $ip ) ) {
1181 return -1;
1182 }
1183
1184 $max_attempts = absint( $this->options['max_attempts'] ?? 5 );
1185 $failed_count = $this->database->get_failed_attempt_count( $ip, 60 );
1186
1187 return max( 0, $max_attempts - $failed_count );
1188 }
1189
1190 /**
1191 * Get count of previous lockouts for an IP
1192 *
1193 * @param string $ip IP address.
1194 * @return int
1195 */
1196 private function get_previous_lockout_count( $ip ) {
1197 $transient_key = 'vigilante_lockout_count_' . md5( $ip );
1198 $count = get_transient( $transient_key );
1199
1200 if ( false === $count ) {
1201 $count = 0;
1202 }
1203
1204 // Increment and store
1205 set_transient( $transient_key, $count + 1, DAY_IN_SECONDS );
1206
1207 return $count;
1208 }
1209
1210 /**
1211 * Handle successful login
1212 *
1213 * @param string $user_login Username.
1214 * @param WP_User $user User object.
1215 */
1216 public function handle_successful_login( $user_login, $user ) {
1217 $ip = $this->database->get_client_ip();
1218
1219 // Clear any failed attempts for this IP
1220 $this->database->reset_login_attempts( $ip );
1221
1222 // Log the successful login
1223 if ( $this->activity_log ) {
1224 $this->activity_log->log(
1225 'login',
1226 'success',
1227 sprintf(
1228 /* translators: %s: Username */
1229 __( 'Successful login: %s', 'vigilante' ),
1230 $user_login
1231 ),
1232 array(
1233 'ip' => $ip,
1234 'user_id' => $user->ID,
1235 'role' => implode( ', ', $user->roles ),
1236 ),
1237 'info'
1238 );
1239 }
1240 }
1241
1242 /**
1243 * Detect Vigilant-specific error codes on the login page
1244 *
1245 * Hooked to 'wp_login_errors' (which receives the full WP_Error object,
1246 * unlike 'login_errors' that only sees the rendered message string).
1247 * If any of the codes we recognize is present, sets a flag so that
1248 * hide_login_errors() lets the message through. Matching by code is
1249 * locale-independent — checking the message string would break on
1250 * translated sites because __() returns the translation, not the
1251 * original English text.
1252 *
1253 * @param WP_Error $errors Errors object.
1254 * @param string $redirect_to Redirect URL.
1255 * @return WP_Error
1256 */
1257 public function detect_specific_login_error( $errors, $redirect_to ) {
1258 // wp_login_errors only fires for the login action, so reaching this
1259 // method means we are on the login screen — not register, lost-password
1260 // or reset-password, where masking the message makes no sense.
1261 $this->in_login_context = true;
1262
1263 if ( ! ( $errors instanceof WP_Error ) || ! $errors->has_errors() ) {
1264 return $errors;
1265 }
1266
1267 $allowed_codes = array(
1268 // Login Security
1269 'vigilante_lockout',
1270 // User Security
1271 'vigilante_force_reset',
1272 // pending_approval, email_not_verified and session_limit_exceeded
1273 // are deliberately NOT here since 2.11.0: they are only raised once
1274 // the password is correct, so letting them through told an
1275 // unauthenticated visitor which accounts exist (S10). Those users
1276 // learn their status from the registration and verification emails.
1277 // Two-Factor Email
1278 'no_code',
1279 'code_expired',
1280 'code_used',
1281 // Two-Factor TOTP
1282 'code_reused',
1283 'invalid_format',
1284 'not_configured',
1285 'decrypt_failed',
1286 'invalid_backup',
1287 'no_backup_codes',
1288 'corrupt_data',
1289 );
1290
1291 foreach ( $errors->get_error_codes() as $code ) {
1292 if ( in_array( $code, $allowed_codes, true ) ) {
1293 $this->show_specific_login_error = true;
1294 break;
1295 }
1296 }
1297
1298 return $errors;
1299 }
1300
1301 /**
1302 * Hide login error messages
1303 *
1304 * Only masks errors on the login action. Register, lost-password and
1305 * reset-password share the login_errors filter but must keep their real
1306 * validation messages.
1307 *
1308 * @param string $error Error message.
1309 * @return string
1310 */
1311 public function hide_login_errors( $error ) {
1312 // The login_errors filter is fired by login_header() on every
1313 // wp-login.php screen, not just the login form. On register,
1314 // lost-password and reset-password the generic "Invalid username or
1315 // password" is meaningless, so only mask when we are actually on the
1316 // login action (detect_specific_login_error, hooked to the
1317 // login-only wp_login_errors filter, sets this flag).
1318 if ( ! $this->in_login_context ) {
1319 return $error;
1320 }
1321
1322 // Primary check: a recognized Vigilant error code was seen on the
1323 // wp_login_errors filter — let the message through verbatim.
1324 if ( $this->show_specific_login_error ) {
1325 return $error;
1326 }
1327
1328 // Fallback: English string match. Kept for cases where the message
1329 // arrives without going through wp_login_errors (e.g. a third-party
1330 // plugin filtering 'login_errors' directly), and as a safety net for
1331 // any allowed code we may have missed in detect_specific_login_error().
1332 // Note: this fallback won't match on translated sites — the
1333 // code-based check above is the locale-safe path.
1334 $allowed_patterns = array(
1335 'vigilante_lockout',
1336 // The pending-approval, unverified-email and session-limit strings
1337 // were removed in 2.11.0 for the same reason as their codes above (S10).
1338 'verification code',
1339 'authenticator app',
1340 'two-factor',
1341 'grace period',
1342 'Password reset required',
1343 );
1344
1345 foreach ( $allowed_patterns as $pattern ) {
1346 if ( stripos( $error, $pattern ) !== false ) {
1347 return $error;
1348 }
1349 }
1350
1351 return __( '<strong>Error</strong>: Invalid username or password.', 'vigilante' );
1352 }
1353
1354 /**
1355 * Remove shake animation error codes
1356 *
1357 * Keeps Vigilante-specific error codes to show the shake animation
1358 *
1359 * @param array $codes Error codes.
1360 * @return array
1361 */
1362 public function remove_shake_errors( $codes ) {
1363 // Keep shake for Vigilante-specific errors that indicate real problems
1364 // Do NOT include 2FA codes - the form transition should be smooth.
1365 // The three account-status codes are not here either since 2.11.0: a
1366 // shake that only plays for existing accounts is the same tell as the
1367 // message it replaced (S10).
1368 return array(
1369 'vigilante_lockout',
1370 'vigilante_force_reset',
1371 );
1372 }
1373
1374
1375 /**
1376 * Disable XML-RPC pingback method
1377 *
1378 * @param array $methods XML-RPC methods.
1379 * @return array
1380 */
1381 /**
1382 * Notify admin of admin login
1383 *
1384 * @param string $user_login Username.
1385 * @param WP_User $user User object.
1386 */
1387 public function notify_admin_login( $user_login, $user ) {
1388 // Only notify for admin users
1389 if ( ! user_can( $user, 'administrator' ) ) {
1390 return;
1391 }
1392
1393 $ip = $this->database->get_client_ip();
1394 $to = $this->get_notification_email();
1395
1396 $site_name = get_bloginfo( 'name' );
1397 $subject = sprintf(
1398 /* translators: 1: Site name, 2: Username */
1399 __( '[%1$s] Administrator login: %2$s', 'vigilante' ),
1400 $site_name,
1401 $user_login
1402 );
1403
1404 $body = Vigilante_Email_Template::p( __( 'An administrator login has been detected on your site.', 'vigilante' ) );
1405 $body .= Vigilante_Email_Template::data_table( array(
1406 __( 'User', 'vigilante' ) => $user_login,
1407 __( 'IP address', 'vigilante' ) => $ip,
1408 __( 'Date/Time', 'vigilante' ) => wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ),
1409 ) );
1410 $body .= Vigilante_Email_Template::warning_box( __( 'If this was not you, please check your site security immediately.', 'vigilante' ) );
1411
1412 Vigilante_Email_Template::send( $to, $subject, __( 'Administrator login detected', 'vigilante' ), $body );
1413 }
1414
1415 /**
1416 * Send lockout notification email
1417 *
1418 * @param string $ip IP address.
1419 * @param string $username Username.
1420 * @param int $attempts Number of attempts.
1421 * @param int $duration Lockout duration in seconds.
1422 */
1423 private function send_lockout_notification( $ip, $username, $attempts, $duration ) {
1424 $to = $this->get_notification_email();
1425 $site_name = get_bloginfo( 'name' );
1426
1427 $subject = sprintf(
1428 /* translators: %s: Site name */
1429 __( '[%s] Login lockout triggered', 'vigilante' ),
1430 $site_name
1431 );
1432
1433 $body = Vigilante_Email_Template::alert_box( __( 'A login lockout has been triggered on your site. The IP address has been temporarily blocked.', 'vigilante' ) );
1434 $body .= Vigilante_Email_Template::data_table( array(
1435 __( 'IP address', 'vigilante' ) => $ip,
1436 __( 'Username attempted', 'vigilante' ) => $username,
1437 __( 'Failed attempts', 'vigilante' ) => (string) $attempts,
1438 __( 'Lockout duration', 'vigilante' ) => ceil( $duration / 60 ) . ' ' . __( 'minutes', 'vigilante' ),
1439 __( 'Date/Time', 'vigilante' ) => wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ),
1440 ) );
1441 $body .= Vigilante_Email_Template::button( admin_url( 'admin.php?page=vigilante&tab=login#vigilante-section-login-status' ), __( 'View lockouts', 'vigilante' ) );
1442
1443 Vigilante_Email_Template::send( $to, $subject, __( 'Login lockout triggered', 'vigilante' ), $body, true );
1444 }
1445
1446 /**
1447 * Show remaining attempts on login form
1448 */
1449 public function show_remaining_attempts() {
1450 $ip = $this->database->get_client_ip();
1451
1452 if ( $this->is_ip_whitelisted( $ip ) ) {
1453 return;
1454 }
1455
1456 $max_attempts = absint( $this->options['max_attempts'] ?? 5 );
1457 $failed_count = $this->database->get_failed_attempt_count( $ip, 60 );
1458
1459 if ( $failed_count > 0 && $failed_count < $max_attempts ) {
1460 $remaining = $max_attempts - $failed_count;
1461 ?>
1462 <p class="vigilante-login-warning" style="background: #fff3cd; border-left: 4px solid #ffc107; padding: 10px; margin-bottom: 15px;">
1463 <?php
1464 printf(
1465 /* translators: %d: Number of remaining attempts */
1466 esc_html( _n(
1467 'Warning: %d login attempt remaining before lockout.',
1468 'Warning: %d login attempts remaining before lockout.',
1469 $remaining,
1470 'vigilante'
1471 ) ),
1472 absint( $remaining )
1473 );
1474 ?>
1475 </p>
1476 <?php
1477 }
1478 }
1479
1480 /**
1481 * Check if IP is whitelisted
1482 *
1483 * @param string $ip IP address.
1484 * @return bool
1485 */
1486 private function is_ip_whitelisted( $ip ) {
1487 $whitelist = $this->options['ip_whitelist'] ?? array();
1488
1489 return Vigilante_IP_Utils::in_list( $ip, $whitelist );
1490 }
1491
1492 /**
1493 * Turn away an anonymous wp-admin request before WordPress finishes booting
1494 *
1495 * The modules are built on init priority 1, so a request that was going to
1496 * be refused had already paid for the whole boot: the theme, every plugin
1497 * and every init callback. Measured on a real site, a rejected
1498 * /wp-admin/index.php cost as much as serving a page.
1499 *
1500 * Only the case that can be judged with certainty this early is handled
1501 * here, an anonymous GET with no session cookie at all; everything else
1502 * falls through to the usual path untouched. The cookie is only checked for
1503 * presence: resolving the user here would run is_user_logged_in() before
1504 * other plugins register their determine_current_user filters, which is how
1505 * token, JWT and SSO logins are wired.
1506 *
1507 * @since 2.9.9
1508 *
1509 * @param array $options The plugin options, already read by the caller.
1510 */
1511 public static function maybe_block_hidden_admin_early( $options ) {
1512 if ( self::is_open_admin_endpoint() ) {
1513 return;
1514 }
1515
1516 if ( '' === sanitize_title( $options['login_security']['custom_login_url'] ) ) {
1517 return;
1518 }
1519
1520 if ( self::has_session_cookie() ) {
1521 return;
1522 }
1523
1524 $whitelist = isset( $options['firewall']['ip_whitelist'] ) ? (array) $options['firewall']['ip_whitelist'] : array();
1525
1526 if ( ! empty( $whitelist ) && Vigilante_IP_Utils::in_list( Vigilante_IP_Utils::get_client_ip(), $whitelist ) ) {
1527 return;
1528 }
1529
1530 /*
1531 * Last, and only for a request that was about to be turned away: whether
1532 * anybody is actually there.
1533 *
1534 * A remote manager signs its own call with a token and asks for the
1535 * dashboard before holding any cookie; its connector resolves the user
1536 * through determine_current_user and only then, on 'init', sets the
1537 * cookie and redirects. Turning the request away here, three hooks
1538 * earlier, means the connector never reaches the point where it would
1539 * have logged itself in, so it reads the 404 as a site that is broken
1540 * and retries the whole job. Observed in the wild with
1541 * ModularConnector/3.2.1, whose every request landed here.
1542 *
1543 * Which is also why this went unnoticed for two releases: a connector
1544 * that already holds a cookie by the time it asks for the dashboard
1545 * leaves at has_session_cookie() above and never reaches this line. How
1546 * many connectors work that way is not something to guess at here; what
1547 * is certain is that reports only came from sites where one did not.
1548 *
1549 * The criterion is the one block_wp_admin_access() has always applied,
1550 * brought to the door 2.9.9 put in front of it. It costs nothing on the
1551 * ordinary request, which left long before reaching this line, and
1552 * nothing on the database either: with no cookie to validate, the three
1553 * core determine_current_user callbacks all decline without a query. The
1554 * rejection below already pays for an INSERT into the activity log, and
1555 * resolves this very same user one step later to record who was refused.
1556 */
1557 if ( get_current_user_id() ) {
1558 return;
1559 }
1560
1561 self::log_early_hidden_admin_attempt();
1562
1563 status_header( 404 );
1564 nocache_headers();
1565
1566 /*
1567 * Deliberately not translated. This runs on plugins_loaded, where asking
1568 * for a translation triggers the just in time text domain notice of
1569 * WordPress 6.7 and returns the English string anyway. The reader is an
1570 * anonymous request to an address that is supposed to look absent.
1571 */
1572 wp_die(
1573 '<h1>Page not found</h1><p>The page you are looking for does not exist.</p>',
1574 '404 Not Found',
1575 array(
1576 'response' => 404,
1577 'back_link' => false,
1578 )
1579 );
1580 }
1581
1582 /**
1583 * Whether the request carries a WordPress session cookie, without resolving it
1584 *
1585 * @since 2.9.9
1586 *
1587 * @return bool
1588 */
1589 private static function has_session_cookie() {
1590 if ( defined( 'LOGGED_IN_COOKIE' ) && isset( $_COOKIE[ LOGGED_IN_COOKIE ] ) ) {
1591 return true;
1592 }
1593
1594 foreach ( array_keys( (array) $_COOKIE ) as $name ) {
1595 if ( 0 === strpos( (string) $name, 'wordpress_logged_in_' ) || 0 === strpos( (string) $name, 'wordpress_sec_' ) ) {
1596 return true;
1597 }
1598 }
1599
1600 return false;
1601 }
1602
1603 /**
1604 * Record an early rejection in the activity log
1605 *
1606 * @since 2.9.9
1607 */
1608 private static function log_early_hidden_admin_attempt() {
1609 require_once VIGILANTE_INCLUDES_DIR . 'class-settings.php';
1610 require_once VIGILANTE_INCLUDES_DIR . 'class-database.php';
1611 require_once VIGILANTE_INCLUDES_DIR . 'class-activity-log.php';
1612
1613 $request = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
1614
1615 $activity_log = new Vigilante_Activity_Log( new Vigilante_Settings(), new Vigilante_Database() );
1616 $activity_log->log(
1617 'login',
1618 'hidden_admin_access',
1619 'Attempt to access hidden wp-admin',
1620 array( 'request_uri' => $request ),
1621 'warning'
1622 );
1623 }
1624
1625 /**
1626 * Whether the current request comes from an IP that may bypass the
1627 * hidden wp-admin masking.
1628 *
1629 * Reads the firewall's global IP whitelist (the visible "IP whitelist"
1630 * box) so trusted services such as MainWP or ManageWP, which reach
1631 * wp-admin without a WordPress session cookie, are not turned away with
1632 * a 404. This relaxes only the URL masking, never authentication: an
1633 * exempt IP still has to log in normally.
1634 *
1635 * wp-admin only, and that is the point. Until 2.9.9 the same exemption
1636 * also applied to the two wp-login.php paths, where it did not serve that
1637 * purpose and did real harm: block_wp_login_access() handed the real login
1638 * form to any whitelisted IP with the custom login URL active, and
1639 * block_login_shortcuts() is precisely what stops core's
1640 * wp_redirect_admin_locations() from answering /login with a 302 to
1641 * wp_login_url(), which under a custom login URL is the secret slug. So
1642 * exempting it did not merely expose the form, it handed the slug over in
1643 * the Location header. Remote managers never needed either one: both
1644 * blockers already let every POST through, which is how they authenticate.
1645 *
1646 * @return bool
1647 */
1648 /**
1649 * Stop this request from putting the secret login address in any URL
1650 *
1651 * The filters that rewrite a WordPress login URL into the custom slug are
1652 * what makes the feature work, and also what leaks it the moment a page is
1653 * rendered on a path the visitor was not supposed to reach. Dropping them
1654 * for the rest of the request keeps whatever renders afterwards free of the
1655 * slug, while everything else about the request goes on as before.
1656 *
1657 * The two redirect filters are here because the first version of this list
1658 * had only the five URL ones, and a POST to ?action=lostpassword still
1659 * handed the address over in the hidden redirect_to field that
1660 * lostpassword_redirect fills. Found by the cross review of 2.11.10. Any
1661 * filter of this class that can put the slug in front of a visitor belongs
1662 * in this list; if a new one is added, add it here too.
1663 *
1664 * @since 2.11.10
1665 */
1666 private function stop_emitting_custom_login_url() {
1667 remove_filter( 'site_url', array( $this, 'filter_site_url' ), 10 );
1668 remove_filter( 'login_url', array( $this, 'filter_login_url' ), 10 );
1669 remove_filter( 'logout_url', array( $this, 'filter_logout_url' ), 10 );
1670 remove_filter( 'lostpassword_url', array( $this, 'filter_lostpassword_url' ), 10 );
1671 remove_filter( 'register_url', array( $this, 'filter_register_url' ), 10 );
1672 remove_filter( 'lostpassword_redirect', array( $this, 'filter_lostpassword_redirect' ), 10 );
1673 remove_filter( 'logout_redirect', array( $this, 'filter_logout_redirect' ), 10 );
1674 }
1675
1676 private function is_ip_exempt_from_hiding() {
1677 $whitelist = $this->settings->get_option( 'firewall', 'ip_whitelist', array() );
1678
1679 if ( empty( $whitelist ) ) {
1680 return false;
1681 }
1682
1683 return Vigilante_IP_Utils::in_list( $this->database->get_client_ip(), $whitelist );
1684 }
1685
1686 /**
1687 * Get notification email
1688 *
1689 * @return string
1690 */
1691 /**
1692 * Get notification recipients (centralized)
1693 *
1694 * @return array Array of email addresses.
1695 */
1696 private function get_notification_email() {
1697 return Vigilante_Email_Template::get_admin_recipients();
1698 }
1699
1700 /**
1701 * Manually clear lockout for an IP
1702 *
1703 * @param string $ip IP address.
1704 * @return bool
1705 */
1706 public function clear_lockout( $ip ) {
1707 return $this->database->clear_lockout( $ip );
1708 }
1709
1710 /**
1711 * Get currently locked out IPs
1712 *
1713 * @return array
1714 */
1715 public function get_locked_out_ips() {
1716 return $this->database->get_locked_out_ips();
1717 }
1718
1719 }
1720
1721 /**
1722 * Disabled XML-RPC Server class
1723 */
1724 class Vigilante_Disabled_XMLRPC_Server {
1725 /**
1726 * Constructor - return error for any request
1727 */
1728 public function __construct() {
1729 // Return error for any XML-RPC request
1730 header( 'HTTP/1.1 403 Forbidden' );
1731 header( 'Content-Type: text/plain' );
1732 die( 'XML-RPC is disabled' );
1733 }
1734 }