Files
InformatiQ-Toolkit/includes/class-itk-protection.php

307 lines
12 KiB
PHP
Raw Normal View History

<?php
if (!defined('ABSPATH')) exit;
/**
* ITK Protection
*
* Handles wp-login protection, security headers, sensitive file blocking,
* malicious query blocking, and custom login URL.
*
* Deactivation bug fix: every method checks its own option key before acting.
* Hooks are always registered; guards live inside the callbacks.
*/
class ITK_Protection {
private array $allowed_ips = [];
private string $allowed_ips_file;
public function __construct() {
$this->allowed_ips_file = ITK_PATH . 'config/allowed-ips.conf';
$this->load_allowed_ips();
add_action('init', [$this, 'protect_wp_login'], 0);
add_action('init', [$this, 'block_sensitive_files'], 0);
add_action('init', [$this, 'block_malicious_queries'], 0);
add_action('init', [$this, 'block_author_scans'], 0);
add_action('init', [$this, 'custom_login_url'], 0);
add_action('send_headers', [$this, 'add_security_headers']);
add_action('wp_loaded', [$this, 'wp_loaded_custom_login']);
add_filter('the_generator', '__return_empty_string');
add_filter('wp_redirect', [$this, 'redirect_filter'], 10, 2);
add_filter('network_site_url', [$this, 'network_url_filter'], 10, 3);
add_filter('site_url', [$this, 'site_url_filter'], 10, 4);
add_filter('wp_handle_upload_prefilter', [$this, 'filter_uploaded_files']);
}
/* ── wp-login protection ──────────────────────────────────── */
public function protect_wp_login(): void {
$options = get_option('itk_security', []);
if (empty($options['protect_wp_login'])) return;
$uri = $_SERVER['REQUEST_URI'] ?? '';
if (strpos($uri, 'wp-login.php') === false) return;
$ip = $this->get_client_ip();
$is_allowed = false;
foreach ($this->allowed_ips as $allowed) {
if ($ip === $allowed) { $is_allowed = true; break; }
if (strpos($allowed, '/') !== false && $this->ip_in_cidr($ip, $allowed)) {
$is_allowed = true; break;
}
}
if (!$is_allowed) {
$this->send_403($options, 'Access to login page not allowed from your IP address.');
}
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
$protocol = $_SERVER['SERVER_PROTOCOL'] ?? '';
if (empty($ua) || $protocol === 'HTTP/1.0') {
$this->send_403($options, 'Invalid request detected.');
}
}
/* ── Security headers ─────────────────────────────────────── */
public function add_security_headers(): void {
$options = get_option('itk_security', []);
if (empty($options['add_security_headers'])) return;
if (headers_sent()) return;
header_remove('X-Powered-By');
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: SAMEORIGIN');
header('X-XSS-Protection: 1; mode=block');
header('Referrer-Policy: strict-origin-when-cross-origin');
}
/* ── Sensitive file blocking ──────────────────────────────── */
public function block_sensitive_files(): void {
$options = get_option('itk_security', []);
$uri = $_SERVER['REQUEST_URI'] ?? '';
if (!empty($options['protect_wp_includes'])) {
if (preg_match('#^/wp-includes/[^/]+\.php$#i', $uri)) {
$this->send_403($options, 'Access to this file is not allowed.');
}
if (preg_match('#^/wp-admin/includes/#i', $uri)) {
$this->send_403($options, 'Access to this file is not allowed.');
}
if (preg_match('#^/wp-includes/theme-compat/#i', $uri)) {
$this->send_403($options, 'Access to this file is not allowed.');
}
if (preg_match('#/wp-includes/js/tinymce/langs/.+\.php#i', $uri)) {
$this->send_403($options, 'Access to this file is not allowed.');
}
if (preg_match('#(license\.txt|wp-config-sample\.php|readme\.html)$#i', $uri)) {
$this->send_403($options, 'Access to this file is not allowed.');
}
if (preg_match('#(?:^|/)\.(?!well-known)#', $uri)) {
$this->send_403($options, 'Access to hidden files is not allowed.');
}
}
if (!empty($options['protect_uploads'])) {
if (preg_match('#^/wp-content/uploads/.*\.(?:php[1-6]?|pht|phtml?)$#i', $uri)) {
$this->send_403($options, 'PHP files are not allowed in the uploads directory.');
}
}
if (!empty($options['block_xmlrpc'])) {
if (strpos($uri, 'xmlrpc.php') !== false) {
$this->send_403($options, 'XML-RPC is disabled on this site.');
}
}
}
/* ── Malicious query blocking ─────────────────────────────── */
public function block_malicious_queries(): void {
$options = get_option('itk_security', []);
if (empty($options['block_malicious_queries'])) return;
$qs = $_SERVER['QUERY_STRING'] ?? '';
if (empty($qs)) return;
$patterns = [
'(eval\()',
'(127\.0\.0\.1)',
'([a-z0-9]{2000})',
'(javascript:)(.*)(;)',
'(base64_encode)(.*)(\()',
'(GLOBALS|REQUEST)(=|\[|%)',
'(<|%3C)(.*)script(.*)(>|%3)',
'(boot\.ini|etc/passwd|self/environ)',
'(thumbs?(_editor|open)?|tim(thumb)?)\.php',
'(\'|\\")(.*)(drop|insert|md5|select|union)',
];
foreach ($patterns as $pattern) {
if (preg_match('#' . $pattern . '#i', $qs)) {
$this->send_403($options, 'Malicious query detected.');
}
}
$method = strtolower($_SERVER['REQUEST_METHOD'] ?? '');
if (preg_match('#^(connect|debug|delete|move|put|trace|track)$#', $method)) {
$this->send_403($options, 'This request method is not allowed.');
}
}
/* ── Author scan blocking ─────────────────────────────────── */
public function block_author_scans(): void {
$options = get_option('itk_security', []);
if (empty($options['block_author_scans'])) return;
$uri = $_SERVER['REQUEST_URI'] ?? '';
$qs = $_SERVER['QUERY_STRING'] ?? '';
if (strpos($uri, '/wp-admin') === false && preg_match('/author=\d+/i', $qs)) {
wp_redirect(home_url(), 301);
exit;
}
}
/* ── Custom login URL ─────────────────────────────────────── */
public function custom_login_url(): void {
$options = get_option('itk_security', []);
if (empty($options['enable_custom_login'])) return;
$slug = $this->custom_slug($options);
$path = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?? '';
$qs = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_QUERY) ?? '';
if (strpos($path, '/' . $slug) !== false) {
if (!session_id()) session_start();
$_SESSION['itk_login_access'] = time();
require_once ABSPATH . 'wp-login.php';
exit;
}
$blocked = ['/wp-login.php', '/wp-admin/', '/login/', '/admin/'];
foreach ($blocked as $b) {
if (strpos($path, $b) !== false) {
if (defined('DOING_AJAX') && DOING_AJAX) return;
if (!session_id()) session_start();
if (isset($_SESSION['itk_login_access']) && (time() - $_SESSION['itk_login_access']) < 300) return;
if (is_user_logged_in()) return;
$this->send_403($options, 'Access denied. Please use the correct login URL.');
}
}
}
public function wp_loaded_custom_login(): void {
$options = get_option('itk_security', []);
if (empty($options['enable_custom_login'])) return;
global $pagenow;
if ($pagenow === 'wp-login.php') {
if (!session_id()) session_start();
if (!isset($_SESSION['itk_login_access'])) {
$this->send_403($options, 'Access denied. Please use the correct login URL.');
}
}
}
public function redirect_filter(string $location, int $status): string {
$options = get_option('itk_security', []);
if (empty($options['enable_custom_login'])) return $location;
return str_replace('wp-login.php', $this->custom_slug($options), $location);
}
public function network_url_filter(string $url, string $path): string {
$options = get_option('itk_security', []);
if (empty($options['enable_custom_login'])) return $url;
if (strpos($path, 'wp-login.php') !== false) {
return str_replace('wp-login.php', $this->custom_slug($options), $url);
}
return $url;
}
public function site_url_filter(string $url, string $path): string {
$options = get_option('itk_security', []);
if (empty($options['enable_custom_login'])) return $url;
if (strpos($path, 'wp-login.php') !== false) {
return str_replace('wp-login.php', $this->custom_slug($options), $url);
}
return $url;
}
/* ── File upload filter ───────────────────────────────────── */
public function filter_uploaded_files(array $file): array {
$options = get_option('itk_security', []);
if (empty($options['protect_uploads'])) return $file;
if (preg_match('/\.(php|phtml|php\d|pht|exe|dll|asp|aspx|jsp|cgi|pl)$/i', $file['name'] ?? '')) {
$file['error'] = 'PHP and executable files cannot be uploaded.';
}
return $file;
}
/* ── Helpers ──────────────────────────────────────────────── */
private function send_403(array $options, string $message): void {
$code = $options['response_code'] ?? '403';
$redir = $options['redirect_url'] ?? '';
if ($code === '301_custom' && !empty($redir)) {
header('Location: ' . esc_url_raw($redir), true, 301);
} else {
status_header(403);
echo esc_html($message);
}
exit;
}
private function custom_slug(array $options): string {
return !empty($options['custom_login_slug']) ? $options['custom_login_slug'] : 'thoushallpass';
}
private function load_allowed_ips(): void {
$defaults = ['127.0.0.1', '::1'];
if (file_exists($this->allowed_ips_file)) {
$lines = file($this->allowed_ips_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$line = trim($line);
if ($line !== '' && $line[0] !== '#') {
$this->allowed_ips[] = $line;
}
}
}
if (empty($this->allowed_ips)) {
$this->allowed_ips = $defaults;
}
}
private function get_client_ip(): string {
$keys = [
'HTTP_CLIENT_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED',
'HTTP_X_CLUSTER_CLIENT_IP', 'HTTP_FORWARDED_FOR', 'HTTP_FORWARDED',
'REMOTE_ADDR',
];
foreach ($keys as $key) {
if (empty($_SERVER[$key])) continue;
$ip = trim(explode(',', $_SERVER[$key])[0]);
if (filter_var($ip, FILTER_VALIDATE_IP)) return $ip;
}
return 'UNKNOWN';
}
private function ip_in_cidr(string $ip, string $cidr): bool {
[$subnet, $mask] = explode('/', $cidr, 2);
if (!filter_var($subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) return false;
if (!is_numeric($mask) || $mask < 0 || $mask > 32) return false;
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) return false;
$mask_dec = ~((1 << (32 - (int)$mask)) - 1);
return (ip2long($ip) & $mask_dec) === (ip2long($subnet) & $mask_dec);
}
public function get_allowed_ips(): array { return $this->allowed_ips; }
public function get_allowed_ips_file(): string { return $this->allowed_ips_file; }
}