[审计]vn_block(屏蔽用户)插件审计报告

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

本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/docs/vn_block插件审计报告_2026-09-23.md。

vn_block(屏蔽用户)插件审计报告

📌 后续:本报告的 14 项问题已全部修复(含修复中新发现的 3 项),见

docs/vn_block插件修复报告_2026-09-23.md。本报告保留为修复前的历史基线。

  • 审计对象:plugins/vn_block/(21 个文件:3 PHP 类 + 7 钩子 + 2 视图 + 3 语言包 + 1 CSS + plugin.json + 独立库)
  • 审计日期:2026-09-23
  • 审计依据:docs/插件开发规范.md(2026-09-22 版,91KB)
  • 审计方式:静态逐行核对 + 本地 HTTP 端到端实测(php -S 127.0.0.1:8899,PHP 8.2.9nts)
  • 发布状态:未进入任何发布包(update/ 与 CHANGELOG.md 均无 vn_block 记录)→ 故障仅影响本机开发环境,生产未受影响
  • 本次是否改动代码:否。全部探针数据与临时文件已清理(见 §7)

一、结论速览

编号级别问题位置
P0-1🔴 阻断点击「屏蔽 / 取消屏蔽」必定 404 —— 写操作后 redirect() 到 POST-only 的自身路由FrontController.php:49 + :71-82
P1-1🟠 高目录名含下划线但未加 class_alias → activate/deactivate/uninstall 三方法被核心静默跳过(卸载留孤儿表)Plugin.php(缺尾部别名块)
P1-2🟠 高16 个文本文件全部 CRLF,违反「UTF-8 无 BOM + LF」,过不了发布卫生门禁全插件
P1-3🟠 高后台路由注册在 route_register(规范要求 admin_route_register);声明的 route:admin 权限实际不生效(全仓库唯一)hook/route_register.php:15-17、plugin.json:19-21
P1-4🟠 高回复区表单位于 #replies(hx-boost="true")内,**无 hx-disinherit="*"** → 被 htmx 接管,修好 P0 后仍会异常hook/post_operation_after.php:33
P2-1🟡 中init_after 每请求执行 2 条 DDL,无戳文件守卫(规范明文禁止)hook/init_after.php:7
P2-2🟡 中uninstall() 只删 blk_blocks,漏删 blk_settingsPlugin.php:45-52
P2-3🟡 中搜索页列表过滤映射键写错(threads 不存在)→ 静默失效hook/controller_view_before.php:36
P2-4🟡 中视图未用 section('css') / section('title'),CSS 以 <link> 混在内容区views/manage.php:15、views/admin_settings.php:15
P3-1🔵 低nav_user_menu_items 忽略 placement(PC / 移动同一份输出)hook/nav_user_menu_items.php:13
P3-2🔵 低帖子详情页同一份 CSS 被注入两次(head + body)layout_head_end.php:14 + thread_operation_after.php:26
P3-3🔵 低视图内硬编码中文「未知用户」,未走 i18nviews/manage.php:40
P3-4🔵 低导航图标硬编码 &#xf05e;,与 plugin.json 声明的 user-slash(f506)不一致hook/nav_user_menu_items.php:12
P3-5🔵 低setSetting() 不清 setting() 的请求级缓存(当前无实际影响)Plugin.php:218-248

一句话结论:设计骨架是合规的(独立库、CSRF、预处理、i18n、零内联、主题变量全部到位),但收尾细节与规范脱节:一个必现的 404 阻断 + 4 个高危规范项,其中「CRLF 全量」和「缺 class_alias」这两条会直接卡发布门禁。


二、P0-1:屏蔽 / 取消屏蔽 404 —— 根因与证据链

2.1 根因(一句话)

toggle() 写入后调用 $this->redirect($this->currentPath())。而 currentPath() 取的是当前请求 URI——也就是 POST 的目标 /block/toggle 本身;该路径只注册了 POST(route_register.php:12),浏览器跟随后续的 GET /block/toggle → 路由无匹配 → 404。

2.2 完整证据链(全部实测)

① 路由注册表:/block/toggle 只有 POST,没有 GET

// plugins/vn_block/hook/route_register.php:11-13
$router->get('/blocked',       ['\\Plugin\\VnBlock\\FrontController', 'manage']);
$router->post('/block/toggle', ['\\Plugin\\VnBlock\\FrontController', 'toggle']);  // ← 只有 POST
$router->post('/block/add',    ['\\Plugin\\VnBlock\\FrontController', 'add']);

② 控制器把用户送回这个 POST-only 地址

// plugins/vn_block/FrontController.php:39-50
public function toggle()
{
    $this->requireLogin();
    ...
    Plugin::toggle($user['id'], $target);
    $this->redirect($this->currentPath());   // ← :49  currentPath() == '/block/toggle'
}

// plugins/vn_block/FrontController.php:71-82
private function currentPath(): string
{
    $uri = $_SERVER['REQUEST_URI'] ?? '/';   // ← 就是 POST 的 URI,不是「来源页」
    ...
    return $uri;
}

③ 实测(登录态 user_id=2,CSRF 有效)

POST /block/toggle  →  HTTP/1.1 302 Found
                       Location: /block/toggle        ← 自己跳自己
GET  /block/toggle  →  404

同一次请求 blk_blocks 确实写入了数据(证明业务逻辑本身正常,坏的只是跳转):

blk_blocks: 3 | 2 -> 3 | 2026-09-23 17:46:08

④ 三个入口全部命中同一个坑

入口位置结果
主帖「屏蔽 / 取消屏蔽」按钮hook/thread_operation_after.php:33 → action="/block/toggle"404
每层回复的按钮hook/post_operation_after.php:33 → action="/block/toggle"404(且被 htmx 接管,见 P1-4)
「我的屏蔽」页的取消屏蔽views/manage.php:44 → action="/block/toggle"404

实测 /thread/89(uid1 已屏蔽作者 275)页面内共渲染 9 个 action="/block/toggle" 表单(1 个「取消屏蔽」+ 8 个「屏蔽」):

9 action="/block/toggle"
1 vnb-block-btn">取消屏蔽
8 vnb-block-btn">屏蔽

→ 与用户报告「点击屏蔽和取消屏蔽都显示 404」完全吻合。

⑤ 反证:add() 走的是同款 redirect,但它没事

// FrontController.php:67
$this->redirect('/blocked');   // /blocked 是 GET 路由 → 正常

说明作者知道「跳回 GET 页」的写法,唯独 toggle 用错了参数——不是设计缺陷,是 currentPath() 这个「回跳来源页」的实现取错了变量。

2.3 修复方案(三个选项,请拍板)

方案做法代价 / 风险
A(推荐)表单加隐藏域 return_to(= 当前详情页/管理页路径),toggle() 用 redirect($returnTo);服务端校验:必须以 / 开头、不含 //、不含 \、不得等于 /block/toggle,不合法则回退 /blocked改 2 个钩子 + 1 个视图 + 控制器;必须带校验,否则引入开放重定向(§11.1 #6)
B(最小)toggle() 直接 redirect('/blocked')只改 1 行;但详情页点屏蔽会跳到「我的屏蔽」页,偏离直觉(取消屏蔽场景反而合理)
C(不推荐)用 HTTP_REFERER 回跳无 REFERER / 被策略裁剪时需兜底;隐私设置严格时不发该头

⚠️ 明确反对:给 /block/toggle 补一条 GET 路由来「消除 404」。那会把写操作暴露成可被预取器 / 爬虫 / <img src> 触发的 GET,CSRF 面被放大(当前实现有 Csrf::verifyOrDie,GET 化后仍会被预取打到)。


三、P1 级问题明细

P1-1 目录名含下划线但缺 class_alias(§4.1 / §11.12)

实测(用核心同一个表达式):

Plugin\vn_block\Plugin        => false   ← 核心生命周期推导用的就是这个
Plugin\VnBlock\Plugin         => true
Plugin\VnBlock\FrontController => true
Plugin\VnBlock\AdminController => true

app/Helpers/Plugin.php:699 / :750 / :846 用 "\\Plugin\\{$name}\\Plugin"($name = 目录名)拼类名,class_exists 恒 false → activate() / deactivate() / uninstall() 三方法静默跳过,无日志、后台仍提示「操作成功」。

  • 目前表是存在的(blk_blocks / blk_settings),靠的是 hook/init_after.php 的兜底——不能据此认为没问题。
  • 真实后果 = 卸载留孤儿表:点「卸载」后 plugins/vn_block/data/vn_block.sqlite 里的两张表永久残留。
  • 修法(规范 §4.1 指定,零核心改动,放 Plugin.php 类定义之外的文件末尾):
if (!\class_exists('Plugin\\vn_block\\Plugin', false)) {
    \class_alias(Plugin::class, 'Plugin\\vn_block\\Plugin', false);
}

P1-2 全部 16 个文本文件为 CRLF(§11.5 #32 / §11.19)

用 Python 数 b"\r\n"(规范指定的可靠判法):

AdminController.php  CRLF=42      Plugin.php  CRLF=249      lang/zh.php  CRLF=23
FrontController.php  CRLF=83      style.css   CRLF=149      lang/en.php  CRLF=22
hook/*.php  7 个文件 CRLF 合计 185   views/*.php 2 个 CRLF 合计 92   lang/zh_tw.php CRLF=22

  • 无 BOM(这点是好的),但行尾 100% 是 CRLF,规范明文:「BOM 与 CRLF 都会被打包 / 卫生门禁判为 FAIL」。
  • 该插件未进入发布包,所以还没被门禁拦过——但一旦进包就会 FAIL。
  • 修复前请确认这 16 个文件不在 REF_样式净化与插件.md §A.6 的「15 个必须保持 CRLF 的历史遗留」清单里(该清单只涉及 mod_system 等核心/既有文件,与 vn_block 无关)。

P1-3 后台路由注册在 route_register(§5.3)

// hook/route_register.php:15-17
// 后台设置页(入口由 plugin.json 的 admin_url 指向;控制器内 requireAdmin 鉴权)
$router->get('/admin/vn-block',  ['\\Plugin\\VnBlock\\AdminController', 'settings']);
$router->post('/admin/vn-block', ['\\Plugin\\VnBlock\\AdminController', 'save']);

三点问题:

  1. 规范 §5.3 明确:后台路由放 hook/admin_route_register.php。全仓库 28 个插件中,只有 vn_block 把 /admin 路由写进 route_register(grep -ln "'/admin" plugins/*/hook/route_register.php 仅此 1 个命中)。
  2. 声明的权限形同虚设:plugin.json 声明了 "permissions": ["route:admin"],但核心的权限校验只挂在 admin_route_register 分支上(app/Helpers/Plugin.php:614)→ 因为走的是 route_register,这条声明从未被消费。
  3. 功能上目前可用(实测 GET /admin/vn-block → 200,POST → 302 保存成功,blk_settings 落库 show_button=0),但属"照抄会外溢"的坏范式。

修法:钩子拆成 hook/admin_route_register.php(内容不变),plugin.json 的 hooks 清单改键名 → 必须 Plugin::refresh() 重建 plugins/plugins_cache.json(§11.5 #30)。

P1-4 #replies 内表单缺 hx-disinherit="*"(§11.9)

app/Views/thread/show.php:235

hx-boost="true" 会接管容器内所有 <form> 与 <a>。而 post_operation_after(show.php:268,在 #replies 内部)注入的 8 个屏蔽表单因此被 htmx 接管:

  • 请求变成 AJAX(HX-Request),302 被 fetch 透明跟随 → 最终拿到 404 页面,htmx 按 hx-select="#replies" 去裁切一个 404 页 → 表现为"点了没反应 / 局部塞进怪东西";
  • 即便 P0 修好(改成回跳详情页),htmx 也会把整页 HTML 塞进 #replies(boost 请求期望的是片段)。

修法(§11.9 模板):给表单加 **hx-disinherit="*"**(推荐,一劳永逸),或逐项反向覆盖 hx-push-url="false" / hx-select="unset"。

注:主帖按钮(thread_operation_after,show.php:211)在 #replies 之外,不受影响。


四、P2 / P3 级问题明细

P2-1 init_after 每请求跑 DDL,无守卫(§11.15)

// hook/init_after.php:7
\Plugin\VnBlock\Plugin::boot();
// Plugin.php:55-63  → ensureSchema()(CREATE TABLE blk_blocks)+ ensureSettingsTable()(CREATE TABLE blk_settings)

规范 §11.15 原文:「⚠️ 别在控制器每请求跑 DDL;ensureSchema() 本身无缓存门控,要在插件侧加戳文件 / 会话守卫。」

对照:仓库里 7 个插件用戳文件短路(protected/.{plugin}_table_cache 实测存在:floor_reply / friend_links / points_mall / post_favorite / single_page / task_center / user_profile),user_level 用 Plugin::ensureOnce()。vn_block 是唯一没有守卫的 → 每一次页面请求(含游客、含与插件无关的页面)都会打开插件 SQLite 并执行 2 条 DDL。

修法:照 plugins/points_mall/hook/init_after.php 的戳文件模板改写。

P2-2 uninstall() 漏删 blk_settings(§11.5 #17)

// Plugin.php:45-52
public static function uninstall(): void
{
    self::db()->exec('DROP TABLE IF EXISTS blk_blocks');   // 只删了这一张
}

blk_settings(Plugin.php:206-215 建)未被清理。规范要求「uninstall() 必须清理所有相关数据,不留残留」。叠加 P1-1 后,目前卸载一张表都不会删。

P2-3 搜索页过滤映射键写错(静默失效)

// hook/controller_view_before.php:29-37
$listMap = [
    ...
    'search/index' => 'threads',   // :36  ← 该变量在搜索视图里不存在
];

搜索视图实际读的是(app/Views/search/index.php:9-12):

$results       = $this->getData('results', []);        // :9  混合 tab
$threadResults = $this->getData('threadResults', []);  // :10 帖子 tab(foreach 在 :60)

→ 搜索列表里被屏蔽用户的帖子照常显示。同批核对,其余 6 条映射的变量名均正确存在:

映射变量核对结果
forum/index、forum/categorylatestThreads✅ ForumController.php:173/306
home/index、home/index_defaultlatestThreads✅ HomeController.php:31(两种首页模式)
tags/showthreads✅ Views/tags/show.php:7
profile/indexuserThreads✅ ProfileController.php:136
search/index~~threads~~❌ 应为 results / threadResults

P2-4 视图未用 section('css') / section('title')(§6.1)

views/manage.php:15、views/admin_settings.php:15 都把 <link rel="stylesheet"> 写在 section('content') 内部;规范 §6.1 把 section('css') 列为「插件页面加载样式的首选方式,比 layout_head_end 更精准」。另外两页均未声明 section('title') → 页面标题回落站点默认。

P3 级(低)

  • P3-1 nav_user_menu_items.php:13 未按 $placement(pc / mobile)分支渲染(§9.1 #10 要求「需按 placement 分别渲染」)。实测 $placement 是注入的(main.php:246 / :360),但插件两次输出同一份 <a class="mn-dropdown-item">。因 .mn-dropdown-item 的样式作用域是 .mn-dropdown-panel .mn-dropdown-item(modern.css:615),移动端抽屉里该类不生效,退化成裸 <a>(与核心移动端条目同为裸 <a>,故不算错,只是无间距/字号对齐)。
  • P3-2 帖子详情页 CSS 被注入两次:layout_head_end.php:14(head)+ thread_operation_after.php:26(body,为 htmx 换 body 的兜底)。首屏会多一个同 URL <link>。
  • P3-3 views/manage.php:40 硬编码 '未知用户'(应走 plugin.vn_block.* 键)。
  • P3-4 nav_user_menu_items.php:12 硬编码 &#xf05e;(ban),而 plugin.json:8 声明 icon: "user-slash"(TemplateCompiler::$map 里是 &#xf506;)—— 同一插件两处图标语义不一致。
  • P3-5 Plugin::setSetting()(:239-248)写入后不清 Plugin::setting() 的 static $cache(:220),同请求内先读后写再读会拿到旧值。当前调用路径(save → redirect → exit)不触发,属预防性提示。

五、已验证正常项(请勿重复发现)

以下均已逐条核对,无问题:

维度结论证据
CSRF3 处写操作全覆盖FrontController.php:44/57、AdminController.php:37 均 Csrf::verifyOrDie;视图隐藏域 manage.php:22/45、admin_settings.php:28
SQL 注入全部预处理 + 占位符,ID 全 (int) 强转Plugin.php:71/89/107/118/147/226/242;无字符串拼接
IDOR无越权面:blocker 恒取 Auth::getCurrentUser()['id'],target_id 仅作被屏蔽方FrontController.php:43/46-48、:61-62;Plugin.php:103 拦自屏蔽
XSS视图全 $this->e();钩子全 htmlspecialchars(..., ENT_QUOTES)manage.php:17-47、admin_settings.php:20-34、thread_operation_after.php:20、nav_user_menu_items.php:11
独立库走 Plugin::db(),未碰核心 business.sqlite;.htaccess / web.config 防下载文件已自动生成Plugin.php:17-20;data/.htaccess(490B)、data/web.config(281B)实测存在
核心库访问边界批量补用户信息走 User::batchGetUsers()(§10.1 / §11.8 #3),未直查 users 表Plugin.php:169;返回结构 [id => [username,avatar,level]] 与用法一致(User.php:367)
Model 实例方法(new User())->findByUsername() 为实例调用(§5.1 坑)FrontController.php:60、User.php:27
i18n三语 13 键零差异 + 键序一致;代码引用 13 键零缺失、零冗余脚本比对 lang/{zh,en,zh_tw}.php + views/hook/控制器引用
零内联`<style<script>
主题适配(§11.7)8 个 --mn-* 变量全部真实存在(--mn-bg-body/bg-card/border/border-light/primary/text/text-muted/text-secondary,均定义于 assets/css/modern.css 等 11 个文件);无硬编码色(全部写在 var() fallback 内);花括号 22/22 配对脚本核对
语法php -l 全部通过19 个 PHP 文件
残留文件无 .bak / .revert / .tmp(§11.3 #13)全目录扫描
DDL 语法SQLite 正确(INTEGER PRIMARY KEY AUTOINCREMENT、UNIQUE(blocker_id, blocked_id)、CREATE TABLE IF NOT EXISTS);UNIQUE(a,b) 的左前缀天然覆盖 WHERE blocker_id = ? 查询Plugin.php:22-31
deactivate()未删表,符合「临时禁用」语义(§4.1)Plugin.php:40-43
缓存清单plugins_cache.json 已含 vn_block 且 hooks 清单与 plugin.json 一致 → 钩子确实生效缓存实测
后台页渲染GET /admin/vn-block → 200,.admin-container + admin/_sidebar 结构与核心后台页一致实测(管理员会话 + admin_verified_at)
详情页屏蔽生效A/B 实测:游客看 /thread/89 正常;uid1(已屏蔽作者 275)看到占位提示「已屏蔽该用户,此帖内容不可见。」且正文隐藏实测 _renderedContent 契约与 thread/show.php:26 一致


六、修复优先级建议(供参考,不代表已动手)

第一批(阻断,必须)
  P0-1  修 toggle 回跳           ← 让「屏蔽」能用
  P1-4  表单加 hx-disinherit="*" ← 与 P0-1 必须同批,否则修了仍异常

第二批(发布门禁,进包前必须)
  P1-1  加 class_alias
  P1-2  16 个文件转 LF(UTF-8 无 BOM)
  P2-2  uninstall() 补删 blk_settings

第三批(规范对齐)
  P1-3  拆出 admin_route_register + refresh 缓存
  P2-1  init_after 加戳文件守卫
  P2-3  搜索映射键改 results / threadResults
  P2-4  视图改用 section('css') / section('title')

第四批(打磨)
  P3-1 ~ P3-5

⚠️ 改动注意(来自项目既有教训):

1. 同一文件多处改动必须串行编辑,改完 grep 锚点回读校验(并行 Edit 会静默互相覆盖);

2. P1-2 转 LF 时必须用 Python 二进制读写并逐文件 sha256 前后比对;

3. P1-3 改完 hooks 清单后必须 Plugin::refresh() 重建 plugins/plugins_cache.json,并清前台页面缓存(nav_user_menu_items 属布局级钩子,§11.5 #31);

4. P0-1 若选方案 A,return_to 必须做「以 / 开头、不含 //、不含 \、不等于自身」四重校验。


七、本次审计的探针与清理记录

项目内容状态
HTTP 服务php -S 127.0.0.1:8899(PHP 8.2.9nts),已停止✅ 已停
会话探针sessions 表插入 2 行(vnbprobe0001 / vnbadmprobe01,含 csrf / admin_verified_at)✅ 已 DELETE,残留 0
数据探针blk_blocks 插入 1 行(2 → 3,created_at=2026-09-23 17:46:08)✅ 按内容标记精确删除;现 MAX(id)=2、行数 2(= 原始真实数据 1→43、1→275)
设置探针后台保存测试写入 blk_settings.show_button=0✅ 已删除,blk_settings 恢复 0 行(原始状态)
临时文件.workbuddy-ai/tmp/_vnb_*(12 个:夹具脚本 + 抓取的 HTML + 日志)✅ 已全部删除
插件代码 / 视图 / 语言包 / 库结构未做任何修改✅ 零改动


报告完 — 等待指令后再动手。

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

全部回复 (0)

暂无回复

请 登录