log = new easy_basic_authentication_log_class(); $this->email = new easy_basic_authentication_emailalert_class(); $this->form = new easy_basic_authentication_form_class(); $notice = new easy_basic_authentication_notice_class(); $this->compatcheck = new easy_basic_authentication_compatcheck_class(); $this->compatcheck->register_hooks(); // Fuori da una richiesta HTTP la sfida 401 non ha alcun senso: da riga di comando // diventa un exit() silenzioso che blocca WP-CLI, il cron di sistema e qualunque // script che faccia require di wp-load.php. if ( ! self::is_cli() ) { $pagenow = isset( $GLOBALS['pagenow'] ) ? $GLOBALS['pagenow'] : ''; if(get_option( 'basic_auth_plugin_admin_enable' )) { if (in_array($pagenow, array('wp-login.php', 'wp-register.php'))) { add_action( 'init', array($this,'basic_auth_root') ); } } if(get_option( 'basic_auth_plugin_enable' ) && get_option( 'basic_auth_plugin_admin_enable' )){ add_action( 'init', array($this,'basic_auth_root') ); } } add_action( 'init', array($this, 'maybe_upgrade'), 1 ); add_action( 'admin_menu', array($this,'basic_auth_plugin_menu' )); add_action( 'admin_init', array($this->form,'basic_auth_plugin_settings_init' )); add_action('admin_init', function () { if ( ! current_user_can( 'manage_options' ) ) { return; } // Il nonce e verificato dentro basic_auth_plugin_save_settings(), che riceve // i dati grezzi perche deve distinguere un campo assente da uno vuoto (le // checkbox non spuntate non vengono inviate). // phpcs:ignore WordPress.Security.NonceVerification.Missing $post_data = $_POST; $this->form->basic_auth_plugin_save_settings($post_data); }); } /** * Se stiamo girando fuori da una richiesta HTTP (WP-CLI, cron di sistema, script). * * @return bool */ public static function is_cli() { if ( defined( 'WP_CLI' ) && WP_CLI ) { return true; } return 'cli' === PHP_SAPI || 'phpdbg' === PHP_SAPI; } public function basic_auth_root() { if ( self::is_cli() ) { return; } // wp-cron.php chiude la risposta con fastcgi_finish_request() prima ancora di // caricare WordPress: quando arriviamo qui gli header sono gia partiti. La sfida // 401 non raggiunge nessuno, lascia un warning "headers already sent" nel log a // ogni giro, e l'exit che la segue ferma tutte le azioni pianificate del sito. if ( defined( 'DOING_CRON' ) && DOING_CRON ) { return; } $user = get_option('basic_auth_plugin_username'); $pass = get_option('basic_auth_plugin_password'); if ($this->whiteListChecker()) { return; } if ($this->urlWhiteListChecker()) { return; } if ($this->tokenChecker()) { return; } // Alcuni server (nginx + FastCGI su tutti) non popolano PHP_AUTH_USER da soli: // le credenziali arrivano solo nell'header Authorization e vanno estratte a mano. if (!isset($_SERVER['PHP_AUTH_USER']) && isset($_SERVER['HTTP_AUTHORIZATION'])) { $authorization = sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) ); if ( preg_match( '#^Basic\s+([A-Za-z0-9+/=]+)$#i', $authorization, $matches ) ) { $decoded = base64_decode( $matches[1], true ); // Senza i due punti non ci sono due campi: la list() originale // generava un warning "Undefined array key 1" su PHP 8. if ( false !== $decoded && false !== strpos( $decoded, ':' ) ) { list( $sent_user, $sent_pass ) = explode( ':', $decoded, 2 ); $_SERVER['PHP_AUTH_USER'] = $sent_user; $_SERVER['PHP_AUTH_PW'] = $sent_pass; } } } // Basic Auth prevede sempre un primo giro senza credenziali: il browser chiede, // riceve 401, e solo allora rimanda con utente e password. Quel 401 non e un // tentativo fallito, e registrarlo significherebbe una voce di log (e una mail) // per ogni visitatore e ogni bot che passa. $credentials_sent = isset($_SERVER['PHP_AUTH_USER']) && isset($_SERVER['PHP_AUTH_PW']); if (!$credentials_sent) { $this->do_exit(true, false); return; } // L'utente e salvato passando per sanitize_text_field(), quindi quello in arrivo // va normalizzato allo stesso modo perche il confronto sia sensato. $sent_user = sanitize_text_field( wp_unslash( $_SERVER['PHP_AUTH_USER'] ) ); // La password invece NON va sanitizzata: alterarla farebbe fallire l'accesso a // chiunque ne usi una con caratteri speciali. Non viene mai stampata, solo // confrontata con l'hash. // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $sent_pass = wp_unslash( $_SERVER['PHP_AUTH_PW'] ); $credentials_valid = hash_equals( (string) $user, $sent_user ) && wp_check_password( $sent_pass, $pass ); if (!$credentials_valid) { // Credenziali inviate e sbagliate: questo si che va segnalato. $this->do_exit(true, true); } } public function urlWhiteListChecker() { if (empty($_SERVER['HTTP_HOST']) || empty($_SERVER['REQUEST_URI'])) { return false; } $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; $host = sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ); // L'URI serve solo al confronto con la whitelist, non viene mai stampato. // sanitize_text_field() qui sarebbe dannoso: rimuove le sequenze %xx e // cambierebbe il confronto su qualsiasi percorso con caratteri codificati. // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $request_uri = wp_unslash( $_SERVER['REQUEST_URI'] ); $currentUrl = $scheme . '://' . $host . $request_uri; $whitelist = $this->getUrlWhiteList(); foreach ($whitelist as $entry) { if ($this->isUrlAllowed($currentUrl, $entry)) { return true; } } return false; } private function isUrlAllowed($currentUrl, $entry) { $currentUrl = rtrim($currentUrl, '/'); $entry = rtrim($entry, '/'); if (strpos($entry, '/') === 0) { $path = wp_parse_url($currentUrl, PHP_URL_PATH); return stripos($path, $entry) === 0; } if (!preg_match('#^https?://#i', $entry)) { $scheme = wp_parse_url($currentUrl, PHP_URL_SCHEME) ?: 'https'; $entry = $scheme . '://' . $entry; } return stripos($currentUrl, $entry) === 0; } public function whiteListChecker() { if (!isset($_SERVER['REMOTE_ADDR'])) { return false; } // Un REMOTE_ADDR malformato non deve arrivare ai confronti della whitelist: // ip2long() restituirebbe false e il risultato sarebbe imprevedibile. $ip = filter_var( wp_unslash( $_SERVER['REMOTE_ADDR'] ), FILTER_VALIDATE_IP ); if ( false === $ip ) { return false; } $whitelist = $this->getWhiteList(); foreach ($whitelist as $entry) { if ($this->isIpAllowed($ip, $entry)) { return true; } } return false; } private function isIpAllowed($ip, $entry) { if (filter_var($entry, FILTER_VALIDATE_IP)) { return $ip === $entry; } elseif (strpos($entry, '/') !== false) { return $this->isIpInCidr($ip, $entry); } elseif (strpos($entry, '-') !== false) { return $this->isIpInRange($ip, $entry); } return false; } private function isIpInCidr($ip, $cidr) { list($subnet, $mask) = explode('/', $cidr); $ipLong = ip2long($ip); $subnetLong = ip2long($subnet); $maskLong = -1 << (32 - $mask); return ($ipLong & $maskLong) === ($subnetLong & $maskLong); } private function isIpInRange($ip, $range) { list($start, $end) = array_map('trim', explode('-', $range)); $ipLong = ip2long($ip); $startLong = ip2long($start); $endLong = ip2long($end); return ($ipLong >= $startLong && $ipLong <= $endLong); } /** * Un crawler o un servizio esterno sta passando col token d'accesso? * * Serve a far entrare chi non puo usare l'autenticazione HTTP: un crawler, un * controllo di raggiungibilita, un front-end headless. Il token vale solo per * il sito: l'amministrazione e la pagina di accesso restano protette, perche * un crawler non ha motivo di entrarci e aprirgliele sarebbe rischio gratuito. * * @return bool */ public function tokenChecker() { $token = (string) get_option('basic_auth_plugin_access_token', ''); // Funzione spenta finche non si genera un token. if ('' === $token) { return false; } // Mai per l'amministrazione o la pagina di accesso. if (is_admin() || $this->isLoginRequest()) { return false; } $sent = $this->getRequestToken(); if ('' === $sent) { return false; } // hash_equals: il confronto non deve rivelare quanti caratteri erano giusti. if (!hash_equals($token, $sent)) { // Un token sbagliato e un tentativo deliberato, non il primo giro a vuoto // del protocollo: va segnalato come una password sbagliata. $this->basic_auth_action_failed_access(); return false; } /** * Scatta quando una richiesta entra col token invece che con le credenziali. */ do_action('basic_auth_token_access_granted'); return true; } /** * Il token inviato con la richiesta, da uno dei due header accettati. * * @return string */ private function getRequestToken() { // Header dedicato: la strada piu pulita. if (!empty($_SERVER['HTTP_X_BASIC_AUTH_TOKEN'])) { return trim(sanitize_text_field(wp_unslash($_SERVER['HTTP_X_BASIC_AUTH_TOKEN']))); } // Authorization: Bearer, per i servizi che sanno mandare solo questo. if (!empty($_SERVER['HTTP_AUTHORIZATION'])) { $header = sanitize_text_field(wp_unslash($_SERVER['HTTP_AUTHORIZATION'])); if (preg_match('#^Bearer\s+(\S+)$#i', $header, $matches)) { return $matches[1]; } } return ''; } /** * Se la richiesta corrente e la pagina di accesso. * * @return bool */ private function isLoginRequest() { $pagenow = isset($GLOBALS['pagenow']) ? $GLOBALS['pagenow'] : ''; if (in_array($pagenow, array('wp-login.php', 'wp-register.php'), true)) { return true; } if (empty($_SERVER['REQUEST_URI'])) { return false; } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- solo confronto, mai stampato. $uri = wp_unslash($_SERVER['REQUEST_URI']); return false !== stripos($uri, 'wp-login.php') || false !== stripos($uri, 'wp-register.php'); } /** * Manda la sfida 401. * * @param bool $admin_area Se la richiesta riguarda l'area di amministrazione. * @param bool $log_attempt Se registrare l'accesso fra i tentativi falliti. * False per il 401 iniziale, che fa parte del protocollo. */ public function do_exit($admin_area = false, $log_attempt = true) { if ($log_attempt) { $this->basic_auth_action_failed_access(); } do_action('basic_auth_before_401'); if ($admin_area) { do_action('basic_auth_before_401_admin_area'); } // I browser memorizzano le credenziali per realm: cambiare questa stringa fa // ricomparire la richiesta di accesso a chi era gia autenticato. Il valore // predefinito resta quello storico; chi vuole cambiarlo usa il filtro. $realm = apply_filters('basic_auth_realm', 'My Website', $admin_area); $realm = str_replace(array('"', "\r", "\n"), '', (string) $realm); header('WWW-Authenticate: Basic realm="' . $realm . '"'); status_header(401); exit; } public function getWhiteList() { return get_option( 'basic_auth_plugin_whitelist' )?explode(',',get_option( 'basic_auth_plugin_whitelist' )):[]; } public function getUrlWhiteList() { return get_option( 'basic_auth_plugin_urlwhitelist' )?explode(',',get_option( 'basic_auth_plugin_urlwhitelist' )):[]; } public function basic_auth_plugin_menu() { add_menu_page( __('Configurations for Easy Basic Authentication', 'easy-basic-authentication'), __('Easy Basic A.', 'easy-basic-authentication'), 'manage_options', 'basic-auth-plugin', array($this->form, 'basic_auth_plugin_settings_page'), 'dashicons-lock' ); if($this->log->is_enabled()) { $this->log->getMenu(); } } /** * Migrazioni una tantum, eseguite quando cambia la versione del plugin. * * Fino alla 4.0.0 il log degli accessi stava in un'opzione in autoload: fino a * 500 voci, circa 144 KB, deserializzate a ogni richiesta del sito. */ public function maybe_upgrade() { if ( get_option( 'basic_auth_plugin_db_version' ) === EASY_BASIC_AUTHENTICATION_VERSION ) { return; } $logs = get_option( 'basic_auth_failure_logs', null ); if ( null !== $logs ) { update_option( 'basic_auth_failure_logs', $logs, false ); } update_option( 'basic_auth_plugin_db_version', EASY_BASIC_AUTHENTICATION_VERSION, false ); } public function basic_auth_action_failed_access() { if($this->log->is_enabled()) { $this->log->update_status($_SERVER); } if($this->email->is_enabled()) { $this->email->sendAlert($_SERVER); } } }