[审计]用户组和板块权限 — 安全与逻辑审查报告

👑Lv.11 元老 🌞 骨干会员
2026-10-25 00:04:17

本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:7月5日复制的资料/用户组和板块权限.md。

用户组和板块权限 — 安全与逻辑审查报告

审查日期:2026-07-02

审查范围:用户组系统 + 权限控制系统 + 前后端权限落地

涉及数据库:user_groups、category_permissions、users.group_id


目录


一、审查结论概要

总体评价:权限系统核心类 Permission.php 设计合理(管理员跳过、回退兼容、查询隔离),前后台 Controller 也尝试了权限落地。但存在以下关键问题:

严重程度数量关键问题
🔴 致命4role 与 group_id 可分离、Post POST 未校验、Thread edit/delete 无版块权限、搜索/标签/profile 跨业务泄露隐藏版块内容
🟠 高危6自降级保护不全、删除用户组产生孤儿、表单 ID 篡改、游客 fallback 一刀切、API 上传无权限校验、插件系统绕过权限
🟡 重要6userId 与 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 下拉两个控件合并为一个:用户组下拉即决定一切

迁移步骤:

  1. Auth::isAdmin() 改为 return (int)($user['group_id'] ?? 1) === 4;
  2. BaseController 的 __construct() 中依赖 Auth::isAdmin()
  3. 后台用户编辑页去掉 role 下拉框,保留 group_id 下拉框
  4. 数据库中 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 行
🟠 P1API 上传无权限校验(7.1)存储滥用8 行
🟠 P1role/group_id 双轨制统一(8.2)架构改造较大
🟠 P1游客 Default Deny(8.3)安全原则5 行
🟡 P2插件系统权限检查(7.6)视插件而定不定
🟡 P2权限校验中间件化(8.1)架构优化较大
🟢 P3后台 API 二次验证覆盖(8.4)健壮性3 行
🟢 P3权限审计日志(8.5)可追溯性较大

全部问题总优先级(合并 + 重排序)

优先级问题影响面估算工量
🔴 P0role 与 group_id 强绑定(2.1)安全5 行
🔴 P0管理员自降级保护补全(2.2)安全1 行
🔴 P0Post POST 加 canPost 校验(2.3)安全6 行
🟠 P1deleteGroup 重置用户组(3.1)数据一致性4 行
🟠 P1游客回退改为版块级别(3.3)权限逻辑15 行
🟠 P1Thread edit/delete 加版块校验(5.1)安全4 行
🟡 P2内置组编辑限制(3.2)防误操作4 行
🟡 P2$perm 白名单(4.2)防御性编程3 行
🟡 P2前台浮动按钮权限控制(5.4)用户体验3 行
🟢 P3UserId/GroupId 重构(4.1)架构整洁较大
🟢 P3首页删除多余查询(5.2)性能1 行
🟢 P3deleteGroup 加事务(4.3)健壮性3 行
🟢 P3全局计数按权限过滤(5.5)体验较大


文档维护说明:本文件记录了当前审查的全部发现和建议。每修复一条问题后,可在对应条目后标注 ✅ 已修复 及日期。

轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| 浏览 3 次 | 回复 0 次

全部回复 (0)

暂无回复

请 登录