Compare commits

..

4 Commits

Author SHA1 Message Date
hyzen
9037354195 Update: adminpanel block/delete removes xmpp account too 2026-06-26 21:12:03 +02:00
hyzen
a21e5f64ed Update: relative post_id 2026-06-26 13:24:24 +02:00
hyzen
a15bcedac1 Add: account based rate limiting login attempts 2026-06-26 12:58:28 +02:00
hyzen
5029eba715 Fix: cross-origin hardened 2026-06-26 12:52:08 +02:00
3 changed files with 201 additions and 3 deletions

149
admin.php
View File

@@ -30,6 +30,10 @@ define('DB_PORT', $env['DB_PORT'] ?? '5432');
define('DB_NAME', $env['DB_NAME'] ?? 'freedoms4'); define('DB_NAME', $env['DB_NAME'] ?? 'freedoms4');
define('DB_USER', $env['DB_USER'] ?? 'freedoms4_user'); define('DB_USER', $env['DB_USER'] ?? 'freedoms4_user');
define('DB_PASS', $env['DB_PASS'] ?? ''); define('DB_PASS', $env['DB_PASS'] ?? '');
define('PROSODY_DB_NAME', $env['PROSODY_DB_NAME'] ?? 'prosody');
define('PROSODY_DB_USER', $env['PROSODY_DB_USER'] ?? 'prosody');
define('PROSODY_DB_PASS', $env['PROSODY_DB_PASS'] ?? '');
define('PROSODY_HOST', $env['PROSODY_HOST'] ?? 'freedoms4.org');
define('SESSION_NAME', 'f4_session'); define('SESSION_NAME', 'f4_session');
define('SESSION_SECURE', true); define('SESSION_SECURE', true);
@@ -41,7 +45,7 @@ define('ADMIN_USER', 'hyzen');
$origin = $_SERVER['HTTP_ORIGIN'] ?? ''; $origin = $_SERVER['HTTP_ORIGIN'] ?? '';
$allowed_origins = ['https://freedoms4.org', 'https://www.freedoms4.org']; $allowed_origins = ['https://freedoms4.org', 'https://www.freedoms4.org'];
if ($origin && !in_array($origin, $allowed_origins, true)) { if (!$origin || !in_array($origin, $allowed_origins, true)) {
http_response_code(403); http_response_code(403);
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');
echo json_encode(['success' => false, 'message' => 'Forbidden.']); echo json_encode(['success' => false, 'message' => 'Forbidden.']);
@@ -83,6 +87,135 @@ function db_connect(): PDO {
return $pdo; return $pdo;
} }
function prosody_db_connect(): ?PDO {
static $pdo = null;
static $failed = false;
if ($pdo !== null) return $pdo;
if ($failed) return null;
try {
$dsn = sprintf('pgsql:host=127.0.0.1;port=5432;dbname=%s', PROSODY_DB_NAME);
$pdo = new PDO($dsn, PROSODY_DB_USER, PROSODY_DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return $pdo;
} catch (PDOException $e) {
error_log('admin.php Prosody DB error: ' . $e->getMessage());
$failed = true;
return null;
}
}
// Remove a user's XMPP account (all stores: accounts, roster, vcard, etc.)
// so it doesn't keep working indefinitely after the user is deleted.
function delete_xmpp_account(string $username): void {
$pdo = prosody_db_connect();
if (!$pdo) return; // Prosody DB unreachable — already logged, don't block user deletion on it
try {
$pdo->prepare('DELETE FROM prosody WHERE host = :h AND "user" = :u')
->execute([':h' => PROSODY_HOST, ':u' => strtolower($username)]);
} catch (Exception $e) {
error_log("delete_xmpp_account failed for {$username}: " . $e->getMessage());
}
}
function ensure_xmpp_backup_table(PDO $pdo): void {
$pdo->exec(
"CREATE TABLE IF NOT EXISTS xmpp_account_backups (
username VARCHAR(32) PRIMARY KEY,
rows JSONB NOT NULL,
blocked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)"
);
}
// Back up a user's Prosody rows (all stores) into xmpp_account_backups,
// then remove them from the live prosody table — mirroring how
// email-block.sh moves the Dovecot passwd-file entry into a backup file.
function xmpp_backup_and_remove(string $username): void {
$prosody = prosody_db_connect();
if (!$prosody) return; // logged already — don't block the block/delete action on it
try {
$stmt = $prosody->prepare('SELECT host, "user", store, key, type, value FROM prosody WHERE host = :h AND "user" = :u');
$stmt->execute([':h' => PROSODY_HOST, ':u' => strtolower($username)]);
$rows = $stmt->fetchAll();
if (!$rows) return; // nothing to back up (e.g. never had an XMPP account)
$pdo = db_connect();
ensure_xmpp_backup_table($pdo);
$pdo->prepare(
'INSERT INTO xmpp_account_backups (username, rows, blocked_at)
VALUES (:u, :rows, NOW())
ON CONFLICT (username) DO UPDATE SET rows = :rows2, blocked_at = NOW()'
)->execute([
':u' => strtolower($username),
':rows' => json_encode($rows),
':rows2' => json_encode($rows),
]);
$prosody->prepare('DELETE FROM prosody WHERE host = :h AND "user" = :u')
->execute([':h' => PROSODY_HOST, ':u' => strtolower($username)]);
} catch (Exception $e) {
error_log("xmpp_backup_and_remove failed for {$username}: " . $e->getMessage());
}
}
// Restore a previously backed-up XMPP account (on unblock) and remove the
// backup row once restored.
function xmpp_restore_from_backup(string $username): void {
try {
$pdo = db_connect();
ensure_xmpp_backup_table($pdo);
$stmt = $pdo->prepare('SELECT rows FROM xmpp_account_backups WHERE username = :u LIMIT 1');
$stmt->execute([':u' => strtolower($username)]);
$row = $stmt->fetch();
if (!$row) return; // nothing backed up — nothing to restore
$prosody = prosody_db_connect();
if (!$prosody) return; // can't restore right now — backup row stays put, safe to retry later
$entries = json_decode($row['rows'], true) ?: [];
$insert = $prosody->prepare(
'INSERT INTO prosody (host, "user", store, key, type, value) VALUES (:host, :user, :store, :key, :type, :value)'
);
foreach ($entries as $entry) {
try {
$insert->execute([
':host' => $entry['host'],
':user' => $entry['user'],
':store' => $entry['store'],
':key' => $entry['key'],
':type' => $entry['type'],
':value' => $entry['value'],
]);
} catch (Exception $e) {
// Row may already exist (e.g. partial prior restore) — skip and continue
error_log("xmpp_restore_from_backup: skipped one row for {$username}: " . $e->getMessage());
}
}
$pdo->prepare('DELETE FROM xmpp_account_backups WHERE username = :u')
->execute([':u' => strtolower($username)]);
} catch (Exception $e) {
error_log("xmpp_restore_from_backup failed for {$username}: " . $e->getMessage());
}
}
// Remove any leftover XMPP backup row for a user (used on permanent
// deletion, in case they were blocked at some point before being deleted).
function xmpp_delete_backup(string $username): void {
try {
$pdo = db_connect();
ensure_xmpp_backup_table($pdo);
$pdo->prepare('DELETE FROM xmpp_account_backups WHERE username = :u')
->execute([':u' => strtolower($username)]);
} catch (Exception $e) {
error_log("xmpp_delete_backup failed for {$username}: " . $e->getMessage());
}
}
// ── Session + admin check ── // ── Session + admin check ──
if (session_status() === PHP_SESSION_NONE) { if (session_status() === PHP_SESSION_NONE) {
session_name(SESSION_NAME); session_name(SESSION_NAME);
@@ -160,6 +293,10 @@ if ($action === 'block_user') {
$safe_user = escapeshellarg($target['username']); $safe_user = escapeshellarg($target['username']);
shell_exec("sudo /usr/local/bin/email-block block {$safe_user} 2>&1"); shell_exec("sudo /usr/local/bin/email-block block {$safe_user} 2>&1");
// Backup and remove their XMPP/Prosody account so it stops working
// while blocked, restorable on unblock.
xmpp_backup_and_remove($target['username']);
json_out(['success' => true]); json_out(['success' => true]);
} }
@@ -174,6 +311,9 @@ if ($action === 'unblock_user') {
$safe_user = escapeshellarg($target['username']); $safe_user = escapeshellarg($target['username']);
shell_exec("sudo /usr/local/bin/email-block unblock {$safe_user} 2>&1"); shell_exec("sudo /usr/local/bin/email-block unblock {$safe_user} 2>&1");
// Restore their XMPP/Prosody account from backup, if one exists.
xmpp_restore_from_backup($target['username']);
json_out(['success' => true]); json_out(['success' => true]);
} }
@@ -203,6 +343,13 @@ if ($action === 'delete_user') {
$pdo->prepare('DELETE FROM users WHERE id = :id') $pdo->prepare('DELETE FROM users WHERE id = :id')
->execute([':id' => $user_id]); ->execute([':id' => $user_id]);
// Remove the user's XMPP/Prosody account so it stops working once their
// freedoms4 account is gone, instead of remaining usable indefinitely.
delete_xmpp_account($target['username']);
// ...and remove any leftover backup row too, in case they were blocked
// at some point before being deleted outright.
xmpp_delete_backup($target['username']);
// Clear OTP history for this email so re-signing-up doesn't hit the // Clear OTP history for this email so re-signing-up doesn't hit the
// daily OTP request limit because of OTPs sent before deletion. // daily OTP request limit because of OTPs sent before deletion.
$pdo->prepare('DELETE FROM email_otps WHERE email = :e') $pdo->prepare('DELETE FROM email_otps WHERE email = :e')

View File

@@ -43,13 +43,14 @@ define('OTP_FROM', 'no-reply@freedoms4.org');
define('OTP_TTL', 600); // 10 minutes define('OTP_TTL', 600); // 10 minutes
define('OTP_MAX_DAY', 5); // max OTPs per email per 24 h define('OTP_MAX_DAY', 5); // max OTPs per email per 24 h
define('OTP_MAX_FAILS', 10); // max failed OTP attempts per IP before lockout define('OTP_MAX_FAILS', 10); // max failed OTP attempts per IP before lockout
define('LOGIN_MAX_FAILS', 10); // max failed login attempts per username before lockout (independent of IP)
define('MAX_BODY_BYTES', 4096); define('MAX_BODY_BYTES', 4096);
// ── CORS ── // ── CORS ──
$origin = $_SERVER['HTTP_ORIGIN'] ?? ''; $origin = $_SERVER['HTTP_ORIGIN'] ?? '';
$allowed_origins = ['https://freedoms4.org', 'https://www.freedoms4.org']; $allowed_origins = ['https://freedoms4.org', 'https://www.freedoms4.org'];
if ($origin && !in_array($origin, $allowed_origins, true)) { if (!$origin || !in_array($origin, $allowed_origins, true)) {
http_response_code(403); http_response_code(403);
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');
echo json_encode(['success' => false, 'message' => 'Forbidden.']); echo json_encode(['success' => false, 'message' => 'Forbidden.']);
@@ -177,6 +178,40 @@ function otp_fail_reset(string $ip): void {
unset($_SESSION[$key]); unset($_SESSION[$key]);
} }
// Per-account login failure tracking — independent of the global per-IP
// rate limit, so a brute-force attack distributed across many IPs still
// gets locked out once a single account has too many failed attempts.
function login_fail_count(string $username): int {
$key = 'loginfail_' . hash('sha256', strtolower($username));
if (function_exists('apcu_fetch')) {
$count = apcu_fetch($key, $ok);
return $ok ? (int)$count : 0;
}
return $_SESSION[$key] ?? 0;
}
function login_fail_increment(string $username): void {
$key = 'loginfail_' . hash('sha256', strtolower($username));
if (function_exists('apcu_inc')) {
if (!apcu_fetch($key)) {
apcu_store($key, 1, 900); // 15 minute lockout window
} else {
apcu_inc($key);
}
return;
}
$_SESSION[$key] = ($_SESSION[$key] ?? 0) + 1;
}
function login_fail_reset(string $username): void {
$key = 'loginfail_' . hash('sha256', strtolower($username));
if (function_exists('apcu_delete')) {
apcu_delete($key);
return;
}
unset($_SESSION[$key]);
}
function create_xmpp_account(string $username, string $password): bool { function create_xmpp_account(string $username, string $password): bool {
try { try {
$pdo = prosody_db_connect(); $pdo = prosody_db_connect();
@@ -376,6 +411,12 @@ if ($action === 'login') {
json_out(['success' => false, 'message' => 'Username and password are required.']); json_out(['success' => false, 'message' => 'Username and password are required.']);
} }
// Per-account brute-force lockout — checked before touching the DB, and
// independent of the global per-IP limit above.
if (login_fail_count($username) >= LOGIN_MAX_FAILS) {
json_out(['success' => false, 'message' => 'Too many failed attempts for this account. Please wait 15 minutes and try again.'], 429);
}
$pdo = db_connect(); $pdo = db_connect();
$stmt = $pdo->prepare('SELECT id, username, password_hash, blocked FROM users WHERE username = :u LIMIT 1'); $stmt = $pdo->prepare('SELECT id, username, password_hash, blocked FROM users WHERE username = :u LIMIT 1');
$stmt->execute([':u' => $username]); $stmt->execute([':u' => $username]);
@@ -383,9 +424,13 @@ if ($action === 'login') {
$hash = $user['password_hash'] ?? '$2y$12$invalidhashpadding000000000000000000000000000000000000000'; $hash = $user['password_hash'] ?? '$2y$12$invalidhashpadding000000000000000000000000000000000000000';
if (!$user || !password_verify($password, $hash)) { if (!$user || !password_verify($password, $hash)) {
login_fail_increment($username);
json_out(['success' => false, 'message' => 'Invalid username or password.']); json_out(['success' => false, 'message' => 'Invalid username or password.']);
} }
// Correct password — clear the failure counter for this account.
login_fail_reset($username);
if ($user && ($user['blocked'] === true || $user['blocked'] === 't')) { if ($user && ($user['blocked'] === true || $user['blocked'] === 't')) {
json_out(['success' => false, 'message' => 'This account has been blocked.']); json_out(['success' => false, 'message' => 'This account has been blocked.']);
} }

View File

@@ -40,7 +40,7 @@ define('MAX_COMMENT_LEN', 2000);
$origin = $_SERVER['HTTP_ORIGIN'] ?? ''; $origin = $_SERVER['HTTP_ORIGIN'] ?? '';
$allowed_origins = ['https://freedoms4.org', 'https://www.freedoms4.org']; $allowed_origins = ['https://freedoms4.org', 'https://www.freedoms4.org'];
if ($origin && !in_array($origin, $allowed_origins, true)) { if (!$origin || !in_array($origin, $allowed_origins, true)) {
http_response_code(403); http_response_code(403);
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');
echo json_encode(['success' => false, 'message' => 'Forbidden.']); echo json_encode(['success' => false, 'message' => 'Forbidden.']);
@@ -273,6 +273,12 @@ if ($action === 'post' || $action === 'reply') {
if ($post_id === '') { if ($post_id === '') {
json_out(['success' => false, 'message' => 'post_id is required.']); json_out(['success' => false, 'message' => 'post_id is required.']);
} }
// post_id should always be a site-relative path like "/blog/some-post/".
// Reject anything else here, before it can shape outgoing notification
// email content or anything else downstream.
if (!preg_match('#^/[a-zA-Z0-9_/-]{1,200}/$#', $post_id)) {
json_out(['success' => false, 'message' => 'Invalid post_id.']);
}
if ($text === '') { if ($text === '') {
json_out(['success' => false, 'message' => 'Comment cannot be empty.']); json_out(['success' => false, 'message' => 'Comment cannot be empty.']);
} }