本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:7月5日复制的资料/深度安全审查报告_20260703.md。
SoSite 深度安全与代码审查报告
审查日期:2026-07-03
审查范围:全部 128+ 个 PHP 文件 + 4 个 JS 文件 + 19 个 CSS 文件 + 插件代码
审查方法:逐文件阅读 + 全局交叉验证 + 不依赖历史MD文档的独立审查
目标:发现未被 MD 文档记载的遗留问题
一、审查结论
| 严重级别 | 数量 | 分类 |
|---|---|---|
| 🔴 严重安全风险 | 2 | 权限绕过、SQL注入风险 |
| 🟠 高危风险 | 4 | 逻辑漏洞、设计缺陷 |
| 🟡 重要缺陷 | 8 | 功能异常、代码质量问题 |
| 🟢 优化建议 | 5 | 冗余代码、性能优化 |
二、🔴 严重安全风险(2项)
2.1 收藏插件展示无权访问的帖子(权限绕过)
文件:plugins/post_favorite/FavoriteController.php L32-43
风险等级:🔴 严重
问题:收藏列表直接从 threads 表读取帖子标题/内容,完全不做权限过滤:
$favorites = $db->fetchAll(
"SELECT f.id as fav_id, f.thread_id, f.created_at as fav_time,
t.title, t.created_at, t.reply_count, t.view_count,
t.user_id as thread_user_id, u.username
FROM post_favorites f
LEFT JOIN threads t ON f.thread_id = t.id
LEFT JOIN users u ON t.user_id = u.id
WHERE f.user_id = :uid
ORDER BY f.id DESC LIMIT :lim OFFSET :off",
[':uid' => $userId, ':lim' => $perPage, ':off' => $offset]
);攻击场景:
- 版块 A 设为仅 VIP 可见,用户先在有权时收藏了里边的帖子
- 管理员将用户降级为普通会员,用户失去版块 A 的访问权限
- 用户打开收藏列表 → 仍然能看到版块 A 帖子的标题和链接
- 用户点击链接 → 被
ThreadController::show()拦截 → 但标题已泄露
修复建议:在收藏列表查询后,用 Permission::getAuthorizedCategoryIds() 过滤结果:
$allowedIds = \app\Helpers\Permission::getAuthorizedCategoryIds((int)$user['id']);
if (!empty($allowedIds)) {
$favorites = array_filter($favorites, function($f) use ($allowedIds) {
// 但这里没有 category_id!需要先JOIN category_id
});
}更彻底的修复:查询时 JOIN categories 并 WHERE t.category_id IN ($allowedIds)。
2.2 init.php 数据库初始化函数存在注入风险
文件:protected/database/init.php L82-83
风险等级:🔴 严重(但仅限安装场景)
问题:tableExists() 函数使用字符串插值拼接表名:
function tableExists($db, $tableName) {
$conn = $db->getConnection();
$result = $conn->query("SHOW TABLES LIKE '{$tableName}'");
return $result->rowCount() > 0;
}getTableColumns() 同样使用 "SHOW COLUMNS FROM {$tableName}"。
当前安全:所有调用处传的是硬编码字符串('thread_votes'、'tags' 等),不是用户输入。
风险:如果未来有代码在安装过程后还调用此函数,且 $tableName 来源于用户输入,即可实现 SQL 注入。
修复建议:加反引号转义或白名单校验:
function tableExists($db, $tableName) {
if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $tableName)) return false;
// ...
}三、🟠 高危风险(4项)
3.1 RateLimiter 完全基于 Session — 无 Cookie 即可绕过
文件:app/Helpers/RateLimiter.php
风险等级:🟠 高危
问题:所有频率限制数据存储在 $_SESSION 中,攻击者可以:
- 不携带 Cookie 发起请求 → 每次请求都是新 Session → 频率限制被完全绕过
- 甚至不需要换 IP,同一个 IP 不发送 Cookie 即可无限次尝试注册/投票/上传
影响面:
- 注册频率限制(
AuthController.phpL130):每小时3次 → 实际可无限注册 - 投票频率限制(
ApiController.phpL31):每分钟30次 → 实际可无限投票 - 上传频率限制(
ApiController.phpL98):每分钟5次 → 实际可无限上传 - 远程拉图频率限制(
ApiController.phpL130):每分钟20次 → 实际可无限拉图
MD 文档已知但一直未修复:安全审查备忘.md 7.7节记录了此问题,标为"建议"级别。
修复建议:将注册/投票等敏感操作的限流改为基于 IP 存储在 DB/Redis 中:
// 临时方案(DB存储,基于IP)
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$key = '_rate_limit_' . $action . '_' . $ip;
// 使用 settings 表或独立 rate_limits 表3.2 QA 问答插件完全独立于权限系统之外
文件:plugins/qa/QaController.php 全部
风险等级:🟠 高危
问题:QA 问答系统使用了独立的表(qa_questions、qa_answers 等),完全不引用 app\Helpers\Permission。管理员无法限制特定用户组的问答访问权限。
具体风险:
- QA 问题对所有已登录用户可见,无版块级别的权限控制
- 即使设置了某个版块为 VIP 可见,QA 系统不受影响
- QA 中创建/回答问题的频率无限制(缺
RateLimiter)
影响面:权限系统存在"后门"——管理者以为限制了某个用户组的访问,但 QA 问答系统完全绕过。
修复建议(二选一):
方案 A:为 QA 增加独立的权限表
方案 B:在 QA 控制器入口处引用 Permission::canView() 做全局权限检查
3.3 签到插件每次请求都建表 + SQLite 分支
文件:plugins/daily_checkin/CheckinController.php L142-154
风险等级:🟠 高危(性能 + 安全隐患)
问题:每次签到请求都执行一次建表语句:
$db->query("CREATE TABLE IF NOT EXISTS daily_checkin (
id {$ai},
user_id INTEGER NOT NULL,
checkin_date {$dateType} NOT NULL,
...
)");并且 L144-145 仍在使用 SQLite 分支:
$ai = $db->getDbType() === 'mysql' ? 'INT AUTO_INCREMENT PRIMARY KEY' : 'INTEGER PRIMARY KEY AUTOINCREMENT';
$dateType = $db->getDbType() === 'mysql' ? 'VARCHAR(10)' : 'TEXT';问题:
- 每次签到都多一次
CREATE TABLE IF NOT EXISTS查询(MySQL 需要检查表是否存在) - SQLite 分支代码仅用于兼容但项目已"仅 MySQL"
- 严重影响性能:签到页面每次加载都调用
CREATE TABLE
修复建议:
- 将建表逻辑剥离到
activate()方法中(插件启用时执行) - 删除 SQLite 分支代码,仅保留 MySQL 语法
3.4 友情链接插件每次请求建表 + SQLite 分支
文件:plugins/friend_links/AdminController.php L15-28
风险等级:🟠 高危
问题:与 3.3 相同,每次进入友情链接管理页面都执行建表语句和 SQLite 分支判断:
$dbType = $db->getDbType();
$ai = $dbType === 'mysql' ? 'INT AUTO_INCREMENT PRIMARY KEY' : 'INTEGER PRIMARY KEY AUTOINCREMENT';
$db->query("CREATE TABLE IF NOT EXISTS friend_links (id {$ai}, ...)");修复建议:将建表逻辑移到插件初始化钩子中。
四、🟡 重要缺陷(8项)
4.1 删版块后帖子变为 category_id=0(孤儿)
文件:app/Models/Category.php L45-49
风险等级:🟡 中
代码:
public function deleteWithReset(int $id): void
{
$this->db->query('UPDATE threads SET category_id = 0 WHERE category_id = :cid', [':cid' => $id]);
$this->delete($id);
}问题:
category_id = 0在categories表中不存在,帖子变成"孤儿"- 首页
HomeController用categoriesJOIN 查询,这些帖子的category_name为 NULL - 版块页
ForumController::category()不再能查到这些帖子(因为category_id匹配不上)
影响:删版块后,该版块的帖子在其他地方不再显示,除非管理员手动指定新分类。
修复建议:
// 方案一:强制要求先手动转移帖子再删除版块(UI 提示)
// 方案二:弹窗提示"此版下有 N 个帖子,将移至未分类"4.2 ForumController 计数查询使用脆弱的字符串替换
文件:app/Controllers/ForumController.php L29-43
风险等级:🟡 低
$allowedWhere = '';
if (!empty($allowedIds)) {
$inClause = implode(',', $allowedIds);
$allowedWhere = " AND t.category_id IN ({$inClause})";
}
// ...
$total = $threadModel->count('deleted_at IS NULL' . ($allowedWhere ? str_replace('t.', '', $allowedWhere) : ''));问题:
str_replace('t.', '', $allowedWhere)— 用字符串替换来去掉表别名,写法脆弱- 如果
$allowedIds中有非 int 值(理论上不会,但无防御),会导致 SQL 注入
修复建议:将 count() 方法改为支持别名参数:
$total = $threadModel->count('t.deleted_at IS NULL' . $allowedWhere, []);4.3 博客修改封面图片存在 TOCTOU 竞态
文件:app/Controllers/BlogController.php L193-206
风险等级:🟡 低
代码:
$coverImage = (string)($blog['cover_image'] ?? ''); // L193: 从DB读取旧封面
// ...
if (!empty($_FILES['cover_image']['name'])) {
$result = Upload::file($_FILES['cover_image']);
if ($result['success']) {
if ($coverImage && is_file(\UPLOAD_PATH . $coverImage)) { @unlink(\UPLOAD_PATH . $coverImage); } // L199: 删除旧封面
$coverImage = $result['filename']; // L200: 设置新封面
}
}问题:并发请求删除旧封面时,可能两个请求都读到同一个旧封面文件名,然后都尝试 unlink — 第二个请求文件已不存在,@unlink 静默失败。虽不产生数据不一致,但行为不符合预期。
影响:极低,只在罕见并发场景下产生无意义的 unlink 调用。
4.4 博客评论无频率限制和权限控制
文件:app/Controllers/BlogController.php L86-105
风险等级:🟡 中
问题:博客评论没有任何 RateLimiter 调用和权限检查:
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$this->requireLogin();
Csrf::verifyOrDie($_POST['csrf'] ?? '');
$content = trim($_POST['content'] ?? '');
if ($content !== '') {
// 直接插入评论,无频率限制,无权限校验
$blogModel->execute('INSERT INTO blog_comments ...');
}
}攻击场景:攻击者可脚本无限刷博客评论,无任何频率限制。
修复建议:在评论插入前加 RateLimiter::hit('blog_comment', 10, 60);
4.5 QA 控制器缺少 RateLimiter
文件:plugins/qa/QaController.php
风险等级:🟡 中
问题:QA 的提问(save())、回答(answer())、投票(vote())接口都没有调用 RateLimiter::hit()。
攻击场景:攻击者可脚本无限创建问题/回答/投票。
修复建议:在 save()、answer()、vote() 方法入口加:
RateLimiter::hit('qa_save', 5, 60); // 每小时最多5个问题
RateLimiter::hit('qa_answer', 20, 60); // 每小时最多20个回答
RateLimiter::hit('qa_vote', 30, 60); // 每分钟最多30次投票4.6 论坛版块总数统计不按权限过滤
文件:app/Controllers/ForumController.php L97-98
风险等级:🟡 低(体验问题)
$totalThreads = Settings::getTotalThreads();
$totalPosts = Settings::getTotalPosts();这两个数字是全局的,用户会看到"总帖子 100"但只能看到 20 条(因为他无权访问某些版块)。体验不一致。
修复建议:计算用户可见版块的帖子数(已有 $allowedIds)。
4.7 帖子和博客批量删除不清理附件
文件:app/Models/Thread.php L170-177, app/Models/Blog.php L96-104
风险等级:🟡 中
// Thread::batchDelete()
public function batchDelete(array $ids): int
{
$ids = \array_filter(\array_map('intval', $ids));
if (empty($ids)) return 0;
$placeholders = \implode(',', $ids);
$this->execute("DELETE FROM {$this->table} WHERE id IN ({$placeholders})");
return \count($ids);
}问题:批量删除帖子/博客时只删数据库记录,不清理上传的附件文件。这些文件变成"孤儿"文件,只能通过后台"清理孤儿附件"功能清理。
同样的问题在 Blog::batchDelete()(Blog.php L96-104)中也存在——只删 blogs 和 blog_comments,不删附件。
修复建议:batchDelete 中增加附件清理逻辑:
// 删除前先记录附件文件名
$atts = $db->fetchAll("SELECT filename FROM attachments WHERE thread_id IN ({$placeholders})");
foreach ($atts as $att) { @unlink(UPLOAD_PATH . $att['filename']); }
$db->query("DELETE FROM attachments WHERE thread_id IN ({$placeholders})");4.8 $_GET['r'] fallback 未做安全限制
文件:app/Core/Router.php L76-78
风险等级:🟡 低
if (($uri === '/' || $uri === '/index.php' || $uri === '') && !empty($_GET['r'])) {
$uri = '/' . ltrim($_GET['r'], '/');
}问题:$_GET['r'] 直接作为路由路径,虽然路由匹配会校验(不存在时返回 404),但可以用于探测后台页面是否存在(如 ?r=admin/users),即使被重定向也会先走一次路由匹配。
修复建议:限制 $_GET['r'] 中不允许出现 admin 前缀,或直接移除这个 fallback 机制(旧版本兼容)。
五、🟢 优化建议(5项)
5.1 插件钩子文件中残留 SQLite 兼容代码
涉及文件:
plugins/daily_checkin/CheckinController.phpL144-145(每次签到建表检查)plugins/daily_checkin/Plugin.php(activate 和 deactivate 方法)plugins/friend_links/AdminController.phpL15-16(每次管理页面建表检查)plugins/post_favorite/Plugin.phpL14-15
问题:多处存在 $db->getDbType() === 'mysql' ? 'INT AUTO_INCREMENT' : 'INTEGER PRIMARY KEY AUTOINCREMENT'。项目已仅 MySQL,这些分支永远不会走 SQLite 路径。
修复建议:统一删除 SQLite 分支,仅保留 MySQL 语法。
5.2 几个 Model 类仅为空壳
文件(6个空Model):
app/Models/BlogCategory.phpapp/Models/BlogComment.phpapp/Models/Message.phpapp/Models/Tag.phpapp/Models/OnlineUser.phpapp/Models/PointsLog.phpapp/Models/ThreadTag.phpapp/Models/ThreadVote.phpapp/Models/PostVote.phpapp/Models/Attachment.php
问题:大部分仅定义了 $table 属性,实际查询通过 query() 或 Database::getInstance() 直接执行。这些空 Model 类增加了文件数但不提供实际价值。
修复建议:按需保留有使用价值的(如 Message 有 deleteOwned 方法),删除完全无方法的空壳类。
5.3 Controller 层仍有大量直接 SQL 调用
涉及文件:
ForumController.php—$threadModel->query(...)(L50)BlogController.php—$blogModel->query(...)(L39)ProfileController.php—$userModel->getThreads(...)(已有封装)MessageController.php— 基本通过MessageModel
问题:SQL一致性迁移计划.md 计划将 Controller 中的所有 $db->query() 调用搬到 Helper 中,但目前尚未执行。
5.4 User::getStats() 4 条独立查询可合并
文件:app/Models/User.php L85-97
$threads = (int)$this->queryOne("SELECT COUNT(*) as c FROM threads WHERE user_id = :uid ...")['c'];
$replies = (int)$this->queryOne("SELECT COUNT(*) as c FROM posts WHERE user_id = :uid ...")['c'];
$views = (int)$this->queryOne("SELECT SUM(view_count) as total FROM threads WHERE user_id = :uid ...")['total'];
$blogs = (int)$this->queryOne("SELECT COUNT(*) as c FROM blogs WHERE user_id = :uid ...")['c'];可合并为一条 SQL:
SELECT
(SELECT COUNT(*) FROM threads WHERE user_id = :uid AND deleted_at IS NULL) as thread_count,
(SELECT COUNT(*) FROM posts WHERE user_id = :uid) as reply_count,
(SELECT SUM(view_count) FROM threads WHERE user_id = :uid AND deleted_at IS NULL) as total_views,
(SELECT COUNT(*) FROM blogs WHERE user_id = :uid) as blog_count5.5 Posts 查询结果含过多冗余字段
文件:app/Models/Post.php L9-19
"SELECT p.*, u.username, u.avatar, u.signature, u.role, u.points, u.level, u.post_count ..."每个回复都查询了 u.signature, u.role, u.points, u.level, u.post_count 等字段。这些在回复列表中很少用到,但增加了传输数据量。默认每页20条回复,80条回复页就传了 80×6=480个冗余字段值。
修复建议:只在需要时才 SELECT 这些字段,或缓存在 Auth::getCurrentUser() 中。
六、已确认遗留(与MD文档一致的未修复项)
| # | 问题 | 优先级 | 首次提及 | 说明 |
|---|---|---|---|---|
| 1 | fillable 白名单 | P2 | 07-02 | Model 层 insert/update 无批量赋值保护 |
| 2 | SQL 一致性迁移 | 中 | SQL一致性迁移计划.md | Controller 中散落 $db->query() |
| 3 | 旧帖子批量补缩略图脚本 | 低 | 07-02 | 未编写 CLI 脚本 |
| 4 | 标签云颜色按频率分级 | 低 | 项目档案.md | 未实现 |
| 5 | role/group_id 双轨制统一 | 架构级 | 07-01 | role 字段仍在使用 |
七、修复优先级总表
立即修复 — 🔴 P0
| # | 问题 | 修复方案 | 预估工时 |
|---|---|---|---|
| 2.1 | 收藏插件权限绕过 | 查询中 JOIN categories + WHERE IN 权限过滤 | 30分钟 |
| 2.2 | init.php SQL注入风险 | 加正则白名单校验 | 10分钟 |
尽快修复 — 🟠 P1
| # | 问题 | 修复方案 | 预估工时 |
|---|---|---|---|
| 3.1 | RateLimiter Session绕过 | 关键接口改用 DB 存储 | 1小时 |
| 3.2 | QA无权限控制 | 加 Permission 检查或独立权限表 | 1-2天 |
| 3.3 | 签到插件每次建表 | 移至 activate() | 30分钟 |
| 3.4 | 友情链接每次建表 | 移至 activate() | 30分钟 |
建议修复 — 🟡 P2
| # | 问题 | 修复方案 | 预估工时 |
|---|---|---|---|
| 4.1 | 删版块后帖子变孤儿 | 提示先转移 | 30分钟 |
| 4.5 | QA缺RateLimiter | 加频率限制 | 30分钟 |
| 4.4 | 博客评论缺RateLimiter | 加频率限制 | 10分钟 |
| 4.7 | 批量删除不清理附件 | 加附件清理 | 30分钟 |
顺手修复 — 🟢 P3
| # | 问题 | 修复方案 |
|---|---|---|
| 5.1 | 插件SQLite残留 | 删除分支代码 |
| 5.2 | 空壳Model类 | 删除无用文件 |
| 5.3 | SQL一致性迁移 | 按计划逐步执行 |
| 5.4 | getStats 4条查询 | 合并为单条 |
| 5.5 | Post查询冗余字段 | 精简SELECT |
八、总结
本次深度审查共发现 19 项新增问题(2项严重 + 4项高危 + 8项中危 + 5项建议)。
最值得关注的 3 个问题:
- 🥇 收藏列表权限绕过(2.1)— 用户收藏的隐藏版块帖子在收藏列表中标题直接泄露,影响数据保密性
- 🥈 QA 完全绕过权限系统(3.2)— 整个问答插件不引用任何 Permission 类,后台设置的版块权限对 QA 完全无效
- 🥉 签到插件每次请求建表(3.3)— 每个签到页面请求都多一次
CREATE TABLE IF NOT EXISTS,影响性能且包含死代码
整体评价:相比第一次审查时的"黑名单绕过可上传 WebShell"级别,当前项目安全性已有质的提升。目前的问题主要集中在"插件层权限控制缺失"和"遗留的兼容代码"上,而非核心框架的架构缺陷。