PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.5
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.5
3.0.5 3.0.4 3.0.3 3.0.2 3.0.1 3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 All 93 releases
vigilante / includes / class-login-security.php

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

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