回覆 7 / 23
獎勵 + 1 獎勵積分
BUG 待修复

PS: 已通过修改 /app/views/htm/message.htm 实现目标效果,不知有无未知问题。
獎勵 + 2 獎勵積分
可以接受
找到根因了。這不是 CSRF 驗證失敗,而是一個被 誤包裝成 CSRF 錯誤 的 PHP TypeError。
真正的出錯位置
app/Utils/HttpLink.php:26-28 的 httpUrlPath():
$len = strrpos($php_self, '//');
false === $len and $len = strrpos($php_self, '/');
$path = substr($php_self, 0, $len); // ← 當 $len === false 時,strict_types 下拋 TypeError當 $php_self(來自 IpHelper::scriptName() → $_SERVER['PHP_SELF']/SCRIPT_NAME)不含 / 或為空字串 時,兩個 strrpos 都回傳 false,$len = false,於是 substr('', 0, false) 在 declare(strict_types=1) 下拋出: substr(): Argument #3 ($length) must be of type ?int, false given
觸發鏈路
POST /admin/store/signin
→ StoreController::signin() (StoreController.php:309)
→ MarketClient::getCommonParams() (MarketClient.php:847, 組裝 'app_url')
→ HttpLink::httpUrlPath() (HttpLink.php:28) ← TypeError為什麼會顯示「CSRF verification error」
關鍵在 CsrfMiddleware 是最內層的 meta 中間件:
- 路由 meta 依序 merge 成
requiresAdminSignIn→requiresUserPerm→requiresCsrf(Router.php:82array_merge($groupMeta, $meta))。 - MetaDispatcher 依此順序組建 stack,所以執行鏈是
AdminSignIn → UserPerm → Csrf → 控制器,Csrf 離控制器最近。 CsrfMiddleware::process()的 try 包住了$handler->handle($request)(CsrfMiddleware.php:55),因此下游控制器的任何 Throwable 都會被它捕獲並包裝成'CSRF verification error: ' . $e->getMessage()(CsrfMiddleware.php:63)。
也就是說:CSRF token 本身校驗通過,真正炸掉的是控制器內 httpUrlPath() 的 substr。這也解釋了為何「用戶名密碼正確仍報錯」——請求根本沒發到 wellcms.com。
修復方案(建議)
- 修
HttpLink::httpUrlPath()(真正根因):另外可考慮讓$len = strrpos($php_self, '//'); if (false === $len) { $len = strrpos($php_self, '/'); } $path = false === $len ? '' : substr($php_self, 0, $len);IpHelper::scriptName()空值時回退到REQUEST_URI解析。 - (可選)改善
CsrfMiddleware的誤導性包裝:只有 CSRF 校驗本身的邏輯失敗才用該訊息,下游異常應重新拋出(if (\DEBUG >= 2) throw $e;目前只對 DEBUG 生效)。
app/Utils/HttpLink.php 相應代碼後,確實順利登入插件商店。WellCMS 3.0.1 开发插件时遇到的几个核心问题与建议
作者您好,我在 WellCMS 3.0.1 上开发一个社区类插件时,遇到并定位了几个核心层的问题。为了不影响以后升级,目前都在插件里临时处理,核心文件保持原版。下面按影响大小整理了现象、原因、复现方法和修复建议,供您参考。文中行号以 3.0.1 为准。
环境: PHP 8(代码按 7.2 语法写)、MySQL 8(严格模式)、Nginx + PHP-FPM、DEBUG=0;站点在 CDN 后面,CDN 同时提供 IPv4 与 IPv6。
问题一览
| # | 问题 | 影响 | 建议优先级 |
|---|---|---|---|
| 1 | 分区注册表只存在缓存里 | 清内存缓存后,分区维护按默认值把两年前的分区直接删除 | 高(数据丢失) |
| 2 | `AttachmentService::insert()` 写附件关联行时不转换 IP | IPv6 用户带图发帖失败(论坛、文章及所有调用 `finalizeAttachments()` 的插件) | 高 |
| 3 | CSRF 令牌无条件绑定 IP | 双栈网络或中途换网时,提交随机报 Forbidden,且不写日志 | 高 |
| 4 | 资产压缩用正则改写 JS/CSS | 可能静默删掉代码、改坏脚本或改变字符串 | 中 |
| 5 | `IpHelper::normalizeIp()` 对二进制 IPv4 不幂等 | 1452 个 IPv4 再转一次会变成错误的 IPv6 | 中 |
| 6 | `UserPermMiddleware` 把下游异常都包装成权限错误 | 排查时误导 | 低 |
| 7 | `header_nav.inc.htm` 读取不存在的 `sitename` | 手机端抽屉标题为空 | 低 |
1. 分区注册表只存在缓存里,清缓存后会按默认值删除旧分区
原因:
- `PartitionRegistry` 把注册表存在缓存键 `partitionregistryconfigs`(TTL 0,`PartitionRegistry.php` 约 34、311 行)。后台"清理内存缓存"(`OtherController::postClearCache()` → `CacheManager::clear()`)会把它一起清空。
- 注册表为空时,`PartitionManager::maintain()`(约 189 行)调用 `discoverExisting()`,把所有 RANGE 分区表按固定的 `quarter`、retention=8 重新登记(`PartitionRegistry.php` 约 253 行),不管安装时登记的是多少。
- `dropExpiredPartitions()`(`PartitionManager.php` 约 735 行起)对超出保留期的分区直接 `ALTER TABLE ... DROP PARTITION`,没有归档。
- `PartitionConfig` 构造函数的 retention 默认就是 8,`well_forum/install.php` 约 906–934 行也显式传了 8。所以即使不清缓存,论坛主题、回复等表两年前的分区也会被删除。
- 另一个副作用:注册表为空时,只要有一个插件先登记了自己的表,注册表就不再为空,其他插件的存量表此后不会被接管,也不会再预建未来分区。
复现: 后台清理内存缓存 → 触发一次分区维护(请求惰性检查、后台手动或 03:00 定时任务)→ 查看 `PartitionManager::getStatus()`:所有 RANGE 表的 retention 都变成 8,包括安装时登记为 0 的表。
建议:
- 注册表持久化到数据库或 KV,不只放在缓存里;清缓存时不要清掉这类结构性配置。
- `discoverExisting()` 接管时默认 retention 用 0(永不清除),避免"恢复登记"变成"删除数据"。
- 内容类表是否默认只保留两年,建议由各插件显式决定,而不是继承构造函数的默认值 8。
我们的临时处理: 插件登记自己的表时用 retention=0,并挂 `FrameworkDatabasePartitionPartitionManagermaintain_start` 钩子:每次维护前检查本插件的登记,缺了就补;注册表为空时先调核心的 `adoptExistingTables()` 再登记,不挡住其他插件的表。但这只保护本插件的表,论坛、文章的表仍有上述风险。
2. IPv6 用户带图发帖失败(`create_ip` 超长)
现象: 发帖报 `User Permission verification error: SQLSTATE[22001]: String data, right truncated: 1406 Data too long for column 'create_ip' at row 1`。
原因:
- 上传时临时文件里的 `create_ip` 是 IP 文本(`UploadService.php` 约 619、631 行)。
- `AttachmentService::finalizeBoundAttachments()` 写文件存储行时,经 `FileStorageService::insert()` 转成二进制,没有问题。
- 写附件关联行(约 754–761 行)时调用 `AttachmentService::insert()`。关联行没有 `filehash`,走了约 81–85 行的提前返回分支,跳过了约 94 行的 IP 转换,IP 文本原样写进 `varbinary(16)`。
- IPv4 文本最长 15 字节,能写进去;IPv6 文本超长,MySQL 严格模式下整个发帖事务回滚。
- 影响所有调用 `finalizeAttachments()` 的插件(论坛、文章等)。另外,已有关联行里的 IPv4 存的是 11 字节左右的文本而不是 4 字节二进制:`bin2ip()` 显示正常,但按网段做二进制比较的查询会漏掉这些行。
复现: MySQL 严格模式,在 IPv6 网络下登录,论坛发一个带图片的主题。
建议: 把 IP 转换移到 `filehash` 判断之前,两条分支都转换(与 `FileStorageService::insert()`、`LogService::insert()` 一致):
public function insert(array $data): int
{
Validator::make(['data' => $data], ['data' => 'required|array']);
if (isset($data['create_ip'])) {
list($ip, $ip2bin) = \Framework\Utils\IpHelper::normalizeIp($data['create_ip'] ?? '');
$data['create_ip'] = $ip2bin;
}
$filehash = $data['filehash'] ?? '';
if (empty($filehash)) {
... // 原逻辑不变,并删掉后面那段重复的转换
}
我们用临时表验证过:IPv4 文本、IPv6 文本(压缩与完整写法)、已是二进制的 IPv4/IPv6,都存为 4/16 字节并能正确还原。
我们的临时处理: 插件调用 `finalizeAttachments()` 前,只把 IPv6 文本先转成二进制,IPv4 原样交给核心(IPv4 为什么不能预先转换,见第 5 条)。
3. CSRF 令牌无条件绑定 IP,双栈网络下随机 Forbidden
现象: 站点在同时提供 IPv4/IPv6 的 CDN 后面时,同一个浏览器有时走 IPv4、有时走 IPv6;用户中途换网络(Wi-Fi 与流量切换)也一样。打开页面与提交时 IP 不同,提交被拒绝,返回 `Forbidden`(code 8),日志里没有任何记录。
原因: `TokenService::verifyToken()` 约 114 行无条件比较签发时与当前请求的 IP;`CsrfMiddleware` 约 46 行校验失败直接返回 Forbidden,不写日志。登录态本身的 IP 校验跟随 `login_ip` 开关(`UserService.php` 约 940 行,默认关闭),登录后表单的 CSRF 令牌却始终绑定 IP,两者不一致,用户已登录却提交失败,很难排查。
复现: 站点放在双栈 CDN 后,用双栈网络登录,打开发帖页,等几分钟再提交,会不定期出现 Forbidden。我们的日志里,打开页面时记录的是 IPv6,提交时会话里记录的是 IPv4。
建议: 登录用户的令牌(用户盐签名)的 IP 校验跟随已有的 `login_ip` 开关;访客令牌(站点密钥签名,用于登录、注册、验证码表单)仍然始终绑定 IP,防登录 CSRF:
// TokenService::verifyToken() 中,替换原来的 "1. 校验 IP"
$bindIp = $safeKey === $authKey || !empty($this->appConfig['login_ip']);
if ($bindIp && ($payload['long_ip'] ?? 0) !== $capturedIp) return false;
同时建议 CSRF 校验失败时记一条日志(用户 ID、路由,不记令牌本身),方便排查。这个改法我们在站点上临时验证过,Forbidden 消失了,之后按"不改核心"的原则还原。
我们的临时处理: 在 CDN 关闭 IPv6,浏览器始终走 IPv4,打开页面和提交时 IP 一致。中途换网络时仍可能遇到一次 Forbidden,刷新页面即可。
4. 资产压缩会改写 JS/CSS 的内容
原因: `Compile::compileAssets()` 约 1061–1076 行用几条正则去注释、去空白,不识别字符串、正则字面量和注释边界。另外,`App.default.php` 里 `compress` 的注释写着"1 仅压缩 php、html 代码(不压缩 js 代码)",但资产压缩不看 `compress` 和 `DEBUG`,总是执行。
用同一组正则处理的结果:
| 压缩前 | 压缩后 | 结果 |
|---|---|---|
| `var u = 'https://example.com/a';` 换行 `var n = 1;` | `var u='https:var n=1;` | 语法错误,整个脚本失效 |
| `/ see https://example.com /` 换行 `var a = 1;` 换行 `/ end /` 换行 `var b = 2;` | ` var b=2;` | `var a = 1;` 被静默删掉,语法检查仍通过 |
| `var s = 'a - b';` | `var s='a-b';` | 字符串内容被改 |
| `var a = 1` 换行 `var b = 2`(不写分号) | `var a=1 var b=2 ` | 语法错误 |
| `width: calc(100% - 10px)` | `width:calc(100%-10px)` | calc 的加减号两边必须有空格,声明失效 |
| `margin: 10px -2px` | `margin:10px-2px` | 声明失效 |
| `content: " - "` | `content:"-"` | 显示内容被改 |
建议: 换用成熟的压缩库;或者至少跳过字符串、正则和注释内容,并保留换行。`compress` 的注释与实际行为对齐。
我们的临时处理: 插件 JS 只用块注释、每句加分号、字符串里不出现 `//`;CSS 不写负值简写和 calc 加减。
` 里的 `//` 注释会吞掉后面所有代码,而且只在生产环境出错、开发时正常,不容易发现。<br><h2>5. `IpHelper::normalizeIp()` 对二进制 IPv4 不幂等</h2><br><b>原因:</b> `detect()`(约 376 行)先按文本判断,再按长度判断二进制。有 1452 个 IPv4 的 4 字节二进制恰好只由 `0-9a-fA-F:` 组成,而且是合法的 IPv6 文本,例如 `49.58.58.50` 的字节就是 `"1::2"`。它们会被判成 `IPV6_STRING`,`normalizeIp()`(约 264 行)把它们转成另一个 16 字节 IPv6。<br><b>影响:</b> `FileStorageService::insert()` 写入前会再 `normalizeIp()` 一次。调用方如果先把 IPv4 转成二进制再交给它,这些 IPv4 会被存成错误的 IPv6。我们最初绕开第 2 条时就这样做过,后来发现并改正。<br><b>建议:</b> `detect()` 先按 4/16 字节长度识别二进制,再判断文本;或者在文档中注明 `normalizeIp()` 只接受文本(与安全规范"应用层传标准字符串,入库前转二进制"一致)。<br><h2>6. `UserPermMiddleware` 把下游异常都包装成权限错误</h2><br>`UserPermMiddleware` 的 try/catch 包住了 `$handler->handle($request)`(约 53–61 行),后面控制器、数据库抛出的任何异常都被返回成 "User Permission verification error: …"。第 2 条的数据库错误就是以这个前缀出现的,一开始让人以为是权限问题。建议 try/catch 只包权限判断本身,下游异常交给全局异常处理。<br><h2>7. 手机端抽屉标题为空</h2><br>`app/views/htm/header_nav.inc.htm` 约 39 行读取 `$view->get('sitename')`,但 `BaseController::initializeCommonData()` 把站点名放在 `website.sitename`,顶层没有 `sitename`,所以手机端抽屉标题是空的。改成 `$view->get('website.sitename')` 即可。<br><h2>另外几处容易踩的坑(建议写进开发文档)</h2><br><ul><li>`RequestUtils::param()` 按 cookie → query → body → 请求属性合并,属性最后覆盖。表单字段与请求属性同名(例如 `user`)时,读到的是当前用户数组。</li><li>`XssFilterMiddleware` 开着 `replace<i>original`,会把白名单外的字段 `htmlspecialchars` 后替换原请求。插件需要按原文校验长度时,要先 `htmlspecialchars</i>decode()`。</li><li>DEBUG=0 时编译缓存不检查源文件时间,改了 PHP 源文件要 `Compile::refreshFile()` 或后台清缓存才生效。</li></ul><br>以上问题我们都在插件侧做了临时处理,没有改动核心文件。需要日志或验证脚本的话,我可以再整理提供。谢谢!<br>
請先登入
想參與討論?請先登入您的帳號。
