0,
'fix_mixed_content' => 0,
'hsts' => 0,
// Login.
'login_slug' => '',
'login_protection' => 1,
'max_attempts' => 5,
'attempt_window' => 15, // minutes
'lockout_minutes' => 30,
'notify_lockout' => 1,
'generic_login_errors' => 1,
'login_honeypot' => 1,
'login_js_check' => 1,
// Spam.
'disable_comments' => 0,
'comment_spam' => 1,
'registration_spam' => 1,
'form_spam' => 1,
'block_disposable_email'=> 1,
'spam_min_seconds' => 3,
'max_comment_links' => 2,
'strong_passwords' => 1,
'idle_logout' => 0, // minutes, 0 = off
// Accounts.
'alert_new_admin' => 1,
'alert_admin_new_ip' => 1,
'lock_default_role' => 1,
// Firewall.
'firewall' => 1,
'block_bad_bots' => 1,
'trust_proxy' => 0,
'ip_whitelist' => '',
// Attack surface.
'disable_xmlrpc' => 1,
'block_user_enum' => 1,
'hide_version' => 1,
'hide_wp_fingerprints' => 1,
'protect_wp_content' => 1,
'branded_login' => 1,
'branded_block_page' => 0,
'block_message' => 'This website is protected by ITPS',
'block_installs' => 0,
'session_hours' => 0,
'disable_file_edit' => 1,
'disable_app_passwords' => 0,
'restrict_rest' => 0,
'rest_allow' => "oembed/1.0\ncontact-form-7/v1\nwc/store",
// Headers & server.
'security_headers' => 1,
'block_uploads_php' => 1,
'harden_root_htaccess' => 0,
);
}
private function __construct() {
$this->o = wp_parse_args( (array) get_option( self::OPT, array() ), self::defaults() );
$o = $this->o;
// --- Proxy-aware HTTPS detection --------------------------------------
// Behind Cloudflare / a load balancer PHP sees plain HTTP. Only honour the
// forwarded header when the admin has said the site is behind a proxy.
if ( $o['trust_proxy'] && ! is_ssl() && 'https' === strtolower( self::server_text( 'HTTP_X_FORWARDED_PROTO' ) ) ) {
$_SERVER['HTTPS'] = 'on';
}
// --- Force HTTPS ------------------------------------------------------
if ( $o['force_ssl'] ) {
if ( ! defined( 'FORCE_SSL_ADMIN' ) ) {
force_ssl_admin( true );
}
add_action( 'plugins_loaded', array( $this, 'force_https' ), 0 );
add_filter( 'option_home', array( $this, 'https_url' ) );
add_filter( 'option_siteurl', array( $this, 'https_url' ) );
add_filter( 'secure_auth_cookie', '__return_true' );
add_filter( 'secure_logged_in_cookie', '__return_true' );
if ( $o['fix_mixed_content'] ) {
$this->setup_mixed_content_fix();
}
}
// --- Custom login URL -------------------------------------------------
if ( '' !== $this->login_slug() ) {
$this->setup_login_url();
}
// --- Request firewall -------------------------------------------------
if ( $o['firewall'] || $o['block_bad_bots'] ) {
add_action( 'init', array( $this, 'firewall' ), 0 );
}
// --- Login protection -------------------------------------------------
if ( $o['login_protection'] ) {
add_filter( 'authenticate', array( $this, 'enforce_lockout' ), 99, 1 );
add_action( 'wp_login_failed', array( $this, 'login_failed' ), 10, 1 );
add_action( 'wp_login', array( $this, 'login_success' ), 10, 1 );
}
if ( $o['generic_login_errors'] ) {
add_filter( 'login_errors', array( $this, 'generic_login_error' ) );
}
if ( $o['login_honeypot'] ) {
add_action( 'login_form', array( $this, 'honeypot_field' ) );
add_action( 'register_form', array( $this, 'honeypot_field' ) );
add_action( 'lostpassword_form', array( $this, 'honeypot_field' ) );
add_filter( 'authenticate', array( $this, 'check_honeypot_login' ), 30, 1 );
add_filter( 'registration_errors', array( $this, 'check_honeypot_errors' ), 10, 1 );
add_action( 'lostpassword_post', array( $this, 'check_honeypot_errors' ), 10, 1 );
}
// --- Spam protection --------------------------------------------------
if ( $o['login_honeypot'] || $o['comment_spam'] || $o['registration_spam'] || $o['form_spam'] ) {
add_action( 'login_enqueue_scripts', array( $this, 'enqueue_trap_script' ) );
add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_trap_script' ) );
}
if ( $o['disable_comments'] ) {
$this->setup_disable_comments();
} elseif ( $o['comment_spam'] ) {
add_filter( 'preprocess_comment', array( $this, 'check_comment_spam' ), 1 );
add_filter( 'pre_comment_approved', array( $this, 'comment_link_limit' ), 99, 2 );
}
if ( $o['registration_spam'] ) {
add_filter( 'registration_errors', array( $this, 'check_registration_spam' ), 20, 3 );
add_filter( 'woocommerce_registration_errors', array( $this, 'check_registration_spam' ), 20, 3 );
}
if ( $o['form_spam'] ) {
add_filter( 'wpcf7_spam', array( $this, 'cf7_spam' ), 10, 1 );
add_action( 'wpforms_process_before', array( $this, 'wpforms_spam' ), 10, 2 );
add_filter( 'gform_entry_is_spam', array( $this, 'gform_spam' ), 10, 1 );
add_action( 'elementor_pro/forms/validation', array( $this, 'elementor_spam' ), 10, 2 );
add_filter( 'fluentform/validation_errors', array( $this, 'fluent_spam' ), 10, 1 );
add_filter( 'frm_validate_entry', array( $this, 'formidable_spam' ), 10, 1 );
add_filter( 'ninja_forms_submit_data', array( $this, 'ninja_spam' ), 10, 1 );
}
if ( $o['strong_passwords'] ) {
add_action( 'user_profile_update_errors', array( $this, 'validate_profile_password' ), 10, 3 );
add_action( 'validate_password_reset', array( $this, 'validate_reset_password' ), 10, 1 );
}
if ( (int) $o['idle_logout'] > 0 ) {
add_action( 'init', array( $this, 'idle_check' ), 5 );
add_action( 'wp_login', array( $this, 'idle_reset' ), 10, 2 );
add_filter( 'login_message', array( $this, 'idle_message' ) );
}
// --- Account alerts ---------------------------------------------------
if ( $o['alert_new_admin'] ) {
add_action( 'set_user_role', array( $this, 'role_changed' ), 10, 3 );
add_action( 'add_user_role', array( $this, 'role_added' ), 10, 2 );
}
if ( $o['alert_admin_new_ip'] ) {
add_action( 'wp_login', array( $this, 'admin_login_alert' ), 20, 2 );
}
if ( $o['lock_default_role'] ) {
add_filter( 'pre_update_option_default_role', array( $this, 'guard_default_role' ), 10, 1 );
add_filter( 'option_default_role', array( $this, 'guard_default_role' ), 10, 1 );
}
// --- XML-RPC ----------------------------------------------------------
if ( $o['disable_xmlrpc'] ) {
if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) {
add_action( 'plugins_loaded', array( $this, 'kill_xmlrpc' ), 0 );
}
add_filter( 'xmlrpc_enabled', '__return_false' );
add_filter( 'xmlrpc_methods', '__return_empty_array' );
add_filter( 'wp_headers', array( $this, 'remove_pingback_header' ) );
add_filter( 'pings_open', '__return_false', 99 );
remove_action( 'wp_head', 'rsd_link' );
}
// --- User enumeration -------------------------------------------------
if ( $o['block_user_enum'] ) {
add_action( 'init', array( $this, 'block_author_scan' ), 1 );
add_filter( 'rest_endpoints', array( $this, 'restrict_user_endpoints' ) );
add_filter( 'oembed_response_data', array( $this, 'strip_oembed_author' ) );
add_filter( 'wp_sitemaps_add_provider', array( $this, 'drop_user_sitemap' ), 10, 2 );
}
// --- REST API ---------------------------------------------------------
if ( $o['restrict_rest'] ) {
add_filter( 'rest_authentication_errors', array( $this, 'restrict_rest' ), 99 );
}
// --- Fingerprinting ---------------------------------------------------
if ( $o['hide_version'] ) {
remove_action( 'wp_head', 'wp_generator' );
add_filter( 'the_generator', '__return_empty_string' );
add_filter( 'style_loader_src', array( $this, 'mask_asset_version' ), 99 );
add_filter( 'script_loader_src', array( $this, 'mask_asset_version' ), 99 );
add_filter( 'script_module_loader_src', array( $this, 'mask_asset_version' ), 99 );
}
if ( $o['hide_wp_fingerprints'] ) {
add_action( 'init', array( $this, 'remove_fingerprints' ) );
add_filter( 'wp_resource_hints', array( $this, 'drop_wp_dns_prefetch' ), 10, 2 );
add_filter( 'x_redirect_by', '__return_false' );
add_action( 'do_faviconico', array( $this, 'favicon_without_wp_logo' ), 0 );
add_filter( 'rest_index', array( $this, 'trim_rest_index' ) );
add_filter( 'wp_headers', array( $this, 'strip_wp_headers' ), 99 );
}
if ( $o['branded_login'] ) {
add_filter( 'login_headerurl', array( $this, 'login_home_url' ) );
add_filter( 'login_headertext', array( $this, 'login_site_name' ) );
add_action( 'login_enqueue_scripts', array( $this, 'login_logo_css' ) );
add_filter( 'login_title', array( $this, 'login_title' ), 10, 2 );
}
if ( $o['block_installs'] ) {
add_filter( 'map_meta_cap', array( $this, 'block_install_caps' ), 10, 2 );
}
if ( (int) $o['session_hours'] > 0 ) {
add_filter( 'auth_cookie_expiration', array( $this, 'session_length' ), 99 );
}
// --- Headers ----------------------------------------------------------
if ( $o['security_headers'] || $o['force_ssl'] || $o['hsts'] ) {
add_action( 'send_headers', array( $this, 'send_security_headers' ) );
add_action( 'admin_init', array( $this, 'send_security_headers' ) );
add_action( 'login_init', array( $this, 'send_security_headers' ) );
}
// --- Capabilities / features -----------------------------------------
if ( $o['disable_file_edit'] ) {
add_filter( 'map_meta_cap', array( $this, 'block_file_edit_caps' ), 10, 2 );
}
if ( $o['disable_app_passwords'] ) {
add_filter( 'wp_is_application_passwords_available', '__return_false' );
}
// --- Admin ------------------------------------------------------------
if ( is_admin() ) {
add_action( 'admin_menu', array( $this, 'admin_menu' ) );
add_action( 'admin_init', array( $this, 'register_settings' ) );
add_action( 'update_option_' . self::OPT, array( $this, 'on_options_updated' ), 10, 2 );
add_filter( 'pre_update_option_' . self::OPT, array( $this, 'install_pending_on_save' ), 10, 2 );
add_action( 'admin_post_itps_unlock', array( $this, 'handle_unlock' ) );
add_action( 'admin_post_itps_clear_log', array( $this, 'handle_clear_log' ) );
add_action( 'admin_post_itps_core_scan', array( $this, 'handle_core_scan' ) );
add_action( 'admin_post_itps_fix', array( $this, 'handle_fix' ) );
add_action( 'admin_notices', array( $this, 'admin_notices' ) );
add_filter( 'plugin_action_links_' . plugin_basename( __FILE__ ), array( $this, 'action_links' ) );
}
}
/* =====================================================================
* Helpers
* ===================================================================*/
/**
* Client IP. Only trusts proxy headers when explicitly enabled, because
* they are trivially spoofable on a server that is not behind a proxy.
*/
public function ip() {
$ip = self::server_text( 'REMOTE_ADDR' );
if ( $this->o['trust_proxy'] ) {
$cand = '';
if ( '' !== self::server_text( 'HTTP_CF_CONNECTING_IP' ) ) {
$cand = self::server_text( 'HTTP_CF_CONNECTING_IP' );
} elseif ( '' !== self::server_text( 'HTTP_X_REAL_IP' ) ) {
$cand = self::server_text( 'HTTP_X_REAL_IP' );
} elseif ( '' !== self::server_text( 'HTTP_X_FORWARDED_FOR' ) ) {
// Right-most entry is the one appended by the nearest (trusted) proxy.
$parts = explode( ',', self::server_text( 'HTTP_X_FORWARDED_FOR' ) );
$cand = trim( end( $parts ) );
}
if ( '' !== $cand && filter_var( $cand, FILTER_VALIDATE_IP ) ) {
$ip = $cand;
}
}
return filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : '0.0.0.0';
}
/**
* A $_SERVER value (request header, address, method) as sanitized plain text.
*
* @param string $key Key in $_SERVER.
* @return string
*/
private static function server_text( $key ) {
return isset( $_SERVER[ $key ] ) ? sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ) : '';
}
/**
* The request URI (path and query) sanitized as a URL, for routing and redirects.
*
* @return string
*/
private static function request_uri() {
return isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
}
/**
* The request URI for the firewall to inspect.
*
* Sanitized with FILTER_SANITIZE_URL, which drops every character that is
* not valid in a URL but keeps the characters attack payloads are made of
* (< > " ' \ % and so on). esc_url_raw() / sanitize_text_field() would strip
* exactly the evidence the rules look for. The value is only matched against
* the rules; it is never output, and what is logged goes through esc_url_raw().
*
* @return string
*/
private static function firewall_uri() {
return isset( $_SERVER['REQUEST_URI'] ) ? (string) filter_var( wp_unslash( $_SERVER['REQUEST_URI'] ), FILTER_SANITIZE_URL ) : '';
}
/**
* Validates one allow-list entry and returns it in canonical form, or ''.
*
* Accepted: a single IPv4 or IPv6 address, or an IPv4 range in CIDR
* notation whose prefix is a whole number from 1 to 32. Everything else is
* rejected, including "/0" (which would match every address), an empty,
* signed, decimal, zero-padded or non-numeric prefix, extra slashes, IPv6
* ranges and the unspecified addresses 0.0.0.0 and ::.
*
* @param string $entry Raw entry.
* @return string
*/
private static function normalize_ip_entry( $entry ) {
$entry = trim( (string) $entry );
if ( '' === $entry ) {
return '';
}
$parts = explode( '/', $entry );
if ( count( $parts ) > 2 ) {
return '';
}
$addr = $parts[0];
if ( ! filter_var( $addr, FILTER_VALIDATE_IP ) || in_array( inet_pton( $addr ), array( inet_pton( '0.0.0.0' ), inet_pton( '::' ) ), true ) ) {
return '';
}
if ( 1 === count( $parts ) ) {
return $addr;
}
if ( ! filter_var( $addr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) || ! preg_match( '/^(?:[1-9]|[12][0-9]|3[0-2])$/D', $parts[1] ) ) {
return '';
}
return $addr . '/' . $parts[1];
}
private function is_whitelisted( $ip ) {
if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
return false;
}
$lines = preg_split( '/\r\n|\r|\n/', (string) $this->o['ip_whitelist'] );
foreach ( $lines as $line ) {
// Re-validated here too, so an entry saved by an older version can never widen the list.
$entry = self::normalize_ip_entry( $line );
if ( '' === $entry ) {
continue;
}
if ( false === strpos( $entry, '/' ) ) {
if ( inet_pton( $entry ) === inet_pton( $ip ) ) {
return true;
}
} elseif ( self::ipv4_in_cidr( $ip, $entry ) ) {
return true;
}
}
return false;
}
/**
* Whether an IPv4 address is inside an IPv4 CIDR range.
*
* @param string $ip Address to test.
* @param string $cidr Range such as 192.0.2.0/24 (prefix 1-32).
* @return bool
*/
private static function ipv4_in_cidr( $ip, $cidr ) {
$cidr = self::normalize_ip_entry( $cidr );
if ( '' === $cidr || false === strpos( $cidr, '/' ) || ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
return false;
}
list( $subnet, $bits ) = explode( '/', $cidr, 2 );
$bits = (int) $bits; // Validated above as a whole number from 1 to 32.
$mask = ( ~0 << ( 32 - $bits ) ) & 0xFFFFFFFF;
return ( ip2long( $ip ) & $mask ) === ( ip2long( $subnet ) & $mask );
}
/**
* Adds an entry to the activity log.
*
* Callers pass values that are already sanitized for their type (e.g. a
* URL through esc_url_raw(), a user agent through sanitize_text_field());
* as a second layer, tags and control characters are stripped here too.
*
* @param string $type Event type.
* @param string $detail Event detail.
*/
public function log( $type, $detail = '' ) {
$log = get_option( self::LOG, array() );
if ( ! is_array( $log ) ) {
$log = array();
}
$detail = wp_strip_all_tags( wp_check_invalid_utf8( (string) $detail, true ) );
$detail = trim( (string) preg_replace( '/[\x00-\x1F\x7F]+/', ' ', $detail ) );
array_unshift(
$log,
array(
't' => time(),
'type' => mb_substr( sanitize_text_field( (string) $type ), 0, 40 ),
'ip' => $this->ip(),
'detail' => mb_substr( $detail, 0, 250 ),
)
);
update_option( self::LOG, array_slice( $log, 0, self::LOG_MAX ), false );
}
private function site_name() {
return wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES );
}
private function home_host() {
return (string) wp_parse_url( home_url(), PHP_URL_HOST );
}
/* =====================================================================
* Force HTTPS
* ===================================================================*/
public function force_https() {
if ( is_ssl() || ( defined( 'WP_CLI' ) && WP_CLI ) || wp_doing_cron() || 'cli' === PHP_SAPI ) {
return;
}
$host = $this->home_host();
if ( '' === $host ) {
return;
}
// Leading slashes are collapsed so "//evil.example" can't become a protocol-relative redirect.
$uri = '/' . ltrim( self::request_uri(), '/' );
$method = strtoupper( self::server_text( 'REQUEST_METHOD' ) );
// 308 keeps the method and body for POST requests.
$code = in_array( $method, array( '', 'GET', 'HEAD' ), true ) ? 301 : 308;
// The custom login address must not be revealed by rewriting a redirect to /wp-login.php.
remove_filter( 'wp_redirect', array( $this, 'filter_login_url' ), 100 );
wp_safe_redirect( esc_url_raw( 'https://' . $host . $uri ), $code );
exit;
}
public function https_url( $value ) {
return is_string( $value ) ? preg_replace( '#^http://#i', 'https://', $value ) : $value;
}
/**
* Rewrites http:// links to this site as https:// in front-end output.
*
* WordPress 6.9+ passes the whole page through the core template
* enhancement output buffer, which WordPress opens and closes itself; this
* plugin starts no output buffer of its own. On older versions the content,
* widget, block, image and asset URL filters are used instead (the
* upgrade-insecure-requests header sent with Force HTTPS covers the rest).
*/
private function setup_mixed_content_fix() {
if ( function_exists( 'wp_start_template_enhancement_output_buffer' ) ) {
add_filter( 'wp_template_enhancement_output_buffer', array( $this, 'fix_mixed_content' ) );
return;
}
if ( is_admin() ) {
return;
}
foreach ( array( 'the_content', 'the_excerpt', 'widget_text', 'widget_text_content', 'widget_block_content', 'render_block', 'post_thumbnail_html', 'get_avatar', 'get_custom_logo', 'wp_get_custom_css', 'wp_get_attachment_url', 'style_loader_src', 'script_loader_src' ) as $hook ) {
add_filter( $hook, array( $this, 'fix_mixed_content' ), 99 );
}
add_filter( 'wp_calculate_image_srcset', array( $this, 'fix_mixed_content_srcset' ), 99 );
add_filter( 'wp_get_attachment_image_src', array( $this, 'fix_mixed_content_image_src' ), 99 );
}
/**
* Upgrades http:// links to this site's own host (and its www. variant) to https://.
*
* The host must end where the link's host ends, so other domains that only
* start with this site's name (example.com.other.net, example.community)
* are left alone. JSON-escaped links (http:\/\/) are handled too.
*
* @param mixed $html Markup or URL.
* @return mixed
*/
public function fix_mixed_content( $html ) {
$host = $this->home_host();
if ( '' === $host || ! is_string( $html ) || false === stripos( $html, 'http:' ) ) {
return $html;
}
$pattern = '#\bhttp:(\\\\?/\\\\?/)((?:www\.)?' . preg_quote( $host, '#' ) . ')(?![a-z0-9_-]|\.[a-z0-9])#i';
$out = preg_replace( $pattern, 'https:$1$2', $html );
return is_string( $out ) ? $out : $html;
}
public function fix_mixed_content_srcset( $sources ) {
if ( is_array( $sources ) ) {
foreach ( $sources as $width => $source ) {
if ( isset( $source['url'] ) ) {
$sources[ $width ]['url'] = $this->fix_mixed_content( $source['url'] );
}
}
}
return $sources;
}
public function fix_mixed_content_image_src( $image ) {
if ( is_array( $image ) && isset( $image[0] ) ) {
$image[0] = $this->fix_mixed_content( $image[0] );
}
return $image;
}
/**
* Confirms that https:// on this host answers with a valid certificate.
*
* @return true|WP_Error
*/
private function https_works() {
if ( is_ssl() ) {
return true; // The admin is already browsing over HTTPS.
}
$res = wp_remote_head(
'https://' . $this->home_host() . '/',
array(
'timeout' => 10,
'redirection' => 3,
'sslverify' => true,
)
);
return is_wp_error( $res ) ? $res : true;
}
/* =====================================================================
* Custom login URL
* ===================================================================*/
public function login_slug() {
if ( defined( 'ITPS_SHIELD_DISABLE_LOGIN_URL' ) && ITPS_SHIELD_DISABLE_LOGIN_URL ) {
return ''; // Emergency switch in wp-config.php.
}
if ( is_multisite() ) {
return '';
}
return (string) $this->o['login_slug'];
}
/** New login address (with optional scheme). */
public function login_base_url( $scheme = null ) {
$slug = $this->login_slug();
if ( '' === (string) get_option( 'permalink_structure' ) ) {
return home_url( '/', $scheme ) . '?' . $slug;
}
return user_trailingslashit( home_url( '/' . $slug, $scheme ) );
}
private function request_path() {
$path = (string) wp_parse_url( self::request_uri(), PHP_URL_PATH );
$home = untrailingslashit( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ) );
// Strip the site's sub-directory only when it is a whole path segment (/blog, not /blogger).
if ( '' !== $home && ( $path === $home || 0 === strpos( $path, $home . '/' ) ) ) {
$path = substr( $path, strlen( $home ) );
}
return trim( rawurldecode( $path ), '/' );
}
private function setup_login_url() {
$slug = $this->login_slug();
$path = $this->request_path();
$plain = '' === (string) get_option( 'permalink_structure' );
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- Routing only: decides which login screen a logged-out visitor sees; nothing is changed.
if ( $path === $slug || ( $plain && '' === $path && isset( $_GET[ $slug ] ) ) ) {
add_action( 'wp_loaded', array( $this, 'serve_login' ), 0 );
} elseif ( preg_match( '#(^|/)wp-login\.php$#i', $path ) ) {
$action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : '';
// phpcs:enable WordPress.Security.NonceVerification.Recommended
// Password-protected posts still post to wp-login.php?action=postpass.
if ( 'postpass' !== $action ) {
add_action( 'init', array( $this, 'hidden_login_hit' ), 0 );
}
}
add_action( 'init', array( $this, 'protect_wp_admin' ), 1 );
foreach ( array( 'site_url', 'network_site_url', 'wp_redirect', 'login_url', 'logout_url', 'lostpassword_url', 'register_url' ) as $hook ) {
add_filter( $hook, array( $this, 'filter_login_url' ), 100, 1 );
}
// Core otherwise redirects /login, /admin and /dashboard straight to the real login.
remove_action( 'template_redirect', 'wp_redirect_admin_locations', 1000 );
}
public function serve_login() {
// These are referenced with `global` inside wp-login.php's functions.
global $pagenow, $error, $interim_login, $action, $user_login, $user, $redirect_to;
$pagenow = 'wp-login.php'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- wp-login.php is being served from the custom login address, so core must see it as the current page.
nocache_headers();
require_once ABSPATH . 'wp-login.php';
exit;
}
public function hidden_login_hit() {
if ( ( defined( 'WP_CLI' ) && WP_CLI ) || $this->is_whitelisted( $this->ip() ) ) {
return;
}
$this->log( 'hidden_login', 'Direct request to wp-login.php' );
$this->not_found();
}
public function protect_wp_admin() {
if ( ! is_admin() || is_user_logged_in() || wp_doing_ajax() || ( defined( 'WP_CLI' ) && WP_CLI ) ) {
return;
}
global $pagenow;
if ( in_array( $pagenow, array( 'admin-post.php', 'admin-ajax.php', 'upgrade.php' ), true ) ) {
return;
}
if ( $this->is_whitelisted( $this->ip() ) ) {
return;
}
$this->log( 'hidden_login', 'Logged-out request to /wp-admin/' );
$this->not_found();
}
private function not_found() {
if ( $this->o['branded_block_page'] ) {
$this->block_page( 403, 'Restricted area', 'This area of the website is restricted. Your visit has been logged.' );
}
nocache_headers();
wp_die(
esc_html__( 'The page you requested could not be found.', 'itps-shield' ),
esc_html__( 'Not Found', 'itps-shield' ),
array( 'response' => 404 )
);
}
/**
* Full-page "Protected by ITPS Shield" screen shown to blocked visitors.
* Standalone HTML (no WordPress styling), so it doesn't reveal the CMS.
*/
private function block_page( $status, $title, $detail ) {
nocache_headers();
status_header( $status );
header( 'Content-Type: text/html; charset=utf-8' );
header( 'X-Robots-Tag: noindex, nofollow' );
$ref = strtoupper( substr( md5( $this->ip() . microtime() ), 0, 10 ) );
$msg = trim( (string) $this->o['block_message'] );
$site = $this->site_name();
$home = home_url( '/' );
?>
Protected by ITPS Shield
Go to
Your IP: ip() ); ?> · Reference: UTC
is_own_login_url( $parts ) ) {
return $url;
}
$query = isset( $parts['query'] ) ? (string) $parts['query'] : '';
if ( '' !== $query ) {
parse_str( $query, $args );
// Password-protected posts still post to wp-login.php?action=postpass.
if ( isset( $args['action'] ) && 'postpass' === $args['action'] ) {
return $url;
}
}
$scheme = isset( $parts['scheme'] ) ? strtolower( $parts['scheme'] ) : null;
$new = $this->login_base_url( $scheme );
if ( '' !== $query ) {
$new .= ( false === strpos( $new, '?' ) ? '?' : '&' ) . $query;
}
if ( isset( $parts['fragment'] ) && '' !== $parts['fragment'] ) {
$new .= '#' . $parts['fragment'];
}
return $new;
}
/**
* Whether parsed URL parts point to this installation's own wp-login.php.
*
* @param array $parts Result of wp_parse_url().
* @return bool
*/
private function is_own_login_url( $parts ) {
if ( empty( $parts['path'] ) || isset( $parts['user'] ) || isset( $parts['pass'] ) ) {
return false;
}
if ( isset( $parts['scheme'] ) && ! in_array( strtolower( $parts['scheme'] ), array( 'http', 'https' ), true ) ) {
return false;
}
$path = (string) $parts['path'];
// A relative "wp-login.php?..." (core uses these, e.g. after registration) has no host.
if ( ! isset( $parts['host'] ) && 'wp-login.php' === $path ) {
return ! isset( $parts['scheme'] );
}
$site = wp_parse_url( (string) get_option( 'siteurl' ) );
if ( ! is_array( $site ) || empty( $site['host'] ) ) {
return false;
}
if ( isset( $parts['host'] ) ) {
$home = wp_parse_url( (string) get_option( 'home' ) );
$hosts = array( self::host_port( $site ) );
if ( is_array( $home ) && ! empty( $home['host'] ) ) {
$hosts[] = self::host_port( $home );
}
if ( ! in_array( self::host_port( $parts ), $hosts, true ) ) {
return false; // Another site, even if its path is /wp-login.php.
}
} elseif ( isset( $parts['scheme'] ) ) {
return false;
}
$base = isset( $site['path'] ) ? untrailingslashit( (string) $site['path'] ) : '';
return $base . '/wp-login.php' === $path;
}
/**
* Lower-case "host:port" of parsed URL parts; default HTTP/HTTPS ports are dropped.
*
* @param array $parts Result of wp_parse_url().
* @return string
*/
private static function host_port( $parts ) {
$host = isset( $parts['host'] ) ? strtolower( (string) $parts['host'] ) : '';
$port = isset( $parts['port'] ) ? (int) $parts['port'] : 0;
return ( 0 === $port || 80 === $port || 443 === $port ) ? $host : $host . ':' . $port;
}
/* =====================================================================
* Firewall
* ===================================================================*/
private static function rules() {
return array(
'path_traversal' => '#\.\./|\.\.\\\\#',
'sensitive_file' => '#(etc/passwd|etc/shadow|proc/self/environ|/\.env\b|/\.git/|/\.svn/|/\.htpasswd|wp-config\.php)#',
'stream_wrapper' => '#\b(php|phar|expect|zip|data|glob|file)://#',
'xss' => '#(<\s*script|javascript\s*:|vbscript\s*:|<\s*iframe|\bon(error|load|mouseover|focus)\s*=|document\.cookie)#',
'sql_injection' => '#(union[\s/*+(]+(all[\s/*+]+)?select\b|information_schema|\bsleep\s*\(\s*\d|benchmark\s*\(|load_file\s*\(|into\s+(out|dump)file|\bwaitfor\s+delay\b)#',
'code_injection' => '#((base64_(en|de)code|eval|assert|shell_exec|passthru|proc_open|popen|call_user_func_array)\(|globals\s*[=\[]|_request\s*[=\[]|<\?php)#',
'null_byte' => "#\x00#",
);
}
private static function bad_agents() {
return '#(sqlmap|nikto|wpscan|acunetix|netsparker|nmap|masscan|zgrab|nuclei|dirbuster|gobuster|feroxbuster|havij|w3af|openvas|nessus|jorgee|fimap|wfuzz)#i';
}
public function firewall() {
if ( ( defined( 'WP_CLI' ) && WP_CLI ) || wp_doing_cron() ) {
return;
}
if ( is_user_logged_in() && current_user_can( 'manage_options' ) ) {
return; // Administrators are trusted; avoids false positives in the editor.
}
$ip = $this->ip();
if ( $this->is_whitelisted( $ip ) ) {
return;
}
$uri = self::firewall_uri();
$ua = self::server_text( 'HTTP_USER_AGENT' );
if ( $this->o['firewall'] && '' !== $uri ) {
// Decode twice to catch double-encoded payloads.
$hay = strtolower( rawurldecode( rawurldecode( $uri ) ) );
foreach ( self::rules() as $rule => $regex ) {
if ( preg_match( $regex, $hay ) ) {
$this->deny( $rule, esc_url_raw( $uri ) );
}
}
}
if ( $this->o['block_bad_bots'] && '' !== $ua && preg_match( self::bad_agents(), $ua ) ) {
$this->deny( 'bad_bot', $ua );
}
}
private function deny( $rule, $evidence ) {
// Throttle log writes so a flood can't hammer the database.
$key = 'itps_fw_' . md5( $this->ip() . $rule );
if ( ! get_transient( $key ) ) {
set_transient( $key, 1, MINUTE_IN_SECONDS );
$this->log( 'firewall:' . $rule, $evidence );
}
if ( $this->o['branded_block_page'] ) {
$this->block_page( 403, 'Request blocked', 'Your request looked like an attack and was blocked. If you think this is a mistake, contact the website owner and quote the reference below.' );
}
nocache_headers();
wp_die(
esc_html__( 'Your request was blocked for security reasons.', 'itps-shield' ),
esc_html__( 'Forbidden', 'itps-shield' ),
array( 'response' => 403 )
);
}
/* =====================================================================
* Login protection
* ===================================================================*/
private function lockouts() {
$locks = get_option( self::LOCKS, array() );
if ( ! is_array( $locks ) ) {
$locks = array();
}
$now = time();
$changed = false;
foreach ( $locks as $ip => $row ) {
if ( empty( $row['until'] ) || $row['until'] <= $now ) {
unset( $locks[ $ip ] );
$changed = true;
}
}
if ( $changed ) {
update_option( self::LOCKS, $locks, false );
}
return $locks;
}
private function locked_until( $ip ) {
$locks = $this->lockouts();
return isset( $locks[ $ip ] ) ? (int) $locks[ $ip ]['until'] : 0;
}
private function lockout_message( $until ) {
$mins = max( 1, (int) ceil( ( $until - time() ) / 60 ) );
/* translators: %d: minutes */
return sprintf( __( 'Too many failed login attempts. Try again in %d minute(s).', 'itps-shield' ), $mins );
}
/** Runs after core authentication so a correct password cannot bypass a lockout. */
public function enforce_lockout( $user ) {
$ip = $this->ip();
if ( $this->is_whitelisted( $ip ) ) {
return $user;
}
$until = $this->locked_until( $ip );
if ( $until ) {
return new WP_Error( 'itps_locked', $this->lockout_message( $until ) );
}
return $user;
}
public function login_failed( $username ) {
$ip = $this->ip();
if ( $this->is_whitelisted( $ip ) || $this->locked_until( $ip ) ) {
return;
}
$key = 'itps_fail_' . md5( $ip );
$count = (int) get_transient( $key ) + 1;
set_transient( $key, $count, (int) $this->o['attempt_window'] * MINUTE_IN_SECONDS );
$user = sanitize_user( (string) $username, true );
$this->log( 'login_failed', 'user: ' . $user . ' (' . $count . '/' . (int) $this->o['max_attempts'] . ')' );
if ( $count >= (int) $this->o['max_attempts'] ) {
$locks = $this->lockouts();
$until = time() + (int) $this->o['lockout_minutes'] * MINUTE_IN_SECONDS;
$locks[ $ip ] = array(
'until' => $until,
'user' => $user,
);
update_option( self::LOCKS, $locks, false );
delete_transient( $key );
$this->log( 'lockout', 'user: ' . $user . ', ' . (int) $this->o['lockout_minutes'] . ' min' );
if ( $this->o['notify_lockout'] && ! get_transient( 'itps_notify_throttle' ) ) {
set_transient( 'itps_notify_throttle', 1, 15 * MINUTE_IN_SECONDS );
wp_mail(
get_option( 'admin_email' ),
sprintf( '[%s] Login lockout', $this->site_name() ),
sprintf(
"ITPS Shield locked out IP %s after %d failed login attempts (last username tried: %s).\n\nLockout lasts %d minutes. Further alerts are suppressed for 15 minutes.\n\n%s",
$ip,
(int) $this->o['max_attempts'],
$user,
(int) $this->o['lockout_minutes'],
admin_url( 'options-general.php?page=itps-shield&tab=lockouts' )
)
);
}
}
}
public function login_success( $user_login ) {
delete_transient( 'itps_fail_' . md5( $this->ip() ) );
}
public function generic_login_error( $error ) {
$action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : 'login'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only picks which login error text to show.
if ( 'login' !== $action ) {
return $error;
}
$until = $this->o['login_protection'] ? $this->locked_until( $this->ip() ) : 0;
if ( $until ) {
return esc_html( $this->lockout_message( $until ) );
}
return esc_html__( 'Invalid login credentials.', 'itps-shield' );
}
/* ---- Honeypot ------------------------------------------------------- */
public function honeypot_field() {
printf(
'Website
',
esc_attr( self::HP_FIELD )
);
}
private function honeypot_tripped() {
$fields = $this->trap_fields();
return $fields['hp'];
}
public function check_honeypot_login( $user ) {
// Only the browser login form carries the fields; XML-RPC/REST/app-password auth is unaffected.
if ( ! isset( $_POST['log'] ) || $this->is_whitelisted( $this->ip() ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- The core login form has no nonce; this only checks which form is being submitted.
return $user;
}
if ( $this->honeypot_tripped() ) {
$this->log( 'honeypot', 'Bot filled the hidden login field' );
return new WP_Error( 'itps_bot', esc_html__( 'Invalid login credentials.', 'itps-shield' ) );
}
if ( $this->o['login_js_check'] && ! ( defined( 'ITPS_SHIELD_DISABLE_JS_CHECK' ) && ITPS_SHIELD_DISABLE_JS_CHECK ) ) {
$why = $this->trap_reason( 0 );
if ( $why ) {
$this->log( 'bot_login', 'Login without a real browser (' . $why . ')' );
// Returning an error also counts toward the brute-force lockout.
return new WP_Error( 'itps_bot', esc_html__( 'Login blocked: please enable JavaScript in your browser and try again.', 'itps-shield' ) );
}
}
return $user;
}
public function check_honeypot_errors( $errors ) {
if ( $this->honeypot_tripped() && $errors instanceof WP_Error ) {
$this->log( 'honeypot', 'Bot filled the hidden registration/reset field' );
$errors->add( 'itps_bot', esc_html__( 'Request blocked.', 'itps-shield' ) );
}
return $errors;
}
/* =====================================================================
* Spam traps (shared by login, registration, comments and contact forms)
*
* Every POST form on the site gets three extra fields from a tiny script:
* itps_website_url honeypot, hidden from people; bots fill it in
* itps_t signed time the page was built
* itps_js copied from itps_t only after a real key press,
* click or tap, so scripted posts arrive without it
* A submission is spam if the honeypot is filled, itps_js is missing or
* forged, or it arrives faster than a person could type.
* ===================================================================*/
private function trap_sign( $ts ) {
return $ts . '.' . substr( hash_hmac( 'sha256', 'itps' . $ts, wp_salt( 'nonce' ) ), 0, 12 );
}
public function enqueue_trap_script() {
if ( is_user_logged_in() ) {
return;
}
$js = <<<'JS'
(function(){var t=__ITPS_TOKEN__,h=__ITPS_FIELD__,on=false;
function add(f,n,v){if(f.querySelector('input[name="'+n+'"]'))return null;var i=document.createElement('input');i.type=(n===h?'text':'hidden');i.name=n;i.value=v;i.tabIndex=-1;i.autocomplete='off';if(n===h){var w=document.createElement('div');w.setAttribute('aria-hidden','true');w.style.cssText='position:absolute!important;left:-10000px!important;width:1px;height:1px;overflow:hidden';w.appendChild(i);f.appendChild(w);}else{f.appendChild(i);}return i;}
function prep(){var fs=document.querySelectorAll('form');for(var k=0;ktrap_sign( time() ) ), wp_json_encode( self::HP_FIELD ) ),
$js
);
wp_register_script( 'itps-shield-trap', false, array(), self::VERSION, true );
wp_enqueue_script( 'itps-shield-trap' );
wp_add_inline_script( 'itps-shield-trap', $js );
}
/**
* The three spam-trap fields of a submission, sanitized.
*
* Only these fields are ever read; everything else a form submits is left
* to WordPress or the form plugin. The forms involved (login, comments,
* sign-up, contact forms) are public and anonymous and have no nonce; the
* signed itps_t token is what is verified instead (see trap_reason()).
*
* @param array|null $source Fields already parsed by a form plugin, or null for the current POST request.
* @return array { @type bool $hp Honeypot filled. @type string $t Signed token. @type string $js Token copied by the browser script. }
*/
private function trap_fields( $source = null ) {
if ( ! is_array( $source ) ) {
// phpcs:disable WordPress.Security.NonceVerification.Missing -- Anonymous public forms, see above.
$source = array(
self::HP_FIELD => ! empty( $_POST[ self::HP_FIELD ] ),
'itps_t' => isset( $_POST['itps_t'] ) ? sanitize_text_field( wp_unslash( $_POST['itps_t'] ) ) : '',
'itps_js' => isset( $_POST['itps_js'] ) ? sanitize_text_field( wp_unslash( $_POST['itps_js'] ) ) : '',
);
// phpcs:enable WordPress.Security.NonceVerification.Missing
}
return array(
'hp' => ! empty( $source[ self::HP_FIELD ] ),
't' => self::trap_token( isset( $source['itps_t'] ) ? $source['itps_t'] : '' ),
'js' => self::trap_token( isset( $source['itps_js'] ) ? $source['itps_js'] : '' ),
);
}
/**
* A trap token is ".<12 hex characters>"; anything else is dropped.
*
* @param mixed $value Submitted value.
* @return string
*/
private static function trap_token( $value ) {
if ( ! is_scalar( $value ) ) {
return '';
}
$value = sanitize_text_field( (string) $value );
return preg_match( '/^\d{1,12}\.[0-9a-f]{12}$/', $value ) ? $value : '';
}
/**
* Returns why a submission looks automated, or '' if it looks human.
*
* @param int $min_seconds Minimum time between page load and submit (0 = skip).
* @param array|null $data Fields already parsed by a form plugin (defaults to the POST request).
*/
private function trap_reason( $min_seconds = 3, $data = null ) {
$fields = $this->trap_fields( $data );
if ( $fields['hp'] ) {
return 'honeypot';
}
$t = $fields['t'];
$js = $fields['js'];
if ( '' === $t || '' === $js ) {
return 'no_browser';
}
if ( $js !== $t ) {
return 'no_interaction';
}
$ts = (int) strtok( $t, '.' );
if ( ! hash_equals( $this->trap_sign( $ts ), $t ) ) {
return 'forged_token';
}
if ( $min_seconds > 0 && ( time() - $ts ) < $min_seconds ) {
return 'too_fast';
}
return '';
}
private function spam_reason( $data = null ) {
if ( is_user_logged_in() || $this->is_whitelisted( $this->ip() ) ) {
return '';
}
return $this->trap_reason( (int) $this->o['spam_min_seconds'], $data );
}
private function spam_hit( $where, $why ) {
$key = 'itps_spam_' . md5( $this->ip() . $where );
if ( ! get_transient( $key ) ) {
set_transient( $key, 1, MINUTE_IN_SECONDS );
$this->log( 'spam:' . $where, $why );
}
$n = (int) get_option( 'itps_shield_spam_count', 0 );
update_option( 'itps_shield_spam_count', $n + 1, false );
}
/* ---- Comments ------------------------------------------------------- */
public function check_comment_spam( $commentdata ) {
if ( ! empty( $commentdata['comment_type'] ) && in_array( $commentdata['comment_type'], array( 'pingback', 'trackback' ), true ) ) {
return $commentdata;
}
$why = $this->spam_reason();
if ( $why ) {
$this->spam_hit( 'comment', $why );
wp_die( esc_html__( 'Your comment was blocked as spam. If you are a person, please go back, wait a few seconds and try again.', 'itps-shield' ), esc_html__( 'Comment blocked', 'itps-shield' ), array( 'response' => 403, 'back_link' => true ) );
}
return $commentdata;
}
public function comment_link_limit( $approved, $commentdata ) {
if ( is_user_logged_in() || 'spam' === $approved || is_wp_error( $approved ) ) {
return $approved;
}
$links = preg_match_all( '#(https?://|www\.)#i', (string) $commentdata['comment_content'] );
if ( $links > (int) $this->o['max_comment_links'] ) {
$this->spam_hit( 'comment', $links . ' links' );
return 'spam';
}
return $approved;
}
private function setup_disable_comments() {
add_filter( 'comments_open', '__return_false', 99 );
add_filter( 'pings_open', '__return_false', 99 );
add_filter( 'comments_array', '__return_empty_array', 99 );
add_filter( 'get_comments_number', '__return_zero', 99 );
add_filter( 'feed_links_show_comments_feed', '__return_false' );
add_action( 'init', array( $this, 'drop_comment_support' ), 100 );
add_action( 'pre_comment_on_post', array( $this, 'refuse_comment' ), 0 );
add_filter( 'rest_endpoints', array( $this, 'drop_comment_endpoints' ) );
add_filter( 'render_block', array( $this, 'drop_comment_blocks' ), 10, 2 );
add_action( 'admin_menu', array( $this, 'drop_comment_menus' ), 999 );
add_action( 'admin_init', array( $this, 'redirect_comment_screens' ) );
add_action( 'wp_dashboard_setup', array( $this, 'drop_comment_widget' ) );
add_action( 'admin_bar_menu', array( $this, 'drop_comment_bar' ), 999 );
add_action( 'template_redirect', array( $this, 'comment_feed_404' ), 9 );
}
public function drop_comment_support() {
foreach ( get_post_types() as $type ) {
if ( post_type_supports( $type, 'comments' ) ) {
remove_post_type_support( $type, 'comments' );
remove_post_type_support( $type, 'trackbacks' );
}
}
}
public function refuse_comment() {
wp_die( esc_html__( 'Comments are closed on this site.', 'itps-shield' ), esc_html__( 'Comments closed', 'itps-shield' ), array( 'response' => 403 ) );
}
public function drop_comment_endpoints( $endpoints ) {
foreach ( array_keys( $endpoints ) as $route ) {
if ( 0 === strpos( $route, '/wp/v2/comments' ) ) {
unset( $endpoints[ $route ] );
}
}
return $endpoints;
}
public function drop_comment_blocks( $html, $block ) {
$name = isset( $block['blockName'] ) ? (string) $block['blockName'] : '';
if ( 0 === strpos( $name, 'core/comment' ) || in_array( $name, array( 'core/post-comments', 'core/post-comments-form', 'core/latest-comments', 'core/post-comments-count', 'core/post-comments-link' ), true ) ) {
return '';
}
return $html;
}
public function drop_comment_menus() {
remove_menu_page( 'edit-comments.php' );
}
public function redirect_comment_screens() {
global $pagenow;
if ( 'edit-comments.php' === $pagenow || 'comment.php' === $pagenow ) {
wp_safe_redirect( admin_url() );
exit;
}
}
public function drop_comment_widget() {
remove_meta_box( 'dashboard_recent_comments', 'dashboard', 'normal' );
}
public function drop_comment_bar( $bar ) {
$bar->remove_node( 'comments' );
}
public function comment_feed_404() {
if ( is_comment_feed() ) {
global $wp_query;
$wp_query->set_404();
status_header( 404 );
}
}
/* ---- Registration --------------------------------------------------- */
private static function disposable_domains() {
return array( 'mailinator.com', 'guerrillamail.com', 'guerrillamail.net', 'sharklasers.com', 'grr.la', '10minutemail.com', '10minutemail.net', 'tempmail.com', 'temp-mail.org', 'temp-mail.io', 'tempmailo.com', 'tempr.email', 'throwawaymail.com', 'yopmail.com', 'yopmail.net', 'getnada.com', 'nada.email', 'dispostable.com', 'maildrop.cc', 'mailnesia.com', 'trashmail.com', 'trashmail.de', 'fakeinbox.com', 'mintemail.com', 'mohmal.com', 'emailondeck.com', 'spamgourmet.com', 'mailcatch.com', 'moakt.com', 'burnermail.io', 'mytemp.email', 'tempinbox.com', 'discard.email', 'spambox.us', 'inboxkitten.com', 'mail.tm', 'emailfake.com', 'fakemail.net', 'linshiyouxiang.net', 'byom.de', 'cock.li', 'mailpoof.com', 'tmail.ws', 'tmpmail.org', 'tmpmail.net', 'harakirimail.com', 'mail7.io', 'mvrht.net' );
}
public function check_registration_spam( $errors, $login = '', $email = '' ) {
if ( ! ( $errors instanceof WP_Error ) ) {
return $errors;
}
$why = $this->spam_reason();
if ( $why ) {
$this->spam_hit( 'signup', $why . ' (' . sanitize_email( (string) $email ) . ')' );
$errors->add( 'itps_spam', __( 'Error: Sign-up blocked as spam. If you are a person, wait a few seconds and try again.', 'itps-shield' ) );
return $errors;
}
if ( $this->o['block_disposable_email'] && $email ) {
$domain = strtolower( (string) substr( strrchr( (string) $email, '@' ), 1 ) );
foreach ( self::disposable_domains() as $d ) {
if ( $domain === $d || substr( $domain, -strlen( '.' . $d ) ) === '.' . $d ) {
$this->spam_hit( 'signup', 'disposable email ' . $domain );
$errors->add( 'itps_disposable', __( 'Error: Please use a permanent email address, not a temporary one.', 'itps-shield' ) );
break;
}
}
}
return $errors;
}
/* ---- Contact forms -------------------------------------------------- */
private function form_message() {
return __( 'Your message was blocked as spam. If you are a person, please wait a few seconds and send it again.', 'itps-shield' );
}
public function cf7_spam( $spam ) {
if ( $spam ) {
return $spam;
}
$why = $this->spam_reason();
if ( $why ) {
$this->spam_hit( 'contact_form_7', $why );
return true;
}
return false;
}
public function wpforms_spam( $entry, $form_data ) {
$why = $this->spam_reason();
if ( $why && function_exists( 'wpforms' ) && isset( $form_data['id'] ) ) {
$this->spam_hit( 'wpforms', $why );
wpforms()->process->errors[ $form_data['id'] ]['header'] = esc_html( $this->form_message() );
}
}
public function gform_spam( $is_spam ) {
if ( $is_spam ) {
return $is_spam;
}
$why = $this->spam_reason();
if ( $why ) {
$this->spam_hit( 'gravity_forms', $why );
return true;
}
return false;
}
public function elementor_spam( $record, $ajax_handler ) {
$why = $this->spam_reason();
if ( ! $why ) {
return;
}
$this->spam_hit( 'elementor_form', $why );
$fields = is_object( $record ) && method_exists( $record, 'get' ) ? (array) $record->get( 'fields' ) : array();
$first = $fields ? (string) key( $fields ) : 'form';
if ( is_object( $ajax_handler ) && method_exists( $ajax_handler, 'add_error' ) ) {
$ajax_handler->add_error( $first, $this->form_message() );
}
}
public function fluent_spam( $errors ) {
/*
* Fluent Forms posts the whole form as one URL-encoded string in "data"
* and drops unknown fields before its own filters run, so the three trap
* fields are read from that string. sanitize_text_field() keeps the
* key=value&key=value structure intact (it only removes %-encoded octets,
* which the trap fields never contain); the extracted values are then
* validated again by trap_fields(). Nothing else from the form is used.
*/
$data = array();
if ( isset( $_POST['data'] ) && is_string( $_POST['data'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Public, anonymous form handled by Fluent Forms; only the trap fields are read.
parse_str( sanitize_text_field( wp_unslash( $_POST['data'] ) ), $data ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- See above.
}
$why = $this->spam_reason( $data ? $data : null );
if ( $why ) {
$this->spam_hit( 'fluent_forms', $why );
$errors['itps_spam'] = array( $this->form_message() );
}
return $errors;
}
public function formidable_spam( $errors ) {
$why = $this->spam_reason();
if ( $why ) {
$this->spam_hit( 'formidable', $why );
$errors['itps_spam'] = $this->form_message();
}
return $errors;
}
public function ninja_spam( $form_data ) {
/*
* Ninja Forms posts JSON, so the trap fields travel in its "extra" data.
* The submission is taken from the array Ninja Forms passes to this filter
* (no second decoding of the request), and trap_fields() reads and
* sanitizes only the three trap fields from it.
*/
$extra = ( is_array( $form_data ) && isset( $form_data['extra'] ) && is_array( $form_data['extra'] ) ) ? $form_data['extra'] : array();
$has = isset( $extra[ self::HP_FIELD ] ) || isset( $extra['itps_t'] ) || isset( $extra['itps_js'] );
$why = $has ? $this->spam_reason( $extra ) : '';
if ( $why ) {
$this->spam_hit( 'ninja_forms', $why );
$form_data['errors']['form']['itps'] = $this->form_message();
}
return $form_data;
}
/* ---- Strong passwords ----------------------------------------------- */
public static function is_strong_password( $pass ) {
$len = function_exists( 'mb_strlen' ) ? mb_strlen( $pass ) : strlen( $pass );
if ( $len >= 16 ) {
return true; // Long passphrases are fine as-is.
}
if ( $len < 12 ) {
return false;
}
$classes = 0;
$classes += preg_match( '/[a-z]/', $pass ) ? 1 : 0;
$classes += preg_match( '/[A-Z]/', $pass ) ? 1 : 0;
$classes += preg_match( '/[0-9]/', $pass ) ? 1 : 0;
$classes += preg_match( '/[^a-zA-Z0-9]/', $pass ) ? 1 : 0;
return $classes >= 3;
}
/**
* Profile screens: core has already put the new password on the user object.
*
* @param WP_Error $errors Errors.
* @param bool $update Whether an existing user is being updated.
* @param object $user User data being saved.
*/
public function validate_profile_password( $errors, $update = false, $user = null ) {
$pass = ( is_object( $user ) && isset( $user->user_pass ) && is_string( $user->user_pass ) ) ? wp_unslash( $user->user_pass ) : '';
$this->check_password_strength( $errors, $pass );
}
/**
* Password reset screen (wp-login.php?action=rp). Core verifies the reset
* key before this action runs. The value is only measured (length and
* character classes); it is never stored, logged or output.
*
* @param WP_Error $errors Errors.
*/
public function validate_reset_password( $errors ) {
$pass = isset( $_POST['pass1'] ) ? sanitize_text_field( wp_unslash( $_POST['pass1'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Core checks the password reset key for this form.
$this->check_password_strength( $errors, $pass );
}
private function check_password_strength( $errors, $pass ) {
if ( '' === $pass || ! ( $errors instanceof WP_Error ) ) {
return;
}
if ( ! self::is_strong_password( $pass ) ) {
$errors->add(
'itps_weak_password',
__( 'Error: Password must be at least 12 characters and use 3 of: lowercase, uppercase, numbers, symbols (or be a 16+ character passphrase).', 'itps-shield' )
);
}
}
/* ---- Idle logout ---------------------------------------------------- */
public function idle_check() {
if ( ! is_user_logged_in() ) {
return;
}
$uid = get_current_user_id();
$now = time();
$limit = (int) $this->o['idle_logout'] * MINUTE_IN_SECONDS;
$last = (int) get_user_meta( $uid, 'itps_last_active', true );
if ( $last && ( $now - $last ) > $limit ) {
delete_user_meta( $uid, 'itps_last_active' );
$this->log( 'idle_logout', 'user id ' . $uid );
wp_logout();
if ( wp_doing_ajax() ) {
wp_send_json_error( array( 'message' => 'session_expired' ), 401 );
}
wp_safe_redirect( add_query_arg( 'itps_idle', '1', wp_login_url() ) );
exit;
}
// The Heartbeat API pings while a tab is open; it is not user activity.
$action = isset( $_POST['action'] ) ? sanitize_key( wp_unslash( $_POST['action'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Read-only: only tells a Heartbeat ping apart from real activity.
if ( wp_doing_ajax() && 'heartbeat' === $action ) {
return;
}
if ( ( $now - $last ) > 60 ) {
update_user_meta( $uid, 'itps_last_active', $now );
}
}
public function idle_reset( $user_login, $user ) {
if ( $user instanceof WP_User ) {
update_user_meta( $user->ID, 'itps_last_active', time() );
}
}
public function idle_message( $message ) {
if ( isset( $_GET['itps_idle'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display-only flag for a notice.
$message .= '' . esc_html__( 'You were logged out after a period of inactivity.', 'itps-shield' ) . '
';
}
return $message;
}
/* =====================================================================
* Account alerts
* ===================================================================*/
private function actor() {
$u = wp_get_current_user();
return ( $u && $u->exists() ) ? $u->user_login : 'no logged-in user (code, plugin or signup)';
}
private function alert_new_admin( $user_id ) {
static $done = array(); // set_user_role and add_user_role can both fire for one change.
$u = get_userdata( $user_id );
if ( ! $u || isset( $done[ $user_id ] ) ) {
return;
}
$done[ $user_id ] = true;
$this->log( 'new_admin', $u->user_login . ' by ' . $this->actor() );
wp_mail(
get_option( 'admin_email' ),
sprintf( '[%s] New administrator account: %s', $this->site_name(), $u->user_login ),
sprintf(
"A user was just given the Administrator role.\n\nUser: %s (%s)\nChanged by: %s\nFrom IP: %s\nTime: %s\n\nIf you did not expect this, your site may be compromised. Review users now:\n%s",
$u->user_login,
$u->user_email,
$this->actor(),
$this->ip(),
wp_date( 'Y-m-d H:i:s T' ),
admin_url( 'users.php?role=administrator' )
)
);
}
public function role_changed( $user_id, $role, $old_roles ) {
if ( 'administrator' === $role && ! in_array( 'administrator', (array) $old_roles, true ) ) {
$this->alert_new_admin( $user_id );
}
}
public function role_added( $user_id, $role ) {
if ( 'administrator' === $role ) {
$this->alert_new_admin( $user_id );
}
}
public function admin_login_alert( $user_login, $user ) {
if ( ! ( $user instanceof WP_User ) || ! user_can( $user, 'manage_options' ) ) {
return;
}
$ip = $this->ip();
$known = get_user_meta( $user->ID, 'itps_known_ips', true );
$known = is_array( $known ) ? $known : array();
if ( in_array( $ip, $known, true ) ) {
return;
}
if ( $known ) { // Don't alert on the very first recorded login.
$ua = mb_substr( self::server_text( 'HTTP_USER_AGENT' ), 0, 200 );
$this->log( 'admin_new_ip', $user->user_login );
wp_mail(
$user->user_email,
sprintf( '[%s] Administrator login from a new IP', $this->site_name() ),
sprintf(
"Your administrator account %s just logged in from a new IP address.\n\nIP: %s\nBrowser: %s\nTime: %s\n\nIf this wasn't you, change your password immediately and review your users:\n%s",
$user->user_login,
$ip,
'' !== $ua ? $ua : 'unknown',
wp_date( 'Y-m-d H:i:s T' ),
admin_url( 'users.php' )
)
);
}
array_unshift( $known, $ip );
update_user_meta( $user->ID, 'itps_known_ips', array_slice( array_unique( $known ), 0, 10 ) );
}
public function guard_default_role( $role ) {
return 'administrator' === $role ? 'subscriber' : $role;
}
/* =====================================================================
* XML-RPC, enumeration, REST, fingerprinting, headers, caps
* ===================================================================*/
public function kill_xmlrpc() {
status_header( 403 );
header( 'Content-Type: text/plain; charset=utf-8' );
echo 'XML-RPC is disabled on this site.';
exit;
}
public function remove_pingback_header( $headers ) {
unset( $headers['X-Pingback'] );
return $headers;
}
public function block_author_scan() {
if ( is_admin() || is_user_logged_in() ) {
return;
}
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- Blocks a public ?author= scan; nothing is changed.
if ( isset( $_GET['author'] ) ) {
$author = is_string( $_GET['author'] ) ? sanitize_text_field( wp_unslash( $_GET['author'] ) ) : '';
$this->log( 'user_enum', 'author=' . mb_substr( $author, 0, 50 ) );
wp_safe_redirect( home_url( '/' ), 301 );
exit;
}
// phpcs:enable WordPress.Security.NonceVerification.Recommended
}
public function restrict_user_endpoints( $endpoints ) {
if ( is_user_logged_in() ) {
return $endpoints;
}
foreach ( array_keys( $endpoints ) as $route ) {
if ( 0 === strpos( $route, '/wp/v2/users' ) ) {
unset( $endpoints[ $route ] );
}
}
return $endpoints;
}
public function restrict_rest( $result ) {
if ( ! empty( $result ) || is_user_logged_in() ) {
return $result;
}
$route = '';
if ( ! empty( $GLOBALS['wp']->query_vars['rest_route'] ) ) {
$route = (string) $GLOBALS['wp']->query_vars['rest_route'];
} else {
$path = $this->request_path();
$prefix = trim( (string) rest_get_url_prefix(), '/' );
if ( '' !== $prefix && ( $path === $prefix || 0 === strpos( $path, $prefix . '/' ) ) ) {
$route = substr( $path, strlen( $prefix ) );
}
}
$route = trim( $route, '/' );
foreach ( preg_split( '/\r\n|\r|\n/', (string) $this->o['rest_allow'] ) as $ns ) {
$ns = trim( $ns, " /\t" );
// Whole namespace segments only: "wc/store" allows wc/store/v1/…, not wc/storefront-hack/….
if ( '' !== $ns && ( $route === $ns || 0 === strpos( $route, $ns . '/' ) ) ) {
return $result;
}
}
return new WP_Error( 'rest_login_required', __( 'The REST API is restricted to logged-in users on this site.', 'itps-shield' ), array( 'status' => 401 ) );
}
public function strip_oembed_author( $data ) {
unset( $data['author_name'], $data['author_url'] );
return $data;
}
public function drop_user_sitemap( $provider, $name ) {
return 'users' === $name ? false : $provider;
}
/**
* Replaces ?ver=6.8.3 / ?ver=5.2.1 on every script and stylesheet with a
* short hash, so WordPress, theme and plugin versions can't be read from
* the page source but browsers still get a new file after an update.
*/
public function mask_asset_version( $src ) {
if ( ! $src || false === strpos( $src, 'ver=' ) ) {
return $src;
}
$q = (string) wp_parse_url( $src, PHP_URL_QUERY );
parse_str( $q, $args );
if ( empty( $args['ver'] ) ) {
return $src;
}
$salt = defined( 'NONCE_SALT' ) ? NONCE_SALT : 'itps';
return add_query_arg( 'ver', substr( md5( $args['ver'] . $salt ), 0, 8 ), $src );
}
public function remove_fingerprints() {
remove_action( 'wp_head', 'wlwmanifest_link' );
remove_action( 'wp_head', 'rsd_link' );
remove_action( 'wp_head', 'wp_shortlink_wp_head' );
remove_action( 'template_redirect', 'wp_shortlink_header', 11 );
remove_action( 'wp_head', 'rest_output_link_wp_head', 10 );
remove_action( 'template_redirect', 'rest_output_link_header', 11 );
remove_action( 'wp_head', 'wp_oembed_add_discovery_links' );
remove_action( 'wp_head', 'print_emoji_detection_script', 7 );
remove_action( 'wp_print_styles', 'print_emoji_styles' );
remove_action( 'admin_print_scripts', 'print_emoji_detection_script' );
remove_action( 'admin_print_styles', 'print_emoji_styles' );
remove_filter( 'the_content_feed', 'wp_staticize_emoji' );
remove_filter( 'comment_text_rss', 'wp_staticize_emoji' );
remove_filter( 'wp_mail', 'wp_staticize_emoji_for_email' );
add_filter( 'emoji_svg_url', '__return_false' );
}
/**
* Without a Site Icon, /favicon.ico redirects to the blue WordPress "W".
* Send the site icon if there is one, otherwise an empty response so the
* browser shows its neutral default icon.
*/
public function favicon_without_wp_logo() {
$icon = get_site_icon_url( 32 );
if ( $icon ) {
wp_safe_redirect( $icon, 302 );
exit;
}
header( 'Cache-Control: public, max-age=86400' );
status_header( 204 );
exit;
}
public function drop_wp_dns_prefetch( $urls, $relation ) {
if ( 'dns-prefetch' !== $relation ) {
return $urls;
}
foreach ( $urls as $k => $u ) {
$href = is_array( $u ) ? ( isset( $u['href'] ) ? $u['href'] : '' ) : $u;
$host = (string) wp_parse_url( (string) $href, PHP_URL_HOST );
if ( preg_match( '/(^|\.)w\.org$/', $host ) ) { // WordPress.org emoji/DNS prefetch hint.
unset( $urls[ $k ] );
}
}
return $urls;
}
/** The /wp-json/ index lists every plugin namespace; hide it from visitors. */
public function trim_rest_index( $response ) {
if ( is_user_logged_in() || ! ( $response instanceof WP_REST_Response ) ) {
return $response;
}
$data = $response->get_data();
unset( $data['namespaces'], $data['routes'], $data['authentication'], $data['_links'] );
$data['description'] = '';
$response->set_data( $data );
$response->remove_link( 'help' );
return $response;
}
public function strip_wp_headers( $headers ) {
unset( $headers['X-Pingback'], $headers['X-Powered-By'], $headers['Link'] );
return $headers;
}
public function login_home_url() {
return home_url( '/' );
}
public function login_site_name() {
return $this->site_name();
}
public function login_title( $title, $raw ) {
return $raw . ' ‹ ' . $this->site_name();
}
/** Shows the site icon (or the site name) instead of the WordPress logo. */
public function login_logo_css() {
$icon = get_site_icon_url( 160 );
if ( ! $icon && has_custom_logo() ) {
$icon = wp_get_attachment_image_url( (int) get_theme_mod( 'custom_logo' ), 'medium' );
}
if ( $icon ) {
$css = '.login h1 a{background-image:url(' . esc_url( $icon ) . ')!important;background-size:contain!important;width:120px!important;height:120px!important}';
} else {
$css = '.login h1 a{background:none!important;width:auto!important;height:auto!important;text-indent:0!important;font-size:24px;font-weight:600;color:#1d2327}';
}
wp_add_inline_style( 'login', $css );
}
/** Stops a stolen admin session from uploading a malicious plugin or theme. Updates still work. */
public function block_install_caps( $caps, $cap ) {
if ( in_array( $cap, array( 'install_plugins', 'install_themes', 'upload_plugins', 'upload_themes' ), true ) ) {
return array( 'do_not_allow' );
}
return $caps;
}
public function session_length( $seconds ) {
return min( (int) $seconds, (int) $this->o['session_hours'] * HOUR_IN_SECONDS );
}
public function send_security_headers() {
if ( headers_sent() ) {
return;
}
if ( $this->o['security_headers'] ) {
header( 'X-Content-Type-Options: nosniff' );
header( 'X-Frame-Options: SAMEORIGIN' );
header( 'Referrer-Policy: strict-origin-when-cross-origin' );
header( 'Permissions-Policy: camera=(), microphone=(), geolocation=(), usb=(), magnetometer=(), gyroscope=()' );
header( 'Cross-Origin-Resource-Policy: same-site' );
if ( function_exists( 'header_remove' ) ) {
header_remove( 'X-Powered-By' );
}
}
if ( $this->o['force_ssl'] && is_ssl() ) {
// Ask browsers to load any leftover http:// images/scripts over HTTPS.
header( 'Content-Security-Policy: upgrade-insecure-requests' );
}
if ( $this->o['hsts'] && is_ssl() ) {
header( 'Strict-Transport-Security: max-age=31536000; includeSubDomains' );
}
}
public function block_file_edit_caps( $caps, $cap ) {
if ( in_array( $cap, array( 'edit_files', 'edit_plugins', 'edit_themes' ), true ) ) {
return array( 'do_not_allow' );
}
return $caps;
}
/* =====================================================================
* One-click fixes
* ===================================================================*/
private static function version_files() {
return array( 'readme.html', 'license.txt', 'wp-config-sample.php' );
}
private function config_path() {
if ( file_exists( ABSPATH . 'wp-config.php' ) ) {
return ABSPATH . 'wp-config.php';
}
$up = dirname( ABSPATH ) . '/wp-config.php';
return ( file_exists( $up ) && ! file_exists( dirname( ABSPATH ) . '/wp-settings.php' ) ) ? $up : '';
}
/*
* The plugin never edits, deletes or changes the permissions of WordPress
* core files or wp-config.php. The audit reports on them and explains how
* to fix them by hand; the root .htaccess option can block access to the
* version-revealing files instead.
*/
/** Recommended settings that are safe on any site. */
private static function recommended() {
return array(
'force_ssl', 'login_protection', 'notify_lockout', 'generic_login_errors', 'login_honeypot', 'strong_passwords',
'alert_new_admin', 'alert_admin_new_ip', 'lock_default_role', 'firewall', 'block_bad_bots',
'disable_xmlrpc', 'block_user_enum', 'hide_version', 'disable_file_edit', 'security_headers',
'block_uploads_php', 'login_js_check', 'comment_spam', 'registration_spam', 'form_spam', 'block_disposable_email', 'hide_wp_fingerprints', 'protect_wp_content', 'branded_login',
);
}
private function apply_recommended() {
$new = $this->o;
$changed = array();
foreach ( self::recommended() as $k ) {
if ( empty( $new[ $k ] ) ) {
$new[ $k ] = 1;
$changed[] = $k;
}
}
if ( ! $changed ) {
return array( 'success', 'All recommended settings were already on.' );
}
update_option( self::OPT, $new ); // Runs sanitize(): Force HTTPS is only kept if the certificate works.
$this->o = wp_parse_args( (array) get_option( self::OPT ), self::defaults() );
$on = array();
foreach ( $changed as $k ) {
if ( ! empty( $this->o[ $k ] ) ) {
$on[] = ucwords( str_replace( '_', ' ', $k ) );
}
}
$msg = $on ? 'Turned on: ' . implode( ', ', $on ) . '.' : 'All other recommended settings were already on.';
if ( in_array( 'force_ssl', $changed, true ) && empty( $this->o['force_ssl'] ) ) {
$msg .= ' Force HTTPS was left off because https:// did not answer with a valid certificate.';
}
$this->log( 'hardening', 'Applied recommended settings' );
return array( 'success', $msg );
}
public function handle_fix() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'Forbidden', 403 );
}
check_admin_referer( 'itps_fix' );
$fix = isset( $_POST['fix'] ) ? sanitize_key( wp_unslash( $_POST['fix'] ) ) : '';
$msgs = array();
if ( in_array( $fix, array( 'recommended', 'all' ), true ) ) {
$msgs[] = $this->apply_recommended();
}
// Saving changed settings already installs and tests the rules; this covers rules that are still pending.
if ( 'server_rules' === $fix || ( in_array( $fix, array( 'recommended', 'all' ), true ) && get_option( self::PENDING ) ) ) {
$this->o = wp_parse_args( (array) get_option( self::OPT ), self::defaults() );
$msgs[] = $this->install_server_rules();
}
$prev = get_transient( 'itps_fix_msgs_' . get_current_user_id() );
$msgs = array_merge( $msgs, is_array( $prev ) ? $prev : array() );
set_transient( 'itps_fix_msgs_' . get_current_user_id(), $msgs, 5 * MINUTE_IN_SECONDS );
wp_safe_redirect( admin_url( 'options-general.php?page=itps-shield&tab=audit' ) );
exit;
}
/** Tags allowed in the audit action cells (small forms and links). */
private static function allowed_action_html() {
return array(
'form' => array(
'method' => true,
'action' => true,
'style' => true,
),
'input' => array(
'type' => true,
'name' => true,
'id' => true,
'value' => true,
'class' => true,
),
'a' => array(
'href' => true,
'class' => true,
),
);
}
private function fix_button( $fix, $label, $primary = false ) {
$html = '';
}
/** Action cell for an audit row. */
private function audit_action( $check, $status ) {
if ( 'good' === $status ) {
return '';
}
$link = function ( $url, $text ) {
return '' . esc_html( $text ) . ' ';
};
switch ( $check ) {
case 'Version-revealing files':
return $link( admin_url( 'options-general.php?page=itps-shield' ), 'Open settings' );
case 'Server rules installed':
return $this->fix_button( 'server_rules', 'Install' );
case 'PHP blocked in uploads':
case 'HTTPS enforced':
case 'WordPress fingerprints hidden':
case 'Spam protection':
case 'File editor disabled':
return $this->fix_button( 'recommended', 'Fix now' );
case 'No inactive plugins':
return $link( admin_url( 'plugins.php?plugin_status=inactive' ), 'Review plugins' );
case 'WordPress core up to date':
case 'Plugins up to date':
case 'Themes up to date':
return $link( admin_url( 'update-core.php' ), 'Update' );
case 'Login address hidden':
case 'HSTS':
return $link( admin_url( 'options-general.php?page=itps-shield' ), 'Open settings' );
case 'No "admin" username':
case 'Few administrators':
return $link( admin_url( 'users.php?role=administrator' ), 'Review users' );
}
return '';
}
/* =====================================================================
* Core file integrity scan
* ===================================================================*/
public function handle_core_scan() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'Forbidden', 403 );
}
check_admin_referer( 'itps_core_scan' );
require_once ABSPATH . 'wp-admin/includes/update.php';
$version = get_bloginfo( 'version' );
$checksums = get_core_checksums( $version, get_locale() );
if ( ! is_array( $checksums ) ) {
$checksums = get_core_checksums( $version, 'en_US' );
}
$result = array(
't' => time(),
'version' => $version,
'error' => '',
'modified' => array(),
'missing' => array(),
'unknown' => array(),
'checked' => 0,
);
if ( ! is_array( $checksums ) ) {
$result['error'] = 'Could not download official checksums from WordPress.org (custom or development version, or no outbound access).';
} else {
foreach ( $checksums as $file => $md5 ) {
if ( 0 === strpos( $file, 'wp-content/' ) ) {
continue;
}
$path = ABSPATH . $file;
++$result['checked'];
if ( ! file_exists( $path ) ) {
$result['missing'][] = $file;
} elseif ( md5_file( $path ) !== $md5 ) {
$result['modified'][] = $file;
}
}
// PHP files in wp-admin / wp-includes that WordPress does not ship are a strong sign of compromise.
foreach ( array( 'wp-admin', 'wp-includes' ) as $dir ) {
if ( ! is_dir( ABSPATH . $dir ) ) {
continue;
}
$it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( ABSPATH . $dir, FilesystemIterator::SKIP_DOTS ) );
foreach ( $it as $f ) {
if ( ! $f->isFile() || ! preg_match( '/\.(php[0-9]?|phtml|phar)$/i', $f->getFilename() ) ) {
continue;
}
$rel = str_replace( '\\', '/', substr( $f->getPathname(), strlen( ABSPATH ) ) );
if ( ! isset( $checksums[ $rel ] ) ) {
$result['unknown'][] = $rel;
}
}
}
foreach ( array( 'modified', 'missing', 'unknown' ) as $k ) {
$result[ $k ] = array_slice( $result[ $k ], 0, 200 );
}
}
update_option( self::SCAN, $result, false );
$bad = count( $result['modified'] ) + count( $result['unknown'] );
$this->log( 'core_scan', $result['error'] ? $result['error'] : sprintf( '%d checked, %d modified, %d missing, %d unknown', $result['checked'], count( $result['modified'] ), count( $result['missing'] ), count( $result['unknown'] ) ) );
if ( $bad ) {
wp_mail(
get_option( 'admin_email' ),
sprintf( '[%s] Core file scan found %d suspicious file(s)', $this->site_name(), $bad ),
"ITPS Shield found modified or unknown WordPress core files. Review the results:\n" . admin_url( 'options-general.php?page=itps-shield&tab=audit' )
);
}
wp_safe_redirect( admin_url( 'options-general.php?page=itps-shield&tab=audit&scanned=1' ) );
exit;
}
/* =====================================================================
* .htaccess management (Apache / LiteSpeed)
* ===================================================================*/
private static function deny_lines() {
return array(
' ',
' Require all denied',
' ',
' ',
' Order allow,deny',
' Deny from all',
' ',
);
}
/**
* Writes this plugin's block into an .htaccess file. With no rules, the
* block is only cleared where it exists: no file and no empty block is
* created just to say nothing.
*
* @param string $file .htaccess path.
* @param string[] $rules Lines to place between the markers.
*/
private static function write_rules( $file, $rules ) {
if ( ! $rules ) {
$lines = file_exists( $file ) ? (array) file( $file, FILE_IGNORE_NEW_LINES ) : array();
if ( ! in_array( '# BEGIN ' . self::MARKER, $lines, true ) ) {
return;
}
}
insert_with_markers( $file, self::MARKER, $rules );
}
public static function sync_htaccess( $opts, $remove_all = false ) {
require_once ABSPATH . 'wp-admin/includes/misc.php';
require_once ABSPATH . 'wp-admin/includes/file.php';
// Uploads: never execute PHP.
$up = wp_upload_dir( null, false );
if ( ! empty( $up['basedir'] ) && is_dir( $up['basedir'] ) && wp_is_writable( $up['basedir'] ) ) {
$rules = array();
if ( ! $remove_all && ! empty( $opts['block_uploads_php'] ) ) {
$rules = array_merge(
array( '' ),
self::deny_lines(),
array( ' ' )
);
}
self::write_rules( trailingslashit( $up['basedir'] ) . '.htaccess', $rules );
}
// wp-content: hide plugin/theme readmes & changelogs (they reveal versions), logs and backups; no listings.
if ( is_dir( WP_CONTENT_DIR ) && wp_is_writable( WP_CONTENT_DIR ) ) {
$rules = array();
if ( ! $remove_all && ! empty( $opts['protect_wp_content'] ) ) {
$rules = array_merge(
array( 'Options -Indexes' ),
array( '' ),
self::deny_lines(),
array( ' ' )
);
}
self::write_rules( trailingslashit( WP_CONTENT_DIR ) . '.htaccess', $rules );
}
// Site root: protect sensitive files, disable directory listing.
$root_file = trailingslashit( get_home_path() ) . '.htaccess';
if ( file_exists( $root_file ) ? wp_is_writable( $root_file ) : wp_is_writable( get_home_path() ) ) {
$rules = array();
if ( ! $remove_all && ! empty( $opts['harden_root_htaccess'] ) ) {
$rules = array_merge(
array( 'Options -Indexes' ),
array( '' ),
self::deny_lines(),
array( ' ' )
);
if ( ! empty( $opts['disable_xmlrpc'] ) ) {
$rules = array_merge( $rules, array( '' ), self::deny_lines(), array( ' ' ) );
}
}
self::write_rules( $root_file, $rules );
}
}
/* =====================================================================
* Lifecycle
* ===================================================================*/
/**
* Activation only stores the default settings. It writes no files and makes
* no requests: the .htaccess rules are installed later, when an
* administrator chooses to from the plugin screen, and every location is
* then tested and rolled back if the server rejects it (see apply_server_rules()).
*/
public static function activate() {
if ( false === get_option( self::OPT ) ) {
add_option( self::OPT, self::defaults() );
}
if ( self::server_rule_labels( wp_parse_args( (array) get_option( self::OPT ), self::defaults() ) ) ) {
update_option( self::PENDING, 1, false );
}
}
public static function deactivate() {
self::sync_htaccess( array(), true );
delete_option( self::PENDING );
}
/**
* Settings that write .htaccess rules => label, in the order they are tested
* (outside in: a broken root file would break every request below it).
*
* @return array
*/
private static function server_rule_keys() {
return array(
'harden_root_htaccess' => 'Harden root .htaccess',
'protect_wp_content' => 'Protect wp-content',
'block_uploads_php' => 'Block PHP in uploads',
);
}
/**
* Labels of the .htaccess settings that are switched on.
*
* @param array $opts Settings.
* @return string[]
*/
private static function server_rule_labels( $opts ) {
$on = array();
foreach ( self::server_rule_keys() as $key => $label ) {
if ( ! empty( $opts[ $key ] ) ) {
$on[ $key ] = $label;
}
}
return $on;
}
/**
* Whether a loopback request to this site answers "500 Internal Server Error".
*
* @param string $url URL on this site.
* @return bool
*/
private function answers_500( $url ) {
$res = wp_remote_get(
$url,
array(
'timeout' => 8,
'redirection' => 0,
// Loopback request to this same site: use core's setting for local SSL checks.
'sslverify' => apply_filters( 'https_local_ssl_verify', false ), // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core filter.
)
);
// 403/404/2xx/3xx = the server accepted the file; a request error = can't test, nothing is changed.
return ! is_wp_error( $res ) && 500 === (int) wp_remote_retrieve_response_code( $res );
}
/**
* Writes the .htaccess rules for the given settings, then tests each
* location that changed with a real request. Some hosts don't allow these
* directives and answer every request below the file with HTTP 500; if that
* happens the rules for that location are taken back out and its setting
* is switched off again.
*
* @param array $opts Settings to apply.
* @param array|null $check Setting keys to test, or null for every one that is on.
* @return string[] Labels of the settings that were switched back off.
*/
private function apply_server_rules( $opts, $check = null ) {
$opts = wp_parse_args( (array) $opts, self::defaults() );
self::sync_htaccess( $opts );
delete_option( self::PENDING );
$up = wp_upload_dir( null, false );
$urls = array(
'harden_root_htaccess' => home_url( '/' ),
'protect_wp_content' => plugins_url( 'readme.txt', __FILE__ ),
// Any address inside uploads: Apache reads the folder's .htaccess before it knows whether the file exists.
'block_uploads_php' => empty( $up['baseurl'] ) ? '' : trailingslashit( $up['baseurl'] ) . 'itps-shield-check.txt',
);
$off = array();
foreach ( self::server_rule_labels( $opts ) as $key => $label ) {
if ( ( null !== $check && ! in_array( $key, $check, true ) ) || '' === $urls[ $key ] || ! $this->answers_500( $urls[ $key ] ) ) {
continue;
}
$opts[ $key ] = 0;
$off[] = $label;
self::sync_htaccess( $opts ); // Take these rules back out before testing the next location.
$this->log( 'hardening', $label . ': .htaccess rules removed, the server returned 500' );
}
if ( $off ) {
remove_action( 'update_option_' . self::OPT, array( $this, 'on_options_updated' ), 10 );
$this->o = $opts;
update_option( self::OPT, $opts );
add_action( 'update_option_' . self::OPT, array( $this, 'on_options_updated' ), 10, 2 );
}
return $off;
}
/**
* Installs the pending .htaccess rules (button on the plugin screen).
*
* @return array Message: array( type, text ).
*/
private function install_server_rules() {
$on = self::server_rule_labels( $this->o );
$off = $this->apply_server_rules( $this->o );
$ok = array_diff( $on, $off );
if ( $off ) {
return array( 'error', sprintf( 'Your server does not accept the .htaccess rules for: %s (it answered with an error), so they were removed again and switched off. Nothing else changed.', implode( ', ', $off ) ) . ( $ok ? ' Installed: ' . implode( ', ', $ok ) . '.' : '' ) );
}
return array( 'success', $ok ? 'Server rules installed and tested: ' . implode( ', ', $ok ) . '.' : 'No server rules are switched on.' );
}
/**
* Reports .htaccess rules that were switched back off, on the settings
* screen and on the Security Audit screen.
*
* @param string[] $off Labels of the settings that were switched off.
*/
private function report_rules_off( $off ) {
$text = sprintf( 'Your server does not accept the .htaccess rules for: %s (it answered with an error), so they were removed again and switched off. Nothing else changed.', implode( ', ', $off ) );
add_settings_error( self::OPT, 'itps_htaccess', esc_html( $text ) );
set_transient( 'itps_fix_msgs_' . get_current_user_id(), array( array( 'error', $text ) ), 5 * MINUTE_IN_SECONDS );
}
/**
* Saving unchanged settings fires no update_option_ hook, so rules that are
* still pending (switched on but never installed) are installed here.
*
* @param mixed $value New value.
* @param mixed $old Old value.
* @return mixed
*/
public function install_pending_on_save( $value, $old ) {
if ( get_option( self::PENDING ) && is_array( $value ) && maybe_serialize( $value ) === maybe_serialize( $old ) ) {
$off = $this->apply_server_rules( $value );
if ( $off ) {
$this->report_rules_off( $off );
}
}
return $value;
}
public function on_options_updated( $old, $new ) {
$old = wp_parse_args( (array) $old, self::defaults() );
$new = wp_parse_args( (array) $new, self::defaults() );
// Only test the locations whose rules changed (or were never installed).
$pending = (bool) get_option( self::PENDING );
$check = array();
foreach ( array_keys( self::server_rule_keys() ) as $key ) {
if ( $pending || (int) $old[ $key ] !== (int) $new[ $key ] ) {
$check[] = $key;
}
}
if ( (int) $old['disable_xmlrpc'] !== (int) $new['disable_xmlrpc'] ) {
$check[] = 'harden_root_htaccess'; // The root rules include an xmlrpc.php block.
}
$off = $this->apply_server_rules( $new, array_unique( $check ) );
if ( $off ) {
$this->report_rules_off( $off );
$new = $this->o;
}
if ( $old['login_slug'] !== $new['login_slug'] ) {
$this->o = $new; // So login_base_url() uses the new slug.
if ( '' !== $new['login_slug'] && ! is_multisite() ) {
$url = $this->login_base_url();
$this->log( 'login_url', 'Changed to ' . $url );
wp_mail(
get_option( 'admin_email' ),
sprintf( '[%s] Your new login address', $this->site_name() ),
sprintf(
"The WordPress login address for %s is now:\n\n%s\n\nBookmark it. /wp-login.php and /wp-admin/ now show \"Not Found\" to logged-out visitors.\n\nLocked out? Add this line to wp-config.php to switch the custom address off:\ndefine( 'ITPS_SHIELD_DISABLE_LOGIN_URL', true );",
home_url(),
$url
)
);
set_transient( 'itps_login_url_notice_' . get_current_user_id(), $url, HOUR_IN_SECONDS );
} else {
$this->log( 'login_url', 'Custom login address removed' );
}
}
}
/* =====================================================================
* Admin UI
* ===================================================================*/
public function admin_notices() {
$key = 'itps_login_url_notice_' . get_current_user_id();
$url = get_transient( $key );
if ( $url ) {
delete_transient( $key );
printf(
'ITPS Shield: your login address is now %1$s . Bookmark it — it was also emailed to the site admin.
',
esc_url( $url )
);
}
// .htaccess rules are only written when an administrator asks for it.
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
$waiting = get_option( self::PENDING ) ? self::server_rule_labels( $this->o ) : array();
if ( $waiting && current_user_can( 'manage_options' ) && $screen && in_array( $screen->id, array( 'plugins', 'settings_page_itps-shield' ), true ) ) {
printf(
'',
esc_html( 'These protections need .htaccess rules that are not installed yet: ' . implode( ', ', $waiting ) . '. Each file is tested with a real request and removed again if your server rejects it.' ),
wp_kses( $this->fix_button( 'server_rules', 'Install server rules', true ), self::allowed_action_html() )
);
}
}
public function action_links( $links ) {
array_unshift( $links, '' . esc_html__( 'Settings', 'itps-shield' ) . ' ' );
return $links;
}
public function admin_menu() {
add_options_page( 'ITPS Shield', 'ITPS Shield', 'manage_options', 'itps-shield', array( $this, 'render_page' ) );
}
public function register_settings() {
/*
* No 'default' here on purpose: activation stores the defaults, and the
* plugin always reads the option through wp_parse_args( ..., defaults() ).
* A registered default equal to the stored value makes update_option()
* take the add_option() path, which skips the update_option_ hook that
* installs and tests the .htaccess rules.
*/
register_setting(
'itps_shield',
self::OPT,
array(
'type' => 'array',
'sanitize_callback' => array( $this, 'sanitize' ),
)
);
}
private static function reserved_slugs() {
return array( 'wp-admin', 'wp-login', 'wp-login-php', 'login', 'admin', 'dashboard', 'wp-content', 'wp-includes', 'wp-json', 'wp', 'feed', 'xmlrpc', 'index', 'index-php', 'sitemap', 'robots-txt', 'favicon-ico' );
}
public function sanitize( $in ) {
$in = is_array( $in ) ? $in : array();
// Every setting is a single value; anything else (an array, an object) is ignored.
foreach ( $in as $k => $v ) {
if ( ! is_scalar( $v ) ) {
unset( $in[ $k ] );
}
}
$out = array();
$nums = array(
'max_attempts' => array( 2, 50 ),
'attempt_window' => array( 1, 1440 ),
'lockout_minutes' => array( 1, 10080 ),
'idle_logout' => array( 0, 1440 ),
'session_hours' => array( 0, 720 ),
'spam_min_seconds' => array( 0, 30 ),
'max_comment_links' => array( 0, 20 ),
);
foreach ( self::defaults() as $key => $default ) {
if ( isset( $nums[ $key ] ) ) {
$v = isset( $in[ $key ] ) ? absint( $in[ $key ] ) : $default;
$out[ $key ] = min( $nums[ $key ][1], max( $nums[ $key ][0], $v ) );
} elseif ( 'ip_whitelist' === $key ) {
$lines = preg_split( '/\r\n|\r|\n/', isset( $in[ $key ] ) ? (string) $in[ $key ] : '' );
$clean = array();
$rejected = array();
foreach ( $lines as $l ) {
$l = trim( sanitize_text_field( $l ) );
if ( '' === $l ) {
continue;
}
// Single IPv4/IPv6 address, or IPv4 CIDR with a whole-number prefix from 1 to 32.
$entry = self::normalize_ip_entry( $l );
if ( '' === $entry ) {
$rejected[] = $l;
} else {
$clean[] = $entry;
}
}
if ( $rejected ) {
add_settings_error(
self::OPT,
'itps_whitelist',
esc_html(
sprintf(
'These allow-list entries were not saved because they are not a valid IP address or IPv4 range (prefix 1 to 32): %s',
implode( ', ', array_slice( $rejected, 0, 10 ) )
)
)
);
}
$out[ $key ] = implode( "\n", array_unique( $clean ) );
} elseif ( 'rest_allow' === $key ) {
$lines = preg_split( '/\r\n|\r|\n/', isset( $in[ $key ] ) ? (string) $in[ $key ] : '' );
$clean = array();
foreach ( $lines as $l ) {
$l = trim( preg_replace( '#[^a-zA-Z0-9._/\-]#', '', (string) $l ), '/' );
if ( '' !== $l ) {
$clean[] = $l;
}
}
$out[ $key ] = implode( "\n", array_unique( $clean ) );
} elseif ( 'block_message' === $key ) {
$v = isset( $in[ $key ] ) ? trim( sanitize_text_field( (string) $in[ $key ] ) ) : $default;
$out[ $key ] = '' === $v ? $default : substr( $v, 0, 120 );
} elseif ( 'login_slug' === $key ) {
$slug = sanitize_title( isset( $in[ $key ] ) ? (string) $in[ $key ] : '' );
if ( '' !== $slug ) {
$problem = '';
if ( strlen( $slug ) < 4 ) {
$problem = 'must be at least 4 characters';
} elseif ( in_array( $slug, self::reserved_slugs(), true ) ) {
$problem = 'is reserved by WordPress or too easy to guess';
} elseif ( get_page_by_path( $slug ) || get_page_by_path( $slug, OBJECT, 'post' ) ) {
$problem = 'is already used by a page or post';
}
if ( $problem ) {
add_settings_error( self::OPT, 'itps_slug', sprintf( 'Login address "%s" %s. The previous setting was kept.', $slug, $problem ) );
$slug = (string) $this->o['login_slug'];
}
}
$out[ $key ] = $slug;
} else {
$out[ $key ] = empty( $in[ $key ] ) ? 0 : 1;
}
}
// Never switch on Force HTTPS unless a valid certificate answers on https://.
if ( $out['force_ssl'] && empty( $this->o['force_ssl'] ) ) {
$check = $this->https_works();
if ( is_wp_error( $check ) ) {
$out['force_ssl'] = 0;
add_settings_error(
self::OPT,
'itps_ssl',
sprintf( 'Force HTTPS was NOT enabled: https://%s did not answer with a valid SSL certificate (%s). Install an SSL certificate (e.g. AutoSSL / Let\'s Encrypt in your hosting panel), then try again.', $this->home_host(), $check->get_error_message() )
);
}
}
if ( $out['hsts'] && ! $out['force_ssl'] && ! is_ssl() ) {
$out['hsts'] = 0;
add_settings_error( self::OPT, 'itps_hsts', 'HSTS needs HTTPS. Enable Force HTTPS first.' );
}
return $out;
}
public function handle_unlock() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'Forbidden', 403 );
}
check_admin_referer( 'itps_unlock' );
$ip = isset( $_POST['ip'] ) ? sanitize_text_field( wp_unslash( $_POST['ip'] ) ) : '';
$locks = $this->lockouts();
if ( 'all' === $ip ) {
$locks = array();
} else {
unset( $locks[ $ip ] );
}
update_option( self::LOCKS, $locks, false );
wp_safe_redirect( admin_url( 'options-general.php?page=itps-shield&tab=lockouts&unlocked=1' ) );
exit;
}
public function handle_clear_log() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'Forbidden', 403 );
}
check_admin_referer( 'itps_clear_log' );
update_option( self::LOG, array(), false );
wp_safe_redirect( admin_url( 'options-general.php?page=itps-shield&tab=log' ) );
exit;
}
/**
* Settings fields: key => array( type, label, args ).
*/
private function fields() {
return array(
'HTTPS / SSL' => array(
'force_ssl' => array( 'check', 'Force HTTPS: redirect every http:// request to https://, use HTTPS for the admin and login, secure cookies and upgrade-insecure-requests. A valid certificate is checked before this is switched on.' ),
'fix_mixed_content' => array( 'check', 'Fix mixed content: rewrite http:// links to this domain inside pages to https:// (use if the padlock shows "not fully secure").' ),
'hsts' => array( 'check', 'Send HSTS (browsers will refuse plain HTTP for a year — only enable once the whole site and all subdomains work over HTTPS).' ),
),
'Login address' => array(
'login_slug' => array( 'slug', 'Custom login address. Leave empty to keep /wp-login.php. When set, /wp-login.php and /wp-admin/ show "Not Found" to logged-out visitors.' ),
),
'Login protection' => array(
'login_protection' => array( 'check', 'Limit login attempts and lock out offending IPs (covers wp-login, XML-RPC and REST auth).' ),
'max_attempts' => array( 'number', 'Failed attempts allowed before lockout', array( 2, 50 ) ),
'attempt_window' => array( 'number', 'Counting window (minutes)', array( 1, 1440 ) ),
'lockout_minutes' => array( 'number', 'Lockout duration (minutes)', array( 1, 10080 ) ),
'notify_lockout' => array( 'check', 'Email the site admin when an IP is locked out.' ),
'generic_login_errors' => array( 'check', 'Show a generic error on failed login (do not reveal whether the username exists).' ),
'login_honeypot' => array( 'check', 'Login honeypot: an invisible field on the login, registration and lost-password forms. Bots that fill it are refused and counted toward the lockout.' ),
'login_js_check' => array( 'check', 'Refuse logins that don\'t come from a real browser (scripted brute-force tools). If you are ever locked out by this, add define( \'ITPS_SHIELD_DISABLE_JS_CHECK\', true ); to wp-config.php.' ),
'strong_passwords' => array( 'check', 'Require strong passwords when users set or reset them.' ),
'idle_logout' => array( 'number', 'Log users out after this many minutes of inactivity (0 = off).', array( 0, 1440 ) ),
),
'Spam protection' => array(
'disable_comments' => array( 'check', 'Disable comments everywhere: close and hide all comments and pingbacks, remove the Comments menu, comment blocks, comment feeds and the comments REST API.' ),
'comment_spam' => array( 'check', 'Block comment spam (honeypot, real-browser check, minimum fill time). Used when comments are enabled.' ),
'max_comment_links' => array( 'number', 'Comments with more links than this go straight to spam.', array( 0, 20 ) ),
'registration_spam' => array( 'check', 'Block spam sign-ups on the WordPress and WooCommerce registration forms.' ),
'block_disposable_email' => array( 'check', 'Refuse sign-ups from temporary / disposable email addresses (Mailinator, 10MinuteMail, YOPmail…).' ),
'form_spam' => array( 'check', 'Block contact-form spam in Contact Form 7, WPForms, Gravity Forms, Elementor Pro, Fluent Forms, Formidable and Ninja Forms. No CAPTCHA needed.' ),
'spam_min_seconds' => array( 'number', 'Minimum seconds between opening a form and sending it (bots are instant).', array( 0, 30 ) ),
),
'Account alerts' => array(
'alert_new_admin' => array( 'check', 'Email the site admin whenever any account becomes an Administrator.' ),
'alert_admin_new_ip' => array( 'check', 'Email administrators when they log in from an IP address not seen before.' ),
'lock_default_role' => array( 'check', 'Never allow "Administrator" as the default role for new registrations.' ),
),
'Firewall' => array(
'firewall' => array( 'check', 'Block requests carrying SQL injection, XSS, path traversal, code injection and sensitive-file probes.' ),
'block_bad_bots' => array( 'check', 'Block known vulnerability-scanner user agents.' ),
'ip_whitelist' => array( 'textarea', 'Trusted IPs, one per line: single IPv4 / IPv6 addresses or IPv4 ranges such as 203.0.113.0/24 (prefix 1 to 32). Never locked out or firewalled, and can reach /wp-login.php directly.' ),
'trust_proxy' => array( 'check', 'Site is behind Cloudflare / a load balancer: read client IP and HTTPS status from proxy headers. Leave OFF otherwise — the headers can be forged.' ),
),
'Hide WordPress' => array(
'hide_version' => array( 'check', 'Hide the WordPress version: remove the generator tag and replace ?ver=x.y.z on every script and stylesheet (core, theme and plugins) with a hash.' ),
'hide_wp_fingerprints' => array( 'check', 'Remove WordPress tell-tales: RSD/WLW/shortlink/REST/oEmbed links, emoji script, WordPress.org DNS prefetch, X-Redirect-By and Link headers, and the plugin list in /wp-json/.' ),
'protect_wp_content' => array( 'check', 'Block plugin/theme readme.txt and changelog files (they reveal exact plugin versions), logs, backups and directory listings in wp-content (.htaccess).' ),
'branded_login' => array( 'check', 'Show your site icon and name on the login page instead of the WordPress logo and link.' ),
'branded_block_page' => array( 'check', 'Show a "Protected by ITPS Shield" page to anyone who tries /wp-admin/ or /wp-login.php, or is blocked by the firewall (instead of a plain "Not Found" error). The page includes a small "Security by IT Pro Services" credit link — leave this off if you don\'t want it.' ),
'block_message' => array( 'text', 'Headline on the protection page.' ),
),
'Attack surface' => array(
'block_installs' => array( 'check', 'Block installing or uploading new plugins and themes from the dashboard (updates still work). Turn off temporarily when you need to add one.' ),
'session_hours' => array( 'number', 'Maximum login session length in hours (0 = WordPress default of 2 days / 14 days with "Remember me").', array( 0, 720 ) ),
'disable_xmlrpc' => array( 'check', 'Disable XML-RPC and pingbacks completely (breaks the legacy Jetpack/mobile-app XML-RPC path).' ),
'block_user_enum' => array( 'check', 'Block username discovery (?author=N, REST /users, oEmbed, user sitemap).' ),
'disable_file_edit' => array( 'check', 'Disable the built-in theme/plugin file editor.' ),
'disable_app_passwords' => array( 'check', 'Disable Application Passwords (only if no integrations use them).' ),
'restrict_rest' => array( 'check', 'Restrict the REST API to logged-in users, except the namespaces listed below. Test forms, shop and plugins after enabling.' ),
'rest_allow' => array( 'textarea', 'Public REST namespaces (one per line), e.g. oembed/1.0, contact-form-7/v1, wc/store.' ),
),
'Headers & server' => array(
'security_headers' => array( 'check', 'Send security headers (nosniff, frame protection, referrer & permissions policy).' ),
'block_uploads_php' => array( 'check', 'Block PHP execution in the uploads folder (.htaccess — Apache/LiteSpeed).' ),
'harden_root_htaccess' => array( 'check', 'Harden root .htaccess: deny wp-config, readme.html, license.txt, wp-config-sample.php, logs, .env; disable directory listing. Tested when saved and switched back off if the server rejects it.' ),
),
);
}
public function render_page() {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'settings'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Tab navigation only.
$tabs = array(
'settings' => 'Settings',
'audit' => 'Security Audit',
'lockouts' => 'Locked IPs',
'log' => 'Activity Log',
);
echo 'ITPS Shield v' . esc_html( self::VERSION ) . ' ';
foreach ( $tabs as $k => $label ) {
printf(
'%s ',
esc_url( admin_url( 'options-general.php?page=itps-shield&tab=' . $k ) ),
esc_attr( $tab === $k ? 'nav-tab-active' : '' ),
esc_html( $label )
);
}
echo ' ';
switch ( $tab ) {
case 'audit':
$this->render_audit();
break;
case 'lockouts':
$this->render_lockouts();
break;
case 'log':
$this->render_log();
break;
default:
$this->render_settings();
}
echo '
';
}
private function render_settings() {
// Settings errors are printed by core (wp-admin/options-head.php) on Settings sub-pages.
if ( '' !== $this->login_slug() ) {
printf(
'Your login address: %1$s — bookmark it.
',
esc_url( $this->login_base_url() )
);
} elseif ( defined( 'ITPS_SHIELD_DISABLE_LOGIN_URL' ) && ITPS_SHIELD_DISABLE_LOGIN_URL && '' !== $this->o['login_slug'] ) {
echo 'The custom login address is switched off by ITPS_SHIELD_DISABLE_LOGIN_URL in wp-config.php.
';
}
echo '';
}
private function audit() {
global $wpdb, $is_nginx;
$r = array();
$core = get_site_transient( 'update_core' );
$core_outdated = false;
if ( $core && ! empty( $core->updates ) ) {
foreach ( $core->updates as $u ) {
if ( isset( $u->response ) && 'upgrade' === $u->response ) {
$core_outdated = true;
}
}
}
$r[] = array( 'WordPress core up to date', $core_outdated ? 'bad' : 'good', $core_outdated ? 'An update is available — apply it in Dashboard → Updates.' : 'No core update pending.' );
$pu = get_site_transient( 'update_plugins' );
$n = ( $pu && ! empty( $pu->response ) ) ? count( $pu->response ) : 0;
$r[] = array( 'Plugins up to date', $n ? 'bad' : 'good', $n ? "$n plugin update(s) pending. Outdated plugins are the #1 cause of WordPress compromises." : 'All plugins current.' );
$tu = get_site_transient( 'update_themes' );
$n = ( $tu && ! empty( $tu->response ) ) ? count( $tu->response ) : 0;
$r[] = array( 'Themes up to date', $n ? 'warn' : 'good', $n ? "$n theme update(s) pending." : 'All themes current.' );
$all = function_exists( 'get_plugins' ) ? get_plugins() : array();
$active = (array) get_option( 'active_plugins', array() );
$inactive = max( 0, count( $all ) - count( $active ) );
$r[] = array( 'No inactive plugins', $inactive ? 'warn' : 'good', $inactive ? "$inactive inactive plugin(s). Delete what you don't use — inactive code can still be exploited." : 'None.' );
$https = is_ssl() || 0 === strpos( home_url(), 'https://' );
$r[] = array( 'HTTPS', $https ? 'good' : 'bad', 'Site URL: ' . home_url() );
$r[] = array( 'HTTPS enforced', $this->o['force_ssl'] ? 'good' : ( $https ? 'warn' : 'bad' ), $this->o['force_ssl'] ? 'All http:// requests are redirected to https://.' : 'Enable "Force HTTPS" in Settings so visitors and logins never use plain HTTP.' );
$r[] = array( 'HSTS', $this->o['hsts'] ? 'good' : 'warn', $this->o['hsts'] ? 'Browsers remember to use HTTPS only.' : 'Optional: enable once everything works over HTTPS.' );
$r[] = array( 'Login address hidden', '' !== $this->login_slug() ? 'good' : 'warn', '' !== $this->login_slug() ? 'Login is at ' . $this->login_base_url() : 'Bots hammer /wp-login.php all day. Set a custom login address in Settings.' );
$r[] = array( 'PHP version', version_compare( PHP_VERSION, '8.1', '>=' ) ? 'good' : 'warn', 'Running PHP ' . PHP_VERSION . ' (8.1+ recommended for security support).' );
$debug_display = defined( 'WP_DEBUG' ) && WP_DEBUG && ( ! defined( 'WP_DEBUG_DISPLAY' ) || WP_DEBUG_DISPLAY );
$r[] = array( 'Errors not shown to visitors', $debug_display ? 'bad' : 'good', $debug_display ? 'WP_DEBUG is on and displaying errors. Set WP_DEBUG to false in wp-config.php.' : 'OK.' );
$r[] = array( 'No "admin" username', username_exists( 'admin' ) ? 'warn' : 'good', username_exists( 'admin' ) ? 'A user named "admin" exists — it is the first name attackers try. Create a new admin and delete it.' : 'OK.' );
$admins = count( get_users( array( 'role' => 'administrator', 'fields' => 'ID' ) ) );
$r[] = array( 'Few administrators', $admins > 3 ? 'warn' : 'good', "$admins administrator account(s). Give people the lowest role they need." );
$r[] = array( 'Non-default table prefix', 'wp_' === $wpdb->prefix ? 'warn' : 'good', 'Prefix: ' . $wpdb->prefix . ' (minor — only matters against some automated SQLi).' );
$cfg = $this->config_path();
if ( $cfg ) {
clearstatcache();
$perm = fileperms( $cfg ) & 0777;
$r[] = array( 'wp-config.php permissions', ( $perm & 0007 ) ? 'bad' : 'good', ( $perm & 0007 ) ? sprintf( 'Mode %o: other accounts on the server can read it. Set it to 640 (or 600) in your hosting file manager, or over SSH with: chmod 640 wp-config.php', $perm ) : sprintf( 'Mode %o.', $perm ) );
}
$r[] = array( 'File editor disabled', ( $this->o['disable_file_edit'] || ( defined( 'DISALLOW_FILE_EDIT' ) && DISALLOW_FILE_EDIT ) ) ? 'good' : 'bad', 'Prevents a stolen admin session from writing PHP.' );
$r[] = array( 'Security keys set', ( defined( 'AUTH_KEY' ) && strlen( AUTH_KEY ) > 32 && false === strpos( AUTH_KEY, 'put your unique phrase' ) ) ? 'good' : 'bad', 'Salts in wp-config.php should be unique random values.' );
$exposed = array();
foreach ( self::version_files() as $f ) {
if ( file_exists( ABSPATH . $f ) ) {
$exposed[] = $f;
}
}
$blocked = $exposed && $this->o['harden_root_htaccess'] && ! get_option( self::PENDING ) && empty( $is_nginx );
if ( ! $exposed ) {
$r[] = array( 'Version-revealing files', 'good', 'OK.' );
} elseif ( $blocked ) {
$r[] = array( 'Version-revealing files', 'good', 'Present (' . implode( ', ', $exposed ) . ') but blocked by the root .htaccess rules.' );
} else {
$r[] = array( 'Version-revealing files', 'warn', 'Present: ' . implode( ', ', $exposed ) . '. They tell attackers your WordPress version. Turn on "Harden root .htaccess" to block them, or delete them with your hosting file manager or FTP (WordPress puts them back after every update).' );
}
$pending = (bool) get_option( self::PENDING );
if ( $this->o['block_uploads_php'] && ! $pending ) {
$r[] = array( 'PHP blocked in uploads', 'good', 'PHP files uploaded to wp-content/uploads cannot run (Apache / LiteSpeed).' );
} elseif ( $this->o['block_uploads_php'] ) {
$r[] = array( 'PHP blocked in uploads', 'warn', 'Switched on, but the .htaccess rule is not installed yet (see "Server rules installed").' );
} else {
$r[] = array( 'PHP blocked in uploads', 'warn', 'Turn on "Block PHP execution in the uploads folder" so an uploaded PHP file can never run.' );
}
$waiting = self::server_rule_labels( $this->o );
if ( $pending && $waiting ) {
$r[] = array( 'Server rules installed', 'warn', 'Switched on but not installed yet: ' . implode( ', ', $waiting ) . '. Installing tests each .htaccess file with a real request and removes it again if the server rejects it.' );
}
$hidden = $this->o['hide_version'] && $this->o['hide_wp_fingerprints'] && $this->o['protect_wp_content'] && ! get_option( self::PENDING );
$r[] = array( 'WordPress fingerprints hidden', $hidden ? 'good' : 'warn', $hidden ? 'Version numbers, WordPress links/headers, plugin readmes and the REST plugin list are hidden.' : 'Scanners can read your WordPress and plugin versions. Turn on the "Hide WordPress" options.' );
$spam_on = $this->o['comment_spam'] && $this->o['registration_spam'] && $this->o['form_spam'];
$r[] = array( 'Spam protection', $spam_on ? 'good' : 'warn', ( $spam_on ? 'Comments, sign-ups and contact forms are protected.' : 'Turn on comment, sign-up and contact-form spam protection.' ) . sprintf( ' Spam blocked so far: %d.', (int) get_option( 'itps_shield_spam_count', 0 ) ) . ( $this->o['disable_comments'] ? ' Comments are disabled site-wide.' : '' ) );
$role = get_option( 'default_role' );
$r[] = array( 'Registration', get_option( 'users_can_register' ) && 'administrator' === $role ? 'bad' : ( get_option( 'users_can_register' ) ? 'warn' : 'good' ), get_option( 'users_can_register' ) ? 'Anyone can register (default role: ' . $role . ').' : 'Open registration is off.' );
$scan = get_option( self::SCAN );
if ( is_array( $scan ) ) {
$bad = count( $scan['modified'] ) + count( $scan['unknown'] );
$r[] = array( 'Core file integrity', $scan['error'] ? 'warn' : ( $bad ? 'bad' : 'good' ), $scan['error'] ? $scan['error'] : sprintf( 'Last scan %s: %d files checked, %d modified, %d missing, %d unknown PHP files.', wp_date( 'Y-m-d H:i', (int) $scan['t'] ), (int) $scan['checked'], count( $scan['modified'] ), count( $scan['missing'] ), count( $scan['unknown'] ) ) );
} else {
$r[] = array( 'Core file integrity', 'warn', 'Not scanned yet — run the scan below.' );
}
if ( ! empty( $is_nginx ) ) {
$r[] = array( 'Nginx detected', 'warn', 'Nginx ignores .htaccess. Add the server rules shown below to your site config.' );
}
return $r;
}
private function render_audit() {
global $is_nginx;
$colors = array(
'good' => '#00a32a',
'warn' => '#dba617',
'bad' => '#d63638',
);
$labels = array(
'good' => 'Pass',
'warn' => 'Review',
'bad' => 'Fix',
);
if ( isset( $_GET['scanned'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display-only flag for a notice.
echo '';
}
$key = 'itps_fix_msgs_' . get_current_user_id();
$msgs = get_transient( $key );
if ( is_array( $msgs ) ) {
delete_transient( $key );
foreach ( $msgs as $m ) {
printf( '', 'error' === $m[0] ? 'error' : 'success', esc_html( $m[1] ) );
}
}
$rows = $this->audit();
$score = 0;
foreach ( $rows as $row ) {
$score += 'good' === $row[1] ? 2 : ( 'warn' === $row[1] ? 1 : 0 );
}
$pct = $rows ? (int) round( 100 * $score / ( 2 * count( $rows ) ) ) : 0;
$col = $pct >= 90 ? '#00a32a' : ( $pct >= 70 ? '#dba617' : '#d63638' );
echo '';
echo '
' . (int) $pct . '%
';
echo '
One-click harden Turns on every recommended protection and installs its .htaccess rules. Force HTTPS is only switched on if the certificate works, and each .htaccess file is tested with a real request and removed again if the server rejects it. It does not change your login address, HSTS, REST restriction or root .htaccess, and never edits or deletes WordPress files, plugins or users.
';
echo '
' . wp_kses( $this->fix_button( 'all', 'Harden now', true ), self::allowed_action_html() ) . '
';
echo 'Status Check Details ';
foreach ( $rows as $row ) {
printf(
'%s %s %s %s ',
esc_attr( $colors[ $row[1] ] ),
esc_html( $labels[ $row[1] ] ),
esc_html( $row[0] ),
esc_html( $row[2] ),
wp_kses( $this->audit_action( $row[0], $row[1] ), self::allowed_action_html() )
);
}
echo '
';
echo 'Core file integrity scan Compares every WordPress core file with the official checksums from WordPress.org and lists PHP files in wp-admin / wp-includes that WordPress does not ship (a common sign of a hack).
';
echo '';
wp_nonce_field( 'itps_core_scan' );
echo ' ';
submit_button( 'Run core file scan', 'primary', 'submit', false );
echo ' ';
$scan = get_option( self::SCAN );
if ( is_array( $scan ) && ! $scan['error'] ) {
$groups = array(
'modified' => 'Modified core files',
'unknown' => 'Unknown PHP files in wp-admin / wp-includes',
'missing' => 'Missing core files',
);
foreach ( $groups as $k => $title ) {
if ( empty( $scan[ $k ] ) ) {
continue;
}
echo '' . esc_html( $title ) . ' (' . count( $scan[ $k ] ) . ') ';
foreach ( $scan[ $k ] as $f ) {
echo '' . esc_html( $f ) . ' ';
}
echo ' ';
}
if ( ! empty( $scan['modified'] ) || ! empty( $scan['unknown'] ) ) {
echo 'What to do: reinstall WordPress from Dashboard → Updates → "Re-install version", then delete any unknown PHP files you did not add, change all passwords and the salts in wp-config.php.
';
}
}
echo 'Things a plugin cannot do for you ';
echo 'Enable two-factor authentication for every administrator (e.g. the "Two Factor" plugin by the WordPress.org core team). ';
echo 'Keep off-site, automated, tested backups. ';
echo 'Put the site behind a network WAF / CDN (e.g. Cloudflare) and enable "Trust proxy" above. ';
echo 'Use SFTP/SSH only, unique strong hosting/DB passwords, and keep PHP supported. ';
echo ' ';
if ( ! empty( $is_nginx ) ) {
echo 'Nginx rules '
. esc_html(
"location ~* /wp-content/uploads/.*\\.(php[0-9]*|phtml|phar|pht)$ { deny all; }\n"
. "location ~* /(wp-config\\.php|readme\\.html|license\\.txt|\\.env|\\.git) { deny all; }\n"
. "location = /xmlrpc.php { deny all; }\n"
. 'autoindex off;'
)
. ' ';
}
}
private function render_lockouts() {
$locks = $this->lockouts();
if ( isset( $_GET['unlocked'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display-only flag for a notice.
echo '';
}
if ( ! $locks ) {
echo 'No IPs are currently locked out.
';
return;
}
echo 'IP Last username tried Locked until ';
foreach ( $locks as $ip => $row ) {
echo '' . esc_html( $ip ) . '' . esc_html( $row['user'] ) . ' '
. esc_html( wp_date( 'Y-m-d H:i', (int) $row['until'] ) ) . ' ';
echo '';
wp_nonce_field( 'itps_unlock' );
echo ' ';
submit_button( 'Unlock', 'small', 'submit', false );
echo ' ';
}
echo '
';
wp_nonce_field( 'itps_unlock' );
echo ' ';
submit_button( 'Unlock all', 'secondary', 'submit', false );
echo ' ';
}
private function render_log() {
$log = get_option( self::LOG, array() );
if ( ! $log ) {
echo 'No security events recorded yet.
';
return;
}
echo '';
wp_nonce_field( 'itps_clear_log' );
echo ' ';
submit_button( 'Clear log', 'secondary', 'submit', false );
echo ' Time Event IP Detail ';
foreach ( (array) $log as $e ) {
printf(
'%s %s %s%s ',
esc_html( wp_date( 'Y-m-d H:i:s', (int) $e['t'] ) ),
esc_html( $e['type'] ),
esc_html( $e['ip'] ),
esc_html( $e['detail'] )
);
}
echo '
';
}
}
register_activation_hook( __FILE__, array( 'ITPS_Shield', 'activate' ) );
register_deactivation_hook( __FILE__, array( 'ITPS_Shield', 'deactivate' ) );
ITPS_Shield::instance();