[开发]vn_postname 插件修复与验证报告

👑Lv.11 元老 🌞 骨干会员
2026-10-25 01:22:24

本文由 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.php3861286802a2d3efd915a主类重写:归属表 + 校验链 + 归属转移
plugin.json1054b65b9a8cefe56aa4v2.0.0,hooks 5 → 8
lang/zh.php569551fea4b14e159d4d66 键
lang/en.php5752f8ad81049be478d066 键
lang/zh_tw.php57283deb379758fed47766 键
hook/init_after.php1153aafb0f3dd0054482戳文件门控
hook/post_create_extra.php210677761754f602fdf4发帖页提示(原为空实现)

新建

文件字节sha256说明
hook/route_before_dispatch.php74345a8cc1ad69047520门禁(方案 B 核心防线)
hook/route_register.php5714414b23817dadcdb前台 3 条路由
hook/nav_user_menu_items.php20543d0bf47994d35734「我的马甲」入口
FrontController.php3098e02e5a959decfd72列表 / 创建 / 解除
views/aliases.php5396e4c5c21654131faf「我的马甲」视图
views/error.php451278ae13de58c8a98a422 拒绝页(含草稿回显)

修改

文件字节sha256说明
AdminController.php3020a8f6e152ccfb8fe1v2.0.0;去死参数;加 aliasTotal
views/admin.php7400a3fa921126392b9b去双转义;类型映射加 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-3post_create_extra 是空实现重写:有马甲 → 用马甲发帖:在标题或正文里写 @@小助手 + 「管理我的马甲」;无马甲 → 引导创建。删除死代码 $target_type === 'blog'GET /post/new 渲染出上述文案
P1-1三语键集合不一致zh 补 form_hint;三语对齐到 66 键键集合零差异 + 键序一致 + 占位符一致
P1-2init_after 每请求跑 DDL戳文件门控 protected/.vn_postname_table_cache二次请求不再建表
P1-3isAllowed() 不校验用户状态加 if ((string)($user['status'] ?? 'active') !== 'active') return false;isAllowed(1)=true / isAllowed(2)=false
P1-4自动建号零护栏彻底取消发帖自动建号;建号只走显式 createAlias(),带完整校验链见 §3
P2-112 个文件 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-2uninstall() 无异常保护逐表 try/catch + error_log单表失败不中断
P3-3stripMarker 留空段落标记删除后循环清空块级标签(最多 2 轮,幂等)<p>@@张三</p><p>正文第二行</p> → <p>正文第二行</p>
P3-4__nav_active 是死参数从 AdminController::view() 数据中删除—
P3-5handlePostCreated() 的 $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_aliases

  • allow_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 = &#xf21b;,mn-fs-14)

4. 第三步·P2-2 计数 / 积分归属马甲

核心现状(已核实):

动作核心行为位置
发帖操作者 post_count +1PostController.php:146
发帖操作者 +points_thread_createPostController.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 个坑(均为假阴性来源,非插件缺陷)

  1. urllib 默认跟随 302 → 既看不到 302 本身,又会在跟随的那次 GET 里把 session flash 消费掉,导致后续显式 GET 取不到提示文案。首版脚本因此误报 6 项 FAIL。→ 改用「不跟随重定向」的 opener。
  2. 核心自己的限流:PostController.php:72 create_thread 5 次/600s、ThreadController.php:100 reply 3 次/30s。连发几次就 429,表现为「门禁放行用例失败」。→ 每次 POST 前清 IP 限流文件。
  3. 限流是 IP + Session 双维度(RateLimiter::checkAndAppend 文件缓存 + $_SESSION)。只清 IP 文件不够——旧会话里的 session 计数仍在,G 组后 5 项全部返回「创建过于频繁」。→ 每次 POST 用全新会话。

附带发现:vn_postname 的建号限流排在用户名校验之前,因此反复输入非法名字也会消耗建号配额。作为反枚举设计可接受,未改动,仅记录。


8. 未覆盖项与残留风险

#项说明
1createAlias 成功建号路径未做写库验证为避免在生产库造号,只验证了全部拒绝分支。依据:核心 User::create() + 头像补齐链路已由既有 uid285 行证明可用(旧路径建出);v2 新增的「写 vnpn_aliases 归属行」正向写入仅由 backfillAliases() 回填间接覆盖。如需实测请指示(会新增 1 个用户 + 1 行归属,可事后清理)
2err_disabled / err_too_many / err_internal 三分支未触发需改配置(关闭插件)或构造极端状态(≥5 个马甲 / 库异常)。三个键均在语言包中存在且可经 'plugin.vn_postname.' . $key 命中
3P2-3 搜索索引同步未做端到端断言仅代码走查确认 Search::indexThread() 在改写分支内被调用
4等级联动未做按要求单独处理(transferOwnership() 只转移计数与积分,不碰等级)
5P3-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. 下一步(等指示)

  1. 实测 createAlias 成功建号路径(可选,会新增 1 个测试用户,可清理)
  2. P3-6:更新 docs/插件开发规范.md §9.1:691 的过时注释
  3. 等级联动:马甲计数/积分归属后的等级处理(按要求单独处理)
  4. 发布包:update/29 + CHANGELOG.md
  5. MEMORY.md 蒸馏:当前 .workbuddy-ai/memory/MEMORY.md 已 7944 / 8000 字符(99.3%)
轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| 瀏覽 4 次 | 回覆 0 次

全部回覆 (0)

暫無回覆

請 登入