From a29becc9442622ba162189b0db2451dd52797ee1 Mon Sep 17 00:00:00 2001 From: Hosteroid Date: Fri, 10 Oct 2025 00:04:12 +0300 Subject: [PATCH] Add CSRF, CAPTCHA, and input validation improvements Introduces CSRF protection to all sensitive controller actions, integrates configurable CAPTCHA (reCAPTCHA v2/v3, Turnstile) for authentication and registration flows, and centralizes input validation via a new InputValidator helper. Adds new helpers and services for CSRF and CAPTCHA, updates settings and migration for CAPTCHA configuration, and enhances logging and error handling in TLD registry import processes. Also improves validation for user, domain, group, and profile inputs throughout the application. --- app/Controllers/AuthController.php | 102 +++++++- app/Controllers/DomainController.php | 73 +++++- app/Controllers/InstallerController.php | 14 +- .../NotificationGroupController.php | 39 +++ app/Controllers/ProfileController.php | 25 ++ app/Controllers/SearchController.php | 4 +- app/Controllers/SettingsController.php | 93 +++++++ app/Controllers/TldRegistryController.php | 181 ++++++++++++-- app/Controllers/UserController.php | 32 ++- app/Helpers/CsrfHelper.php | 32 +++ app/Helpers/InputValidator.php | 207 ++++++++++++++++ app/Models/Setting.php | 55 +++++ app/Models/TldImportLog.php | 9 + app/Services/CaptchaService.php | 227 ++++++++++++++++++ app/Services/TldRegistryService.php | 86 ++++++- app/Views/auth/captcha-widget.php | 86 +++++++ app/Views/auth/forgot-password.php | 4 + app/Views/auth/login.php | 4 + app/Views/auth/register.php | 4 + app/Views/auth/reset-password.php | 4 + app/Views/domains/bulk-add.php | 1 + app/Views/domains/create.php | 1 + app/Views/domains/edit.php | 3 + app/Views/domains/index.php | 4 + app/Views/domains/view.php | 3 + app/Views/groups/create.php | 1 + app/Views/groups/edit.php | 2 + app/Views/profile/index.php | 4 + app/Views/settings/index.php | 170 ++++++++++++- app/Views/tld-registry/import-progress.php | 7 +- app/Views/tld-registry/index.php | 3 + app/Views/users/create.php | 1 + app/Views/users/edit.php | 1 + core/Controller.php | 21 ++ core/Csrf.php | 115 +++++++++ core/SessionConfig.php | 79 ++++++ .../migrations/000_initial_schema_v1.1.0.sql | 8 +- .../migrations/014_add_captcha_settings.sql | 24 ++ database/migrations/README.md | 4 + public/index.php | 32 +-- routes/web.php | 1 + 41 files changed, 1689 insertions(+), 77 deletions(-) create mode 100644 app/Helpers/CsrfHelper.php create mode 100644 app/Helpers/InputValidator.php create mode 100644 app/Services/CaptchaService.php create mode 100644 app/Views/auth/captcha-widget.php create mode 100644 core/Csrf.php create mode 100644 core/SessionConfig.php create mode 100644 database/migrations/014_add_captcha_settings.sql diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php index dbd9b24..71f5378 100644 --- a/app/Controllers/AuthController.php +++ b/app/Controllers/AuthController.php @@ -33,10 +33,14 @@ class AuthController extends Controller // Check if registration is enabled $registrationEnabled = $this->settingModel->getValue('registration_enabled'); + + // Get CAPTCHA settings + $captchaSettings = $this->settingModel->getCaptchaSettings(); $this->view('auth/login', [ 'title' => 'Login', - 'registrationEnabled' => $registrationEnabled + 'registrationEnabled' => $registrationEnabled, + 'captchaSettings' => $captchaSettings ]); } @@ -50,10 +54,25 @@ class AuthController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/login'); + $username = trim($_POST['username'] ?? ''); $password = $_POST['password'] ?? ''; // Don't trim - passwords may have intentional spaces $remember = isset($_POST['remember']); + // Verify CAPTCHA + $captchaService = new \App\Services\CaptchaService(); + $captchaResponse = $_POST['captcha_response'] ?? ''; + $remoteIp = $_SERVER['REMOTE_ADDR'] ?? null; + $captchaResult = $captchaService->verifyCaptcha($captchaResponse, $remoteIp); + + if (!$captchaResult['success']) { + $_SESSION['error'] = $captchaResult['error'] ?? 'CAPTCHA verification failed'; + $this->redirect('/login'); + return; + } + // Validate input if (empty($username) || empty($password)) { $_SESSION['error'] = 'Username and password are required'; @@ -140,8 +159,12 @@ class AuthController extends Controller return; } + // Get CAPTCHA settings + $captchaSettings = $this->settingModel->getCaptchaSettings(); + $this->view('auth/register', [ - 'title' => 'Register' + 'title' => 'Register', + 'captchaSettings' => $captchaSettings ]); } @@ -155,6 +178,9 @@ class AuthController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/register'); + // Check if registration is enabled $registrationEnabled = $this->settingModel->getValue('registration_enabled'); if (!$registrationEnabled) { @@ -163,6 +189,18 @@ class AuthController extends Controller return; } + // Verify CAPTCHA + $captchaService = new \App\Services\CaptchaService(); + $captchaResponse = $_POST['captcha_response'] ?? ''; + $remoteIp = $_SERVER['REMOTE_ADDR'] ?? null; + $captchaResult = $captchaService->verifyCaptcha($captchaResponse, $remoteIp); + + if (!$captchaResult['success']) { + $_SESSION['error'] = $captchaResult['error'] ?? 'CAPTCHA verification failed'; + $this->redirect('/register'); + return; + } + $username = trim($_POST['username'] ?? ''); $email = trim($_POST['email'] ?? ''); $fullName = trim($_POST['full_name'] ?? ''); @@ -176,6 +214,22 @@ class AuthController extends Controller return; } + // Validate username format and length + $usernameError = \App\Helpers\InputValidator::validateUsername($username, 3, 50); + if ($usernameError) { + $_SESSION['error'] = $usernameError; + $this->redirect('/register'); + return; + } + + // Validate full name length + $nameError = \App\Helpers\InputValidator::validateLength($fullName, 255, 'Full name'); + if ($nameError) { + $_SESSION['error'] = $nameError; + $this->redirect('/register'); + return; + } + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $_SESSION['error'] = 'Please enter a valid email address'; $this->redirect('/register'); @@ -398,8 +452,12 @@ class AuthController extends Controller return; } + // Get CAPTCHA settings + $captchaSettings = $this->settingModel->getCaptchaSettings(); + $this->view('auth/forgot-password', [ - 'title' => 'Forgot Password' + 'title' => 'Forgot Password', + 'captchaSettings' => $captchaSettings ]); } @@ -413,6 +471,21 @@ class AuthController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/forgot-password'); + + // Verify CAPTCHA + $captchaService = new \App\Services\CaptchaService(); + $captchaResponse = $_POST['captcha_response'] ?? ''; + $remoteIp = $_SERVER['REMOTE_ADDR'] ?? null; + $captchaResult = $captchaService->verifyCaptcha($captchaResponse, $remoteIp); + + if (!$captchaResult['success']) { + $_SESSION['error'] = $captchaResult['error'] ?? 'CAPTCHA verification failed'; + $this->redirect('/forgot-password'); + return; + } + $email = trim($_POST['email'] ?? ''); if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) { @@ -478,9 +551,13 @@ class AuthController extends Controller return; } + // Get CAPTCHA settings + $captchaSettings = $this->settingModel->getCaptchaSettings(); + $this->view('auth/reset-password', [ 'title' => 'Reset Password', - 'token' => $token + 'token' => $token, + 'captchaSettings' => $captchaSettings ]); } @@ -494,6 +571,23 @@ class AuthController extends Controller return; } + // CSRF Protection + $token = $_POST['token'] ?? ''; + $this->verifyCsrf('/reset-password?token=' . urlencode($token)); + + // Verify CAPTCHA + $captchaService = new \App\Services\CaptchaService(); + $captchaResponse = $_POST['captcha_response'] ?? ''; + $remoteIp = $_SERVER['REMOTE_ADDR'] ?? null; + $captchaResult = $captchaService->verifyCaptcha($captchaResponse, $remoteIp); + + if (!$captchaResult['success']) { + $token = $_POST['token'] ?? ''; + $_SESSION['error'] = $captchaResult['error'] ?? 'CAPTCHA verification failed'; + $this->redirect('/reset-password?token=' . urlencode($token)); + return; + } + $token = $_POST['token'] ?? ''; $password = $_POST['password'] ?? ''; $passwordConfirm = $_POST['password_confirm'] ?? ''; diff --git a/app/Controllers/DomainController.php b/app/Controllers/DomainController.php index 5e4a630..1c16c38 100644 --- a/app/Controllers/DomainController.php +++ b/app/Controllers/DomainController.php @@ -23,7 +23,7 @@ class DomainController extends Controller public function index() { // Get filter parameters - $search = $_GET['search'] ?? ''; + $search = \App\Helpers\InputValidator::sanitizeSearch($_GET['search'] ?? '', 100); $status = $_GET['status'] ?? ''; $groupId = $_GET['group'] ?? ''; $sortBy = $_GET['sort'] ?? 'domain_name'; @@ -131,6 +131,9 @@ class DomainController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/domains/create'); + $domainName = trim($_POST['domain_name'] ?? ''); $groupId = !empty($_POST['notification_group_id']) ? (int)$_POST['notification_group_id'] : null; @@ -141,6 +144,13 @@ class DomainController extends Controller return; } + // Validate domain format + if (!\App\Helpers\InputValidator::validateDomain($domainName)) { + $_SESSION['error'] = 'Invalid domain name format (e.g., example.com)'; + $this->redirect('/domains/create'); + return; + } + // Check if domain already exists if ($this->domainModel->existsByDomain($domainName)) { $_SESSION['error'] = 'Domain already exists'; @@ -212,6 +222,9 @@ class DomainController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/domains'); + $id = (int)($params['id'] ?? 0); $domain = $this->domainModel->find($id); @@ -391,6 +404,9 @@ class DomainController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/domains/bulk-add'); + // POST - Process bulk add $domainsText = trim($_POST['domains'] ?? ''); $groupId = !empty($_POST['notification_group_id']) ? (int)$_POST['notification_group_id'] : null; @@ -467,6 +483,9 @@ class DomainController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/domains'); + $domainIds = $_POST['domain_ids'] ?? []; if (empty($domainIds)) { @@ -475,6 +494,14 @@ class DomainController extends Controller return; } + // Validate bulk operation size + $sizeError = \App\Helpers\InputValidator::validateArraySize($domainIds, 1000, 'Domain selection'); + if ($sizeError) { + $_SESSION['error'] = $sizeError; + $this->redirect('/domains'); + return; + } + $refreshed = 0; $failed = 0; @@ -516,6 +543,9 @@ class DomainController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/domains'); + $domainIds = $_POST['domain_ids'] ?? []; if (empty($domainIds)) { @@ -524,6 +554,14 @@ class DomainController extends Controller return; } + // Validate bulk operation size + $sizeError = \App\Helpers\InputValidator::validateArraySize($domainIds, 1000, 'Domain selection'); + if ($sizeError) { + $_SESSION['error'] = $sizeError; + $this->redirect('/domains'); + return; + } + $deleted = 0; foreach ($domainIds as $id) { if ($this->domainModel->delete($id)) { @@ -542,6 +580,9 @@ class DomainController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/domains'); + $domainIds = $_POST['domain_ids'] ?? []; $groupId = !empty($_POST['group_id']) ? (int)$_POST['group_id'] : null; @@ -551,6 +592,14 @@ class DomainController extends Controller return; } + // Validate bulk operation size + $sizeError = \App\Helpers\InputValidator::validateArraySize($domainIds, 1000, 'Domain selection'); + if ($sizeError) { + $_SESSION['error'] = $sizeError; + $this->redirect('/domains'); + return; + } + $updated = 0; foreach ($domainIds as $id) { if ($this->domainModel->update($id, ['notification_group_id' => $groupId])) { @@ -569,6 +618,9 @@ class DomainController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/domains'); + $domainIds = $_POST['domain_ids'] ?? []; $isActive = isset($_POST['is_active']) ? (int)$_POST['is_active'] : 1; @@ -578,6 +630,14 @@ class DomainController extends Controller return; } + // Validate bulk operation size + $sizeError = \App\Helpers\InputValidator::validateArraySize($domainIds, 1000, 'Domain selection'); + if ($sizeError) { + $_SESSION['error'] = $sizeError; + $this->redirect('/domains'); + return; + } + $updated = 0; foreach ($domainIds as $id) { if ($this->domainModel->update($id, ['is_active' => $isActive])) { @@ -597,6 +657,9 @@ class DomainController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/domains'); + $id = (int)($params['id'] ?? 0); $domain = $this->domainModel->find($id); @@ -608,6 +671,14 @@ class DomainController extends Controller $notes = $_POST['notes'] ?? ''; + // Validate notes length + $lengthError = \App\Helpers\InputValidator::validateLength($notes, 5000, 'Notes'); + if ($lengthError) { + $_SESSION['error'] = $lengthError; + $this->redirect('/domains/' . $id); + return; + } + $this->domainModel->update($id, [ 'notes' => $notes ]); diff --git a/app/Controllers/InstallerController.php b/app/Controllers/InstallerController.php index afebf7c..38a3ba6 100644 --- a/app/Controllers/InstallerController.php +++ b/app/Controllers/InstallerController.php @@ -45,6 +45,7 @@ class InstallerController extends Controller '011_create_sessions_table.sql', '012_link_remember_tokens_to_sessions.sql', '013_create_user_notifications_table.sql', + '014_add_captcha_settings.sql', ]; try { @@ -103,7 +104,8 @@ class InstallerController extends Controller '010_add_app_version_setting.sql', '011_create_sessions_table.sql', '012_link_remember_tokens_to_sessions.sql', - '013_create_user_notifications_table.sql' + '013_create_user_notifications_table.sql', + '014_add_captcha_settings.sql' ]; } @@ -179,9 +181,10 @@ class InstallerController extends Controller $adminPassword = trim($_POST['admin_password'] ?? ''); $adminEmail = trim($_POST['admin_email'] ?? ''); - // Validate - if (empty($adminUsername) || !preg_match('/^[a-zA-Z0-9_]+$/', $adminUsername)) { - $_SESSION['error'] = 'Username can only contain letters, numbers, and underscores'; + // Validate username format and length + $usernameError = \App\Helpers\InputValidator::validateUsername($adminUsername, 3, 50); + if ($usernameError) { + $_SESSION['error'] = $usernameError; $this->redirect('/install'); return; } @@ -257,7 +260,8 @@ class InstallerController extends Controller '010_add_app_version_setting.sql', '011_create_sessions_table.sql', '012_link_remember_tokens_to_sessions.sql', - '013_create_user_notifications_table.sql' + '013_create_user_notifications_table.sql', + '014_add_captcha_settings.sql' ]; $stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?) ON DUPLICATE KEY UPDATE migration=migration"); diff --git a/app/Controllers/NotificationGroupController.php b/app/Controllers/NotificationGroupController.php index a01a6bd..953880f 100644 --- a/app/Controllers/NotificationGroupController.php +++ b/app/Controllers/NotificationGroupController.php @@ -41,6 +41,9 @@ class NotificationGroupController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/groups/create'); + $name = trim($_POST['name'] ?? ''); $description = trim($_POST['description'] ?? ''); @@ -50,6 +53,21 @@ class NotificationGroupController extends Controller return; } + // Validate length + $nameError = \App\Helpers\InputValidator::validateLength($name, 255, 'Group name'); + if ($nameError) { + $_SESSION['error'] = $nameError; + $this->redirect('/groups/create'); + return; + } + + $descError = \App\Helpers\InputValidator::validateLength($description, 1000, 'Description'); + if ($descError) { + $_SESSION['error'] = $descError; + $this->redirect('/groups/create'); + return; + } + $id = $this->groupModel->create([ 'name' => $name, 'description' => $description @@ -83,6 +101,9 @@ class NotificationGroupController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/groups'); + $id = (int)$_POST['id']; $name = trim($_POST['name'] ?? ''); $description = trim($_POST['description'] ?? ''); @@ -93,6 +114,21 @@ class NotificationGroupController extends Controller return; } + // Validate length + $nameError = \App\Helpers\InputValidator::validateLength($name, 255, 'Group name'); + if ($nameError) { + $_SESSION['error'] = $nameError; + $this->redirect("/groups/edit?id=$id"); + return; + } + + $descError = \App\Helpers\InputValidator::validateLength($description, 1000, 'Description'); + if ($descError) { + $_SESSION['error'] = $descError; + $this->redirect("/groups/edit?id=$id"); + return; + } + $this->groupModel->update($id, [ 'name' => $name, 'description' => $description @@ -125,6 +161,9 @@ class NotificationGroupController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/groups'); + $groupId = (int)$_POST['group_id']; $channelType = $_POST['channel_type'] ?? ''; diff --git a/app/Controllers/ProfileController.php b/app/Controllers/ProfileController.php index d672cbb..e12d6e4 100644 --- a/app/Controllers/ProfileController.php +++ b/app/Controllers/ProfileController.php @@ -80,6 +80,9 @@ class ProfileController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/profile'); + $userId = Auth::id(); $fullName = trim($_POST['full_name'] ?? ''); $email = trim($_POST['email'] ?? ''); @@ -91,6 +94,14 @@ class ProfileController extends Controller return; } + // Validate full name length + $nameError = \App\Helpers\InputValidator::validateLength($fullName, 255, 'Full name'); + if ($nameError) { + $_SESSION['error'] = $nameError; + $this->redirect('/profile'); + return; + } + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $_SESSION['error'] = 'Please enter a valid email address'; $this->redirect('/profile'); @@ -131,6 +142,9 @@ class ProfileController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/profile'); + $userId = Auth::id(); $currentPassword = $_POST['current_password'] ?? ''; $newPassword = $_POST['new_password'] ?? ''; @@ -226,6 +240,14 @@ class ProfileController extends Controller */ public function logoutOtherSessions() { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/profile'); + return; + } + + // CSRF Protection + $this->verifyCsrf('/profile'); + $userId = Auth::id(); $currentSessionId = session_id(); @@ -273,6 +295,9 @@ class ProfileController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/profile'); + $sessionId = $params['sessionId'] ?? ''; $userId = Auth::id(); $currentSessionId = session_id(); diff --git a/app/Controllers/SearchController.php b/app/Controllers/SearchController.php index b74de19..694109d 100644 --- a/app/Controllers/SearchController.php +++ b/app/Controllers/SearchController.php @@ -19,7 +19,7 @@ class SearchController extends Controller public function index() { - $query = trim($_GET['q'] ?? ''); + $query = \App\Helpers\InputValidator::sanitizeSearch($_GET['q'] ?? '', 100); if (empty($query)) { $_SESSION['error'] = 'Please enter a search term'; @@ -86,7 +86,7 @@ class SearchController extends Controller { header('Content-Type: application/json'); - $query = trim($_GET['q'] ?? ''); + $query = \App\Helpers\InputValidator::sanitizeSearch($_GET['q'] ?? '', 100); if (empty($query)) { echo json_encode(['domains' => [], 'isDomainLike' => false]); diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php index 5a40747..3672a93 100644 --- a/app/Controllers/SettingsController.php +++ b/app/Controllers/SettingsController.php @@ -26,6 +26,7 @@ class SettingsController extends Controller $settings = $this->settingModel->getAllAsKeyValue(); $appSettings = $this->settingModel->getAppSettings(); $emailSettings = $this->settingModel->getEmailSettings(); + $captchaSettings = $this->settingModel->getCaptchaSettings(); // Predefined notification day options $notificationPresets = [ @@ -68,6 +69,7 @@ class SettingsController extends Controller 'settings' => $settings, 'appSettings' => $appSettings, 'emailSettings' => $emailSettings, + 'captchaSettings' => $captchaSettings, 'notificationPresets' => $notificationPresets, 'checkIntervalPresets' => $checkIntervalPresets, 'title' => 'Settings' @@ -81,6 +83,9 @@ class SettingsController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/settings#monitoring'); + try { // Update notification days $notificationPreset = $_POST['notification_preset'] ?? 'standard'; @@ -144,6 +149,9 @@ class SettingsController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/settings'); + // Update last check run time to show the test worked $this->settingModel->updateLastCheckRun(); @@ -158,6 +166,9 @@ class SettingsController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/settings#maintenance'); + try { // Clear notification logs older than 30 days $stmt = $this->settingModel->db->prepare( @@ -182,6 +193,9 @@ class SettingsController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/settings#app'); + try { $appSettings = [ 'app_name' => trim($_POST['app_name'] ?? 'Domain Monitor'), @@ -237,6 +251,9 @@ class SettingsController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/settings#email'); + try { $emailSettings = [ 'mail_host' => trim($_POST['mail_host'] ?? ''), @@ -278,6 +295,79 @@ class SettingsController extends Controller } } + public function updateCaptcha() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/settings'); + return; + } + + // CSRF Protection + $this->verifyCsrf('/settings#security'); + + try { + $captchaProvider = trim($_POST['captcha_provider'] ?? 'disabled'); + $captchaSiteKey = trim($_POST['captcha_site_key'] ?? ''); + $captchaSecretKey = trim($_POST['captcha_secret_key'] ?? ''); + $recaptchaV3Threshold = trim($_POST['recaptcha_v3_score_threshold'] ?? '0.5'); + + // Validate provider + $validProviders = ['disabled', 'recaptcha_v2', 'recaptcha_v3', 'turnstile']; + if (!in_array($captchaProvider, $validProviders)) { + $_SESSION['error'] = 'Invalid CAPTCHA provider selected'; + $this->redirect('/settings#security'); + return; + } + + // If CAPTCHA is enabled, validate keys + if ($captchaProvider !== 'disabled') { + if (empty($captchaSiteKey)) { + $_SESSION['error'] = 'Site key is required when CAPTCHA is enabled'; + $this->redirect('/settings#security'); + return; + } + + if (empty($captchaSecretKey)) { + $_SESSION['error'] = 'Secret key is required when CAPTCHA is enabled'; + $this->redirect('/settings#security'); + return; + } + } + + // Validate v3 score threshold + if ($captchaProvider === 'recaptcha_v3') { + $threshold = floatval($recaptchaV3Threshold); + if ($threshold < 0.0 || $threshold > 1.0) { + $_SESSION['error'] = 'reCAPTCHA v3 score threshold must be between 0.0 and 1.0'; + $this->redirect('/settings#security'); + return; + } + } + + // Prepare settings array + $captchaSettings = [ + 'captcha_provider' => $captchaProvider, + 'captcha_site_key' => $captchaSiteKey, + 'recaptcha_v3_score_threshold' => $recaptchaV3Threshold + ]; + + // Only update secret key if provided (to allow updating other settings without re-entering secret) + if (!empty($captchaSecretKey)) { + $captchaSettings['captcha_secret_key'] = $captchaSecretKey; + } + + // Update CAPTCHA settings + $this->settingModel->updateCaptchaSettings($captchaSettings); + + $_SESSION['success'] = 'CAPTCHA settings updated successfully'; + $this->redirect('/settings#security'); + + } catch (\Exception $e) { + $_SESSION['error'] = 'Failed to update CAPTCHA settings: ' . $e->getMessage(); + $this->redirect('/settings#security'); + } + } + public function testEmail() { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { @@ -285,6 +375,9 @@ class SettingsController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/settings#email'); + $testEmail = trim($_POST['test_email'] ?? ''); if (empty($testEmail) || !filter_var($testEmail, FILTER_VALIDATE_EMAIL)) { diff --git a/app/Controllers/TldRegistryController.php b/app/Controllers/TldRegistryController.php index f77f185..2b9f85d 100644 --- a/app/Controllers/TldRegistryController.php +++ b/app/Controllers/TldRegistryController.php @@ -6,18 +6,21 @@ use Core\Controller; use App\Models\TldRegistry; use App\Models\TldImportLog; use App\Services\TldRegistryService; +use App\Services\Logger; class TldRegistryController extends Controller { private TldRegistry $tldModel; private TldImportLog $importLogModel; private TldRegistryService $tldService; + private Logger $logger; public function __construct() { $this->tldModel = new TldRegistry(); $this->importLogModel = new TldImportLog(); $this->tldService = new TldRegistryService(); + $this->logger = new Logger('tld_registry_controller'); } /** @@ -37,7 +40,7 @@ class TldRegistryController extends Controller */ public function index() { - $search = $_GET['search'] ?? ''; + $search = \App\Helpers\InputValidator::sanitizeSearch($_GET['search'] ?? '', 100); $status = $_GET['status'] ?? ''; $dataType = $_GET['data_type'] ?? ''; $page = max(1, (int)($_GET['page'] ?? 1)); @@ -95,6 +98,9 @@ class TldRegistryController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/tld-registry'); + try { $stats = $this->tldService->importTldList(); @@ -130,6 +136,9 @@ class TldRegistryController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/tld-registry'); + try { $stats = $this->tldService->importRdapData(); @@ -165,6 +174,9 @@ class TldRegistryController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/tld-registry'); + try { $stats = $this->tldService->importWhoisDataForMissingTlds(); $remainingCount = $this->tldService->getTldsNeedingWhoisCount(); @@ -246,9 +258,20 @@ class TldRegistryController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/tld-registry'); + $importType = $_POST['import_type'] ?? ''; + $this->logger->separator('Start Progressive Import'); + $this->logger->info('Import requested', [ + 'type' => $importType, + 'user_id' => $_SESSION['user_id'] ?? 'unknown', + 'username' => $_SESSION['username'] ?? 'unknown' + ]); + if (!in_array($importType, ['tld_list', 'rdap', 'whois', 'check_updates', 'complete_workflow'])) { + $this->logger->warning('Invalid import type provided', ['type' => $importType]); $_SESSION['error'] = 'Invalid import type'; $this->redirect('/tld-registry'); return; @@ -257,15 +280,27 @@ class TldRegistryController extends Controller try { $result = $this->tldService->startProgressiveImport($importType); + $this->logger->info('Import started', [ + 'status' => $result['status'], + 'log_id' => $result['log_id'] ?? null, + 'message' => $result['message'] ?? '' + ]); + if ($result['status'] === 'complete') { $_SESSION['success'] = $result['message']; $this->redirect('/tld-registry'); } else { // Redirect to progress page + $this->logger->info('Redirecting to progress page', ['log_id' => $result['log_id']]); $this->redirect('/tld-registry/import-progress/' . $result['log_id']); } } catch (\Exception $e) { + $this->logger->error('Failed to start import', [ + 'error' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine() + ]); $_SESSION['error'] = 'Failed to start import: ' . $e->getMessage(); $this->redirect('/tld-registry'); } @@ -278,7 +313,10 @@ class TldRegistryController extends Controller { $logId = $params['log_id'] ?? 0; + $this->logger->info('Import progress page requested', ['log_id' => $logId]); + if (!$logId) { + $this->logger->warning('Progress page requested with no log_id'); $_SESSION['error'] = 'Invalid import session'; $this->redirect('/tld-registry'); return; @@ -287,17 +325,25 @@ class TldRegistryController extends Controller // Get import type from log $log = $this->importLogModel->find($logId); if (!$log) { + $this->logger->error('Import log not found', ['log_id' => $logId]); $_SESSION['error'] = 'Import log not found'; $this->redirect('/tld-registry'); return; } $importType = $log['import_type']; + $this->logger->info('Showing progress page', [ + 'log_id' => $logId, + 'import_type' => $importType, + 'status' => $log['status'] + ]); + $titles = [ 'tld_list' => 'TLD List Import Progress', 'rdap' => 'RDAP Import Progress', 'whois' => 'WHOIS Import Progress', - 'check_updates' => 'Update Check Progress' + 'check_updates' => 'Update Check Progress', + 'complete_workflow' => 'Complete TLD Import Workflow' ]; $this->view('tld-registry/import-progress', [ @@ -312,20 +358,104 @@ class TldRegistryController extends Controller */ public function apiGetImportProgress() { - $logId = $_GET['log_id'] ?? 0; + // Start detailed logging + $this->logger->separator('API Import Progress Request'); + $this->logger->info('API called', [ + 'log_id' => $_GET['log_id'] ?? 'none', + 'user_id' => $_SESSION['user_id'] ?? 'not set', + 'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown' + ]); + + // Start output buffering to catch any accidental output + ob_start(); - if (!$logId) { - http_response_code(400); - echo json_encode(['error' => 'Log ID required']); - return; - } - try { - $result = $this->tldService->processNextBatch($logId); - echo json_encode($result); - } catch (\Exception $e) { - http_response_code(500); - echo json_encode(['error' => $e->getMessage()]); + // Clear any previous output + ob_clean(); + + // Set JSON header immediately + header('Content-Type: application/json'); + + $logId = $_GET['log_id'] ?? 0; + + if (!$logId) { + $this->logger->warning('API call with missing log_id'); + ob_end_clean(); + $this->json(['error' => 'Log ID required'], 400); + return; + } + + // Ensure user is authenticated + if (!isset($_SESSION['user_id'])) { + $this->logger->warning('Unauthenticated API call attempt', ['log_id' => $logId]); + ob_end_clean(); + $this->json(['error' => 'Authentication required'], 401); + return; + } + + $this->logger->info('Processing batch', ['log_id' => $logId]); + + // Add detailed logging around the service call + $this->logger->info('About to call TldRegistryService::processNextBatch', [ + 'log_id' => $logId, + 'service_class' => get_class($this->tldService) + ]); + + try { + $result = $this->tldService->processNextBatch($logId); + $this->logger->info('processNextBatch returned successfully'); + } catch (\Throwable $e) { + $this->logger->critical('processNextBatch threw exception', [ + 'type' => get_class($e), + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine() + ]); + throw $e; + } + + $this->logger->info('Batch processing result', [ + 'status' => $result['status'] ?? 'unknown', + 'processed' => $result['processed'] ?? 0, + 'remaining' => $result['remaining'] ?? 0 + ]); + + // Clean output buffer before sending JSON + ob_end_clean(); + $this->json($result); + + } catch (\Throwable $e) { + // Catch ALL errors including fatal errors + // Clean any buffered output + if (ob_get_level() > 0) { + ob_end_clean(); + } + + // Detailed error logging + $this->logger->critical('API Import Progress Fatal Error', [ + 'type' => get_class($e), + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'log_id' => $_GET['log_id'] ?? 'none' + ]); + $this->logger->error('Stack trace', ['trace' => $e->getTraceAsString()]); + $this->logger->separator('API Error End'); + + // Ensure we always send JSON + if (!headers_sent()) { + http_response_code(500); + header('Content-Type: application/json'); + } + + echo json_encode([ + 'error' => 'An error occurred while processing the import', + 'message' => $e->getMessage(), + 'status' => 'error', + 'log_id' => $_GET['log_id'] ?? null, + 'error_type' => get_class($e) + ]); + exit; } } @@ -341,6 +471,9 @@ class TldRegistryController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/tld-registry'); + $tldIds = $_POST['tld_ids'] ?? []; if (empty($tldIds)) { @@ -349,6 +482,14 @@ class TldRegistryController extends Controller return; } + // Validate bulk operation size + $sizeError = \App\Helpers\InputValidator::validateArraySize($tldIds, 500, 'TLD selection'); + if ($sizeError) { + $_SESSION['error'] = $sizeError; + $this->redirect('/tld-registry'); + return; + } + try { $deletedCount = 0; foreach ($tldIds as $id) { @@ -468,8 +609,7 @@ class TldRegistryController extends Controller $domain = $_GET['domain'] ?? ''; if (empty($domain)) { - http_response_code(400); - echo json_encode(['error' => 'Domain parameter is required']); + $this->json(['error' => 'Domain parameter is required'], 400); return; } @@ -477,23 +617,22 @@ class TldRegistryController extends Controller $tldInfo = $this->tldService->getTldInfo($domain); if ($tldInfo) { - echo json_encode([ + $this->json([ 'success' => true, 'data' => $tldInfo ]); } else { - echo json_encode([ + $this->json([ 'success' => false, 'message' => 'TLD information not found' ]); } } catch (\Exception $e) { - http_response_code(500); - echo json_encode([ + $this->json([ 'success' => false, 'error' => $e->getMessage() - ]); + ], 500); } } diff --git a/app/Controllers/UserController.php b/app/Controllers/UserController.php index cb1a8c9..7d00cb9 100644 --- a/app/Controllers/UserController.php +++ b/app/Controllers/UserController.php @@ -28,7 +28,7 @@ class UserController extends Controller public function index() { // Get filter parameters - $search = trim($_GET['search'] ?? ''); + $search = \App\Helpers\InputValidator::sanitizeSearch($_GET['search'] ?? '', 100); $roleFilter = $_GET['role'] ?? ''; $statusFilter = $_GET['status'] ?? ''; $sort = $_GET['sort'] ?? 'username'; @@ -97,6 +97,9 @@ class UserController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/users/create'); + $username = trim($_POST['username'] ?? ''); $email = trim($_POST['email'] ?? ''); $fullName = trim($_POST['full_name'] ?? ''); @@ -111,12 +114,28 @@ class UserController extends Controller return; } + // Validate username format and length + $usernameError = \App\Helpers\InputValidator::validateUsername($username, 3, 50); + if ($usernameError) { + $_SESSION['error'] = $usernameError; + $this->redirect('/users/create'); + return; + } + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $_SESSION['error'] = 'Invalid email address'; $this->redirect('/users/create'); return; } + // Validate full name length + $nameError = \App\Helpers\InputValidator::validateLength($fullName, 255, 'Full name'); + if ($nameError) { + $_SESSION['error'] = $nameError; + $this->redirect('/users/create'); + return; + } + if (!preg_match('/^[a-zA-Z0-9_]+$/', $username)) { $_SESSION['error'] = 'Username can only contain letters, numbers, and underscores'; $this->redirect('/users/create'); @@ -208,6 +227,9 @@ class UserController extends Controller return; } + // CSRF Protection + $this->verifyCsrf('/users'); + $userId = (int)($_POST['id'] ?? 0); $user = $this->userModel->find($userId); @@ -236,6 +258,14 @@ class UserController extends Controller return; } + // Validate full name length + $nameError = \App\Helpers\InputValidator::validateLength($fullName, 255, 'Full name'); + if ($nameError) { + $_SESSION['error'] = $nameError; + $this->redirect('/users/edit?id=' . $userId); + return; + } + // Check if email is taken by another user $existingUsers = $this->userModel->where('email', $email); if (!empty($existingUsers) && $existingUsers[0]['id'] != $userId) { diff --git a/app/Helpers/CsrfHelper.php b/app/Helpers/CsrfHelper.php new file mode 100644 index 0000000..f847386 --- /dev/null +++ b/app/Helpers/CsrfHelper.php @@ -0,0 +1,32 @@ + 253 || strlen($domain) < 3) { + return false; + } + + // Validate domain format + // Allows: example.com, sub.example.com, example.co.uk + // Pattern: alphanumeric with hyphens, dots between labels, valid TLD + return (bool)preg_match('/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i', $domain); + } + + /** + * Validate text field length + * + * @param string $value Value to validate + * @param int $max Maximum length + * @param string $fieldName Field name for error message + * @return string|null Error message or null if valid + */ + public static function validateLength(string $value, int $max, string $fieldName = 'Field'): ?string + { + if (strlen($value) > $max) { + return "$fieldName is too long (maximum $max characters)"; + } + return null; + } + + /** + * Validate string is within min and max length + * + * @param string $value Value to validate + * @param int $min Minimum length + * @param int $max Maximum length + * @param string $fieldName Field name for error message + * @return string|null Error message or null if valid + */ + public static function validateLengthRange(string $value, int $min, int $max, string $fieldName = 'Field'): ?string + { + $len = strlen($value); + + if ($len < $min) { + return "$fieldName is too short (minimum $min characters)"; + } + + if ($len > $max) { + return "$fieldName is too long (maximum $max characters)"; + } + + return null; + } + + /** + * Sanitize text input (remove control characters) + * + * @param string $text Text to sanitize + * @return string Sanitized text + */ + public static function sanitizeText(string $text): string + { + // Remove control characters (except tabs and newlines for text areas) + $text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $text); + return trim($text); + } + + /** + * Validate array size is within limits + * + * @param array $array Array to validate + * @param int $max Maximum number of elements + * @param string $fieldName Field name for error message + * @return string|null Error message or null if valid + */ + public static function validateArraySize(array $array, int $max, string $fieldName = 'Selection'): ?string + { + if (count($array) > $max) { + return "$fieldName exceeds maximum of $max items"; + } + return null; + } + + /** + * Sanitize search query + * + * @param string $query Search query + * @param int $maxLength Maximum length (default 100) + * @return string Sanitized query + */ + public static function sanitizeSearch(string $query, int $maxLength = 100): string + { + // Remove control characters + $query = self::sanitizeText($query); + + // Limit length + if (strlen($query) > $maxLength) { + $query = substr($query, 0, $maxLength); + } + + return $query; + } + + /** + * Validate username format + * + * @param string $username Username to validate + * @param int $minLength Minimum length (default 3) + * @param int $maxLength Maximum length (default 50) + * @return string|null Error message or null if valid + */ + public static function validateUsername(string $username, int $minLength = 3, int $maxLength = 50): ?string + { + if (strlen($username) < $minLength) { + return "Username must be at least $minLength characters"; + } + + if (strlen($username) > $maxLength) { + return "Username must not exceed $maxLength characters"; + } + + if (!preg_match('/^[a-zA-Z0-9_]+$/', $username)) { + return 'Username can only contain letters, numbers, and underscores'; + } + + return null; + } + + /** + * Validate URL format + * + * @param string $url URL to validate + * @return bool True if valid, false otherwise + */ + public static function validateUrl(string $url): bool + { + return filter_var($url, FILTER_VALIDATE_URL) !== false; + } + + /** + * Validate email format + * + * @param string $email Email to validate + * @return bool True if valid, false otherwise + */ + public static function validateEmail(string $email): bool + { + return filter_var($email, FILTER_VALIDATE_EMAIL) !== false; + } + + /** + * Validate numeric value is within range + * + * @param mixed $value Value to validate + * @param int|float $min Minimum value + * @param int|float $max Maximum value + * @param string $fieldName Field name for error message + * @return string|null Error message or null if valid + */ + public static function validateRange($value, $min, $max, string $fieldName = 'Value'): ?string + { + if (!is_numeric($value)) { + return "$fieldName must be a number"; + } + + $numValue = is_float($min) || is_float($max) ? floatval($value) : intval($value); + + if ($numValue < $min || $numValue > $max) { + return "$fieldName must be between $min and $max"; + } + + return null; + } + + /** + * Validate value is in allowed list (whitelist) + * + * @param mixed $value Value to validate + * @param array $allowed Allowed values + * @param string $fieldName Field name for error message + * @return string|null Error message or null if valid + */ + public static function validateInList($value, array $allowed, string $fieldName = 'Value'): ?string + { + if (!in_array($value, $allowed, true)) { + return "$fieldName has an invalid value"; + } + return null; + } +} + diff --git a/app/Models/Setting.php b/app/Models/Setting.php index a35a9af..a16ca89 100644 --- a/app/Models/Setting.php +++ b/app/Models/Setting.php @@ -201,5 +201,60 @@ class Setting extends Model } return $result; } + + /** + * Get CAPTCHA settings + */ + public function getCaptchaSettings(): array + { + $encryptedSecret = $this->getValue('captcha_secret_key', ''); + + // Decrypt secret key if it's encrypted + $decryptedSecret = ''; + if (!empty($encryptedSecret)) { + try { + $encryption = new \Core\Encryption(); + $decryptedSecret = $encryption->decrypt($encryptedSecret); + } catch (\Exception $e) { + // If decryption fails, it might be plaintext (migration scenario) + error_log("Failed to decrypt captcha_secret_key: " . $e->getMessage()); + $decryptedSecret = $encryptedSecret; + } + } + + return [ + 'provider' => $this->getValue('captcha_provider', 'disabled'), + 'site_key' => $this->getValue('captcha_site_key', ''), + 'secret_key' => $decryptedSecret, + 'score_threshold' => $this->getValue('recaptcha_v3_score_threshold', '0.5') + ]; + } + + /** + * Update CAPTCHA settings + */ + public function updateCaptchaSettings(array $settings): bool + { + $result = true; + + // Encrypt secret key before storing + if (isset($settings['captcha_secret_key']) && !empty($settings['captcha_secret_key'])) { + try { + $encryption = new \Core\Encryption(); + $settings['captcha_secret_key'] = $encryption->encrypt($settings['captcha_secret_key']); + } catch (\Exception $e) { + error_log("Failed to encrypt captcha_secret_key: " . $e->getMessage()); + return false; + } + } + + foreach ($settings as $key => $value) { + if (!$this->setValue($key, $value)) { + $result = false; + } + } + + return $result; + } } diff --git a/app/Models/TldImportLog.php b/app/Models/TldImportLog.php index 90e8eb8..4a0fd09 100644 --- a/app/Models/TldImportLog.php +++ b/app/Models/TldImportLog.php @@ -152,4 +152,13 @@ class TldImportLog extends Model ] ]; } + + /** + * Execute a custom SQL query + */ + public function query(string $sql): array + { + $stmt = $this->db->query($sql); + return $stmt->fetchAll(); + } } diff --git a/app/Services/CaptchaService.php b/app/Services/CaptchaService.php new file mode 100644 index 0000000..4545380 --- /dev/null +++ b/app/Services/CaptchaService.php @@ -0,0 +1,227 @@ +settingModel = new Setting(); + $this->captchaSettings = $this->settingModel->getCaptchaSettings(); + } + + /** + * Verify CAPTCHA response based on configured provider + * + * @param string|null $response CAPTCHA response token from client + * @param string|null $remoteIp Remote IP address of the user + * @return array ['success' => bool, 'error' => string|null, 'score' => float|null] + */ + public function verifyCaptcha(?string $response, ?string $remoteIp = null): array + { + $provider = $this->captchaSettings['provider'] ?? 'disabled'; + + // If CAPTCHA is disabled, always return success + if ($provider === 'disabled') { + return ['success' => true, 'error' => null, 'score' => null]; + } + + // Validate that response token is provided + if (empty($response)) { + return ['success' => false, 'error' => 'CAPTCHA verification failed. Please try again.', 'score' => null]; + } + + // Verify based on provider + switch ($provider) { + case 'recaptcha_v2': + return $this->verifyRecaptchaV2($response, $remoteIp); + + case 'recaptcha_v3': + return $this->verifyRecaptchaV3($response, $remoteIp); + + case 'turnstile': + return $this->verifyTurnstile($response, $remoteIp); + + default: + // Unknown provider - allow through but log + error_log("Unknown CAPTCHA provider: $provider"); + return ['success' => true, 'error' => null, 'score' => null]; + } + } + + /** + * Verify reCAPTCHA v2 response + */ + private function verifyRecaptchaV2(string $response, ?string $remoteIp): array + { + $secretKey = $this->captchaSettings['secret_key'] ?? ''; + + if (empty($secretKey)) { + error_log('reCAPTCHA v2 secret key is not configured'); + return ['success' => false, 'error' => 'CAPTCHA is misconfigured. Please contact administrator.', 'score' => null]; + } + + $data = [ + 'secret' => $secretKey, + 'response' => $response + ]; + + if ($remoteIp) { + $data['remoteip'] = $remoteIp; + } + + $result = $this->sendVerificationRequest(self::RECAPTCHA_VERIFY_URL, $data); + + if ($result === null) { + return ['success' => false, 'error' => 'CAPTCHA verification service unavailable. Please try again later.', 'score' => null]; + } + + if (!isset($result['success']) || !$result['success']) { + $errorCodes = $result['error-codes'] ?? []; + error_log('reCAPTCHA v2 verification failed: ' . json_encode($errorCodes)); + return ['success' => false, 'error' => 'CAPTCHA verification failed. Please try again.', 'score' => null]; + } + + return ['success' => true, 'error' => null, 'score' => null]; + } + + /** + * Verify reCAPTCHA v3 response (score-based) + */ + private function verifyRecaptchaV3(string $response, ?string $remoteIp): array + { + $secretKey = $this->captchaSettings['secret_key'] ?? ''; + $threshold = floatval($this->captchaSettings['score_threshold'] ?? 0.5); + + if (empty($secretKey)) { + error_log('reCAPTCHA v3 secret key is not configured'); + return ['success' => false, 'error' => 'CAPTCHA is misconfigured. Please contact administrator.', 'score' => null]; + } + + $data = [ + 'secret' => $secretKey, + 'response' => $response + ]; + + if ($remoteIp) { + $data['remoteip'] = $remoteIp; + } + + $result = $this->sendVerificationRequest(self::RECAPTCHA_VERIFY_URL, $data); + + if ($result === null) { + return ['success' => false, 'error' => 'CAPTCHA verification service unavailable. Please try again later.', 'score' => null]; + } + + if (!isset($result['success']) || !$result['success']) { + $errorCodes = $result['error-codes'] ?? []; + error_log('reCAPTCHA v3 verification failed: ' . json_encode($errorCodes)); + return ['success' => false, 'error' => 'CAPTCHA verification failed. Please try again.', 'score' => null]; + } + + // Check score + $score = floatval($result['score'] ?? 0); + + if ($score < $threshold) { + error_log("reCAPTCHA v3 score too low: $score (threshold: $threshold)"); + return ['success' => false, 'error' => 'Security verification failed. Please try again or contact support.', 'score' => $score]; + } + + return ['success' => true, 'error' => null, 'score' => $score]; + } + + /** + * Verify Cloudflare Turnstile response + */ + private function verifyTurnstile(string $response, ?string $remoteIp): array + { + $secretKey = $this->captchaSettings['secret_key'] ?? ''; + + if (empty($secretKey)) { + error_log('Turnstile secret key is not configured'); + return ['success' => false, 'error' => 'CAPTCHA is misconfigured. Please contact administrator.', 'score' => null]; + } + + $data = [ + 'secret' => $secretKey, + 'response' => $response + ]; + + if ($remoteIp) { + $data['remoteip'] = $remoteIp; + } + + $result = $this->sendVerificationRequest(self::TURNSTILE_VERIFY_URL, $data); + + if ($result === null) { + return ['success' => false, 'error' => 'CAPTCHA verification service unavailable. Please try again later.', 'score' => null]; + } + + if (!isset($result['success']) || !$result['success']) { + $errorCodes = $result['error-codes'] ?? []; + error_log('Turnstile verification failed: ' . json_encode($errorCodes)); + return ['success' => false, 'error' => 'CAPTCHA verification failed. Please try again.', 'score' => null]; + } + + return ['success' => true, 'error' => null, 'score' => null]; + } + + /** + * Send verification request to CAPTCHA provider API + */ + private function sendVerificationRequest(string $url, array $data): ?array + { + $options = [ + 'http' => [ + 'method' => 'POST', + 'header' => 'Content-Type: application/x-www-form-urlencoded', + 'content' => http_build_query($data), + 'timeout' => 10 + ] + ]; + + $context = stream_context_create($options); + $response = @file_get_contents($url, false, $context); + + if ($response === false) { + error_log("Failed to connect to CAPTCHA verification service: $url"); + return null; + } + + $result = json_decode($response, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + error_log("Failed to parse CAPTCHA verification response: " . json_last_error_msg()); + return null; + } + + return $result; + } + + /** + * Get current CAPTCHA settings for view rendering + */ + public function getCaptchaSettings(): array + { + return $this->captchaSettings; + } + + /** + * Check if CAPTCHA is enabled + */ + public function isEnabled(): bool + { + $provider = $this->captchaSettings['provider'] ?? 'disabled'; + return $provider !== 'disabled'; + } +} + diff --git a/app/Services/TldRegistryService.php b/app/Services/TldRegistryService.php index ee212d8..70a8c20 100644 --- a/app/Services/TldRegistryService.php +++ b/app/Services/TldRegistryService.php @@ -196,9 +196,12 @@ class TldRegistryService $stats['total_tlds'] = count($tlds); + // Normalize last updated date to UTC format + $normalizedLastUpdated = $this->normalizeDate($lastUpdated); + // Update log with version and timestamp $this->importLogModel->update($logId, [ - 'iana_publication_date' => $lastUpdated, + 'iana_publication_date' => $normalizedLastUpdated, 'version' => $version ]); @@ -260,8 +263,11 @@ class TldRegistryService $publicationDate = $data['publication'] ?? null; $services = $data['services'] ?? []; + // Normalize publication date to UTC format before saving + $normalizedPublicationDate = $this->normalizeDate($publicationDate); + // Update log with publication date - $this->importLogModel->update($logId, ['iana_publication_date' => $publicationDate]); + $this->importLogModel->update($logId, ['iana_publication_date' => $normalizedPublicationDate]); foreach ($services as $service) { $tlds = $service[0] ?? []; // TLD patterns @@ -271,7 +277,7 @@ class TldRegistryService $stats['total_tlds']++; try { - $result = $this->processTldRdapData($tld, $rdapServers, $publicationDate); + $result = $this->processTldRdapData($tld, $rdapServers, $normalizedPublicationDate); if ($result['is_new']) { $stats['new_tlds']++; @@ -911,8 +917,28 @@ class TldRegistryService 'failed_tlds' => 0 ]); - $message = $updateInfo['overall_needs_update'] ? - 'Updates available' : 'TLD registry is up to date'; + // Build detailed message + $messages = []; + + if ($updateInfo['tld_list']['needs_update'] ?? false) { + $current = $updateInfo['tld_list']['current_version'] ?? 'Unknown'; + $last = $updateInfo['tld_list']['last_version'] ?? 'None'; + $messages[] = "TLD List: New version available (current: $current, previous: $last)"; + } + + if ($updateInfo['rdap']['needs_update'] ?? false) { + $current = $updateInfo['rdap']['current_publication'] ?? 'Unknown'; + $last = $updateInfo['rdap']['last_publication'] ?? 'None'; + $messages[] = "RDAP Data: New publication available (current: $current, previous: $last)"; + } + + if ($updateInfo['overall_needs_update']) { + $message = "🔔 Updates Available! " . implode(' • ', $messages) . " Click 'Import TLDs' to update your database."; + } else { + $tldVersion = $updateInfo['tld_list']['current_version'] ?? 'N/A'; + $rdapDate = $updateInfo['rdap']['current_publication'] ?? 'N/A'; + $message = "✅ TLD Registry is Up to Date! (TLD List version: $tldVersion, RDAP publication: $rdapDate)"; + } return [ 'status' => 'complete', @@ -921,7 +947,8 @@ class TldRegistryService 'processed' => 2, 'failed' => 0, 'remaining' => 0, - 'message' => $message + 'message' => $message, + 'update_info' => $updateInfo ]; } catch (\Exception $e) { $this->importLogModel->completeImport($logId, [ @@ -1692,21 +1719,28 @@ class TldRegistryService $data = json_decode($response->getBody()->getContents(), true); $currentPublication = $data['publication'] ?? null; - // Get last RDAP import - $lastRdapImport = $this->importLogModel->query( + // Get last RDAP import using database directly + $db = \Core\Database::getConnection(); + $stmt = $db->prepare( "SELECT iana_publication_date FROM tld_import_logs WHERE import_type = 'rdap' AND status = 'completed' ORDER BY started_at DESC LIMIT 1" ); + $stmt->execute(); + $lastRdapImport = $stmt->fetch(); - $lastPublication = $lastRdapImport[0]['iana_publication_date'] ?? null; + $lastPublication = $lastRdapImport['iana_publication_date'] ?? null; - $needsUpdate = $currentPublication !== $lastPublication; + // Normalize date formats for comparison (ISO 8601 vs MySQL datetime) + $currentNormalized = $this->normalizeDate($currentPublication); + $lastNormalized = $this->normalizeDate($lastPublication); + + $needsUpdate = ($currentNormalized !== $lastNormalized) && ($currentNormalized !== null); return [ 'needs_update' => $needsUpdate, - 'current_publication' => $currentPublication, - 'last_publication' => $lastPublication + 'current_publication' => $currentNormalized ?: $currentPublication, + 'last_publication' => $lastNormalized ?: $lastPublication ]; } catch (\Exception $e) { @@ -1863,4 +1897,32 @@ class TldRegistryService return $stats; } + + /** + * Normalize date string for comparison + * Converts both ISO 8601 and MySQL datetime to same format (UTC) + * + * @param string|null $date Date string to normalize + * @return string|null Normalized date in UTC (Y-m-d H:i:s) or null + */ + private function normalizeDate(?string $date): ?string + { + if (empty($date)) { + return null; + } + + try { + // Create DateTime object (handles both ISO 8601 and MySQL datetime) + $dateTime = new \DateTime($date); + + // Convert to UTC to ensure consistent comparison + $dateTime->setTimezone(new \DateTimeZone('UTC')); + + // Return in MySQL datetime format (Y-m-d H:i:s) in UTC + return $dateTime->format('Y-m-d H:i:s'); + } catch (\Exception $e) { + // Fallback to null if date parsing fails + return null; + } + } } diff --git a/app/Views/auth/captcha-widget.php b/app/Views/auth/captcha-widget.php new file mode 100644 index 0000000..4be7b22 --- /dev/null +++ b/app/Views/auth/captcha-widget.php @@ -0,0 +1,86 @@ + + + +
+ + +
+ + + + + + + + + +
+ + + +
+ + + + + + + + + diff --git a/app/Views/auth/forgot-password.php b/app/Views/auth/forgot-password.php index 655cdeb..dc563d5 100644 --- a/app/Views/auth/forgot-password.php +++ b/app/Views/auth/forgot-password.php @@ -35,6 +35,7 @@ ob_start();
+
+ + +
+