安全闸门最小化信息透露:非白名单请求统一返 nginx 原生 404,新增 DB 引导标记

- secGatePage / secGateApi 不再回 403(页面 HTML 与 JSON),一律 secNginx404(),
  探测者分不清是文件不存在还是被拦截;拒绝仍写 db/security.log。
- 新增 secIsBootstrapped() 双判据:config SEC-UPDATE 区已有令牌(既有站点即刻生效)
  或 web.db 的 sec_bootstrap 标记表有行(secWriteConfig 保存时写入)。
- 全新部署首次访问(两者皆空)为引导模式:放行并把 admin.php 跳到 admin_2fa_setup.php,
  配齐白名单/访问令牌/2FA 后一保存即自动转强制;读库异常从严按已引导。
- secTokenGate 引导模式不强制,已引导后照旧 ?token= 否则 404。
- setup 页状态卡片新增「门禁状态」展示;config.php 注释同步 404 与引导标记说明。
- 不调用 SQLite3::enableExceptions(false):PHP 8.4 该写法触发 E_DEPRECATED,
  提前输出会把 nginx404 的状态行挤掉(404 变 200),改由 @ + try/catch 兼容新旧版本。
This commit is contained in:
ypsdf1
2026-10-02 13:42:02 +08:00
parent 2f5f02b72d
commit 196e20868e
4 changed files with 191 additions and 44 deletions
+181 -41
View File
@@ -5,7 +5,11 @@
* 第一层:TOTP 二次验证(6 位动态码,兼容 1Password / Microsoft Authenticator /
* Google Authenticator 等标准 RFC6238 认证器,仅本地校验、无推送)
* 第二层:管理面 IP 白名单(ADMIN_IP_WHITELIST 为空 = 仅服务器本机 127.0.0.1
* 与 boot 引导令牌可访问;任何人一律拒绝)
* 与 boot 引导令牌可访问;不在白名单的请求一律返回 nginx 原生 404 页,
* 让探测者分不清是文件不存在还是被拦截 —— 最小化信息透露,维护者自己
* 知道哪里有问题(服务器在他手上)。全新部署首次访问例外:config 的
* SEC-UPDATE 区还是空的、web.db 里也没有引导标记 → 先放行,引导部署者
* 配好白名单 / 访问令牌 / 2FA,配置页一保存即自动转为强制模式)。
* 第三层:admin.php 动态访问令牌 —— 必须是 admin.php?token=<SEC_ACCESS_TOKEN>
* 才算"这个文件存在",否则一律返回 nginx 原生 404 页(与文件不存在时
* 的表现完全一致,让探测者分不清是文件不在还是自己请求错了)。
@@ -199,11 +203,15 @@ function secNginx404() {
/**
* 第三层闸门:只有 admin.php?token=<SEC_ACCESS_TOKEN> 才放行。
* - 令牌未配置(SEC_ACCESS_TOKEN 为空/不存在)→ 直接放行,向后兼容;
* - 尚未引导(全新部署第一次访问,见 secIsBootstrapped)→ 本层整体不生效,
* 否则部署者连配置页都进不去,首次引导无从完成;
* - $allowBoot=true 时 ?boot=<SEC_BOOT_TOKEN> 也可放行(配置页逃生舱,
* boot 令牌本身就是"读得到 config.php 的服务器管理员"凭据);
* - 其余一律 nginx 404(不是 403 —— 403 会暴露文件确实存在)。
*/
function secTokenGate($allowBoot = false) {
// 引导模式:config 里还没有任何令牌、web.db 里也没有引导标记 → 本层先不生效。
if (!secIsBootstrapped()) return;
$want = secToken();
if ($want === '') return;
$got = '';
@@ -224,61 +232,190 @@ function secTokenUrl($page = 'admin.php', $extra = '') {
return $page . (strpos($page, '?') === false ? '?' : '&') . 'token=' . rawurlencode($t) . $extra;
}
// ======================================================================
// 引导标记(bootstrap marker)—— 判断"这站是否已经用过后台配置页"
// ======================================================================
//
// 判据(任一成立 = 已引导,闸门转为强制模式):
// 1) config.php 的 SEC-UPDATE 区已有 SEC_BOOT_TOKEN / SEC_ACCESS_TOKEN
// (线上既有站点早就写过 → 改动上线即刻生效,不存在暴露窗口);
// 2) web.db 的 sec_bootstrap 标记表有行 —— admin_2fa_setup.php 每次保存都由
// secWriteConfig() 顺手写入(Java 插件产生的业务数据不算数)。
//
// 两者皆空 = 全新部署的第一次访问 → 引导模式:先放行,让部署者把自己的
// IP 白名单 / 访问令牌 / 2FA 配齐,配置页一保存即自动转强制。
// 读库异常(文件损坏、被锁死、打不开)→ 从严按"已引导"处理:宁可把本机之外的
// 访问挡成 404,也不把闸门敞开;127.0.0.1 与 boot 令牌永远不受影响。
//
// 为什么不用"库里有没有数据"当判据:Java 插件建站第一天就会往 web.db 写业务
// 数据,按数据量判断会把还没配置完的部署者直接锁死在门外,故用显式标记。
/** 打开 web.db(仅供引导标记读写)。$write=true 时允许新建文件;失败返回 null。 */
function secBootstrapDb($write = false) {
if (!class_exists('SQLite3')) return null;
if (!defined('DB_PATH') || DB_PATH === '') return null;
$path = DB_PATH;
if ($write) {
$dir = dirname($path);
if (!is_dir($dir)) { @mkdir($dir, 0755, true); }
$flags = SQLITE3_OPEN_READWRITE | SQLITE3_OPEN_CREATE;
} else {
if (!file_exists($path)) return null;
$flags = SQLITE3_OPEN_READWRITE;
}
try {
$db = new SQLite3($path, $flags);
// ★ 刻意不调用 enableExceptions():PHP 8.4+ 该方法只保留异常模式,传 false
// 会触发 E_DEPRECATED —— 本函数跑在闸门判定里,任何提前输出都会把后面
// secNginx404() 的状态行挤掉(headers already sent → 404 变 200)。
// 两种版本差异由调用方兜住:老版本返回 false、新版本抛异常,
// 而下面所有查询都带 @ 并处在 try/catch 里。
@$db->exec('PRAGMA busy_timeout=3000');
return $db;
} catch (Throwable $e) {
secLog('bootstrap_db_open_fail', $e->getMessage());
return null;
}
}
/**
* 写入引导标记(secWriteConfig 成功后调用)。
* 失败只记日志、不抛错 —— 判据 1(config 已有令牌)足以兜底,不能因为写标记
* 失败就把一次好好的配置保存变成报错。
*/
function secMarkBootstrapped($note = '') {
$db = secBootstrapDb(true);
if ($db === null) {
secLog('bootstrap_mark_fail', 'open failed path=' . (defined('DB_PATH') ? DB_PATH : '(undefined)'));
return false;
}
try {
$ok = $db->exec('CREATE TABLE IF NOT EXISTS sec_bootstrap ('
. 'id INTEGER PRIMARY KEY CHECK (id = 1),'
. 'marked_at INTEGER NOT NULL,'
. 'note TEXT)');
if ($ok === false) {
secLog('bootstrap_mark_fail', 'create table: ' . $db->lastErrorMsg());
$db->close();
return false;
}
$t = (string)time();
$n = str_replace("'", "''", substr((string)$note, 0, 200));
$ok = $db->exec("INSERT OR REPLACE INTO sec_bootstrap (id, marked_at, note) VALUES (1, {$t}, '{$n}')");
if ($ok === false) {
secLog('bootstrap_mark_fail', 'insert: ' . $db->lastErrorMsg());
$db->close();
return false;
}
$db->close();
secLog('bootstrap_marked', 'note=' . $note);
return true;
} catch (Throwable $e) {
secLog('bootstrap_mark_fail', $e->getMessage());
return false;
}
}
/** 是否已引导(双判据说明见本段注释)。结果在单次请求内缓存。 */
function secIsBootstrapped() {
static $cached = null;
if ($cached !== null) return $cached;
// 判据 1:config 里已经有引导 / 访问令牌(分发版是空的,线上早就写过)
if (secBootToken() !== '' || secToken() !== '') {
$cached = true;
return true;
}
// 判据 2:web.db 还没建 = 全新部署第一次访问 → 引导模式
if (!defined('DB_PATH') || DB_PATH === '' || !file_exists(DB_PATH)) {
$cached = false;
return false;
}
$db = secBootstrapDb(false);
if ($db === null) {
$cached = true; // 有库却打不开 → 从严按已引导
return true;
}
try {
$m = @$db->query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'sec_bootstrap' LIMIT 1");
if ($m === false) {
$db->close();
$cached = true; // 连 sqlite_master 都读不了 → 从严
return true;
}
if ($m->fetchArray() === false) {
$db->close();
$cached = false; // 标记表还没建 = 没人保存过配置 → 引导模式
return false;
}
$r = @$db->query('SELECT marked_at FROM sec_bootstrap LIMIT 1');
if ($r === false) {
$db->close();
$cached = true; // 从严
return true;
}
$has = ($r->fetchArray() !== false);
$db->close();
$cached = $has;
return $cached;
} catch (Throwable $e) {
secLog('bootstrap_read_fail', $e->getMessage());
$cached = true; // 读库异常 → 从严按已引导
return true;
}
}
// ======================================================================
// 页面 / API 闸门(第二层 IP 白名单的挂载点)
// ======================================================================
/** 页面级闸门。$allowBoot=true 时接受 boot 令牌解锁(仅配置页使用)。 */
/**
* 页面级闸门。$allowBoot=true 时接受 boot 令牌解锁(仅配置页使用)。
* - 已引导 且 不在 IP 白名单 → nginx 原生 404(不是 403:403 会告诉探测者
* "这个文件确实存在",与最小化信息透露原则冲突);
* - 未引导(全新部署第一次访问)→ 放行;后台主入口 admin.php 不直接渲染,
* 改跳到 admin_2fa_setup.php 完成白名单 / 令牌 / 2FA 的首次配置。
*/
function secGatePage($allowBoot = false) {
// ★ boot 必须先于 IP 判定:IP 已在白名单时原逻辑直接 return,
// 会导致 admin_2fa_setup.php?boot=... 在"自己 IP 已加白"的情况下进不了引导模式。
if ($allowBoot && secBootAuthenticate()) return;
if (secIpAllowed()) return;
// 引导模式:全新部署的第一次访问(config 无令牌、web.db 无引导标记)
// → 先放行,让部署者完成首次配置;后台主入口改跳配置页,这就是"引导"的落点。
if (!secIsBootstrapped()) {
secLog('ip_gate_open_bootstrap', 'path=' . (isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '-'));
$script = basename(isset($_SERVER['SCRIPT_NAME']) ? $_SERVER['SCRIPT_NAME'] : '');
if ($script === 'admin.php') {
header('Location: admin_2fa_setup.php');
exit;
}
return;
}
// 已引导且不在白名单 → nginx 原生 404(绝不回 403:403 等于承认文件存在)。
// 维护者不需要提示页 —— 服务器在他手上,他知道该查 db/security.log;
// 试探者只能看到与"文件不存在"完全一致的表现。恢复入口写在 config.php 注释里
// (boot 引导令牌访问 admin_2fa_setup.php)。
secLog('ip_block_page', 'path=' . (isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '-'));
while (ob_get_level() > 0) { @ob_end_clean(); }
http_response_code(403);
header('Content-Type: text/html; charset=utf-8');
$ip = secClientIp() !== '' ? secClientIp() : '(未知)';
echo '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">'
. '<meta name="viewport" content="width=device-width,initial-scale=1">'
. '<title>403 - 访问被拒绝</title><style>'
. 'body{background:#0d1117;color:#e6edf3;font-family:Segoe UI,system-ui,sans-serif;'
. 'display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0}'
. '.b{background:#161b22;border:1px solid #30363d;border-radius:12px;padding:36px;'
. 'width:460px;max-width:92%}h1{color:#f85149;font-size:22px;margin:0 0 10px}'
. '.ip{font-family:monospace;color:#58a6ff;background:#0d1117;border:1px solid #30363d;'
. 'border-radius:6px;padding:8px 12px;margin:12px 0;font-size:14px}'
. 'p{color:#8b949e;font-size:13px;line-height:1.7;margin:8px 0}'
. 'code{color:#d29922;font-family:monospace}</style></head><body><div class="b">'
. '<h1>⛔ 403 访问被拒绝</h1>'
. '<p>管理后台已启用 <b style="color:#e6edf3">IP 白名单</b>(未配置白名单时仅服务器本机可访问)。</p>'
. '<div class="ip">你的 IP:' . htmlspecialchars($ip, ENT_QUOTES, 'UTF-8') . '(不在白名单内)</div>'
. '<p>管理员恢复访问的两种方式:<br>'
. '1. 通过宝塔/SSH 编辑 <code>plugin/config.php</code>,把上面的 IP 加进 '
. '<code>ADMIN_IP_WHITELIST</code>;<br>'
. '2. 或用同一文件中的 <code>SEC_BOOT_TOKEN</code> 访问:<br>'
. '<code>你的域名/plugin/admin_2fa_setup.php?boot=令牌</code></p>'
. '<p style="color:#f85149;font-size:12px">本页拒绝记录已写入 db/security.log</p>'
. '</div></body></html>';
exit;
secNginx404();
}
/** API 级闸门(JSON 403)。 */
/**
* API 级闸门。与页面闸门同规则:已引导且不在白名单 → nginx 原生 404;
* 引导模式 → 放行(各动作自己仍有登录 / 口令 / 人机验证把关)。
* 不再回 JSON 403 —— 403 等于告诉探测者"这个接口确实存在",
* 而 404 与"服务器上根本没有这个文件"表现完全一致。
*/
function secGateApi() {
if (secIpAllowed()) return;
if (!secIsBootstrapped()) {
secLog('api_gate_open_bootstrap', 'action=' . (isset($_GET['action']) ? $_GET['action'] : '-'));
return;
}
secLog('ip_block_api', 'action=' . (isset($_GET['action']) ? $_GET['action'] : '-'));
while (ob_get_level() > 0) { @ob_end_clean(); }
http_response_code(403);
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
echo json_encode(array(
'success' => false,
'code' => 403,
'need_ip' => true,
'ip' => secClientIp(),
'message' => '访问被拒绝:IP 不在管理白名单内'
), JSON_UNESCAPED_UNICODE);
exit;
secNginx404();
}
// ======================================================================
@@ -681,6 +818,9 @@ function secWriteConfig(array $kv) {
}
if (function_exists('opcache_invalidate')) { @opcache_invalidate($path, true); }
secLog('config_write', 'keys=' . implode(',', array_keys($kv)));
// ★ 引导标记:配置一落盘 = "这站已经用过后台面板",secIsBootstrapped() 的判据 2
// 从此成立(判据 1 是 config 里已有令牌,两者互为兜底)。
secMarkBootstrapped('secWriteConfig:' . implode(',', array_keys($kv)));
return true;
}