本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:暂存20260913/FlintHub_深度架构审计报告_20260904.md。
FlintHub 深度架构审计报告
审计模型: MiMo-V2.5-Pro
审计日期: 2026-09-04
审计范围: app/Controllers/ (30) · app/Helpers/ (24) · app/SplitDB/ (12) · plugins/ (27)
审计维度: 跨层调用 · 权限逻辑 · 数据一致性 · 潜在越权
一、架构概览(正面发现)
审计覆盖的 13 个前台 + 16 个后台控制器 均通过以下机制保护:
| 防线 | 覆盖情况 | 判定 |
|---|---|---|
| 后台 admin 权限 | 全部 16 个 Admin 控制器继承 BaseController,构造器强制 Auth::isAdmin() + 30 分钟二次验证 | ✅ 无遗漏 |
| 前台 CSRF | 所有 POST 处理器(20+)均调用 Csrf::verifyOrDie() | ✅ 无遗漏 |
| 帖子权限校验 | Permission::canView/canPost/canReply/canAttach 统一校验,管理员组(id=4)自动放行 | ✅ 完善 |
| 帖子归属校验 | 编辑/删除帖子均校验 $thread['user_id'] === $currentUser['id'],管理员操作他人帖子需二次验证 | ✅ 无 IDOR |
| 插件后台路由 | Plugin::hook() 对 admin_route_register 强制校验 route:admin 权限声明 | ✅ 有守卫 |
| Plugin::http() | 防 SSRF(内网 IP 封禁 + DNS 解析后二次校验)+ 强制 net:fetch 权限声明 | ✅ 完善 |
| SplitDB 连接池 | DBFactory LRU 池(8 句柄) + 统一 PRAGMA + 活动事务保护 | ✅ 设计合理 |
| 核心 Model 实例方法 | Thread::find() / Post::find() 等均为实例方法,插件已统一用 (new Thread())->find() 调用 | ✅ 已纠正 |
| 页面缓存防护 | data/ 目录自动生成 .htaccess(Apache)+ web.config(IIS),阻止 SQLite 库 HTTP 直访 | ✅ 三端覆盖 |
| 外置正文存储 | ExternStorage 经 ExternMerger 合并 bucket 段落,读取有 APCu 缓存 + 路径边界校验 | ✅ 安全 |
二、🔴 高风险(HIGH)— 5 项
H-1 · 核心 Helper 读退役表:搜索功能在 SplitDB 后遗漏新帖
| 项目 | 内容 |
|---|---|
| 文件:行号 | app/Helpers/Search.php:313, 321 |
| 问题描述 | searchThreads() 方法直接 SELECT ... FROM threads t WHERE ... 查询已退役的 business.threads 表。SplitDB 分片后,新帖写入 bucket 沙箱 + main_index.topic_index,不再回写退役 threads 表。搜索结果将永久丢失所有分片后新帖。 |
| 影响范围 | 搜索功能对新帖完全不可见,用户体感为"帖子搜不到"。 |
| 修复建议 | 改读 main_index.topic_index(经 Thread Model 或直接查索引表),与 Admin/DashboardController 和 Admin/ThreadController 的口径统一。需同时处理帖子正文(extern 文件)的全文匹配,可复用已有 SearchIndexStore 倒排索引基础设施。 |
代码片段:
// 第 313 行 — 退役表 COUNT 查询
$cnt = $db->fetchOne(
"SELECT COUNT(*) as count FROM threads t WHERE (t.title LIKE :q1 OR t.content LIKE :q2) AND t.deleted_at IS NULL" . $catFilter . $timeFilter,
\array_merge([':q1' => $sq, ':q2' => $sq, ':since' => $since], $catParams)
);
// 第 321 行 — 退役表分页查询
$rows = $db->fetchAll(
"SELECT t.*, u.username, c.name as category_name FROM threads t LEFT JOIN users u ON t.user_id = u.id LEFT JOIN categories c ON t.category_id = c.id WHERE (t.title LIKE :q1 OR t.content LIKE :q2) AND t.deleted_at IS NULL{$catFilter}{$timeFilter} ORDER BY t.created_at DESC LIMIT ...",
\array_merge([':q1' => $sq, ':q2' => $sq, ':since' => $since], $catParams)
);H-2 · 核心 Helper 读退役表:附件清理逻辑扫描遗漏新内容
| 项目 | 内容 |
|---|---|
| 文件:行号 | app/Helpers/Settings.php:614-616 |
| 问题描述 | cleanOrphanedUploads() 方法扫描帖子内容中的嵌入图片以判断哪些附件是"孤儿"。三张表查询中 threads 和 posts 均为退役表,分片后新帖正文存储在 extern/*.txt 文件中,不在退役表内。 |
| 影响范围 | 附件清理会误删分片后新帖引用的图片("孤儿判定"误判),导致帖子内图片 404。 |
| 修复建议 | threads/posts 查询改读 main_index.topic_index + reply_index,再通过 ExternStorage 读取正文内容做匹配。blogs 表未分片,保留原查询即可。 |
代码片段:
// 第 613-617 行 — 退役表扫描(threads/posts 在 SplitDB 后不再有新数据)
$contentTables = [
'SELECT content FROM threads WHERE content LIKE :p', // ← 退役表
'SELECT content FROM posts WHERE content LIKE :p', // ← 退役表
'SELECT content FROM blogs WHERE content LIKE :p', // ← 未分片,保留
];H-3 · 插件权限强制模式默认关闭:权限声明形同虚设
| 项目 | 内容 |
|---|---|
| 文件:行号 | app/Helpers/Plugin.php:116 |
| 问题描述 | permission_enforce 设置项默认值为 '0'(警告模式)。当插件未声明所需权限时,requirePermission() 仅记录 error_log 和 AuditLog,仍然放行操作。 |
| 影响范围 | 任何插件即使未在 plugin.json 声明 net:fetch / route:admin / system:settings 权限,也能注册后台路由、发起外部请求、写全局设置——权限声明体系无实际约束力。 |
| 修复建议 | 生产环境应将 permission_enforce 设为 1;或在 Plugin 框架层对 net:fetch 和 system:settings 默认强制(仅 route:admin 保持警告),因为后两者的未授权操作有直接安全影响。 |
代码片段:
// Plugin.php 第 112-121 行
public static function requirePermission(string $name, string $perm): bool
{
if (self::granted($name, $perm)) return true;
$enforce = \app\Helpers\Settings::get('permission_enforce', '0') === '1'; // 默认 '0' = 警告模式
$action = $enforce ? 'DENY' : 'warn';
\error_log("[plugin-permission] {$action} [{$name}] missing permission '{$perm}'");
\app\Helpers\AuditLog::log('plugin_permission', 'plugin', 0, "{$action} [{$name}] missing permission '{$perm}'");
return !$enforce; // 警告模式下始终返回 true(放行)
}H-4 · 插件直写核心表:mod_system 绕过 User Model 修改用户组
| 项目 | 内容 |
|---|---|
| 文件:行号 | plugins/mod_system/Plugin.php:378-381(封禁)· :406-409(解封) |
| 问题描述 | 封禁/解封用户时,ban() 和 unban() 方法直接执行 UPDATE users SET group_id = :g WHERE id = :u,绕过 User Model。 |
| 影响范围 | 封禁/解封操作与 User Model 的缓存清除、审计日志等旁路逻辑脱节。若未来 users 表增加触发器或缓存同步逻辑,此直写将导致数据不一致。 |
| 修复建议 | 改用 (new \app\Models\User())->updateGroup($uid, $groupId) 或在 User Model 中新增 ban()/unban() 方法统一封装。 |
代码片段:
// Plugin.php 第 375-381 行 — 封禁:直写核心表
$banGroupId = (int)self::getSetting('ban_group_id');
if ($banGroupId > 0) {
$coreDb->query(
'UPDATE users SET group_id = :g WHERE id = :u',
[':g' => $banGroupId, ':u' => $uid]
);
}
// Plugin.php 第 404-409 行 — 解封:直写核心表
if (!empty($ban['original_group_id'])) {
$coreDb->query(
'UPDATE users SET group_id = :g WHERE id = :u',
[':g' => (int)$ban['original_group_id'], ':u' => (int)$ban['user_id']]
);
}H-5 · 插件直写核心表:red_packet 直接修改用户积分
| 项目 | 内容 |
|---|---|
| 文件:行号 | plugins/red_packet/Plugin.php:89-91 |
| 问题描述 | 创建红包时直接执行 UPDATE users SET points = points - :p WHERE id = :id,绕过 Points Helper。虽然后续单独 INSERT 了 points_log,但与 Points::deduct() 的事务语义不一致。 |
| 影响范围 | 积分扣减与 Points Helper 的等级计算、缓存失效逻辑脱节。若 Points Helper 增加并发锁或幂等检查,此处直写将成为绕过路径。 |
| 修复建议 | 改用 \app\Helpers\Points::deduct() + \app\Helpers\Points::award() 统一封装积分操作。红包创建和抢领的积分流转均应走 Points Helper。 |
代码片段:
// Plugin.php 第 80-125 行 — 创建红包:直写核心表积分
$db->begin();
try {
$userRow = $db->fetchOne('SELECT points FROM users WHERE id = :id', [':id' => $userId]);
if (!$userRow || (int)$userRow['points'] < $totalPoints) {
throw new \RuntimeException(\app\Helpers\I18n::get('plugin.red_packet.err_points'));
}
// ↓ 直写核心表,绕过 Points Helper
$db->query('UPDATE users SET points = points - :p WHERE id = :id', [':p' => $totalPoints, ':id' => $userId]);
// ... 后续单独 INSERT points_log(与 Points Helper 事务语义不一致)
$db->commit();
}三、🟡 中风险(MEDIUM)— 4 项
M-1 · 13 个插件直查核心库用户表(绕过 User Model)
| 插件 | 文件:行号 |
|---|---|
| content_review | Plugin.php:293, 329 |
| dice | Plugin.php:136 |
| floor_reply | Plugin.php:210, 256 |
| forum_required_tag | AdminController.php:47 |
| invite | InviteController.php:31 |
| medal | AdminMedalController.php:103, 131 |
| mod_system | AdminController.php:25, 189, 285, 324, 377 · FrontController.php:30, 206, 253, 312 · Plugin.php:91, 358, 399, 430, 460 |
| notifications | hook/post_create_after.php:16 · hook/vote_after.php:19 |
| points_mall | AdminController.php:56, 115 |
| post_favorite | FavoriteController.php:84 |
| qa | Plugin.php:420 |
| quiz_duel | Plugin.php:87, 124 · QuizDuelController.php:42, 308, 329 |
| red_packet | RedPacketController.php:34, 185, 261, 402 |
问题描述: 批量补用户名属于读操作(SELECT id, username FROM users WHERE id IN (...)),安全风险较低,但违反"插件用独立库"的架构规范。若核心库连接池满或锁竞争,这些直查会成为瓶颈。
修复建议: 在 User Model 或新 Helper 中提供 User::batchGetNames(array $ids): array 方法,封装核心库查询 + 请求级缓存(同 Permission::groupIdOf() 的缓存策略),插件统一调用。
M-2 · mod_system 用原生 SQL 物理删除核心表记录
| 项目 | 内容 |
|---|---|
| 文件:行号 | plugins/mod_system/AdminController.php:270-271 |
| 问题描述 | 删除博客评论时直接执行 DELETE FROM blog_comments WHERE id = :id,绕过 Blog Model 的级联清理(如评论计数递减、搜索索引同步等)。 |
| 修复建议 | 在 Blog Model 中新增 deleteComment($id) 方法封装级联逻辑,mod_system 调用该方法。 |
代码片段:
// AdminController.php 第 268-272 行
case 'blog_comment':
try {
// ↓ 原生 SQL 物理删除,无级联清理
\app\Core\Database::getInstance()->query(
'DELETE FROM blog_comments WHERE id = :id', [':id' => $id]);
} catch (\Throwable $e) { \error_log('[mod_system] blog_comment delete: ' . $e->getMessage()); }
break;M-3 · seo 插件声明缺失 system:settings 权限
| 项目 | 内容 |
|---|---|
| 文件:行号 | plugins/seo/Plugin.php:41-43 + plugins/seo/plugin.json:16 |
| 问题描述 | activate() 方法调用 Settings::update($dbKey, $value) 写入全局设置,但 plugin.json 的 permissions 只声明了 ["route:admin"],缺少 system:settings。在 permission_enforce=1 模式下将被拒绝。 |
| 修复建议 | 在 plugin.json 中补充 "system:settings" 权限声明。 |
M-4 · notifications 插件权限声明不完整
| 项目 | 内容 |
|---|---|
| 文件:行号 | plugins/notifications/plugin.json:9 |
| 问题描述 | permissions 为空数组 [],但插件注册了 admin_sidebar_links 钩子。虽未注册 admin_route_register,若后续添加后台路由将因缺少声明被 Plugin::hook() 拒绝。 |
| 修复建议 | 如有后台入口需求,补充 "route:admin" 声明;当前可保持为空但需在文档中标注。 |
四、🟢 低风险(LOW)— 3 项
L-1 · seo/FrontController.php 未使用的 Database 实例
| 项目 | 内容 |
|---|---|
| 文件:行号 | plugins/seo/FrontController.php:33 |
| 问题描述 | $db = Database::getInstance(); 赋值后仅用于查询 categories 表(:42 行),变量名与上方已重构的退役表查询共用——属于重构残留。代码可读性问题,无安全影响。 |
| 修复建议 | 保留实例(categories 表仍在 business.sqlite),加注释说明用途。 |
L-2 · 插件用 file_get_contents 读本地文件(非网络请求)
| 文件:行号 | 用途 | 判定 |
|---|---|---|
plugins/ai_assistant/AdminController.php:509 | 读上传临时文件 $file['tmp_name'] | ✅ 安全(PHP 临时文件) |
plugins/code_audit/AuditEngine.php:134 | 读本地源码做审计扫描 | ✅ 安全(有 realpath 边界校验) |
plugins/code_repo/Plugin.php:323, 338, 430, 436 | 读本地仓库文件/VERSION/composer.json | ✅ 安全(有路径边界校验) |
plugins/news_aggregator/Plugin.php:232 | 读本地缓存文件 data/news_cache.json | ✅ 安全(固定路径) |
问题描述: 以上均为本地文件读取,不涉及 SSRF。与 Plugin::http() 无关(后者仅管控外部 HTTP 请求)。
修复建议: 无需修改。code_repo 的文件读取已有 realpath + 前缀校验,边界安全。
L-3 · 搜索 Helper 未走分片索引的全文匹配
| 项目 | 内容 |
|---|---|
| 文件:行号 | app/Helpers/Search.php:313 |
| 问题描述 | 除退役表问题(H-1)外,当前搜索直接 LIKE 匹配 threads.content,而分片架构下正文存储在 extern/*.txt 文件中。即使修复退役表问题,全文搜索仍需额外处理 extern 正文。 |
| 修复建议 | 考虑使用 SearchIndexStore(已有倒排索引基础设施,位于 app/SplitDB/SearchIndexStore.php)做全文匹配,或在搜索路径中增加 extern 内容的 grep 扫描。 |
五、汇总统计
| 级别 | 数量 | 关键主题 |
|---|---|---|
| 🔴 高 | 5 | 退役表读取(2) · 权限强制关闭(1) · 插件直写核心表(2) |
| 🟡 中 | 4 | 插件直查核心库(13 个插件) · 原生 SQL 删除(1) · 权限声明缺失(2) |
| 🟢 低 | 3 | 未使用变量(1) · 本地文件读取(4 个插件) · 全文搜索架构(1) |
六、总体评价
FlintHub 核心层(控制器 / Helper / SplitDB)的安全架构设计完善:
- 认证体系:
Auth::isAdmin()双重校验(role + group_id),前台requireLogin()覆盖所有写操作 - CSRF 防护: 20+ POST 处理器无遗漏,敏感操作(改密码/删帖子)额外消费 Token 防重放
- 权限校验:
Permission::check()白名单 + 请求级缓存 + 游客回退机制,无绕过路径 - SSRF 防护:
Plugin::http()+ApiController::fetchImage()双重 IP/域名校验 - SplitDB 设计: 连接池 LRU + 活动事务保护 + 原子写入 + web 访问防护
主要风险集中在:
- SplitDB 迁移不彻底 — 核心 Helper(Search / Settings)仍有 2 处读退役表,影响搜索和附件清理功能正确性
- 插件跨层调用 — 2 个插件(mod_system / red_packet)直写核心表绕过 Model 层,13 个插件直查核心库用户表
- 权限强制模式 — 默认关闭,权限声明体系无实际约束力
建议优先级:
- 立即修复: H-1(搜索)+ H-2(附件清理)—— 功能性缺陷,影响用户可见
- 短期修复: H-3(权限强制)+ H-4/H-5(直写核心表)—— 安全架构完整性
- 中期优化: M-1 ~ M-4 —— 架构规范收敛
- 低优先级: L-1 ~ L-3 —— 代码质量
报告由 MiMo-V2.5-Pro 模型生成 · 审计范围为源码静态分析 · 未执行运行时验证