本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/docs/site_stats插件审计报告_2026-09-24.md。
site_stats 插件审计报告
- 审计对象:
plugins/site_stats/(站点统计 v1.0.0) - 审计日期:2026-09-24
- 审计依据:
docs/插件开发规范.md(1292 行,2026-09-22 版)+ 核心实际代码交叉验证 - 审计方式:静态代码审计 + 运行时实测(
php -S+ 伪造会话)+ A/B 反证 - 结论:2 项 P0(含 1 项越权、1 项功能必然失败),必须修复后才可上线
⚠️ 插件自带的
说明.md末尾有一张「落地检查清单」,自称 13 项全部 ✅。本次审计证明其中至少 2 项与实现不符(鉴权、CSRF),该自述文档不可作为审计依据。
一、结论摘要
| 编号 | 级别 | 问题 | 位置 | 状态 |
|---|---|---|---|---|
| P0-1 | 🔴 越权 | 普通会员被判定为管理员,可访问后台统计页 | AdminController.php:45-62 | 已实测确证 |
| P0-2 | 🔴 功能坏 | 清理按钮调用 Csrf::verifyOrDie() 缺参 → 必然 500 | AdminController.php:34 | 已实测确证 |
| P1-1 | 🟠 鉴权层 | 绕过后台二次验证(admin_verified_at) | AdminController.php:10,26 | 静态确证 |
| P1-2 | 🟠 卫生 | plugin.json / 说明.md 为 CRLF(§11.19 要求 LF) | 2 个文件 | 已实测 |
| P2-1 | 🟡 健壮性 | 建表会话标记无「数据层版本号」 | hook/init_after.php:8 | 静态确证 |
| P2-2 | 🟡 规范 | db() 自愈每请求跑 DDL(§11.15 不建议) | Plugin.php:10-18 | 已实测(影响可忽略) |
| P2-3 | 🟡 准确性 | ipHash() 信任可伪造的 X-Forwarded-For | Plugin.php:89 | 静态确证 |
| P2-4 | 🟡 准确性 | 趋势日期依赖 SQLite localtime | Plugin.php:143 | 静态确证 |
| P2-5 | 🟡 一致性 | 传死变量 currentNav,未用 __nav_active | AdminController.php:20 | 已实测 |
合规通过项:class_alias、生命周期三方法语义、SQL 参数绑定、SQLite DDL 语法、i18n 三语零差异、主题变量、图标名、零内联、视图转义、CSRF 表单字段、permissions 声明、无 BOM、无残留文件、钩子触发源收敛与节流。
二、P0-1 越权:普通会员可看全站统计
2.1 缺陷代码
plugins/site_stats/AdminController.php:44-62:
/** 管理员判定(group_id=1 / role=admin / is_admin) */
private function checkAdmin(): bool
{
$u = \app\Helpers\Auth::getCurrentUser();
...
if (!empty($u['is_admin'])) { return true; } // ① 死代码
if (($u['role'] ?? '') === 'admin') { return true; } // ② 只有 id=1 满足
return (int)($u['group_id'] ?? 0) === 1; // ③ ← 越权根源
}2.2 为什么错
核心的正确口径(app/Helpers/Auth.php:20-27):
public static function isAdmin(): bool
{
$user = self::getCurrentUser();
return $user !== null
&& ($user['role'] ?? '') === 'admin'
&& (int)($user['group_id'] ?? 1) === 4; // ← 要求 group_id === 4
}数据库实际取值(data/meta/business.sqlite):
user_groups 表:
id=1 普通会员 is_default=1 ← 默认用户组
id=2 VIP会员
id=3 版主
id=4 管理员 ← 真正的管理员组
id=5 封禁账户
users 表分布:
group_id=1, role=user → 284 人
group_id=4, role=admin → 1 人(id=1 flinthub)三点致命错误:
- ③ 的
group_id === 1指向的是「普通会员」默认组,不是管理员组(管理员是 4)。
在 return 短路下,284 个普通会员全部通过判定。
- ① 的
is_admin字段users表根本不存在(已查PRAGMA table_info(users)),
该分支是死代码,empty() 恒为 true → 取反恒 false。
- 插件把三个条件用 OR 串起来,而核心是 AND(
role=admin且group_id=4)——
这是「放宽」而非「等价实现」。
2.3 实测证据
探针会话(user_id=2,即「虚拟化大师」role=user, group_id=1):
GET /admin/site-stats
未登录 -> HTTP 302(跳 /login) ✅ 正常
user_id=2 普通会员 -> HTTP 200, 15827 字节 ❌ 越权
[统计面板已渲染][含PV数据]
user_id=1 管理员 -> HTTP 200, 15820 字节 ✅ 正常普通会员返回 200 且完整渲染出统计面板(含 PV/UV 数值),与管理员页面无差别。
2.4 影响面
任何注册用户(本机 284 人,生产同量级)访问 /admin/site-stats 即可获得:
- 全站今日 / 近 7 天 / 近 30 天 PV、UV
- 热门页面 Top 10 的完整路径 —— 包含
/admin/*、/admin/verify等后台路径,
泄露后台功能面
- 近 7 天流量趋势
虽未直接泄露帖子正文,但属于明确的越权信息泄露;且该页面是 POST 写操作的入口
(清理数据),一旦 P0-2 修好,越权用户即可清空全站统计数据。
2.5 正确范式(同项目已有先例)
plugins/vn_block/AdminController.php:23-39(2026-09-23 刚审计修复):
public function settings()
{
$this->requireAdmin();
$this->requireAdminVerified();
...
}其文件头注释明确写了理由:
鉴权仍在控制器内用基类
requireAdmin()+requireAdminVerified()完成(与后台 BaseController 同口径:管理员 + 30 分钟二次验证)。
2.6 修复
public function index()
{
$this->requireAdmin(); // 替代 requireLogin() + checkAdmin()
$this->requireAdminVerified();
$stats = Plugin::getStats();
$this->view('plugins/site_stats/admin', ['stats' => $stats, '__nav_active' => 'plugins']);
}
public function purge()
{
$this->requireAdmin();
$this->requireAdminVerified();
\app\Helpers\Csrf::verifyOrDie($_POST['csrf'] ?? ''); // 见 P0-2
...
}并删除自造的 checkAdmin() 方法(AdminController.php:44-62)。
行为差异提示:
requireAdmin()未通过时跳/,而原实现返回 404。跳
/是项目现有范式(vn_block同),如希望保留 404 语义需自行处理。
三、P0-2 清理按钮必然 500
3.1 缺陷代码
plugins/site_stats/AdminController.php:34:
// 写操作必须校验 CSRF(红线 8)
\app\Helpers\Csrf::verifyOrDie(); // ← 少传了 token而核心签名(app/Helpers/Csrf.php:60)是必填参数:
public static function verifyOrDie($token): void
{
if (!self::verify($token)) { http_response_code(403); echo '403 - CSRF 验证失败
'; exit; }
}3.2 实测证据
POST /admin/site-stats/purge (管理员会话 + 正确 csrf)
-> HTTP 500, 125 字节
protected/error.log:
[24-Sep-2026 12:00:17 Asia/Shanghai] 500 Error: Too few arguments to function
app\Helpers\Csrf::verifyOrDie(), 0 passed in
D:\phpstudy_pro\WWW\plugins\site_stats\AdminController.php on line 34
and exactly 1 expected in D:\phpstudy_pro\WWW\app\Helpers\Csrf.php:60A/B 反证(同一函数,仅改传参):
[A] 正确传参 Csrf::verifyOrDie("abc") : 通过,无异常
[B] 缺参 Csrf::verifyOrDie() : ArgumentCountError
Too few arguments ... 0 passed ... exactly 1 expected3.3 影响面
- 后台「立即清理」按钮 100% 失败(任何点击都是 500)
- 插件宣称的「后台手动清理」功能从未可用
- 概率自动清理(
route_after_dispatch里 1% 概率的purgeOlderThan(90))不受影响,
因为它不经过 CSRF 校验 —— 所以数据不会无限增长,但用户无法手动控制
3.4 修复
\app\Helpers\Csrf::verifyOrDie($_POST['csrf'] ?? '');核心自身与 vn_block 都是这个写法(如 Admin\BaseController::batchMoveIds() 第一行
即为 Csrf::verifyOrDie($_POST['csrf'] ?? '');)。
视图侧
views/admin.php:105的<input type="hidden" name="csrf" value="...">是正确的——
Controller::view()的 defaults 里有'csrfToken' => Csrf::token()(app/Core/Controller.php:41),会自动注入。问题只在控制器侧的调用。
四、P1 级问题
P1-1 绕过后台二次验证
核心后台的门禁在 app/Controllers/Admin/BaseController.php:17-40 的构造函数里:
public function __construct()
{
if (!Auth::isAdmin()) { ... 跳 /login ... exit; }
$verifyTimeout = max(60, min((int)Settings::get('admin_verify_timeout', '1800'), 604800));
if ($uriPath !== '/admin/verify') {
$verifiedAt = $_SESSION['admin_verified_at'] ?? 0;
if ($verifiedAt < time() - $verifyTimeout) { ... 跳 /admin/verify ... exit; }
}
}插件继承的是 app\Core\Controller(规范 §5.3 允许),不会触发上述构造函数,
因此必须在控制器内自己补等价门禁。site_stats 只做了 requireLogin() + 口径错误的自造判定,
双重缺失:既无正确管理员判定,也无二次验证。
修复见 §2.6(requireAdmin() + requireAdminVerified())。
P1-2 编码卫生:CRLF
§11.19 要求插件内所有文本文件 UTF-8 无 BOM + LF,例外清单只有 plugins/mod_system/ 下 4 个文件。
实测(按字节数 \r\n):
| 文件 | BOM | CRLF 处数 | 判定 |
|---|---|---|---|
plugin.json | 无 | 15 | ❌ 应为 LF |
说明.md | 无 | 751 | ❌ 应为 LF |
| 其余 13 个文件 | 无 | 0 | ✅ |
修复:两个文件转 LF(转完复核字节数)。
五、P2 / 观察项
P2-1 建表会话标记无「数据层版本号」
hook/init_after.php:8:
if (empty($_SESSION['_site_stats_schema'])) {
\Plugin\SiteStats\Plugin::buildSchema();
$_SESSION['_site_stats_schema'] = 1; // 只判「存在」,不比对内容
}项目红线:门控标记必须带数据层版本号(比对内容,而非判存在),否则日后新增表 / 索引时,
老会话永不重跑 buildSchema(),新表静默建不出来。
当前实际无影响 —— 因为 db() 的每请求自愈(P2-2)会把表兜住。但若按 P2-2 的建议
把 db() 自愈发掉,此处就会变成真缺陷。两处应一起改。
P2-2 db() 自愈每请求跑 DDL —— 形式不合规,实测影响可忽略
Plugin.php:10-18 的 $schemaReady 是请求级静态变量,每请求重置:
public static function db(): \PDO
{
$pdo = \app\Helpers\Plugin::db('site_stats');
if (!self::$schemaReady) { self::buildSchema($pdo); self::$schemaReady = true; }
return $pdo;
}而 record() 挂在 route_after_dispatch(每页触发)→ 每个页面请求都执行
1 条 CREATE TABLE IF NOT EXISTS + 2 条 CREATE INDEX IF NOT EXISTS。
§11.15 明确:「别在控制器每请求跑 DDL;ensureSchema() 本身无缓存门控,
要在插件侧加戳文件 / 会话守卫。」
⚠️ 但实测开销极小,不应夸大:本机 200 次基准(
php8.2.9nts):
| 操作 | 单次耗时 |
|---|---|
buildSchema 3 条 DDL | 0.040 ms |
record() 1 条 INSERT | 21.245 ms |
| DDL 占每请求 SQL 总耗时 | 0.2 % |
CREATE ... IF NOT EXISTS 在对象已存在时几乎零成本。这是形式合规问题,不是性能事故。
若要修,建议与 P2-1 合并:改成「戳文件 + 数据层版本号」门控,顺带把 §11.4 第 24 条
(每页 hook 查询 ≤ 5 次)的余量从 4/5 降到 1/5。
P2-3 ipHash() 信任可伪造的 X-Forwarded-For
Plugin.php:89:
$ip = (string)($_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '');X-Forwarded-For 是客户端可控头,且插件未判断请求是否真的经过可信代理。
任何人构造该头即可伪造出无限多个「不同 IP」→ UV 可被随意刷高。
影响仅限统计准确性(IP 只存哈希,无泄露风险)。建议:无反向代理时只取 REMOTE_ADDR;
有代理时校验代理来源 IP 后再取 XFF 链。
P2-4 趋势日期依赖 SQLite localtime
Plugin.php:143:
date(created_at, 'unixepoch', 'localtime') AS daylocaltime 取的是 SQLite 进程的系统时区,与应用时区(Asia/Shanghai)可能不一致。
服务器迁移或时区配置变化时,趋势图的日期会整体偏移。建议在 PHP 侧按应用时区归日。
P2-5 传死变量 currentNav,未用 __nav_active
AdminController.php:20:'currentNav' => 'admin'
实测:
currentNav全站只有一处赋值(app/Core/Controller.php:42),零消费点- 布局实际读的是
__nav_active(app/Views/layouts/main.php:7:
$navActive = $this->getData('__nav_active', ''))
- 20 个插件中,19 个统一传
__nav_active => '<插件标识>',只有 site_stats 传currentNav
当前效果:导航高亮不生效(无功能损坏,但与其他插件不一致)。
建议改为 '__nav_active' => 'plugins'(与 bitiful_storage / vn_block 一致)。
观察项:历史残留探针会话
sessions.sqlite 中有 189 行 vnpnp* 前缀的会话,是 2026-09-23
props / vn_postname 审计遗留的探针数据(csrf 为 32 个 b、含 admin_verified_at)。
本次审计未删除(不属于 site_stats 范围,需你确认)。建议清理:
按 id LIKE 'vnpnp%' 精确删除,或等过期自动回收。
六、合规通过项(逐条实测)
| 检查项 | 依据 | 结果 |
|---|---|---|
目录名含下划线 → class_alias | §4.1 | ✅ Plugin.php:174-176,第三参 false,先判 class_exists 幂等 |
activate() 建表 / deactivate() 不删 / uninstall() 只删独立库表 | §4.1 | ✅ 语义正确 |
| 所有 SQL 参数绑定 | 规则 2 | ✅ 无字符串拼接 |
| SQLite DDL 语法 | §10.3 | ✅ INTEGER PRIMARY KEY / TEXT / IF NOT EXISTS |
独立库 + data/ 防护文件 | §10.1 | ✅ .htaccess + web.config 齐备 |
| i18n 键三语一致 | §八 | ✅ 18 键 × 3 语,键集合零差异、无缺失、无冗余 |
| 主题变量 | §11.7 | ✅ 用到的 7 个 --mn-* 在 9 套主题 + modern.css + xenforo.css 全部存在 |
| 图标名有效 | §6.2 | ✅ chart-line 在 TemplateCompiler.php:786 的 $map 中 |
| 零内联 | §7.3 | ✅ 无 <style>/<script>/on*;唯一 style="--ss-h:N%" 属 §7.3 第 4 条明确合规的「内联 CSS 变量注入」 |
| 视图输出转义 | 规则 3 | ✅ 全部经 $this->e();数字经 (int) |
| 资源引用 | §7.1 | ✅ 拼 BASE_PATH + ?v=filemtime |
| 页面级 CSS | §6.1 | ✅ 走 section('css') |
permissions 声明 | §三 | ✅ ["route:admin"] 与 admin_route_register 匹配 |
钩子 $router 判空 | §9.5 | ✅ admin_route_register.php:4 |
| 钩子顶部先判空/收敛 | §9.5 | ✅ route_after_dispatch 做了 SAPI/方法/路径/爬虫四重收敛 |
| 每页触发钩子做节流 | §11.20 | ✅ 同路径 60 秒会话级节流 |
| 编码无 BOM | §11.19 | ✅ 15 个文件全部无 BOM |
无 .bak/.tmp 残留 | §二 | ✅ |
说明.md 存在 | §11.19 | ✅ 规范认可 |
额外正面发现:爬虫过滤实测有效 —— 本次审计用 curl(UA 含 curl/)发起的
全部 HTTP 请求未被记入统计表(表中 15 行均为 03:42–03:59 的真实浏览),
证明 route_after_dispatch.php:36 的 UA 黑名单生效。
七、修复清单(建议顺序)
| # | 文件 | 改动 | 级别 |
|---|---|---|---|
| 1 | AdminController.php:10-15, 26-31 | requireLogin()+checkAdmin() → requireAdmin()+requireAdminVerified() | P0 |
| 2 | AdminController.php:44-62 | 删除自造 checkAdmin() 方法 | P0 |
| 3 | AdminController.php:34 | verifyOrDie() → verifyOrDie($_POST['csrf'] ?? '') | P0 |
| 4 | AdminController.php:20 | currentNav → __nav_active => 'plugins' | P2 |
| 5 | plugin.json / 说明.md | CRLF → LF | P1 |
| 6 | Plugin.php:10-18 + hook/init_after.php:8 | 建表门控改「戳文件 + 数据层版本号」 | P2 |
| 7 | Plugin.php:89 | XFF 仅在可信代理后取用 | P2 |
| 8 | Plugin.php:143 | 趋势归日改由 PHP 按时区计算 | P2 |
| 9 | 说明.md | 更新自述(当前「避免每请求跑 DDL」与实际不符;清单自称全 ✅ 亦不实) | — |
八、复现步骤
# 1. 注入探针会话(普通会员 user_id=2 / 管理员 user_id=1)
# 写入 data/meta/sessions.sqlite,data = 'user_id|i:2;csrf|s:64:"c×64";csrf_time|i:;'
# 2. 启动本地服务器
php -S 127.0.0.1:8899 -t "D:/phpstudy_pro/WWW"
# 3. 越权验证(预期 200 = 越权成立)
curl -s -o /dev/null -w "%{http_code}\n" --noproxy '*' \
-H "Cookie: PHPSESSID=<普通会员会话>" http://127.0.0.1:8899/admin/site-stats
# 4. CSRF 缺参验证(预期 500)
curl -s -o /dev/null -w "%{http_code}\n" --noproxy '*' -X POST \
-H "Cookie: PHPSESSID=<管理员会话>" \
--data-urlencode "csrf=cccc...(64)" --data-urlencode "days=3650" \
http://127.0.0.1:8899/admin/site-stats/purge
# 5. 查日志确认
tail -3 protected/error.log探针清理记录(本次审计已执行):
- 注入探针会话 2 行(
wbssprobe*)→ 已按精确 id 删除,残留 0 行 - 临时脚本
.workbuddy-ai/tmp/ss_probe_seed.php、ss_bench.php→ 已unlink - 基准测试插入的 200 行(
path='/probe-bench')→ 已按内容标记删除,统计表恢复 15 行 - 统计表 15 行真实数据未改动(全部为 03:42–03:59 的历史浏览记录)
九、修复与增强结果(第 1 批交付,2026-09-24 12:30)
本节由实施阶段追加。第 1 批范围见
site_stats插件功能增强建议_2026-09-24.md§十。
9.1 修复项(P0/P1/P2 全量)
| # | 项 | 状态 | 证据 |
|---|---|---|---|
| P0-1 | 越权:普通会员可看全站统计 | ✅ 已修 | AdminController 三个 action 统一 requireAdmin()+requireAdminVerified(),删除自造 checkAdmin()。实测:会员会话 GET → 302 /;管理员 → 200 |
| P0-2 | 清理按钮 500(verifyOrDie() 缺参) | ✅ 已修 | verifyOrDie($_POST['csrf'] ?? '')。实测:管理员+正确 token → 302 ?purged=0;管理员+错 token → 403;会员 → 302(DELETE 未执行,表行数不变) |
| P1-1 | 绕过后台二次验证 | ✅ 已修 | 同上,requireAdminVerified() |
| P1-2 | CRLF 编码卫生 | ✅ 已修 | plugin.json、说明.md 转 LF |
| P2-1 | 建表门控无「数据层版本号」 | ✅ 已修 | SCHEMA_VERSION=3 + 戳文件 protected/.site_stats_schema_v3.stamp(内容比对,非判存在);旧戳自动清理 |
| P2-2 | db() 每请求跑 DDL | ✅ 已修 | is_file(stamp) 短路;热路径实测 0.0014 ms |
| P2-3 | ipHash() 信任可伪造 XFF | ✅ 已修 | 仅当 REMOTE_ADDR ∈ 可信代理 CIDR 才取 XFF(复刻 AuditLog::TRUSTED_PROXIES 口径) |
| P2-4 | 趋势归日依赖 SQLite localtime | ✅ 已修 | 改用 date(created_at + :off),$off = date('Z');并修正零填充方向(升序,与 ORDER BY day ASC 同向) |
| P2-5 | 传死变量 currentNav | ✅ 已修 | 改 __nav_active => 'plugins'(经 controller view() 数据传入,_sidebar 才读得到) |
9.2 增强项(第 1 批)
| # | 项 | 说明 |
|---|---|---|
| 环比对比 | 6 张卡片各带「较上期」+ 涨/跌/平箭头 + 上期值 | Plugin::card();prev=0 显示「新增」而非除零 |
| 外链三件套 | ①来源类型分布(direct/internal/search/social/external + 占比条)②外链来源域名 Top N ③外链落地页 | getSourceStats(7,20) |
| 域名白名单 | ss_config.site_hosts + 后台可编辑 + 「保存并重新分类」全量重算 | 解决「本站多入口 → 站内跳转被误判外链」 |
| 后台 UI 改造 | 复用核心 .admin-container + admin/_sidebar + .admin-tabs 4 tab;新增 assets/style.css + assets/script.js | 用户要求「参考其他插件 + tab + 左侧导航」 |
9.3 验证结果(Task #11)
| 检查 | 结果 |
|---|---|
| 三语键集合一致性 | ✅ zh/en/zh_tw 各 53 键,键集合零差异 + 键序一致 + 无重复键 + 无未引用键 |
| 引用键覆盖 | ✅ 源码引用 53 键在三语字典全部存在 |
| 零内联复扫 | ✅ 仅 2 处 <script src> 引用;无 <style> 块、无 on*、无静态 style=""(仅 --ss-h/--ss-w 变量注入,§7.3 合规) |
| 双主题走查 | ✅ 默认主题 vs ?preview_theme=modern-night(暗夜星辰):.ss-admin 内容块 sha256 一致(27cb0acef86bc9d9);内容段内 --mn-* 命中 0 |
| 语法/编码 | ✅ 全文件 php -l 通过;UTF-8 无 BOM;LF |
| 残留文件 | ✅ 无 .bak/.tmp/.revert/.orig |
| 数据保全 | ✅ 探针数据 0 残留;curl 被 bot 正则过滤 → HTTP 探针未污染数据表 |
9.4 需老搭档决定的两项
- 前置页面缓存导致的 PV 偏低:纯游客 GET
/、/forum、/forum/category/{id}命中data/runtime/pages/静态缓存后直接exit(早于init.php),插件钩子不执行 → 这几类列表页的游客 PV 会计漏。详情页不受影响。是否接受?(可选缓解:缓存命中前挂一个轻量计数) - 189 行历史残留探针会话(
sessions.sqlite中id LIKE 'vnpnp%',来自 2026-09-23 的 props/vn_postname 审计)—— 是否删除?(本次未动)