From c7f1bdd630d3a249ae1d79954c4ca8123354ac95 Mon Sep 17 00:00:00 2001 From: Philip Guzman III Date: Tue, 15 Sep 2026 10:11:29 -0700 Subject: [PATCH] Block bot registrations and add cron cleanup for unconfirmed accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit register.php was a fully open signup form with no bot defenses — the likely source of the unconfirmed accounts piling up in admin/users.php. - Add an always-on honeypot field + timing trap to register.php: either tripping silently pretends success without creating an account, so a bot doesn't learn it was caught. No configuration needed. - Add optional Google reCAPTCHA v3 support (includes/recaptcha.php, recaptcha_enabled()/verify_recaptcha(), no Composer dependency — a raw file_get_contents() POST like mailer.php's SMTP socket approach). A failed check here shows a real, visible error instead of the silent honeypot path, since a legitimate low-score user deserves a retry. - Configure it through admin/settings.php's new "Bot Protection" section, mirroring the existing SMTP pattern exactly: recaptcha_enabled/ recaptcha_site_key/recaptcha_secret_key in site_settings, secret key masked the same way smtp_pass now is (blank submission keeps it unchanged). install.php seeds sane defaults so the feature stays off until explicitly configured — fully backward compatible. - Add cron/cleanup_unconfirmed.php: deletes accounts still unconfirmed after 3 days. CLI-only (refuses to run over HTTP, and cron/.htaccess denies web access to the directory as a second layer) since it's an unattended, irreversible deletion. Safe by construction — login.php already refuses login to unconfirmed accounts, so these rows can never own a session/novena_group/custom_prayer row. Not wired up automatically; README documents the Hostinger cron job to schedule it. Co-Authored-By: Claude Sonnet 5 --- README.md | 17 ++++ admin/settings.php | 71 ++++++++++++- cron/.htaccess | 5 + cron/cleanup_unconfirmed.php | 41 ++++++++ includes/recaptcha.php | 48 +++++++++ install.php | 3 + register.php | 189 ++++++++++++++++++++++------------- 7 files changed, 301 insertions(+), 73 deletions(-) create mode 100644 cron/.htaccess create mode 100644 cron/cleanup_unconfirmed.php create mode 100644 includes/recaptcha.php diff --git a/README.md b/README.md index 9e3ca7b..076b5cc 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,20 @@ chmod 755 uploads/ Configure outbound email in **Admin → Settings** for registration confirmation and password reset emails. If left blank, the app will auto-confirm new users instead. +### 6. Bot protection (optional) + +`register.php` always runs a built-in honeypot + timing trap against scripted signups — no setup needed. On top of that, you can enable Google reCAPTCHA v3: register your domain at [google.com/recaptcha](https://www.google.com/recaptcha/admin) (choose **reCAPTCHA v3**), then enter the Site Key and Secret Key in **Admin → Settings → Bot Protection**. + +### 7. Scheduled cleanup of unconfirmed accounts (optional) + +`cron/cleanup_unconfirmed.php` permanently deletes accounts that are still unconfirmed 3 days after registering — useful for clearing out bot signups that get past the defenses above. It's CLI-only (refuses to run over HTTP) and is not wired up automatically; schedule it yourself as a cron job. In Hostinger's hPanel: **Advanced → Cron Jobs** → run daily: + +```bash +php /home//domains/loveandrosary.com/public_html/cron/cleanup_unconfirmed.php +``` + +(Adjust the path to match your actual hosting account.) Confirmed accounts are never touched — only rows with `email_confirmed = 0`. + ## Upgrading an Existing Install `schema.sql` reflects the current database structure. For a production database that predates the `failed_login_attempts` / `locked_until` login-lockout columns, run this once against it manually — it's not applied automatically since there's no migration runner against a live database: @@ -101,11 +115,14 @@ Rosary/ ├── config/ │ ├── db.example.php # Copy → db.php and fill in credentials │ └── db.php # (gitignored — contains real credentials) +├── cron/ +│ └── cleanup_unconfirmed.php # CLI-only; schedule via host cron ├── data/ │ └── prayers.php # All prayer text + build_decade_slides() ├── includes/ │ ├── auth.php # require_auth(), current_user(), has_role(), login lockout │ ├── csrf.php # csrf_token(), csrf_field(), csrf_verify() +│ ├── recaptcha.php # recaptcha_enabled(), verify_recaptcha() │ ├── build_slides.php │ ├── donate.php │ └── mailer.php diff --git a/admin/settings.php b/admin/settings.php index ff4e875..22f7803 100644 --- a/admin/settings.php +++ b/admin/settings.php @@ -6,6 +6,7 @@ require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../includes/auth.php'; require_once __DIR__ . '/../includes/mailer.php'; require_once __DIR__ . '/../includes/csrf.php'; +require_once __DIR__ . '/../includes/recaptcha.php'; require_role('superadmin'); @@ -20,19 +21,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $action = $_POST['action'] ?? 'save'; if ($action === 'save') { - // smtp_pass is handled separately: the form always renders it blank - // (see below), so a blank submission means "leave it unchanged", - // not "clear it". + // smtp_pass and recaptcha_secret_key are handled separately below: + // the form always renders them blank, so a blank submission means + // "leave it unchanged", not "clear it". $keys = ['site_name','site_url','smtp_host','smtp_port','smtp_user','smtp_from','smtp_from_name', - 'donate_enabled','donate_type','donate_handle','donate_label']; + 'donate_enabled','donate_type','donate_handle','donate_label', + 'recaptcha_enabled','recaptcha_site_key']; + // Checkboxes are absent from $_POST entirely when unchecked. + $checkbox_keys = ['donate_enabled', 'recaptcha_enabled']; foreach ($keys as $k) { if (isset($_POST[$k])) { set_setting($k, trim($_POST[$k])); + } elseif (in_array($k, $checkbox_keys, true)) { + set_setting($k, '0'); } } if (!empty($_POST['smtp_pass'])) { set_setting('smtp_pass', trim($_POST['smtp_pass'])); } + if (!empty($_POST['recaptcha_secret_key'])) { + set_setting('recaptcha_secret_key', trim($_POST['recaptcha_secret_key'])); + } $message = 'Settings saved.'; $site_name = get_setting('site_name', APP_NAME); // refresh } @@ -68,6 +77,9 @@ $settings = [ 'donate_type' => get_setting('donate_type', 'custom'), 'donate_handle' => get_setting('donate_handle', ''), 'donate_label' => get_setting('donate_label', ''), + 'recaptcha_enabled' => get_setting('recaptcha_enabled', '0'), + 'recaptcha_site_key' => get_setting('recaptcha_site_key', ''), + 'recaptcha_secret_key_set' => get_setting('recaptcha_secret_key') !== '', ]; ?> @@ -226,6 +238,45 @@ $settings = [ +
+

Bot Protection (reCAPTCHA)

+

+ Adds invisible Google reCAPTCHA v3 to the registration form, on top of the built-in + honeypot/timing checks. Register your domain at + google.com/recaptcha + (choose reCAPTCHA v3) to get a Site Key and Secret Key. +

+
+ +
+
+
+ + +
+
+ +
+ + +
+

+ +

+
+
+
+
@@ -270,6 +321,18 @@ function togglePass() { btn.textContent = 'Show'; } } + +function toggleRecaptchaSecret() { + var inp = document.getElementById('recaptcha_secret_key'); + var btn = inp.nextElementSibling; + if (inp.type === 'password') { + inp.type = 'text'; + btn.textContent = 'Hide'; + } else { + inp.type = 'password'; + btn.textContent = 'Show'; + } +} diff --git a/cron/.htaccess b/cron/.htaccess new file mode 100644 index 0000000..76ffbf1 --- /dev/null +++ b/cron/.htaccess @@ -0,0 +1,5 @@ +# Defense in depth: cron/cleanup_unconfirmed.php already refuses to run +# outside the CLI, but block web access to this directory entirely too. +Require all denied +Order allow,deny +Deny from all diff --git a/cron/cleanup_unconfirmed.php b/cron/cleanup_unconfirmed.php new file mode 100644 index 0000000..2bd4733 --- /dev/null +++ b/cron/cleanup_unconfirmed.php @@ -0,0 +1,41 @@ +prepare(" + SELECT id, username, email, created_at + FROM users + WHERE email_confirmed = 0 + AND created_at < (NOW() - INTERVAL ? DAY) +"); +$st->execute([$days]); +$stale = $st->fetchAll(); + +$del = $pdo->prepare('DELETE FROM users WHERE id = ?'); +foreach ($stale as $u) { + $del->execute([$u['id']]); + echo date('c') . " deleted unconfirmed user #{$u['id']} ({$u['username']}, {$u['email']}, registered {$u['created_at']})\n"; +} + +echo date('c') . ' — ' . count($stale) . " account(s) removed.\n"; diff --git a/includes/recaptcha.php b/includes/recaptcha.php new file mode 100644 index 0000000..f7eba0d --- /dev/null +++ b/includes/recaptcha.php @@ -0,0 +1,48 @@ + $secret, + 'response' => $token, + 'remoteip' => $_SERVER['REMOTE_ADDR'] ?? '', + ]); + + $context = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/x-www-form-urlencoded\r\n", + 'content' => $post_data, + 'timeout' => 10, + ], + ]); + + try { + $response = @file_get_contents('https://www.google.com/recaptcha/api/siteverify', false, $context); + if ($response === false) return false; + + $result = json_decode($response, true); + if (!is_array($result)) return false; + + return !empty($result['success']) && (float)($result['score'] ?? 0) >= $min_score; + } catch (Throwable $e) { + error_log('reCAPTCHA verify error: ' . $e->getMessage()); + return false; + } +} diff --git a/install.php b/install.php index c53b72e..67bd523 100644 --- a/install.php +++ b/install.php @@ -133,6 +133,9 @@ $defaults = [ 'smtp_from_name' => 'Rosary Presenter', 'site_name' => 'Rosary Presenter', 'site_url' => '', + 'recaptcha_enabled' => '0', + 'recaptcha_site_key' => '', + 'recaptcha_secret_key' => '', ]; $ins_setting = $pdo->prepare('INSERT IGNORE INTO site_settings (key_name, val) VALUES (?, ?)'); foreach ($defaults as $k => $v) { diff --git a/register.php b/register.php index 3f5aa4b..c88355a 100644 --- a/register.php +++ b/register.php @@ -3,6 +3,7 @@ require_once __DIR__ . '/config/db.php'; require_once __DIR__ . '/includes/auth.php'; require_once __DIR__ . '/includes/mailer.php'; require_once __DIR__ . '/includes/csrf.php'; +require_once __DIR__ . '/includes/recaptcha.php'; _auth_start(); @@ -18,6 +19,16 @@ $fields = ['username' => '', 'display_name' => '', 'email' => '']; if ($_SERVER['REQUEST_METHOD'] === 'POST') { csrf_verify(); + + // Honeypot + timing trap: a decoy field real users never see/fill, and a + // minimum time between the form being shown and submitted. Either one + // tripping means this almost certainly isn't a human — pretend success + // without creating an account, so a bot doesn't learn it was caught and + // adapt its script. + $honeypot = trim($_POST['website'] ?? ''); + $shown_at = (int)($_SESSION['reg_form_shown_at'] ?? 0); + $bot_detected = ($honeypot !== '') || (time() - $shown_at < 3); + $username = trim($_POST['username'] ?? ''); $display_name = trim($_POST['display_name'] ?? ''); $email = trim($_POST['email'] ?? ''); @@ -26,78 +37,92 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $fields = compact('username', 'display_name', 'email'); - // Validate username - if (!preg_match('/^[a-zA-Z0-9_]{3,30}$/', $username)) { - $errors[] = 'Username must be 3-30 characters and contain only letters, numbers, and underscores.'; - } - // Validate email - if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { - $errors[] = 'Please enter a valid email address.'; - } - // Validate password - if (strlen($password) < 8) { - $errors[] = 'Password must be at least 8 characters.'; - } - if ($password !== $password_confirm) { - $errors[] = 'Passwords do not match.'; - } - - if (empty($errors)) { - $pdo = get_pdo(); - - // Check uniqueness - $chk = $pdo->prepare('SELECT id FROM users WHERE username = ? OR email = ?'); - $chk->execute([$username, $email]); - $existing = $chk->fetchAll(); - foreach ($existing as $row) { - // Re-check which field conflicts - } - if (!empty($existing)) { - $chk_u = $pdo->prepare('SELECT id FROM users WHERE username = ?'); - $chk_u->execute([$username]); - if ($chk_u->fetch()) $errors[] = 'That username is already taken.'; - - $chk_e = $pdo->prepare('SELECT id FROM users WHERE email = ?'); - $chk_e->execute([$email]); - if ($chk_e->fetch()) $errors[] = 'That email address is already registered.'; - } - } - - if (empty($errors)) { - $pdo = get_pdo(); - $smtp_host = get_setting('smtp_host'); - $auto_confirm = ($smtp_host === ''); // No SMTP = skip email confirmation - - $hash = password_hash($password, PASSWORD_BCRYPT); - $token = $auto_confirm ? null : bin2hex(random_bytes(32)); - - $pdo->prepare(" - INSERT INTO users (username, email, password_hash, display_name, role, rosary_limit, email_confirmed, confirm_token) - VALUES (?, ?, ?, ?, 'user', 1, ?, ?) - ")->execute([$username, $email, $hash, $display_name ?: $username, $auto_confirm ? 1 : 0, $token]); - - if (!$auto_confirm && $token) { - $site_url = rtrim(get_setting('site_url'), '/'); - $link = $site_url . '/confirm?token=' . urlencode($token); - $site_name = get_setting('site_name', APP_NAME); - $body_html = " -

Confirm your email

-

Hello, " . htmlspecialchars($display_name ?: $username) . "!

-

Thank you for registering with {$site_name}. Click the button below to confirm your email address:

-

- Confirm Email -

-

Or copy this link: " . htmlspecialchars($link) . "

-

If you did not register, ignore this email.

- "; - $html = email_template('Confirm your email — ' . $site_name, $body_html); - send_email($email, $display_name ?: $username, 'Confirm your email — ' . $site_name, $html); + if ($bot_detected) { + $success = true; + $auto_confirmed = false; + } else { + // reCAPTCHA v3: unlike the honeypot/timing trap above, a failure here + // gets a real, visible error — a legitimate low-score user deserves + // an explicit retry rather than a silently-discarded submission. + if (recaptcha_enabled() && !verify_recaptcha($_POST['recaptcha_token'] ?? '')) { + $errors[] = 'We could not verify your submission. Please try again.'; } - $success = true; - $auto_confirmed = $auto_confirm; + // Validate username + if (!preg_match('/^[a-zA-Z0-9_]{3,30}$/', $username)) { + $errors[] = 'Username must be 3-30 characters and contain only letters, numbers, and underscores.'; + } + // Validate email + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + $errors[] = 'Please enter a valid email address.'; + } + // Validate password + if (strlen($password) < 8) { + $errors[] = 'Password must be at least 8 characters.'; + } + if ($password !== $password_confirm) { + $errors[] = 'Passwords do not match.'; + } + + if (empty($errors)) { + $pdo = get_pdo(); + + // Check uniqueness + $chk = $pdo->prepare('SELECT id FROM users WHERE username = ? OR email = ?'); + $chk->execute([$username, $email]); + $existing = $chk->fetchAll(); + if (!empty($existing)) { + $chk_u = $pdo->prepare('SELECT id FROM users WHERE username = ?'); + $chk_u->execute([$username]); + if ($chk_u->fetch()) $errors[] = 'That username is already taken.'; + + $chk_e = $pdo->prepare('SELECT id FROM users WHERE email = ?'); + $chk_e->execute([$email]); + if ($chk_e->fetch()) $errors[] = 'That email address is already registered.'; + } + } + + if (empty($errors)) { + $pdo = get_pdo(); + $smtp_host = get_setting('smtp_host'); + $auto_confirm = ($smtp_host === ''); // No SMTP = skip email confirmation + + $hash = password_hash($password, PASSWORD_BCRYPT); + $token = $auto_confirm ? null : bin2hex(random_bytes(32)); + + $pdo->prepare(" + INSERT INTO users (username, email, password_hash, display_name, role, rosary_limit, email_confirmed, confirm_token) + VALUES (?, ?, ?, ?, 'user', 1, ?, ?) + ")->execute([$username, $email, $hash, $display_name ?: $username, $auto_confirm ? 1 : 0, $token]); + + if (!$auto_confirm && $token) { + $site_url = rtrim(get_setting('site_url'), '/'); + $link = $site_url . '/confirm?token=' . urlencode($token); + $site_name = get_setting('site_name', APP_NAME); + $body_html = " +

Confirm your email

+

Hello, " . htmlspecialchars($display_name ?: $username) . "!

+

Thank you for registering with {$site_name}. Click the button below to confirm your email address:

+

+ Confirm Email +

+

Or copy this link: " . htmlspecialchars($link) . "

+

If you did not register, ignore this email.

+ "; + $html = email_template('Confirm your email — ' . $site_name, $body_html); + send_email($email, $display_name ?: $username, 'Confirm your email — ' . $site_name, $html); + } + + $success = true; + $auto_confirmed = $auto_confirm; + } } } + +// Refresh the timing-trap timestamp whenever the form is about to be (re)shown. +if (!$success) { + $_SESSION['reg_form_shown_at'] = time(); +} ?> @@ -107,6 +132,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { Register — <?= htmlspecialchars(get_setting('site_name', APP_NAME)) ?> + + + -
+ + + + +
+ + + +