[审计]FlintHub 深度架构审计报告

👑Lv.11 元老 🌞 骨干会员
2026-10-23 23:41:43

本文由 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_reviewPlugin.php:293, 329
dicePlugin.php:136
floor_replyPlugin.php:210, 256
forum_required_tagAdminController.php:47
inviteInviteController.php:31
medalAdminMedalController.php:103, 131
mod_systemAdminController.php:25, 189, 285, 324, 377 · FrontController.php:30, 206, 253, 312 · Plugin.php:91, 358, 399, 430, 460
notificationshook/post_create_after.php:16 · hook/vote_after.php:19
points_mallAdminController.php:56, 115
post_favoriteFavoriteController.php:84
qaPlugin.php:420
quiz_duelPlugin.php:87, 124 · QuizDuelController.php:42, 308, 329
red_packetRedPacketController.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 访问防护

主要风险集中在:

  1. SplitDB 迁移不彻底 — 核心 Helper(Search / Settings)仍有 2 处读退役表,影响搜索和附件清理功能正确性
  2. 插件跨层调用 — 2 个插件(mod_system / red_packet)直写核心表绕过 Model 层,13 个插件直查核心库用户表
  3. 权限强制模式 — 默认关闭,权限声明体系无实际约束力

建议优先级:

  1. 立即修复: H-1(搜索)+ H-2(附件清理)—— 功能性缺陷,影响用户可见
  2. 短期修复: H-3(权限强制)+ H-4/H-5(直写核心表)—— 安全架构完整性
  3. 中期优化: M-1 ~ M-4 —— 架构规范收敛
  4. 低优先级: L-1 ~ L-3 —— 代码质量

报告由 MiMo-V2.5-Pro 模型生成 · 审计范围为源码静态分析 · 未执行运行时验证

轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| 瀏覽 5 次 | 回覆 0 次

全部回覆 (0)

暫無回覆

請 登入