新增第四层安全防护:全局限流20次/分钟/IP,白名单与带secret的内部调用豁免,超限挂起连接不返回数据

This commit is contained in:
ypsdf1
2026-09-30 15:56:16 +08:00
parent 4c2e075667
commit b6983f99e4
9 changed files with 242 additions and 0 deletions
+228
View File
@@ -9,6 +9,12 @@
* 第三层:admin.php 动态访问令牌 —— 必须是 admin.php?token=<SEC_ACCESS_TOKEN>
* 才算"这个文件存在",否则一律返回 nginx 原生 404 页(与文件不存在时
* 的表现完全一致,让探测者分不清是文件不在还是自己请求错了)。
* 第四层:全局限流(2026-09-30)—— 每个客户端 IP 每 60 秒最多 20 次请求,
* 玩家端与管理端统一生效。白名单 IP(维护者/管理员)、携带正确
* SECRET_KEY 的 Java/服务端调用、以及登录轮询等机器接口不计数;
* 超限请求**不返回任何数据**,直接挂起连接直到 nginx/浏览器超时
* 自动断开(挂起槽有并发上限,避免把 PHP-FPM worker 占满)。
* 应急关闭:配置 define('SEC_RATE_LIMIT', false); 或建 db/ratelimit.off。
*
* 引导令牌(防自锁逃生舱):
* 忘记密码之外的"被锁在外面"场景,用宝塔/SSH 打开 config.php 查看 SEC_BOOT_TOKEN,
@@ -677,3 +683,225 @@ function secWriteConfig(array $kv) {
secLog('config_write', 'keys=' . implode(',', array_keys($kv)));
return true;
}
// ======================================================================
// 第四层:全局限流(每 IP 每 60 秒最多 20 次;白名单 IP 不限)
// 本文件被任何入口 require 时自动执行一次,玩家端与管理端统一生效。
// ======================================================================
/** 应急开关:config 里 SEC_RATE_LIMIT===false,或存在 db/ratelimit.off 时停用。 */
function secRateLimitOff() {
if (defined('SEC_RATE_LIMIT') && SEC_RATE_LIMIT === false) return true;
return file_exists(__DIR__ . '/db/ratelimit.off');
}
/** 当前脚本名(api/sync.php → sync.php)。 */
function secRateScript() {
$s = '';
if (!empty($_SERVER['SCRIPT_NAME'])) $s = (string)$_SERVER['SCRIPT_NAME'];
elseif (!empty($_SERVER['PHP_SELF'])) $s = (string)$_SERVER['PHP_SELF'];
elseif (!empty($_SERVER['REQUEST_URI'])) $s = (string)parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$s = str_replace('\\', '/', (string)$s);
return basename($s);
}
/**
* 轮询/机器接口豁免表:脚本名 => 免计数的 action 列表。
* 这些是前端每 0.5~5 秒一次的登录轮询与支付补单器,超过 20 次/分钟是正常行为,
* 若也计数会让玩家无法登录、支付无法补单。
* 数组里放空字符串 '' 表示整个脚本豁免。
*/
function secRateExemptMap() {
static $map = array(
'sync.php' => array('check_web_login_result', 'web_access_check'),
'pay.php' => array('query_order'),
'land_api.php' => array('get_add_visitor_status', 'get_visitor_status', 'add_visitor_status'),
'minecraft_auth.php' => array('check_session'),
'poller_online.php' => array(''), // 支付补单器:只被服务端/Java 触发,自带进程锁
);
return $map;
}
/** 当前请求是否命中轮询豁免表。 */
function secRateExempt() {
$map = secRateExemptMap();
$script = secRateScript();
if (!isset($map[$script])) return false;
$list = $map[$script];
if (count($list) === 1 && $list[0] === '') return true;
$action = '';
if (isset($_REQUEST['action'])) $action = trim((string)$_REQUEST['action']);
return $action !== '' && in_array($action, $list, true);
}
/**
* 是否携带了正确的 SECRET_KEY(Java/服务端内部调用,不计数)。
* 取值顺序与各接口一致:GET / POST / JSON 请求体。
*/
function secRateHasSecret() {
if (!defined('SECRET_KEY') || SECRET_KEY === '') return false;
$key = (string)SECRET_KEY;
$cands = array();
if (isset($_GET['secret'])) $cands[] = (string)$_GET['secret'];
if (isset($_POST['secret'])) $cands[] = (string)$_POST['secret'];
if (isset($_REQUEST['secret'])) $cands[] = (string)$_REQUEST['secret'];
foreach ($cands as $c) {
if ($c !== '' && hash_equals($key, $c)) return true;
}
// 个别接口把 secret 放在 JSON 请求体里(Content-Type: application/json)
$ct = isset($_SERVER['CONTENT_TYPE']) ? (string)$_SERVER['CONTENT_TYPE'] : '';
if ($ct !== '' && stripos($ct, 'json') !== false && empty($_POST)) {
$len = isset($_SERVER['CONTENT_LENGTH']) ? (int)$_SERVER['CONTENT_LENGTH'] : 0;
if ($len > 0 && $len <= 262144) {
$raw = file_get_contents('php://input');
if (is_string($raw) && $raw !== '') {
$j = json_decode($raw, true);
if (is_array($j)) {
foreach (array('secret', 'key') as $k) {
if (isset($j[$k]) && is_string($j[$k]) && $j[$k] !== ''
&& hash_equals($key, $j[$k])) return true;
}
}
}
}
}
return false;
}
/**
* 计一次数,返回该 IP 当前 60 秒窗口内的请求数(含本次)。
* 计数与判断放在同一把文件锁里,避免“读到没超、写入后已超”的竞态。
*/
function secRateCount($ip, $max, $win) {
$f = secStateFile();
if (!is_dir(dirname($f))) { @mkdir(dirname($f), 0755, true); }
$fp = @fopen($f, 'c+');
if (!$fp) return 0;
flock($fp, LOCK_EX);
$raw = stream_get_contents($fp);
$state = json_decode($raw, true);
if (!is_array($state)) $state = array();
$now = time();
$k = 'rate|' . $ip;
$arr = (isset($state[$k]) && is_array($state[$k])) ? $state[$k] : array();
$arr[] = $now;
$fresh = array();
foreach ($arr as $ts) { if ((int)$ts > $now - $win) $fresh[] = (int)$ts; }
// 单 IP 最多留 60 条:即便被刷爆,也只按窗口内的条数判超限,不撑爆文件
if (count($fresh) > 60) $fresh = array_slice($fresh, -60);
$state[$k] = $fresh;
// 文件过大时顺手清掉其它已过期 IP 的桶,防止长期堆积
if (strlen((string)$raw) > 262144) {
foreach ($state as $kk => $vv) {
if (!is_array($vv)) { unset($state[$kk]); continue; }
$alive = false;
foreach ($vv as $ts) { if ((int)$ts > $now - $win) { $alive = true; break; } }
if (!$alive) unset($state[$kk]);
}
}
ftruncate($fp, 0); rewind($fp);
fwrite($fp, json_encode($state, JSON_UNESCAPED_UNICODE));
fflush($fp); flock($fp, LOCK_UN); fclose($fp);
return count($fresh);
}
/** 挂起槽文件(记录当前正在被挂起的连接,防止占满 PHP-FPM worker)。 */
function secHangFile() {
return __DIR__ . '/db/sec_hang.json';
}
/**
* 申请一个挂起槽:每 IP 最多 2 个、全局最多 4 个。
* 返回槽 id(成功)或 ''(槽满)。槽位带 300 秒过期自愈,进程被强杀也不会永久泄漏。
*/
function secHangAcquire($ip) {
$f = secHangFile();
if (!is_dir(dirname($f))) { @mkdir(dirname($f), 0755, true); }
$fp = @fopen($f, 'c+');
if (!$fp) return '';
flock($fp, LOCK_EX);
$raw = stream_get_contents($fp);
$list = json_decode($raw, true);
if (!is_array($list)) $list = array();
$now = time();
$mine = 0; $total = 0; $keep = array();
foreach ($list as $e) {
if (!is_array($e) || !isset($e['ip'], $e['t'])) continue;
if ((int)$e['t'] < $now - 300) continue; // 过期自愈
$keep[] = $e;
$total++;
if ((string)$e['ip'] === (string)$ip) $mine++;
}
$id = '';
if ($mine < 2 && $total < 4) {
$id = uniqid('h', true);
$keep[] = array('ip' => (string)$ip, 't' => $now, 'id' => $id);
}
ftruncate($fp, 0); rewind($fp);
fwrite($fp, json_encode($keep, JSON_UNESCAPED_UNICODE));
fflush($fp); flock($fp, LOCK_UN); fclose($fp);
return $id;
}
/** 释放挂起槽。 */
function secHangRelease($id) {
if (!is_string($id) || $id === '') return;
$f = secHangFile();
if (!file_exists($f)) return;
$fp = @fopen($f, 'r+');
if (!$fp) return;
flock($fp, LOCK_EX);
$raw = stream_get_contents($fp);
$list = json_decode($raw, true);
if (is_array($list)) {
$out = array();
foreach ($list as $e) {
if (is_array($e) && isset($e['id']) && (string)$e['id'] === $id) continue;
$out[] = $e;
}
if (count($out) !== count($list)) {
ftruncate($fp, 0); rewind($fp);
fwrite($fp, json_encode($out, JSON_UNESCAPED_UNICODE));
fflush($fp);
}
}
flock($fp, LOCK_UN); fclose($fp);
}
/**
* 超限处理:一个字节都不返回,挂起连接直到超时/对端断开。
* - 挂起槽满时立即结束(同样不输出任何内容),避免把 worker 占满导致全站不可用;
* - 挂起期间关闭 session 写锁,否则同用户的下一个请求会被 session 文件锁卡死;
* - 上限 90 秒:nginx fastcgi_read_timeout 默认 60 秒会先返回 504 断开。
*/
function secRateSuspend($ip) {
$id = secHangAcquire($ip);
if ($id === '') { exit; }
@set_time_limit(0);
if (session_status() === PHP_SESSION_ACTIVE) { @session_write_close(); }
$start = time();
while (time() - $start < 90) {
if (connection_aborted()) break;
@sleep(1);
}
secHangRelease($id);
exit;
}
/** 第四层入口:include security.php 时自动执行一次。 */
function secRateLimitGate() {
if (PHP_SAPI === 'cli') return; // 命令行脚本不参与限流
if (empty($_SERVER['REMOTE_ADDR'])) return; // 无来源地址(CLI/内部)不参与
if (secRateLimitOff()) return; // 应急开关
$ip = secClientIp();
if ($ip === '') return;
if (secIpAllowed()) return; // 第二层白名单 IP = 维护者/管理员,不限
if (secRateHasSecret()) return; // Java/服务端调用(带正确 SECRET_KEY)
if (secRateExempt()) return; // 登录轮询、支付补单等机器接口
$n = secRateCount($ip, 20, 60);
if ($n <= 20) return;
if ($n === 21) { secLog('rate_blocked', '超过 20 次/分钟,开始挂起连接不返回数据'); }
secRateSuspend($ip);
}
secRateLimitGate();