本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/docs/vn_postname插件修复与验证报告_2026-09-23.md。
vn_postname 插件修复与验证报告
- 插件:
plugins/vn_postname(马甲发帖) - 版本:
1.1.0→2.0.0(破坏性语义变更:取消发帖自动建号) - 日期:2026-09-23
- 依据:
docs/vn_postname插件审计报告_2026-09-23.md(17 项:P0×3 / P1×4 / P2×4 / P3×6) - 验证环境:phpstudy CLI PHP 8.2.9nts +
php -S 127.0.0.1:8899(生产为 IIS + PHP 8.4,下限 8.0)
0. 结论
三步全部完成,17 项审计问题修掉 16 项(P3-6 属规范文档侧,未动)。
| 验证门禁 | 结果 |
|---|---|
php -l(17 个 PHP 文件) | ✅ 全通过 |
零内联(<style> / <script> / on*=) | ✅ 零命中 |
| 编码(UTF-8 无 BOM + LF) | ✅ 19 个文本文件,CRLF=0 BOM=False |
| i18n 三语(键集合 / 键序 / 占位符) | ✅ zh=66 en=66 zh_tw=66,零差异 |
| HTTP 端到端冒烟 | ✅ 37 / 37 PASS |
| 直接调用层回归 | ✅ 28 / 28 PASS |
| 数据未被污染 | ✅ users MAX(id)=285 COUNT=285、vnpn_aliases 1 行,均未变 |
核心安全目标达成:@@ 只能指向操作者本人的马甲,越权与不存在一律 422 拒绝,且拒绝发生在 route_before_dispatch(帖子根本不创建,无「先建后删」残留)。
1. 变更文件清单(sha256 前 16 位)
重写
| 文件 | 字节 | sha256 | 说明 |
|---|---|---|---|
Plugin.php | 38612 | 86802a2d3efd915a | 主类重写:归属表 + 校验链 + 归属转移 |
plugin.json | 1054 | b65b9a8cefe56aa4 | v2.0.0,hooks 5 → 8 |
lang/zh.php | 5695 | 51fea4b14e159d4d | 66 键 |
lang/en.php | 5752 | f8ad81049be478d0 | 66 键 |
lang/zh_tw.php | 5728 | 3deb379758fed477 | 66 键 |
hook/init_after.php | 1153 | aafb0f3dd0054482 | 戳文件门控 |
hook/post_create_extra.php | 2106 | 77761754f602fdf4 | 发帖页提示(原为空实现) |
新建
| 文件 | 字节 | sha256 | 说明 |
|---|---|---|---|
hook/route_before_dispatch.php | 7434 | 5a8cc1ad69047520 | 门禁(方案 B 核心防线) |
hook/route_register.php | 571 | 4414b23817dadcdb | 前台 3 条路由 |
hook/nav_user_menu_items.php | 2054 | 3d0bf47994d35734 | 「我的马甲」入口 |
FrontController.php | 3098 | e02e5a959decfd72 | 列表 / 创建 / 解除 |
views/aliases.php | 5396 | e4c5c21654131faf | 「我的马甲」视图 |
views/error.php | 4512 | 78ae13de58c8a98a | 422 拒绝页(含草稿回显) |
修改
| 文件 | 字节 | sha256 | 说明 |
|---|---|---|---|
AdminController.php | 3020 | a8f6e152ccfb8fe1 | v2.0.0;去死参数;加 aliasTotal |
views/admin.php | 7400 | a3fa921126392b9b | 去双转义;类型映射加 alias;显示马甲总数 |
全部 12 个原文本文件行尾 CRLF → LF(P2-1)。
assets/style.css、data/*、hook/thread_create_after.php、hook/post_create_after.php、hook/admin_route_register.php未改内容。
2. 第一步·收尾修复(逐项对照)
| 项 | 问题 | 修法 | 证据 |
|---|---|---|---|
| P0-1 | 生命周期类名推导失效 → 卸载留孤儿表 | Plugin.php 末尾 class_alias(Plugin::class, 'Plugin\vn_postname\Plugin', false) | class_exists / method_exists('uninstall') 修复前 false → 修复后 true |
| P0-3 | post_create_extra 是空实现 | 重写:有马甲 → 用马甲发帖:在标题或正文里写 @@小助手 + 「管理我的马甲」;无马甲 → 引导创建。删除死代码 $target_type === 'blog' | GET /post/new 渲染出上述文案 |
| P1-1 | 三语键集合不一致 | zh 补 form_hint;三语对齐到 66 键 | 键集合零差异 + 键序一致 + 占位符一致 |
| P1-2 | init_after 每请求跑 DDL | 戳文件门控 protected/.vn_postname_table_cache | 二次请求不再建表 |
| P1-3 | isAllowed() 不校验用户状态 | 加 if ((string)($user['status'] ?? 'active') !== 'active') return false; | isAllowed(1)=true / isAllowed(2)=false |
| P1-4 | 自动建号零护栏 | 彻底取消发帖自动建号;建号只走显式 createAlias(),带完整校验链 | 见 §3 |
| P2-1 | 12 个文件 CRLF | 全部转 LF(Python 逐字节 b"\r\n" 计数校验) | 19 文件 CRLF=0 BOM=False |
| P2-2 | 发帖数双计、马甲积分不同步 | 见 §4 | 见 §4 |
| P2-3 | 搜索索引条件性不同步 | 改写正文(strip_marker 生效)分支内补 Search::indexThread($threadId) | 代码走查(未做端到端搜索断言) |
| P2-4 | 后台视图双重转义 | views/admin.php 去掉赋值处的 $this->e(),输出点已有 e() | 单处转义 |
| P3-1 | 版本号不同步 | plugin.json / Plugin.php:7 / AdminController.php:7 统一 2.0.0 | 三处一致 |
| P3-2 | uninstall() 无异常保护 | 逐表 try/catch + error_log | 单表失败不中断 |
| P3-3 | stripMarker 留空段落 | 标记删除后循环清空块级标签(最多 2 轮,幂等) | <p>@@张三</p><p>正文第二行</p> → <p>正文第二行</p> |
| P3-4 | __nav_active 是死参数 | 从 AdminController::view() 数据中删除 | — |
| P3-5 | handlePostCreated() 的 $threadId 未用 | v2 中已用于 transferOwnership(..., $threadId) 作积分关联对象 | Plugin.php:729 |
| P3-6 | 规范 §9.1:691 注释过时(post_create_after 实为事务外) | 未改(属 docs/插件开发规范.md,非插件缺陷) | 待指示 |
3. 第二步·P0-2 改造为方案 B
3.1 归属模型
新增插件自有表 vnpn_aliases(不读不写核心 users 表做归属判断):
CREATE TABLE IF NOT EXISTS vnpn_aliases (
id INTEGER PRIMARY KEY, owner_id INTEGER NOT NULL DEFAULT 0,
uid INTEGER NOT NULL DEFAULT 0, username TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL)
-- 索引:idx_vnpn_alias_owner / idx_vnpn_alias_uid / UNIQUE(owner_id, username)存量回填 backfillAliases():从 vnpn_log WHERE created_new = 1 反查 operator_id → 回填归属,不读核心 users 表,幂等。
3.2 门禁:route_before_dispatch(唯一防线)
@@ 的放行/拒绝全部前置到路由分发之前——帖子根本不创建,避免「先建后删」残留。
流程:非 POST → return | 只认 /post/new 与 #^/thread/(\d+)$# | 未登录 → return | 取标记(标题优先,正文经 Content::decode())| 无标记 → return | 无权 → 拒绝页 | findOwnedAlias() 成功 → 放行 | 失败 → 拒绝页(带本人马甲列表)。
- 顶层函数
vnpnGateErrorPage()用if (!function_exists())包裹且置于文件顶部(规范 §9.4 双触发要求:index.php:296带参 +Router.php:93不带参) - 返回路径白名单构造,不含用户输入(防开放重定向)
- 拒绝页 HTTP 422,草稿回显:
content原样 base64 回填 + 解码后块级标签换行 +strip_tags预览
3.3 建号入口 createAlias()(唯一)
校验链顺序:
enabled → allow_create → isAllowed(含封禁) → RateLimiter(5/3600)
→ validateUsername(正则 + 保留词黑名单)
→ 本人马甲数量上限(5) → 本人同名马甲(err_alias_exists)
→ 全局用户名重名(err_name_taken) → User::create() + 头像补齐 → 写 vnpn_aliasesallow_create语义已改:仅控制「能否创建马甲」,不再控制发帖时自动建号(发帖自动建号已整体移除)ALIAS_MAX_PER_USER = 5;保留词黑名单含admin/管理/版主/官方客服/站长等 22 项,按包含式匹配(sys_admin也拦)- 用户名正则
/^[\p{L}\p{N}_.\-]{2,32}$/u
3.4 前台页面
GET /vn-postname/aliases— 「我的马甲」:列表(含「该马甲账号已不存在」态)、创建表单、解除归属(<details>二次确认,零onclick)、使用说明POST /vn-postname/aliases/create/delete— 均走Csrf::verifyOrDie()- 解除归属只删归属行,不删用户(已发帖子不受影响)
- 入口:
nav_user_menu_items(PC 下拉 + 移动菜单,图标user-secret=,mn-fs-14)
4. 第三步·P2-2 计数 / 积分归属马甲
核心现状(已核实):
| 动作 | 核心行为 | 位置 |
|---|---|---|
| 发帖 | 操作者 post_count +1 | PostController.php:146 |
| 发帖 | 操作者 +points_thread_create | PostController.php:147 |
| 回帖 | 操作者 +points_post_create(不加 post_count) | ThreadController.php:140 |
transferOwnership() 只做归属转移:
① post_count:发帖才回退操作者(回帖核心没加,无需回退);一律给马甲 +1
② 积分:先 Points::deduct(操作者) —— 成功才 Points::award(马甲)
扣失败(余额不足等)则跳过加分并记日志,绝不凭空造分(总量守恒)⚠️ 不碰等级联动(等级由核心 / 等级插件按各自规则处理)——按要求单独处理。
5. 硬约束核对
| 约束 | 结果 |
|---|---|
不碰 app/Core/、app/SplitDB/、Upload.php、Plugin.php(核心) | ✅ 本次改动全部落在 plugins/vn_postname/ |
| 不直写核心用户表 / 积分表 | ✅ 归属判断只读插件自有 vnpn_aliases;计数/积分一律走核心 User Model 与 Points Helper |
| 三语键集合零差异 | ✅ 66 / 66 / 66,键序一致 |
| 零内联 | ✅ 零命中 |
| UTF-8 无 BOM + LF | ✅ 19 文件全部合规 |
| 钩子名与参数与规范一致 | ✅ 8 个钩子:init_after / route_register / admin_route_register / route_before_dispatch / nav_user_menu_items / thread_create_after / post_create_after / post_create_extra |
6. 验证矩阵
6.1 HTTP 端到端冒烟(37 / 37 PASS)
服务:php -S 127.0.0.1:8899 -t D:/phpstudy_pro/WWW;会话:手写 sessions 行(uid1 管理员带 admin_verified_at+csrf、uid2 普通用户)。
A. 门禁 A/B 反证(category_id=999999 → 控制器在写库之前即返回「请选择有效的分类」,因此同一个请求形状只换 @@ 名字就能干净反证门禁,且不落任何数据)
| 用例 | 期望 | 实测 |
|---|---|---|
A1 uid1 @@小助手(本人马甲,标题) | 放行 | 200 放行 ✅ |
A2 uid1 @@flinthub(非本人) | 拒绝 | 422 + 拒绝页 ✅ |
A3 uid1 @@虚拟化大师(他人马甲) | 拒绝 | 422 ✅ |
A4 uid1 @@绝不存在的名字 | 拒绝 | 422 ✅ |
| A5 uid1 无标记 | 放行 | 200 ✅ |
A6 uid1 @@小助手(正文中) | 放行 | 200 ✅ |
A7 uid1 @@flinthub(正文中) | 拒绝 | 422 ✅ |
A8 uid2(无权用户)@@小助手 | 拒绝 | 422 + 你所在的用户组无权使用马甲发帖 ✅ |
B. 拒绝页完整性:422 / 页标题「发帖被拒绝」/ 被拒绝的标记行 / 回显 @@flinthub / 本人马甲列表 / 草稿回显 / 无缺键泄漏 — 7 项全 PASS
C. 无权提示、D. 回帖路径(POST /thread/99999999:本人马甲放行、他人马甲 422)、E. GET 双触发幂等(/ /forum /post/new /vn-postname/aliases 全 200 无缺键)、F. CSRF 与登录态(无 CSRF 403、错 CSRF 403、未登录 302 /login)— 全 PASS
G. 建号拦截分支 + 限流(全部为拒绝分支,零写入)
| 用例 | 期望文案 | 实测 |
|---|---|---|
G1 admin | 保留词 | ✅ |
G2 flinthub | 该用户名已存在 | ✅ |
G3 a | 格式错 | ✅ |
G4 小助手 | 你已经有同名马甲了 | ✅ |
| G5 第 5 次(限内最后一次) | 格式错 | ✅ |
| G6 第 6 次 | 创建过于频繁 | ✅ |
H. 后台页 /admin/vn-postname:uid1 200 + 「当前全站共 N 个马甲」+ 无缺键;uid2 → 302 /login — 全 PASS
6.2 直接调用层回归(28 / 28 PASS)
- P0-1 生命周期:
class_exists/method_exists(uninstall)/method_exists(activate)=true - P0-2
findOwnedAlias:(1,小助手)=285;(1,flinthub)(1,虚拟化大师)(2,小助手)(2,虚拟化大师)(999,小助手)全部null - P1-3
isAllowed:1=true/2=false - P1-4
validateUsername:admin管理员官方客服sys_admin→err_name_reserved;a→err_name_invalid;正常马甲flinthub小助手→ 通过 - P3-3
stripMarker:三种形态空块清理正确 createAlias拒绝分支:admin→err_name_reserved、flinthub→err_name_taken、a→err_name_invalid、小助手→err_alias_exists- 副作用断言:
MAX(uid)before=285 after=285(未产生任何新账号)、vnpn_aliases仍 1 行
6.3 数据完整性
users MAX(id)=285 COUNT=285 (未变)
vnpn_aliases 1 行:owner_id=1 uid=285 username=小助手 created_at=2026-09-23 19:10:35(未变)该行与 topic id=92「[新闻] 马甲发帖测试」均为既有数据(老搭档自己测的),本次未改动。
7. 验证过程中发现并处理的问题
7.1 代码侧:createAlias 校验顺序不精确(已修)
初版把「全局用户名重名」判定排在「本人同名马甲」之前。由于每个马甲本身就是一个真实用户账号,findByUsername() 必然先命中 → 重复创建自己已拥有的马甲名时给出通用文案「该用户名已存在」,而非精确的「你已经有同名马甲了」。
修法:把插件自有归属表的「数量上限 + 本人同名」判定提到全局重名判定之前。
性质:纯代码块换位,两个分支都自带 try/catch 并 return,成功路径零影响。
证据:Plugin.php sha256 6ecc051f7dabf25a… → 86802a2d3efd915a…;php -l 通过;回归 createAlias(1,'小助手') 由 err_name_taken 变为 err_alias_exists;HTTP 冒烟 G4 转 PASS。
7.2 测试脚手架自身的 3 个坑(均为假阴性来源,非插件缺陷)
- urllib 默认跟随 302 → 既看不到
302本身,又会在跟随的那次 GET 里把 session flash 消费掉,导致后续显式 GET 取不到提示文案。首版脚本因此误报 6 项 FAIL。→ 改用「不跟随重定向」的 opener。 - 核心自己的限流:
PostController.php:72create_thread5 次/600s、ThreadController.php:100reply3 次/30s。连发几次就429,表现为「门禁放行用例失败」。→ 每次 POST 前清 IP 限流文件。 - 限流是 IP + Session 双维度(
RateLimiter::checkAndAppend文件缓存 +$_SESSION)。只清 IP 文件不够——旧会话里的 session 计数仍在,G 组后 5 项全部返回「创建过于频繁」。→ 每次 POST 用全新会话。
附带发现:
vn_postname的建号限流排在用户名校验之前,因此反复输入非法名字也会消耗建号配额。作为反枚举设计可接受,未改动,仅记录。
8. 未覆盖项与残留风险
| # | 项 | 说明 |
|---|---|---|
| 1 | createAlias 成功建号路径未做写库验证 | 为避免在生产库造号,只验证了全部拒绝分支。依据:核心 User::create() + 头像补齐链路已由既有 uid285 行证明可用(旧路径建出);v2 新增的「写 vnpn_aliases 归属行」正向写入仅由 backfillAliases() 回填间接覆盖。如需实测请指示(会新增 1 个用户 + 1 行归属,可事后清理) |
| 2 | err_disabled / err_too_many / err_internal 三分支未触发 | 需改配置(关闭插件)或构造极端状态(≥5 个马甲 / 库异常)。三个键均在语言包中存在且可经 'plugin.vn_postname.' . $key 命中 |
| 3 | P2-3 搜索索引同步未做端到端断言 | 仅代码走查确认 Search::indexThread() 在改写分支内被调用 |
| 4 | 等级联动未做 | 按要求单独处理(transferOwnership() 只转移计数与积分,不碰等级) |
| 5 | P3-6 规范 §9.1:691 过时注释未改 | 属 docs/插件开发规范.md,待指示 |
| 6 | 发布包 update/ 与 CHANGELOG.md 未做 | 用户未要求 |
9. 探针清理记录
| 项 | 结果 |
|---|---|
| 测试会话 | DELETE FROM sessions WHERE id LIKE 'vnpnp2%' → 删 38 条,回读残留 0(全库 175 条,按自造前缀定位,未用硬编码 id) |
| 限流缓存 | 删 vn_postname_alias_create / create_thread / reply 三个 IP 键文件;protected/rate_cache/ 剩 6 个(均为非本次测试的存量) |
| 临时文件 | 删 _o1/_o2/_o4.html、_vnpn_sid1/2.txt、_vnpn_g_probe.py、_vnpn_reorder.py、_vnpn_fix1_crlf.py |
| 站点根 | 无探针残留 ✅ |
| 后台服务器 | php -S 127.0.0.1:8899 已停止 |
| 页面缓存 | data/runtime/pages/.html 2 个(home_1_c_e1l180.html,mtime 18:50 / 19:13,非本次测试产物)→ 保留未动 |
保留的可复用回归套件(均在 .workbuddy-ai/tmp/,不进发布包):
_vnpn_smoke.py— HTTP 端到端冒烟(37 项,已内置 §7.2 三个坑的规避)_vnpn_mksess.php— 造测试会话_vnpn_unit.php— 直接调用层回归(28 项)_vnpn_i18n.py— 三语键集合 / 键序 / 占位符比对
10. 下一步(等指示)
- 实测
createAlias成功建号路径(可选,会新增 1 个测试用户,可清理) - P3-6:更新
docs/插件开发规范.md§9.1:691 的过时注释 - 等级联动:马甲计数/积分归属后的等级处理(按要求单独处理)
- 发布包:
update/29+CHANGELOG.md MEMORY.md蒸馏:当前.workbuddy-ai/memory/MEMORY.md已 7944 / 8000 字符(99.3%)