本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:7月5日复制的资料/用户组和板块权限.md。
用户组和板块权限 — 安全与逻辑审查报告
审查日期:2026-07-02
审查范围:用户组系统 + 权限控制系统 + 前后端权限落地
涉及数据库:
user_groups、category_permissions、users.group_id
目录
一、审查结论概要
总体评价:权限系统核心类 Permission.php 设计合理(管理员跳过、回退兼容、查询隔离),前后台 Controller 也尝试了权限落地。但存在以下关键问题:
| 严重程度 | 数量 | 关键问题 |
|---|---|---|
| 🔴 致命 | 4 | role 与 group_id 可分离、Post POST 未校验、Thread edit/delete 无版块权限、搜索/标签/profile 跨业务泄露隐藏版块内容 |
| 🟠 高危 | 6 | 自降级保护不全、删除用户组产生孤儿、表单 ID 篡改、游客 fallback 一刀切、API 上传无权限校验、插件系统绕过权限 |
| 🟡 重要 | 6 | userId 与 groupId 混淆、首页重复查询、前台按钮越权暴露、全局计数不匹配、role/group_id 双轨制内耗、权限校验未中间件化 |
| 🟢 建议 | 5 | $perm 白名单、批量插入 SQL、UserId→GroupId 重构、后台 API 二次验证覆盖、游客 Default Deny 原则 |
二、严重安全隐患(越权与提权漏洞)
🔴 2.1 角色与用户组可分离(提权漏洞)
文件:app/Controllers/Admin/UserController.php
审查发现:UserController.php 中,一个后台用户可以编辑他人的 role 和 group_id,但这两个字段之间没有强制绑定关系:
$role = trim($_POST['role'] ?? 'user');
$group_id = (int)($_POST['group_id'] ?? 1);
// ↓ 没有校验 role 与 group_id 的关联性
$userModel->updateByAdmin((int)$user['id'], [
'role' => $role, ..., 'group_id' => $group_id,
]);攻击场景:一个 role='user' 但拥有后台访问权限的账户,可以将自己的 group_id 改为 4(管理员组)。此后 Permission::check() 因 $groupId === 4 而直接 return true,绕过全部前台版块权限控制,即使他的 role 仍是普通用户。
修复建议:
// role='user' 不能使用管理员组
if ($role !== 'admin' && $group_id === 4) {
$error = '普通用户不能分配到管理员组';
}
// role='admin' 必须属于管理员组
if ($role === 'admin' && $group_id !== 4) {
$group_id = 4; // 强制设置为管理员组
}🔴 2.2 唯一管理员自降级保护不完整
文件:app/Controllers/Admin/UserController.php(第 37 行)
审查发现:当前保护只检查了 role 和 status,遗漏了 group_id:
// 只保护了 role 和 status
if ($user['role'] === 'admin' && ($role !== 'admin' || $status !== 'active')) {
if (User::getAdminCount() <= 1) {
$error = '无法修改唯一的管理员账户角色或状态';
}
}致命隐患:唯一管理员可以保持 role='admin'、status='active' 不变,仅把 group_id 改为 1。保存后 Permission::check() 不再认为他是管理员(因为 $groupId !== 4),整个系统失去可控的管理员账户,陷入死锁。
修复建议:
if ($user['role'] === 'admin' && ($role !== 'admin' || $status !== 'active' || $group_id !== 4)) {
if (User::getAdminCount() <= 1) {
$error = '无法修改或将唯一的管理员账户移出管理员组';
}
}🔴 2.3 PostController POST 发帖接口未校验版块权限
文件:app/Controllers/PostController.php
审查发现:发帖页面的 GET 渲染确实过滤了有权限的版块列表(第 22-25 行),但 POST 提交时只校验了 $categoryId 对应的分类是否存在(第 68 行),没有二次校验当前用户是否有权在该版块发帖:
// ✅ GET 渲染:过滤了版块列表(正确)
foreach ($allCategories as $c) {
if (\app\Helpers\Permission::canPost((int)$c['id'], (int)$currentUser['id'])) {
$categories[] = $c;
}
}
// ...
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$categoryId = (int)($_POST['category_id'] ?? 0);
// ...
$catExists = (new \app\Models\Category())->find($categoryId);
if (!$catExists) {
// ❌ 只检查了分类是否存在,没有检查是否有发帖权限
$error = '请选择有效的分类';
}
// 直接 insert,未校验 canPost
$threadId = $threadModel->insert(['category_id' => $categoryId, ...]);
}攻击场景:用户在发帖页 F12 修改 <select name="category_id"> 的 value 为某个无权版块的 ID → POST 提交 → 后端仅验证分类存在性 → 成功越权发帖。
修复建议:在 INSERT 之前加入权限校验:
if (!\app\Helpers\Permission::canPost($categoryId, (int)$currentUser['id'])) {
$this->view('post/create', [
'categories' => $categories, 'error' => '您没有权限在该版块发帖',
'form' => ['title' => $title, 'content' => $content, 'category_id' => $categoryId],
]);
return;
}⚠️ 我的建议:这条校验最好放在
$catExists检查之后、事务开始之前,这样既保证分类合法、又保证权限合法。
三、业务逻辑漏洞
🟠 3.1 删除用户组产生「孤儿用户」
文件:app/Helpers/Permission.php → deleteGroup()
审查发现:删除用户组时只删除了 user_groups 和 category_permissions 的记录,没有更新 users 表:
$db->query('DELETE FROM category_permissions WHERE group_id = :id', [':id' => $id]);
$db->query('DELETE FROM user_groups WHERE id = :id', [':id' => $id]);
// ❌ 漏了:UPDATE users SET group_id = 1 WHERE group_id = :id后果:被删组的用户 users.group_id 保留着旧 ID。当他们访问网站时,Permission::getUserGroupId() 返回不存在的 ID → 查询 category_permissions 无记录 → 触发 fallback「该组未配置→全部可见」→ 意外获得最大前台权限。
修复建议:
// 删除前将用户重置到默认组
$db = \app\Core\Database::getInstance();
$defaultGroup = $db->fetchOne("SELECT id FROM user_groups WHERE is_default = 1");
$defaultId = (int)($defaultGroup['id'] ?? 1);
$db->query('UPDATE users SET group_id = :defaultId WHERE group_id = :id',
[':defaultId' => $defaultId, ':id' => $id]);
$db->query('DELETE FROM category_permissions WHERE group_id = :id', [':id' => $id]);
$db->query('DELETE FROM user_groups WHERE id = :id', [':id' => $id]);⚠️ 我的建议:
deleteGroup()应传入$db实例或内部获取,与调用方共享同一数据库连接以保持事务一致性(当前GroupController中未开启事务,建议补上begin/commit)。
🟠 3.2 内置用户组编辑无限制(表单 ID 篡改)
文件:app/Controllers/Admin/GroupController.php → index() + app/Views/admin/groups.php
审查发现:保存用户组时,$id 来自 $_POST['id'],没有任何对 1~4 内置组的保护:
$id = (int)($_POST['id'] ?? 0);
$name = trim($_POST['name'] ?? '');
// ...
Permission::saveGroup($id, $name, $color, $isDefault); // 可以直接修改内置组而前端表单有一个隐藏的 <input type="hidden" id="form_id" value="0">,恶意管理员可 F12 将其改为 1 或 4,进而:
- 把「管理员」组的名字改成任意文字
- 把「普通会员」设为默认组(虽然可接受,但若管理员组被设为默认则注册即管理员)
- 修改内置组的
color
修复建议:在保存时限制内置组的关键属性:
if ($id > 0 && $id <= 4) {
// 内置组不允许修改 is_default
$isDefault = 0;
// 内置组不允许改名为空
// 其余属性(name, color)可以修改,无安全影响
}🟠 3.3 游客权限回退「一刀切」问题
文件:app/Helpers/Permission.php → check() 和 getAuthorizedCategoryIds()
审查发现:当前代码使用全表级别的 COUNT(*) WHERE group_id = 0 来判断是否应回退:
if ($groupId === 0) {
$guestHasPerm = $db->fetchOne('SELECT COUNT(*) as cnt FROM category_permissions WHERE group_id = 0');
if ((int)($guestHasPerm['cnt'] ?? 0) === 0) {
$groupId = 1; // 全局回退
}
}逻辑陷阱:只要管理员在任意一个版块配置了游客权限,count > 0 即不再回退。其他没配置过的版块在 check() 中会走到:
$hasAnyPerm = $db->fetchOne("SELECT COUNT(*) as cnt FROM category_permissions WHERE category_id = :cid");
if ((int)($hasAnyPerm['cnt'] ?? 0) > 0) {
return false; // 其他组有记录而游客无记录 → 拒绝
}结果:管理员只配置了一个版块的游客权限,所有其他版块会突然对游客关闭。
我的建议修复方案(三种方案选一种):
方案 A(推荐):将回退改为版块级别,而不是全局级别:
// 在 check() 中,当无 group_id=0 记录时,不全局回退到 group=1,
// 而是针对当前版块查 group=1 的权限
if ($groupId === 0 && !$row) {
// 查 group=1 对该版块是否有权限
$fallbackRow = $db->fetchOne(
"SELECT {$perm} FROM category_permissions WHERE category_id = :cid AND group_id = 1",
[':cid' => $categoryId]
);
if ($fallbackRow) {
return (int)$fallbackRow[$perm] === 1;
}
}方案 B(简单粗暴):在 install_user_groups.php 执行时,强制为所有现有版块插入一条 group_id=0 的记录(浏览=1,发帖=0,回复=0),确保 count > 0 永远为真且行为一致。这样 check() 中每个版块都有明确记录,不会误拒绝。
方案 C(保持现状但改进):保留全局回退逻辑。但在后台首次配置游客权限时,批量初始化所有版块:
INSERT INTO category_permissions (category_id, group_id, can_view, can_post, can_reply)
SELECT c.id, 0, 1, 0, 0 FROM categories c
WHERE NOT EXISTS (SELECT 1 FROM category_permissions cp WHERE cp.category_id = c.id AND cp.group_id = 0);四、代码健壮性与潜在隐患
🟡 4.1 UserId=0 与 GroupId=0 混淆(整体架构问题)
涉及文件:所有前台 Controller
审查发现:当前所有前台 Controller 统一使用:
$userId = $currentUser ? (int)$currentUser['id'] : 0;
// ...
Permission::canView($categoryId, $userId ?: null);这导致两个不同语义的「0」混在一起:
userId = 0→ 表示「没有用户」groupId = 0→ 表示「游客组」
在 Permission::check() 内部,null 导入 $groupId = 0,但 userId = 0 导入时也会走到 $userId !== null && $userId > 0 为 false 的分支,从而也得到 $groupId = 0。目前的实现恰好巧合地正确,但逻辑上不够清晰,容易在后续修改中出错。
我的建议:重构为更清晰的接口——新增一个按 groupId 检查的方法,前台控制器直接传 groupId:
// 新增方法
public static function canViewByGroup(int $categoryId, int $groupId): bool
{
if ($groupId === 4) return true;
$db = \app\Core\Database::getInstance();
$row = $db->fetchOne(
"SELECT can_view FROM category_permissions WHERE category_id = :cid AND group_id = :gid",
[':cid' => $categoryId, ':gid' => $groupId]
);
if ($row) return (int)$row['can_view'] === 1;
// fallback...
}前台 Controller 改为:
$groupId = $currentUser ? (int)$currentUser['group_id'] : 0;
// 如果 users.group_id 不存在(旧数据库),回退到 1
if ($currentUser && $groupId <= 0) $groupId = 1;
\app\Helpers\Permission::canViewByGroup($categoryId, $groupId);这样从根源上消除了 userId=0 和 groupId=0 的混淆,也避免了一次 SQL 查询(Permission::check() 内部还要查一次 users 表获取 group_id)。
🟡 4.2 $perm 参数无白名单
文件:app/Helpers/Permission.php → check()
审查发现:
$row = $db->fetchOne(
"SELECT {$perm} FROM category_permissions WHERE category_id = :cid AND group_id = :gid",
[':cid' => $categoryId, ':gid' => $groupId]
);$perm 虽然当前是内部传入的(can_view、can_post、can_reply),不是用户输入。但将变量直接拼入 SQL 列名位置是有风险的:一旦未来某个扩展调用 check('用户控制的值', ...),就会形成 SQL 注入。
修复建议:
private static function check(string $perm, int $categoryId, ?int $userId = null): bool
{
$allowed = ['can_view', 'can_post', 'can_reply'];
if (!in_array($perm, $allowed, true)) {
return false; // 或 throw
}
// ...
}⚠️ 我的额外建议:这个白名单也起到了文档作用——后期维护者可以一目了然地看到支持哪些权限字段。
🟢 4.3 删除用户组无事务保护
文件:app/Helpers/Permission.php → deleteGroup()
审查发现:deleteGroup() 包含三个写操作(UPDATE users、DELETE category_permissions、DELETE user_groups),但未使用事务。如果中间某个步骤失败(如外键约束),可能导致部分执行、数据不一致。
修复建议:使用事务包裹三个操作。
五、前台权限落地问题
🟠 5.1 ThreadController edit/delete 无版块权限校验
文件:app/Controllers/ThreadController.php
审查发现:show() 方法有 canView 权限检查(第 27 行),但 edit()(第 150 行)和 delete()(第 208 行)只校验了归属权(是否本人或管理员),没有检查当前用户是否有权在该版块操作:
// edit() — 只查所有权,不查版块权限
if (!$this->isAdmin() && (int)$thread['user_id'] !== (int)$currentUser['id']) {
$this->redirect('/');
}
// 直接渲染编辑页面,未校验 canView/canPost攻击场景:用户 A 在自己的私密版块发帖,邀请好友 B 浏览。B 登录后知道了帖子 ID,如果该帖子也在 B 可编辑的版块,B 可以通过直接访问 /thread/X/edit 来修改内容。
审查发现补充:show() 方法中对未登录用户的传参方式 $userId ?: null 当 $userId = 0 时得到 null,Permission::canView() 内部将 null 视为游客 → $groupId = 0 → 当前代码正确。这里实际没有 bug,但建议显式传参,见 4.1。
修复建议:在 edit() 和 delete() 开头也加上版块权限检查:
// edit() 开头
if (!\app\Helpers\Permission::canView((int)$thread['category_id'], $userId ?: null)) {
$this->redirect('/');
}
// delete() 开头同理🟡 5.2 首页多余查询浪费资源
文件:app/Controllers/HomeController.php
审查发现:第 15 行调用 getLatest() 获取全量最新帖子,第 23 行立刻用 getLatestAllowed() 覆写:
$latestThreads = $threadModel->getLatest(...); // ← 无用查询
// ...
$latestThreads = $threadModel->getLatestAllowed($allowedIds, ...); // ← 立即覆写修复建议:删除第 15 行的 getLatest() 调用。$latestBlogs 和 $categories 的查询(第 16-17 行)不影响,可以保留。
🟡 5.3 Thread.php getLatestAllowed 空数组直接返回空
文件:app/Models/Thread.php
审查发现:
public function getLatestAllowed(array $allowedCategoryIds, int $limit = 10): array
{
if (empty($allowedCategoryIds)) return [];
// ...
}当 $allowedCategoryIds 为空时,首页直接显示空白。这不一定是 bug——如果用户没有任何可见版块,理应看到空白。但有一个边界情况:新安装的系统尚未配置任何权限时,getAuthorizedCategoryIds() 应当返回全量版块(有相关 fallback 逻辑),所以实际中不太会触发 return []。
我的建议:这个行为是设计合理的,不需要修改。
🟡 5.4 前台浮动按钮越权暴露
文件:app/Views/thread/show.php(第 377-384 行)
审查发现:详情页右下角的浮动按钮对所有人显示,包括游客和用户:
新帖
回复游客点击「新帖」→ 被 requireLogin() 拦截 → 返回登录页,体验差。用户点击「新帖」→ 跳转到无权版块 → 被告知无权限,体验也不好。
修复建议:
<?php $canPost = $currentUser && \app\Helpers\Permission::canPost((int)($thread['category_id'] ?? 0), (int)$currentUser['id']); ?>
<?php if ($canPost): ?>
新帖
<?php endif; ?>🟡 5.5 全局计数与用户权限不匹配
文件:app/Controllers/ForumController.php
审查发现:论坛侧边栏和页脚显示的 totalThreads 和 totalPosts 是全局计数,包含所有版块(包括用户无权访问的版块)。用户会看到「总帖子 100」但翻遍可见页面只有 20 条。
影响:视觉上数据不一致,影响用户体验,但不涉及安全。
修复建议:在非管理员用户视图中,可以计算其可见版块的帖子总数。但这涉及性能权衡(每次页面加载都要 count)。建议使用缓存方案或在设置中提供「仅公开版块计数」。
六、附带分析
6.1 注册时默认用户组
文件:app/Models/User.php → create()
核实结论:当前代码已正确处理,并非「只流于表面」:
$defaultGroup = $db->fetchOne("SELECT id FROM user_groups WHERE is_default = 1");
$groupId = (int)($defaultGroup['id'] ?? 1);User::create() 确实动态查询了 is_default = 1 的组,而不是硬编码。这个功能是正确的。
6.2 ForumController 的 category() 方法
文件:app/Controllers/ForumController.php
核实结论:category() 方法第 124 行对单个版块页做了 canView 校验,是正确的。拦截未授权用户进入特定版块。
6.3 删除用户时的级联
文件:app/Controllers/Admin/UserController.php
审查发现:删除用户(deleteWithCascade)可能会连带删除该用户发表的帖子。如果被删用户是某些敏感版块的唯一发帖人,删除后这些版块的内容会消失。这不在本次权限审查范围内,但值得关注。
七、权限系统波及与遗漏文件清单
除了已审查的 ForumController、ThreadController、PostController 外,权限变更还会波及以下文件,否则会出现「前门反锁,后窗大开」的漏洞。
🔴 7.1 API 上传接口 — ApiController.php
文件:app/Controllers/ApiController.php → upload()
波及风险:用户在发帖/回帖时可上传附件。如果用户对某个版块没有发帖或回复权限,他是否能通过直接调用 /api/upload 接口向该版块的主题灌水或上传恶意附件?
修复建议:在 upload() 中,如果请求带有 thread_id 或 category_id 参数,必须校验当前用户在该版块是否具备 can_post 或 can_reply 权限。
🔴 7.2 全文搜索 — SearchController.php + Search.php
文件:app/Controllers/SearchController.php、app/Helpers/Search.php
波及风险(重大):如果一个版块被设置为「仅 VIP 可见」(can_view=0 对普通会员),普通会员确实无法通过列表页和详情页看到。但 FTS 全文搜索不分版块权限——如果他在搜索框搜索敏感关键词,搜索结果可能会把隐藏版块的帖子标题和内容摘要吐出来。
修复建议(两种方案):
方案 A(推荐):在 SearchController 中,从 SQLite 检索出结果后,用 Permission::getAuthorizedCategoryIds($userId) 过滤,剔除无权版块的帖子。
方案 B:在搜索 SQL 语句中直接拼接 AND category_id IN (...),从 SQL 层过滤。
⚠️ 我的建议:方案 B 性能更好但需要重建 FTS 索引;方案 A 实现更快。推荐先用方案 A 快速堵漏,后续再优化为方案 B。
🔴 7.3 标签系统 — TagsController.php + Tag.php
文件:app/Controllers/TagsController.php、app/Helpers/Tag.php
波及风险:标签是跨版块的。如果一个隐藏版块的帖子带有了「核心机密」标签,用户访问 /tag/核心机密 时,无权限的帖子会泄露在标签结果页中。
修复建议:在 TagsController 获取标签帖子列表时,注入当前用户的 $allowedCategoryIds 进行过滤,类似于 Thread::getLatestAllowed() 的模式。
🔴 7.4 个人中心动态 — ProfileController.php
文件:app/Controllers/ProfileController.php
波及风险:用户 A 访问用户 B 的个人主页时,系统会列出 B 最近发表的帖子。如果 B 在某个「私密版块」发了贴,A 在 B 的个人主页动态里就能直接看到并点击,导致私密内容外泄。
修复建议:在读取用户动态列表的 SQL 中,必须限制当前浏览者(A 用户)具备 can_view 权限的版块 ID。
🟡 7.5 权限上下文初始化 — init.php + RememberMe.php
文件:app/init.php、app/Helpers/RememberMe.php
波及风险:确保在 init.php 执行完毕、或 RememberMe 自动恢复 Session 后,用户的 group_id 已被正确写入 Session。如果写入不及时,导致某些前置钩子(如 route_before_dispatch)执行时拿到的仍然是游客状态,引发逻辑错乱。
核实结论:当前架构中,Auth::getCurrentUser() 每次从数据库实时查询用户数据(含 group_id),而非仅依赖 Session 中的缓存字段,因此当前不存在此风险。但如果未来为了性能引入 Session 缓存用户字段,需特别注意同步问题。
🟠 7.6 插件系统(隐形炸弹)— plugins/
文件:plugins/ 下所有插件(post_favorite、qa 等)
波及风险:
- 收藏插件(
post_favorite):用户收藏了一个帖子,随后该帖子所在版块的权限被管理员收紧(该用户变为无权查看)。当他打开「我的收藏」页面时,收藏插件是否会绕过权限直接把帖子内容展示出来? - 问答插件(
qa):如果 QA 系统共用了论坛的categories表,QA 插件控制器中是否引入了Permission::check?
修复建议:所有涉及版块帖子展示的插件,在列表页和详情页都必须调用 Permission::canView() / getAuthorizedCategoryIds()。建议在插件开发文档中明确此要求。
八、架构级建议与优化
以下建议不针对具体 bug,而是从架构层面提升权限系统的健壮性和可维护性。
🟡 8.1 将权限校验「中间件化」,告别手动拼接
现状:每个 Controller 的方法里手动写 if (!Permission::canView(...)):
// 每个动作都要写一遍,极易遗漏
public function show($id) {
if (!Permission::canView(...)) { ... }
}
public function edit($id) {
// 开发新功能时很容易忘记写
}建议:利用 Router::group() 和 Controller 基类,实现自动权限拦截:
方案 A(Router 中间件):在路由注册阶段引入中间件概念:
$router->get('/thread/{id}', [ThreadController::class, 'show'])
->middleware('permission:view');方案 B(Controller 基类钩子):在 Controller 基类中增加一个 beforeAction() 钩子,自动根据当前路由和方法提取 category_id 并调用 Permission 检查,通不过直接 403。
我的建议:方案 B 改动最小,适合当前架构。可以在
Controller中添加一个$requirePermission属性数组:
class ThreadController extends Controller {
protected $requirePermission = [
'show' => ['action' => 'canView', 'param' => 'category_id'],
'edit' => ['action' => 'canView', 'param' => 'category_id'],
'delete' => ['action' => 'canView', 'param' => 'category_id'],
];
}然后在 Controller::view() 或新增的 dispatch() 方法中自动遍历校验。
🔴 8.2 单轨制:干掉 role 字段,全盘由 group_id 驱动
现状:目前是双轨制——users 表里既有 role('user' / 'admin'),又有 group_id(1~4)。两套体系互相独立,导致:
role='admin'+group_id=1→ 能进后台但前台被拒绝(自相矛盾)role='user'+group_id=4→ 前台跳过后台进不去(越权)
建议:彻底统一为 group_id 单一信源(Single Source of Truth):
| 当前设计 | 建议设计 |
|---|---|
role = 'admin' 标识管理员 | 删除 role 字段,由 group_id = 4 统一标识管理员 |
Auth::isAdmin() 检查 role === 'admin' | Auth::isAdmin() 改为检查 (int)($user['group_id'] ?? 1) === 4 |
后台 BaseController 同时依赖 role + Session | 后台 BaseController 只依赖 isAdmin() |
| 用户编辑页有 role 下拉和 group_id 下拉两个控件 | 合并为一个:用户组下拉即决定一切 |
迁移步骤:
Auth::isAdmin()改为return (int)($user['group_id'] ?? 1) === 4;BaseController的__construct()中依赖Auth::isAdmin()- 后台用户编辑页去掉
role下拉框,保留group_id下拉框 - 数据库中
users.role字段保留但不再使用,后续清理
优势:为以后扩展「副管理员」「内容审核员」「SEO专员」等更细粒度的后台管理组留下纯净空间。
🟠 8.3 游客回退原则:Default Deny 优于 Default Allow
现状:当前游客(group_id=0)找不到权限记录时,回退到普通会员(group_id=1)的权限:
if ($groupId === 0 && 无游客权限记录) {
$groupId = 1; // 游客继承会员权限
}问题:按最小权限原则(Principle of Least Privilege),安全回退方向应当是向内收紧(拒绝),而不是向外放开(继承)。
场景:管理员创建了一个「VIP 私密版块」,给 group_id=2(VIP)配置了权限,但根本没配置 group_id=0(游客)的记录。按当前逻辑「无记录回退到普通会员」,由于普通会员通常有公开版块的权限记录或不触发拒绝,游客可能意外获得访问权。
建议:改为 Default Deny——找不到 group_id=0 的显式允许记录时,直接拒绝:
if ($groupId === 0) {
// 查 group_id=0 是否有显式记录
$row = $db->fetchOne(
"SELECT {$perm} FROM category_permissions WHERE category_id = :cid AND group_id = 0",
[':cid' => $categoryId]
);
if (!$row) return false; // 无记录 → 拒绝(Default Deny)
return (int)$row[$perm] === 1;
}⚠️ 此改动会破坏向后兼容性!部署此改动前必须确保已为所有版块插入了
group_id=0的权限记录(参考 3.3 方案 B)。否则所有游客将看不到任何版块。
🟢 8.4 增强后台二次密码验证(30 分钟)的严密性
现状:BaseController 的 __construct() 中验证 admin_verified_at 过期时间:
if ($uriPath !== '/admin/verify') {
if ($verifiedAt < time() - 1800) {
redirect('/admin/verify');
}
}建议:
- 确保
/api/下所有属于后台性质的异步接口(如果未来有的话,如后台异步清理缓存、异步删除),也都继承或通过了BaseController的 30 分钟拦截。有些系统只拦截了 HTML 页面路由,却漏掉了异步 API 路由。 - 当前
ApiController不在Admin/命名空间下,不继承BaseController,所以当前没有此风险。但未来添加后台 API 时务必注意。
🟢 8.5 权限审计日志
建议:对以下敏感操作增加审计日志记录:
- 用户组创建/修改/删除
- 版块权限变更
- 用户
group_id变更(管理员手动修改)
日志可写入文件或独立的 audit_log 表,便于事后追溯。
附录:修复优先级建议
新增漏洞(本节 第七、八章 中新发现的)
| 优先级 | 问题 | 影响面 | 估算工量 |
|---|---|---|---|
| 🔴 P0 | 搜索 FTS 泄露隐藏版块内容(7.2) | 信息泄露 | 10 行 |
| 🔴 P0 | 标签列表泄露隐藏版块内容(7.3) | 信息泄露 | 5 行 |
| 🔴 P0 | 个人中心动态泄露隐藏帖(7.4) | 信息泄露 | 8 行 |
| 🟠 P1 | API 上传无权限校验(7.1) | 存储滥用 | 8 行 |
| 🟠 P1 | role/group_id 双轨制统一(8.2) | 架构改造 | 较大 |
| 🟠 P1 | 游客 Default Deny(8.3) | 安全原则 | 5 行 |
| 🟡 P2 | 插件系统权限检查(7.6) | 视插件而定 | 不定 |
| 🟡 P2 | 权限校验中间件化(8.1) | 架构优化 | 较大 |
| 🟢 P3 | 后台 API 二次验证覆盖(8.4) | 健壮性 | 3 行 |
| 🟢 P3 | 权限审计日志(8.5) | 可追溯性 | 较大 |
全部问题总优先级(合并 + 重排序)
| 优先级 | 问题 | 影响面 | 估算工量 |
|---|---|---|---|
| 🔴 P0 | role 与 group_id 强绑定(2.1) | 安全 | 5 行 |
| 🔴 P0 | 管理员自降级保护补全(2.2) | 安全 | 1 行 |
| 🔴 P0 | Post POST 加 canPost 校验(2.3) | 安全 | 6 行 |
| 🟠 P1 | deleteGroup 重置用户组(3.1) | 数据一致性 | 4 行 |
| 🟠 P1 | 游客回退改为版块级别(3.3) | 权限逻辑 | 15 行 |
| 🟠 P1 | Thread edit/delete 加版块校验(5.1) | 安全 | 4 行 |
| 🟡 P2 | 内置组编辑限制(3.2) | 防误操作 | 4 行 |
| 🟡 P2 | $perm 白名单(4.2) | 防御性编程 | 3 行 |
| 🟡 P2 | 前台浮动按钮权限控制(5.4) | 用户体验 | 3 行 |
| 🟢 P3 | UserId/GroupId 重构(4.1) | 架构整洁 | 较大 |
| 🟢 P3 | 首页删除多余查询(5.2) | 性能 | 1 行 |
| 🟢 P3 | deleteGroup 加事务(4.3) | 健壮性 | 3 行 |
| 🟢 P3 | 全局计数按权限过滤(5.5) | 体验 | 较大 |
文档维护说明:本文件记录了当前审查的全部发现和建议。每修复一条问题后,可在对应条目后标注
✅ 已修复及日期。