From 196e20868edee3d905f0bee296a250e3b9757507 Mon Sep 17 00:00:00 2001 From: ypsdf1 Date: Fri, 2 Oct 2026 13:42:02 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=89=E5=85=A8=E9=97=B8=E9=97=A8=E6=9C=80?= =?UTF-8?q?=E5=B0=8F=E5=8C=96=E4=BF=A1=E6=81=AF=E9=80=8F=E9=9C=B2=EF=BC=9A?= =?UTF-8?q?=E9=9D=9E=E7=99=BD=E5=90=8D=E5=8D=95=E8=AF=B7=E6=B1=82=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E8=BF=94=20nginx=20=E5=8E=9F=E7=94=9F=20404=EF=BC=8C?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20DB=20=E5=BC=95=E5=AF=BC=E6=A0=87=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 兼容新旧版本。 --- admin.php | 3 +- admin_2fa_setup.php | 1 + config.php | 9 +- security.php | 222 ++++++++++++++++++++++++++++++++++++-------- 4 files changed, 191 insertions(+), 44 deletions(-) diff --git a/admin.php b/admin.php index ae2b2e5..54830f1 100644 --- a/admin.php +++ b/admin.php @@ -2,7 +2,8 @@ // ★ 紧急加固(2026-09-29):三层加固的第三层 + 第二层 // 第三层动态令牌:必须 admin.php?token= 才放行, // 不带/带错令牌一律返回 nginx 原生 404 页(与文件不存在时完全一致)。 -// 注意必须放在 secGatePage() 之前 —— 否则白名单 403 会暴露这个文件确实存在。 +// 注意必须放在 secGatePage() 之前 —— 令牌层先判(两层现已统一返回 nginx 404, +// 但顺序仍要保持:引导模式下令牌层放行、页面层负责跳配置页)。 // 第二层 IP 白名单 —— 页面本身也只对白名单 IP 开放。 require_once __DIR__ . '/security.php'; secTokenGate(); diff --git a/admin_2fa_setup.php b/admin_2fa_setup.php index ce3ea83..36a4319 100644 --- a/admin_2fa_setup.php +++ b/admin_2fa_setup.php @@ -315,6 +315,7 @@ if (file_exists($secLogPath)) {

📋 当前状态

+ 门禁状态:
二次验证:
IP 白名单:
diff --git a/config.php b/config.php index 6428eb3..8e00b8f 100644 --- a/config.php +++ b/config.php @@ -74,7 +74,10 @@ define('CAPTCHA_PROVIDER', 'CF'); // ← 只改这个值:'CF' 用 Cloudflare // ===== 紧急安全加固(2026-09-29 入侵事件响应,实现见 security.php)===== // 第一层:TOTP 二次验证 —— 1Password / Microsoft Authenticator 等标准 6 位动态码(仅校验,无推送)。 // 第二层:管理面 IP 白名单 —— 未配置白名单时,仅服务器本机 127.0.0.1 与持有 boot 引导令牌的 -// 人可访问后台,其余任何人一律 403。 +// 人可访问后台;其余任何请求一律返回 nginx 原生 404 页(最小化信息透露:让探测者 +// 分不清是文件不存在还是被拦截),拒绝记录写入 db/security.log。 +// ★ 例外 = 全新部署的第一次访问:本区还是空的、web.db 里也没有引导标记时先放行, +// 引导你把自己的 IP 白名单 / 访问令牌 / 2FA 配齐;配置页一保存即自动转强制模式。 // 第三层:admin.php 动态访问令牌 —— 只有 https://域名/plugin/admin.php?token= // 才能打开后台;不带令牌(或令牌错)的请求一律返回 nginx 原生 404 页, // 与"服务器上根本没有这个文件"表现完全一致,探测者无从判断。 @@ -86,7 +89,9 @@ define('CAPTCHA_PROVIDER', 'CF'); // ← 只改这个值:'CF' 用 Cloudflare // ADMIN_IP_WHITELIST 你自己的 IP 白名单 // SEC_2FA_ENABLED / SEC_2FA_SECRET 你自己的二次验证开关与密钥 // 首次访问 https://域名/plugin/admin_2fa_setup.php 并保存任意一项时,缺失的键会由该页 -// 在你自己的服务器上随机生成并就地补全(写前自动备份到 db/config_bak/)。 +// 在你自己的服务器上随机生成并就地补全(写前自动备份到 db/config_bak/), +// 同时在 web.db 的 sec_bootstrap 表写入一行"引导标记"—— +// 判据 = 本区已有令牌 或 db 有引导标记(任一成立即强制模式)。 // 被锁在外面时的恢复方法:宝塔/SSH 查看本文件补全后的 SEC_BOOT_TOKEN,访问 // https://你的域名/plugin/admin_2fa_setup.php?boot=<令牌> // 进入配置页把自己当前 IP 加入白名单(boot 令牌本身也是配置页入口)。 diff --git a/security.php b/security.php index ae2ed50..b57740d 100644 --- a/security.php +++ b/security.php @@ -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= * 才算"这个文件存在",否则一律返回 nginx 原生 404 页(与文件不存在时 * 的表现完全一致,让探测者分不清是文件不在还是自己请求错了)。 @@ -199,11 +203,15 @@ function secNginx404() { /** * 第三层闸门:只有 admin.php?token= 才放行。 * - 令牌未配置(SEC_ACCESS_TOKEN 为空/不存在)→ 直接放行,向后兼容; + * - 尚未引导(全新部署第一次访问,见 secIsBootstrapped)→ 本层整体不生效, + * 否则部署者连配置页都进不去,首次引导无从完成; * - $allowBoot=true 时 ?boot= 也可放行(配置页逃生舱, * 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 '' - . '' - . '403 - 访问被拒绝
' - . '

⛔ 403 访问被拒绝

' - . '

管理后台已启用 IP 白名单(未配置白名单时仅服务器本机可访问)。

' - . '
你的 IP:' . htmlspecialchars($ip, ENT_QUOTES, 'UTF-8') . '(不在白名单内)
' - . '

管理员恢复访问的两种方式:
' - . '1. 通过宝塔/SSH 编辑 plugin/config.php,把上面的 IP 加进 ' - . 'ADMIN_IP_WHITELIST;
' - . '2. 或用同一文件中的 SEC_BOOT_TOKEN 访问:
' - . '你的域名/plugin/admin_2fa_setup.php?boot=令牌

' - . '

本页拒绝记录已写入 db/security.log

' - . '
'; - 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; }