mirror of
https://github.com/Shadowss/TravianZ.git
synced 2026-07-02 18:44:21 +00:00
@@ -53,8 +53,27 @@ if(isset($_POST['action']) && $_POST['action'] == 'addBan') {
|
||||
}
|
||||
}
|
||||
|
||||
// ========================= HANDLE ADD IP BAN (issue #185) =========================
|
||||
if(isset($_POST['action']) && $_POST['action'] == 'addIpBan') {
|
||||
$ip = trim($_POST['ip'] ?? '');
|
||||
$reason = trim($_POST['reason'] ?? '');
|
||||
$time = (int)($_POST['time'] ?? 0);
|
||||
|
||||
if(@inet_pton($ip) === false) {
|
||||
$error = "Invalid IP address!";
|
||||
} else {
|
||||
$end = $time > 0 ? time() + $time : 0;
|
||||
if($admin->AddIpBan($ip, $end, $reason)) {
|
||||
$success = "IP <b>".htmlspecialchars($ip)."</b> has been banned successfully!";
|
||||
} else {
|
||||
$error = "Could not ban this IP!";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========================= DATA =========================
|
||||
$bannedUsers = $admin->search_banned();
|
||||
$bannedIps = $admin->search_banned_ip();
|
||||
$banHistory = mysqli_query($database->dblink,"SELECT * FROM ".TB_PREFIX."banlist WHERE active=0 ORDER BY id DESC LIMIT 50");
|
||||
?>
|
||||
<style>
|
||||
@@ -146,6 +165,58 @@ $banHistory = mysqli_query($database->dblink,"SELECT * FROM ".TB_PREFIX."banlist
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===================== IP BANS (issue #185) ===================== -->
|
||||
<div class="ban-grid">
|
||||
<!-- ADD IP BAN -->
|
||||
<div class="ban-card">
|
||||
<h3>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24"><path d="M12 2L2 7v10l10 5 10-5V7L12 2z" fill="#c0392b"/></svg>
|
||||
Ban IP Address
|
||||
</h3>
|
||||
<form method="post" class="ban-form">
|
||||
<input type="hidden" name="action" value="addIpBan">
|
||||
<div class="row">
|
||||
<input type="text" name="ip" placeholder="IPv4 or IPv6" required>
|
||||
<select name="reason">
|
||||
<?php foreach(['Pushing','Cheat','Hack','Bug','Bad Name','Multi Account','Swearing'] as $r){ echo "<option>$r</option>"; }?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row">
|
||||
<select name="time" style="flex:1">
|
||||
<?php foreach([1,2,5,10,12] as $h) echo "<option value='".($h*3600)."'>$h hour/s</option>";
|
||||
foreach([1,2,5,10,30,50,90] as $d) echo "<option value='".($d*86400)."'>$d day/s</option>"; ?>
|
||||
<option value="0">Forever</option>
|
||||
</select>
|
||||
<button type="submit">Ban IP</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- ACTIVE IP BANS -->
|
||||
<div class="ban-card">
|
||||
<h3>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="#e74c3c"/></svg>
|
||||
Active IP Bans (<?php echo count($bannedIps);?>)
|
||||
</h3>
|
||||
<div class="ban-list">
|
||||
<?php if($bannedIps){ foreach($bannedIps as $b){
|
||||
$end = $b['end'] ? date("d.m H:i",$b['end']) : '∞';
|
||||
?>
|
||||
<div class="ban-item">
|
||||
<div>
|
||||
<div class="user"><?php echo htmlspecialchars($b['ip_text']);?></div>
|
||||
<div class="meta"><?php echo date("d.m H:i",$b['time']);?> → <?php echo $end;?></div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="reason"><?php echo htmlspecialchars($b['reason']);?></span>
|
||||
<a class="del" href="?p=ban&action=delIpBan&id=<?php echo (int)$b['id'];?>" onclick="return confirm('Unban this IP?')" title="Unban IP">✕</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php }} else { echo '<div class="empty">No active IP bans</div>'; }?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HISTORY -->
|
||||
<div class="ban-card">
|
||||
<h3>
|
||||
|
||||
@@ -73,8 +73,9 @@ if($keepAdmin && $adminData){
|
||||
$_SESSION['id'] = 6; // actualizăm sesiunea
|
||||
}
|
||||
|
||||
// 6. Log
|
||||
mysqli_query($GLOBALS["link"], "INSERT INTO `".TB_PREFIX."admin_log` (user, ip, time, action) VALUES (".(int)$_SESSION['id'].", '".$_SERVER['REMOTE_ADDR']."', ".time().", 'Server reset".($keepAdmin ? ' (admin kept)' : '')."')");
|
||||
// 6. Log (proxy-aware, issue #185)
|
||||
$resetIp = \App\Utils\IpResolver::getClientIp() ?? ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
|
||||
mysqli_query($GLOBALS["link"], "INSERT INTO `".TB_PREFIX."admin_log` (user, ip, time, action) VALUES (".(int)$_SESSION['id'].", '".$resetIp."', ".time().", 'Server reset".($keepAdmin ? ' (admin kept)' : '')."')");
|
||||
|
||||
header("Location: ../admin.php?p=resetdone");
|
||||
exit;
|
||||
|
||||
@@ -92,15 +92,17 @@ class adm_DB {
|
||||
}
|
||||
|
||||
$username = htmlspecialchars($username);
|
||||
// proxy-aware (issue #185): log the real client IP behind a trusted reverse proxy
|
||||
$realIp = \App\Utils\IpResolver::getClientIp() ?? ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
|
||||
if ($pwOk) {
|
||||
// upgrade la bcrypt dacă e necesar
|
||||
if (!$dbarray['is_bcrypt'] &&!$bcrypted) {
|
||||
mysqli_query($this->connection, "UPDATE ". TB_PREFIX. "users SET password = '". password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]). "'". ($bcrypt_update_done? ", is_bcrypt = 1" : ""). " WHERE id = ". (int)$dbarray['id']);
|
||||
}
|
||||
mysqli_query($this->connection, "INSERT INTO ". TB_PREFIX. "admin_log VALUES (0,'X','$username logged in (IP: <b>". $_SERVER['REMOTE_ADDR']. "</b>)',". time(). ")");
|
||||
mysqli_query($this->connection, "INSERT INTO ". TB_PREFIX. "admin_log VALUES (0,'X','$username logged in (IP: <b>". $realIp. "</b>)',". time(). ")");
|
||||
return true;
|
||||
} else {
|
||||
mysqli_query($this->connection, "INSERT INTO ". TB_PREFIX. "admin_log VALUES (0,'X','<font color=\'red\'><b>IP: ". $_SERVER['REMOTE_ADDR']. " tried to log in with username <u> $username</u> but access was denied!</font></b>',". time(). ")");
|
||||
mysqli_query($this->connection, "INSERT INTO ". TB_PREFIX. "admin_log VALUES (0,'X','<font color=\'red\'><b>IP: ". $realIp. " tried to log in with username <u> $username</u> but access was denied!</font></b>',". time(). ")");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -317,7 +319,9 @@ class adm_DB {
|
||||
$dbarray = mysqli_fetch_array($result);
|
||||
|
||||
if (!$dbarray) {
|
||||
mysqli_query($this->connection, "INSERT INTO ". TB_PREFIX. "admin_log VALUES (0,'X','<font color=\'red\'><b>IP: ". $_SERVER['REMOTE_ADDR']. " tried to log in with uid $uid but access was denied!</font></b>',". time(). ")");
|
||||
// proxy-aware (issue #185)
|
||||
$realIp = \App\Utils\IpResolver::getClientIp() ?? ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
|
||||
mysqli_query($this->connection, "INSERT INTO ". TB_PREFIX. "admin_log VALUES (0,'X','<font color=\'red\'><b>IP: ". $realIp. " tried to log in with uid $uid but access was denied!</font></b>',". time(). ")");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -430,6 +434,88 @@ class adm_DB {
|
||||
mysqli_query($this->connection, $q);
|
||||
}
|
||||
|
||||
/* ---------------- IP Ban / Unban (issue #185) ---------------- */
|
||||
|
||||
// Lazily creates the IP ban table so existing servers do not need a manual migration.
|
||||
function ensureIpBanTable() {
|
||||
mysqli_query($this->connection,
|
||||
"CREATE TABLE IF NOT EXISTS `". TB_PREFIX. "banlist_ip` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`ip` varbinary(16) NOT NULL,
|
||||
`ip_text` varchar(45) DEFAULT NULL,
|
||||
`reason` varchar(100) DEFAULT NULL,
|
||||
`time` int(11) UNSIGNED DEFAULT NULL,
|
||||
`end` int(11) UNSIGNED DEFAULT NULL,
|
||||
`admin` int(11) DEFAULT NULL,
|
||||
`active` tinyint(1) UNSIGNED DEFAULT 1,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `ip` (`ip`),
|
||||
KEY `active-end` (`active`,`end`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8");
|
||||
}
|
||||
|
||||
// $end is an absolute UNIX timestamp (0 = permanent).
|
||||
function AddIpBan($ip, $end, $reason) {
|
||||
$this->ensureIpBanTable();
|
||||
|
||||
$ip = trim((string)$ip);
|
||||
$bin = @inet_pton($ip);
|
||||
if ($bin === false) {
|
||||
return false; // invalid IP, ignore
|
||||
}
|
||||
|
||||
$reason = substr((string)$reason, 0, 100);
|
||||
$time = time();
|
||||
$end = (int)$end;
|
||||
$admin = (int)$_SESSION['id'];
|
||||
$ipText = $ip;
|
||||
|
||||
$stmt = $this->connection->prepare(
|
||||
"INSERT INTO `". TB_PREFIX. "banlist_ip` (ip, ip_text, reason, time, end, admin, active)
|
||||
VALUES (?,?,?,?,?,?,1)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
ip_text = VALUES(ip_text), reason = VALUES(reason),
|
||||
time = VALUES(time), end = VALUES(end), admin = VALUES(admin), active = 1"
|
||||
);
|
||||
if (!$stmt) {
|
||||
return false;
|
||||
}
|
||||
$stmt->bind_param("sssiii", $bin, $ipText, $reason, $time, $end, $admin);
|
||||
$ok = $stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
$logIp = addslashes($ipText);
|
||||
mysqli_query($this->connection,
|
||||
"INSERT INTO ". TB_PREFIX. "admin_log VALUES (0, $admin, 'Banned IP <b>$logIp</b>', $time)");
|
||||
|
||||
return $ok;
|
||||
}
|
||||
|
||||
function DelIpBan($id) {
|
||||
$id = (int)$id;
|
||||
$admin = (int)$_SESSION['id'];
|
||||
|
||||
$stmt = $this->connection->prepare(
|
||||
"UPDATE `". TB_PREFIX. "banlist_ip` SET active = 0 WHERE id = ?");
|
||||
if ($stmt) {
|
||||
$stmt->bind_param("i", $id);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
}
|
||||
|
||||
mysqli_query($this->connection,
|
||||
"INSERT INTO ". TB_PREFIX. "admin_log VALUES (0, $admin, 'Removed IP ban #$id', ". time(). ")");
|
||||
}
|
||||
|
||||
function search_banned_ip() {
|
||||
$this->ensureIpBanTable();
|
||||
$now = time();
|
||||
$q = "SELECT * FROM ". TB_PREFIX. "banlist_ip
|
||||
WHERE active = 1 AND (end IS NULL OR end = 0 OR end > $now) ORDER BY id DESC";
|
||||
$result = mysqli_query($this->connection, $q);
|
||||
return $this->mysqli_fetch_all($result);
|
||||
}
|
||||
|
||||
/* ---------------- Căutări ---------------- */
|
||||
function search_player($player) {
|
||||
global $database;
|
||||
|
||||
@@ -57,6 +57,12 @@ class funct
|
||||
$admin->DelBan($get['uid'], $get['id']);
|
||||
// remove ban
|
||||
break;
|
||||
case "delIpBan":
|
||||
// remove IP ban (issue #185)
|
||||
if (isset($get['id'])) {
|
||||
$admin->DelIpBan($get['id']);
|
||||
}
|
||||
break;
|
||||
case "addBan":
|
||||
if ($get['time']) {
|
||||
$end = time() + $get['time'];
|
||||
|
||||
@@ -885,6 +885,39 @@ class MYSQLi_DB implements IDbConnection {
|
||||
$q = "DELETE from " . TB_PREFIX . "activate where username = '$username'";
|
||||
return mysqli_query($this->dblink,$q);
|
||||
}
|
||||
|
||||
/**
|
||||
* IP ban lookup (issue #185).
|
||||
* Returns the active ban row for the given packed binary IP, or false.
|
||||
* Uses a prepared statement and tolerates the table not existing yet
|
||||
* (older installs): in that case it simply reports "not banned".
|
||||
*
|
||||
* @param string $ipBinary Packed IP (inet_pton output), 4 or 16 bytes.
|
||||
* @return array|false
|
||||
*/
|
||||
function ipBanActive($ipBinary) {
|
||||
if (!is_string($ipBinary) || $ipBinary === '') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
$now = time();
|
||||
$stmt = $this->dblink->prepare(
|
||||
"SELECT id, ip_text, reason, end FROM `".TB_PREFIX."banlist_ip`
|
||||
WHERE ip = ? AND active = 1 AND (end IS NULL OR end = 0 OR end > ?) LIMIT 1"
|
||||
);
|
||||
if (!$stmt) {
|
||||
return false; // table missing / prepare failed (non-throwing mysqli)
|
||||
}
|
||||
$stmt->bind_param("si", $ipBinary, $now);
|
||||
$stmt->execute();
|
||||
$res = $stmt->get_result();
|
||||
$row = $res ? $res->fetch_assoc() : null;
|
||||
$stmt->close();
|
||||
return $row ?: false;
|
||||
} catch (\Throwable $e) {
|
||||
return false; // table missing (throwing mysqli) → treat as not banned
|
||||
}
|
||||
}
|
||||
function deleteReinf($id) {
|
||||
list($id) = $this->escape_input((int) $id);
|
||||
|
||||
|
||||
@@ -53,7 +53,8 @@ class Logging {
|
||||
if (LOG_LOGIN) {
|
||||
|
||||
if (empty($ip)) {
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
// proxy-aware (issue #185): real client IP behind a trusted reverse proxy
|
||||
$ip = \App\Utils\IpResolver::getClientIp() ?? ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
|
||||
}
|
||||
|
||||
list($ip) = $database->escape_input($ip);
|
||||
|
||||
@@ -107,6 +107,13 @@ function __construct() {
|
||||
$this->access = $this->logged_in ? $database->getUserField($this->uid, "access", 1) : 0;
|
||||
}
|
||||
|
||||
// === IP BAN ENFORCEMENT (issue #185) - DUPA ce avem access ===
|
||||
// Admins / Multihunters are never blocked by an IP ban (avoid self-lockout).
|
||||
// The admin panel (Admin/admin.php) does not bootstrap Session, so it stays reachable.
|
||||
if ((int)$this->access < (defined('MULTIHUNTER') ? MULTIHUNTER : 8)) {
|
||||
\App\Utils\IpResolver::enforce($database);
|
||||
}
|
||||
|
||||
// === MAINTENANCE CHECK - DUPA ce avem access ===
|
||||
$maint = $database->getMaintenance();
|
||||
if($maint['active'] == 1 && $this->access < 9) {
|
||||
@@ -165,7 +172,7 @@ function __construct() {
|
||||
$database->updateUserField($user_sanitized, "sessid", $_SESSION['sessid'], 0);
|
||||
}
|
||||
|
||||
$logging->addLoginLog($dbarray['id'], $_SERVER['REMOTE_ADDR']);
|
||||
$logging->addLoginLog($dbarray['id'], \App\Utils\IpResolver::getClientIp() ?? ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'));
|
||||
|
||||
if ($dbarray['id'] == 1) {
|
||||
header("Location: nachrichten.php");
|
||||
|
||||
@@ -87,6 +87,68 @@ Recent improvements include:
|
||||
- Added cache on Database.php and Automation.php and other important files
|
||||
- Dynamic table prefix support in map tile queries
|
||||
|
||||
## Security: IP bans & reverse proxies
|
||||
|
||||
The admin **Ban** page can ban by **IP address** in addition to per-account bans
|
||||
(`Admin/admin.php?p=ban` -> *Ban IP Address*). A banned IP receives an "Access blocked"
|
||||
page on every game page. Admins and Multihunters are never affected, and the admin
|
||||
panel itself is always reachable (no self-lockout). IPv4 and IPv6 are supported.
|
||||
|
||||
### Configuration (`GameEngine/config.php`)
|
||||
|
||||
| Constant | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `BAN_IP_ENABLED` | `true` | Master switch for IP-ban enforcement. |
|
||||
| `IP_TRUSTED_PROXIES` | `""` | Comma-separated proxy IPs/CIDRs allowed to set the forwarded header. |
|
||||
| `IP_FORWARDED_HEADER` | `"HTTP_X_FORWARDED_FOR"` | `$_SERVER` key read for the real client IP when behind a trusted proxy. |
|
||||
|
||||
**Security model:** only `REMOTE_ADDR` (the direct peer) is trusted by default — it
|
||||
cannot be spoofed. Forwarded headers are honoured **only** when `REMOTE_ADDR` is in
|
||||
`IP_TRUSTED_PROXIES`. This prevents a visitor from bypassing a ban with a forged
|
||||
`X-Forwarded-For` header.
|
||||
|
||||
### Deployment scenarios
|
||||
|
||||
**1. Direct access (no proxy)** — default, nothing to do:
|
||||
|
||||
```php
|
||||
define("IP_TRUSTED_PROXIES", "");
|
||||
```
|
||||
|
||||
**2. Behind a reverse proxy** (Nginx, Nginx Proxy Manager / NPMplus, Traefik, Caddy, …):
|
||||
|
||||
```php
|
||||
// your proxy's address, or a CIDR (e.g. 10.0.0.0/8 / 172.16.0.0/12 for a Docker bridge network)
|
||||
define("IP_TRUSTED_PROXIES", "10.0.0.1");
|
||||
define("IP_FORWARDED_HEADER", "HTTP_X_FORWARDED_FOR");
|
||||
```
|
||||
|
||||
The proxy must forward the client IP. Prefer **overwriting** the header with the real
|
||||
peer rather than appending a client-supplied value (non-spoofable). Nginx example:
|
||||
|
||||
```nginx
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
```
|
||||
|
||||
**3. Behind Cloudflare:**
|
||||
|
||||
```php
|
||||
define("IP_TRUSTED_PROXIES", "<your origin proxy or the Cloudflare IP ranges>");
|
||||
define("IP_FORWARDED_HEADER", "HTTP_CF_CONNECTING_IP");
|
||||
```
|
||||
|
||||
> ⚠️ **Important:** behind a proxy, if `IP_TRUSTED_PROXIES` is left empty, every
|
||||
> visitor is seen with the proxy's IP — a single IP ban would then block everyone.
|
||||
> Always set it to your proxy's address.
|
||||
|
||||
### Verify
|
||||
|
||||
Check the resolved IP in the admin **Unified Admin Log** (a login entry shows the
|
||||
client IP), or temporarily print `$_SERVER['REMOTE_ADDR']` and the forwarded header:
|
||||
`REMOTE_ADDR` should be your proxy, and the forwarded header should carry the real
|
||||
client IP.
|
||||
|
||||
## Performance Notes
|
||||
|
||||
For large worlds (for example `400x400`), generation tasks can be expensive.
|
||||
|
||||
@@ -353,7 +353,19 @@ define("CFM_ADMIN_ACT",true);
|
||||
define("SERVER_WEB_ROOT",false);
|
||||
define("USRNM_SPECIAL",true);
|
||||
define("USRNM_MIN_LENGTH",3);
|
||||
define("USRNM_MAX_LENGTH",15);
|
||||
define("PW_MIN_LENGTH",4);
|
||||
|
||||
// === IP ban (issue #185) ===
|
||||
// Master switch for IP-ban enforcement.
|
||||
define("BAN_IP_ENABLED",true);
|
||||
// Comma-separated list of trusted proxy IPs/CIDRs allowed to set the forwarded
|
||||
// header. Leave EMPTY for direct access (REMOTE_ADDR only - non-spoofable).
|
||||
// Reverse proxy example: "127.0.0.1,::1" | set to your proxy/Cloudflare ranges.
|
||||
define("IP_TRUSTED_PROXIES","");
|
||||
// $_SERVER key read for the real client IP when behind a trusted proxy.
|
||||
// Cloudflare: use "HTTP_CF_CONNECTING_IP".
|
||||
define("IP_FORWARDED_HEADER","HTTP_X_FORWARDED_FOR");
|
||||
define("BANNED",0);
|
||||
define("AUTH",1);
|
||||
define("USER",2);
|
||||
|
||||
@@ -51,9 +51,9 @@ class AccessLogger {
|
||||
$prefix[] = date('j.m.Y H:i:s');
|
||||
}
|
||||
|
||||
// add IP
|
||||
// add IP (proxy-aware, issue #185: real client IP behind a trusted reverse proxy)
|
||||
if (!defined('PAGE_ACCESS_LOG_IP') || (defined('PAGE_ACCESS_LOG_IP') && PAGE_ACCESS_LOG_IP)) {
|
||||
$prefix[] = $_SERVER['REMOTE_ADDR'];
|
||||
$prefix[] = IpResolver::getClientIp() ?? ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
|
||||
}
|
||||
|
||||
// add the actual file name
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
#################################################################################
|
||||
## -= YOU MAY NOT REMOVE OR CHANGE THIS NOTICE =- ##
|
||||
## --------------------------------------------------------------------------- ##
|
||||
## Project: TravianZ ##
|
||||
## Filename IpResolver.php ##
|
||||
## Feature: IP ban support (issue #185) ##
|
||||
## License: TravianZ Project ##
|
||||
## Copyright: TravianZ (c) 2010-2026. All rights reserved. ##
|
||||
## Source code: https://github.com/Shadowss/TravianZ ##
|
||||
## ##
|
||||
#################################################################################
|
||||
|
||||
namespace App\Utils;
|
||||
|
||||
/**
|
||||
* Resolves the real client IP address in a way that works for every
|
||||
* deployment style (direct, reverse-proxy, Cloudflare) WITHOUT opening a
|
||||
* spoofing hole, and enforces IP bans.
|
||||
*
|
||||
* Security model
|
||||
* --------------
|
||||
* By default only REMOTE_ADDR is trusted (it cannot be spoofed). Forwarded
|
||||
* headers (X-Forwarded-For / CF-Connecting-IP) are read ONLY when REMOTE_ADDR
|
||||
* belongs to a configured list of trusted proxies. This prevents an attacker
|
||||
* from bypassing an IP ban by simply sending a fake X-Forwarded-For header.
|
||||
*
|
||||
* Configuration constants (all optional, sane defaults apply):
|
||||
* IP_TRUSTED_PROXIES Comma-separated list (or array) of proxy IPs / CIDRs
|
||||
* that are allowed to set the forwarded header.
|
||||
* Examples:
|
||||
* - direct access: "" (default, REMOTE_ADDR only)
|
||||
* - single reverse proxy: "10.0.0.1"
|
||||
* - Cloudflare: the Cloudflare IP ranges
|
||||
* IP_FORWARDED_HEADER The $_SERVER key to read when behind a trusted proxy.
|
||||
* Default "HTTP_X_FORWARDED_FOR". For Cloudflare use
|
||||
* "HTTP_CF_CONNECTING_IP".
|
||||
* BAN_IP_ENABLED Set to false to disable IP-ban enforcement entirely.
|
||||
*/
|
||||
class IpResolver
|
||||
{
|
||||
/**
|
||||
* Returns the validated client IP string, or null if none could be resolved.
|
||||
*/
|
||||
public static function getClientIp()
|
||||
{
|
||||
$remote = isset($_SERVER['REMOTE_ADDR']) ? trim($_SERVER['REMOTE_ADDR']) : '';
|
||||
|
||||
// Default & safe path: trust only the direct peer address.
|
||||
if (!self::remoteIsTrustedProxy($remote)) {
|
||||
return filter_var($remote, FILTER_VALIDATE_IP) ? $remote : null;
|
||||
}
|
||||
|
||||
// We are behind a trusted proxy: read the forwarded header.
|
||||
$header = (defined('IP_FORWARDED_HEADER') && IP_FORWARDED_HEADER)
|
||||
? IP_FORWARDED_HEADER
|
||||
: 'HTTP_X_FORWARDED_FOR';
|
||||
|
||||
if (!empty($_SERVER[$header])) {
|
||||
// X-Forwarded-For may be a list: "client, proxy1, proxy2".
|
||||
// The left-most valid address is the original client.
|
||||
foreach (explode(',', $_SERVER[$header]) as $candidate) {
|
||||
$candidate = trim($candidate);
|
||||
if (filter_var($candidate, FILTER_VALIDATE_IP)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Header missing/invalid → fall back to the proxy address itself.
|
||||
return filter_var($remote, FILTER_VALIDATE_IP) ? $remote : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether REMOTE_ADDR is in the configured trusted-proxy list.
|
||||
*/
|
||||
private static function remoteIsTrustedProxy($remote)
|
||||
{
|
||||
if ($remote === '' || !defined('IP_TRUSTED_PROXIES') || !IP_TRUSTED_PROXIES) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$list = is_array(IP_TRUSTED_PROXIES)
|
||||
? IP_TRUSTED_PROXIES
|
||||
: preg_split('/\s*,\s*/', trim(IP_TRUSTED_PROXIES));
|
||||
|
||||
foreach ($list as $cidr) {
|
||||
if ($cidr !== '' && self::ipInCidr($remote, $cidr)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when $ip falls inside the $cidr range (IPv4 or IPv6).
|
||||
* A plain IP (no "/") is matched for equality.
|
||||
*/
|
||||
public static function ipInCidr($ip, $cidr)
|
||||
{
|
||||
if (strpos($cidr, '/') === false) {
|
||||
return $ip === $cidr;
|
||||
}
|
||||
|
||||
list($subnet, $bits) = explode('/', $cidr, 2);
|
||||
$bits = (int) $bits;
|
||||
|
||||
$ipBin = @inet_pton($ip);
|
||||
$subBin = @inet_pton($subnet);
|
||||
|
||||
// Both must be valid and of the same family (4 or 16 bytes).
|
||||
if ($ipBin === false || $subBin === false || strlen($ipBin) !== strlen($subBin)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$fullBytes = intdiv($bits, 8);
|
||||
$remBits = $bits % 8;
|
||||
|
||||
if ($fullBytes > 0 && substr($ipBin, 0, $fullBytes) !== substr($subBin, 0, $fullBytes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($remBits > 0) {
|
||||
$mask = chr((0xff << (8 - $remBits)) & 0xff);
|
||||
if ((ord($ipBin[$fullBytes]) & ord($mask)) !== (ord($subBin[$fullBytes]) & ord($mask))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an IP string to its packed binary form (for varbinary storage).
|
||||
* Returns null on invalid input.
|
||||
*/
|
||||
public static function toBinary($ip)
|
||||
{
|
||||
$bin = @inet_pton($ip);
|
||||
return $bin === false ? null : $bin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocks the request if the resolved client IP is banned.
|
||||
* Renders a stand-alone 403 page and stops execution. Never throws.
|
||||
*
|
||||
* @param object $database The global Database instance (must expose ipBanActive()).
|
||||
*/
|
||||
public static function enforce($database)
|
||||
{
|
||||
if (defined('BAN_IP_ENABLED') && !BAN_IP_ENABLED) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ip = self::getClientIp();
|
||||
if ($ip === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$bin = self::toBinary($ip);
|
||||
if ($bin === null || !is_object($database) || !method_exists($database, 'ipBanActive')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ban = $database->ipBanActive($bin);
|
||||
if (!$ban) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!headers_sent()) {
|
||||
header('HTTP/1.1 403 Forbidden');
|
||||
header('Content-Type: text/html; charset=UTF-8');
|
||||
}
|
||||
|
||||
$reason = (isset($ban['reason']) && $ban['reason'] !== '')
|
||||
? htmlspecialchars($ban['reason'], ENT_QUOTES, 'UTF-8')
|
||||
: '';
|
||||
$until = !empty($ban['end']) ? date('Y-m-d H:i', (int) $ban['end']) : '∞';
|
||||
|
||||
echo '<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">'
|
||||
. '<meta name="viewport" content="width=device-width,initial-scale=1">'
|
||||
. '<title>Access blocked</title></head>'
|
||||
. '<body style="font-family:Verdana,Arial,sans-serif;background:#0f172a;color:#e2e8f0;text-align:center;padding:60px 20px">'
|
||||
. '<h1 style="color:#ef4444;margin-bottom:8px">Access blocked</h1>'
|
||||
. '<p>Your IP address has been banned from this server.</p>'
|
||||
. ($reason !== '' ? '<p>Reason: <b>' . $reason . '</b></p>' : '')
|
||||
. '<p style="opacity:.7;font-size:13px">Ban expires: ' . $until . '</p>'
|
||||
. '</body></html>';
|
||||
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -401,6 +401,26 @@ CREATE TABLE IF NOT EXISTS `%PREFIX%banlist` (
|
||||
KEY `active-end` (`active`,`end`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- Table structure for table `%PREFIX%banlist_ip` (issue #185 - IP bans)
|
||||
--
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `%PREFIX%banlist_ip` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`ip` varbinary(16) NOT NULL,
|
||||
`ip_text` varchar(45) DEFAULT NULL,
|
||||
`reason` varchar(100) DEFAULT NULL,
|
||||
`time` int(11) UNSIGNED DEFAULT NULL,
|
||||
`end` int(11) UNSIGNED DEFAULT NULL,
|
||||
`admin` int(11) DEFAULT NULL,
|
||||
`active` tinyint(1) UNSIGNED DEFAULT 1,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `ip` (`ip`),
|
||||
KEY `active-end` (`active`,`end`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||
|
||||
--
|
||||
-- Dumping data for table `%PREFIX%banlist`
|
||||
--
|
||||
|
||||
Reference in New Issue
Block a user