本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/审计报告.md。
FlintHub 核心包审计报告(release/core/flinthub1.1.22)
- 审计对象:
D:\phpstudy_pro\WWW\release\core\flinthub1.1.22(发布包解包镜像,version.php = V1.1.22) - 审计日期:2026-10-14
- 审计方式:静态取证(逐文件阅读 + 全库模式检索),所有结论均附
文件:行号 - 审计范围:该目录内核心代码。不含
plugins/(核心包不打包插件,插件经插件市场单独发行)、不含 config.php(安装时生成、不进包)、不含运行数据(data/ 仅有防护文件) - 基线声明:本报告不采信「看起来像」,每条问题均给出可复核证据;无法验证的项目一律在「五、限制」中显式声明,不当作通过
〇、目录规模与结构(审计面)
release/core/flinthub1.1.22/
├── app/ Core(7) · Controllers(15+Admin 19) · Helpers(31) · Models(10) · SplitDB(14) · Views/**
├── cli/ 24 个脚本(迁移/归档/重建/队列/密钥)
├── lang/ zh.php en.php zh_tw.php
├── assets/ css / js / lib / themes / medals / pwa / uploads / fonts / img
├── docs/ 仅 1 份:插件开发规范.md(打包白名单生效)
├── data/ 仅 .htaccess + web.config(无运行数据)✅
├── protected/ 仅 .htaccess + web.config(**无 .install_done** ← 见 H-1)
└── 根级 18 个文件:index.php install.php version.php cron_trigger.php sw.js
.htaccess nginx-server.conf nginx-baota.conf web.config
README.md INSTALL.md CHANGELOG.md 改动说明.md LICENSE logo/favicon…
打包纪律良好:仓库根的杂散文件(version.php.bak、cb_dbg.log、PHPSESSID=...、main - 备份不要动.php、install2.php、config2.php、update/ 历史包)均未进入本包,.bak 文件全库 0 命中 —— 发布白名单工作正常。
一、高风险问题(优先修,单独列出)
🔴 H-1 发布镜像内含安装向导 install.php(第二轮已降级 — 见 §七·修订 R-1,保留原分析供备案)
| 项 | 内容 |
|---|
| 证据 1 | install.php:16 define('INSTALL_DONE_FILE', ROOT_DIR . '/protected/.install_done');;install.php:22-37 已安装判定 = 本目录 config.php 含 SPLITDB_DATA_PATH 或 data/meta/business.sqlite 存在,两者皆无则继续安装 |
| 证据 2 | 实测目录列表:release/core/flinthub1.1.22/protected/ 只有 .htaccess、web.config(无 .install_done);镜像根无 config.php;data/ 下无 meta/business.sqlite → 判定条件全部满足「未安装」 |
| 证据 3 | 待办事宜/FlintHub_发布流程手册.md §1:「发布源目录 release/core/(在站点根内,必须对公网可读)」→ 该目录按设计对外公开 |
| 证据 4 | nginx-server.conf:42-45 / 48-51 / 54-57 / 62-65 deny 规则只覆盖 `^/(data\ |
| 证据 5 | 根 .htaccess:18 同样只挡 `data\ |
| 影响 | 未授权访问者可在 https://站点/release/core/flinthub1.1.22/ 走完三步安装向导:写入该目录的 config.php、建立 data/ 分片库、创建自己的管理员账号**。结果:在真实域名下获得一个受控论坛实例(可发布垃圾内容/钓鱼/消耗主机资源),并在发布目录内写入安装产物(镜像作为审计凭证失真;升级流程本身读的是 flinthub-core-<ver>.zip 与 manifest-<ver>.json,不读镜像代码) |
| 建议(① 为必做,②③ 择一加固) | ① 服务器层拦截(零代码、立即生效):Nginx 增加 location ^~ /release/ { deny all; return 403; }(若确需同机读取发布源,改由 CLI/内部文件路径读取,不进 HTTP 层;至少加 location ~ ^/release/.+\.php$ { deny all; });Apache 对应 RewriteRule ^release/ - [F,L]。② 从镜像中剔除 install.php(打包白名单去掉;镜像本为审计凭证,无需安装能力)。③ 若必须保留 install.php,则需在其自身目录放一个含 define('SPLITDB_DATA_PATH', …) 的哨兵 config.php 才能命中「已安装」→ 410 —— ⚠️ 注意 .install_done 不参与判定(install.php:22-37 注释明确:只认 config.php 含 SplitDB 配置或 data/meta/business.sqlite 存在),故「只放 .install_done」是无效方案 |
| 验证方式 | 部署后请求 /release/core/flinthub1.1.22/install.php,期望 403/410,且 release/** 下不产生任何新文件 |
②轮修订 R-1(2026-10-14):经站长说明并回读代码确认 —— 本目录仅是交付审计用的干净代码快照,既不属于站点根、也不参与任何部署(站点与用户站均不引用它);且 install.php 在真实站点上有四道门,任一门即可阻断:
① 已安装判定 install.php:22-38(config.php 含 SPLITDB_DATA_PATH 或 data/meta/business.sqlite 存在 → 拒绝并 renderAlreadyInstalled(),:418 注释:返回 410 Gone;.install_done 只是完成标记、不参与判定,判定依据共 3 项已在 :23 注释列明);
② 安装 POST 的 CSRF 常量时间校验 :511-520;③ 环境门禁 envAllOk() :521-536(含 PHP/PDO/SQLite 版本、扩展、目录可写性);④ 许可协议服务端强制 :538-547(「防止绕过前端直接 POST 触发安装」)。
结论:H-1 在代码层不构成缺陷,在部署层也不适用(目录不被服务)→ 降级为「部署注意项」,风险等级由 🔴 高 改为 ⚪ 无需处置;若日后把 release/ 挂上 Web,再按 ① 做一次路径拦截即可。
🔴 H-2 二级目录部署下,后台二次验证会进入重定向死循环(管理员被锁在后台外)
| 项 | 内容 |
|---|
| 证据 | app/Controllers/Admin/BaseController.php:29-44:<br>$uriPath = rtrim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '/');<br>if ($uriPath !== '/admin/verify' && $uriPath !== '/admin/verify/' ) { ... if ($verifiedAt < time()-$verifyTimeout) { $_SESSION['admin_redirect_after_verify']=$_SERVER['REQUEST_URI']; header('Location: BASE_PATH/admin/verify'); exit; } }<br>—— $uriPath 未剥离 BASE_PATH。对照同项目其它入口均已剥离:app/init.php:133(维护模式白名单,$base !== '' && strpos($path,$base)===0)、index.php:386-391(后台路由注册判定) |
| 触发条件 | config.php 定义 BASE_PATH='/forum'(README §3.3 官方支持的二级目录部署)且管理员二次验证超时 |
| 后果链 | 访问 /forum/admin/xxx → 因 URI ≠ /admin/verify 且已超时 → 302 到 /forum/admin/verify → 该页由 VerifyController extends BaseController(VerifyController.php:11)继承同一构造函数 → 仍判定「需验证」且已超时 → 再次 302 到自身 → 浏览器 ERR_TOO_MANY_REDIRECTS。验证页本身不可达 ⇒ 无法写入 $_SESSION['admin_verified_at'] ⇒ 后台永久不可进(根目录部署不受影响,BASE_PATH='' 时比较成立) |
| 建议 | ① 比较前剥离前缀(抄 app/init.php:133 同款片段),或改用内部路由标识而非原始 REQUEST_URI;② admin_redirect_after_verify 目标做同源/前缀复校,非法值回落到 BASE_PATH.'/admin/verify' 而非 REQUEST_URI;③ 补一条回归:define('BASE_PATH','/forum') 下访问 /forum/admin,断言最终可达验证页且 302 链长度 ≤ 2 |
| 附带 | 同构造函数内已 rtrim($uriPath,'/') 后再比 '/admin/verify/',该分支永假(死代码,见 L-5) |
二、中风险问题
🟠 M-1 升级器 abs() 缺路径穿越防护(依赖签名单点兜底)
| 项 | 内容 |
|---|
| 证据 1 | app/Helpers/Updater.php:175-178:<br>public static function abs(string $rel): string { return self::root() . '/' . ltrim(str_replace('\\','/',$rel), '/'); } —— 不检查 ..、不检查盘符、不做 realpath 前缀校验(对照同项目已具备该防护的写法:Plugin.php:91-99、Upload.php:87-90 / 230-233、DatabaseController.php:821-825 解包防 Zip-Slip) |
| 证据 2 | Updater.php:845-857(manifest schema 校验)只校验 files[i].path 与 sha256 存在,不校验 path 形态 |
| 证据 3 | 写入落点:Updater.php:1857 起 $abs = self::abs($rel) 后 fopen($tmp,'wb') + rename —— rel 含 ../ 即写出站点根 |
| 证据 4 | 防护依赖两层:① RSA 验签 Updater.php:930-971(RS256 + hash_equals 语义,缺签名/算法不符/公钥缺失全部 fail-closed ✅);② 管理员开关「允许未签名清单」UpdateController.php:534-547(默认 false,UpdateController.php:1393;界面有醒目告警 lang/zh.php:894) |
| 风险 | 一旦站点曾临时开启「允许未签名清单」(过渡期设计用途),远端 manifest 即可携带 ../../ 逃出站点根写任意文件 → 等价任意文件写。属纵深防御缺失(单点信任),不是当前可直击的漏洞 |
| 建议 | 在 abs() 内加:`if ($rel==='' \ |
🟠 M-2 跨库三步写非原子,进程异常中断会留孤儿(补偿只覆盖异常路径)
| 项 | 内容 |
|---|
| 证据 | app/Models/Post.php:96-140 源码自述:「SQLite 无法跨文件事务,写 extern / 桶行 / 索引行三步天然不是原子的…每步都用 try/catch 兜住:失败就把前序产物撤掉再抛」;补偿仅在 catch 分支执行(:112-121 extern 回滚、:131-140 桶行+extern 逆序回滚)。app/Models/Thread.php:116-149 同款结构 |
| 残留窗口 | 进程被强杀 / PHP 超时 / 主机断电等非异常路径退出时,补偿不执行 → 出现「extern 正文文件在、桶行或索引行缺失」的孤儿(反向亦可能)。这是设计的已知代价,代码注释已如实说明 |
| 兜底 | 项目自带修复工具 cli/repair_extern_idx.php(读 topic_index/reply_index 与 extern 目录对账),但需人工或定时触发 |
| 建议 | ① 把 repair_extern_idx 挂进 cron_trigger.php 的低频维护段(现维护段已有节流机制 cron_trigger.php:104-110);② 或改写入顺序为「索引行先落 status=pending 占位 → 桶行 → extern → 置回 normal」,让中断可被后续扫描识别;③ 在后台「数据库」页暴露孤儿计数,便于站长自查 |
🟠 M-3 文档 / 配置文件可被匿名直接下载(待办 D-2 仍未修)
| 项 | 内容 |
|---|
| 证据 1 | 包根存在 README.md、INSTALL.md、CHANGELOG.md、改动说明.md、nginx-server.conf、nginx-baota.conf |
| 证据 2 | 根 .htaccess 全文仅 5 组 RewriteRule(data/protected/cli、plugins//data、assets/uploads/.php、/repo/、常规伪静态),**无 `\.(md\ |
| 证据 3 | nginx-server.conf 的 deny 块(:42-45、:48-51、:54-57、:62-65)同样未覆盖 .md/.conf |
| 证据 4 | 额外信息泄露:nginx-server.conf:30 硬编码了生产绝对路径 root "D:/phpstudy_pro/WWW"; |
| 影响 | 扫描器可匿名读到完整目录结构、服务器配置模板与本地部署路径(配合 docs/ 内的 插件开发规范.md 可进一步推断内部约定) |
| 建议 | ① Nginx 加 `location ~* \.(md\ |
🟠 M-4 附件响应头直接使用库内 MIME(CRLF 注入面,待办 S-3 未修)
| 项 | 内容 |
|---|
| 证据 | app/Controllers/AttachmentController.php:$mime = $att['mime_type'] ?: 'application/octet-stream'; → … → header('Content-Type: ' . $mime);(未做 \r\n 剥离) |
| 可达性评估 | 正常上传路径 MIME 由 finfo 实测写入(Upload.php:480-483,不可伪造),故正常不可达;但后台「数据库导入 / 整包还原」可写入任意库值(DatabaseController.php:950-958 整包 zip 导入、:866-902 备份还原)→ 管理员误导入恶意库文件时成为注入面 |
| 建议 | 按待办 S-3 落地:$mime = str_replace(["\r","\n"],'',(string)$mime) ?: 'application/octet-stream';(同文件 Content-Disposition 已用 rawurlencode(),无需改) |
🟠 M-5 局部 N+1 / 循环内 IO(热路径与后台批量各有 1 处)
| 位置 | 证据 | 说明 |
|---|
| 版块页 | app/Controllers/ForumController.php:133-141 foreach ($categories as &$cat) { … Category::getLatestThreads($cid, 1) … } + app/Models/Category.php:237-256 | 每版块一次快照文件读;快照缺失时回退 refreshLatestSnapshot() → 每版块一次 topic_index 查询(Category.php:177-206)。版块多时是 N 次文件 IO / N 次查询 |
| 后台批量移动 | app/Controllers/Admin/BaseController.php:79-84 foreach ($ids as $id) { $afterMove($id); } | 最多 500 条逐个回调(如逐条重建搜索索引)→ 500 次调用 |
| 反例(做得对) | HomeController.php:109/120、DashboardController.php:48-66、User.php:49/62/284 | 统一用 IN (...) + $marks 占位批量取,避免循环查库 |
| 建议 | ① 快照改为「一次读取目录内全部 JSON」或单文件聚合;② 批量移动的 afterMove 改为整批回调(一次传数组),由插件批量处理 | |
🟠 M-6 无自动化测试、无基准脚本(待办 B-2 仍未做)
| 项 | 内容 |
|---|
| 证据 | cli/ 目录 24 个文件全为迁移/重建/探针/密钥脚本,无任何断言测试;包内无 tests/、无 PHPUnit 配置 |
| 风险面 | 数据迁移脚本(cli/migrate*.php 7 个)与升级器(Updater::apply)一旦出错不可逆;ShardRouter 32→64→128 扩容路由、Username 唯一性、Csrf 时序等均无回归网 |
| 建议 | 按待办 B-2 落地最小 CLI 断言集(无 Composer,接 cli/_guard.php 同款 CLI-only 守卫):① ShardRouter::bucket/bucketForWrite 扩容前后路由;② Model::buildWhere / sanitizeOrderBy 白名单正反例;③ Csrf::verify TTL 与滑动续期;④ migrate_* 幂等性(跑两遍结果一致) |
三、低风险 / 打磨项
| 编号 | 位置 | 问题 | 建议 |
|---|
| L-1 | cli/backfill_excerpts.php(全文件) | CRLF 换行(证据:全库 ^<\?php\r$ 仅命中此文件,其余 PHP 均为 LF)——违反 README/CHANGELOG 声明的「UTF-8 without BOM + LF」 | 统一转 LF(可加一条打包前自检,防回归) |
| L-2 | cli/upgrade_site_mode.php:30-33 vs 其余 21 脚本 require __DIR__.'/_guard.php'(如 cli/archive.php:30) | CLI 守卫双轨实现(内联 PHP_SAPI!=='cli' vs 统一 _guard.php);cli/archive_mark.php:28-30 又是条件跳过 | 统一走 _guard.php,条件场景改为显式常量约定并写入规范 |
| L-3 | app/Helpers/Updater.php:723、app/Helpers/Market.php:402,799 仍保留 curl_close($ch);而 ApiController.php:535 已删除并注明 PHP 8.5 弃用 | 同一问题的处理不一致 | 统一删除(8.5 前无副作用,删除零风险) |
| L-4 | cron_trigger.php:60-66 | CRON_KEY 优先读 X-Cron-Key 头,兼容 ?key= —— URL 参数会进入 access log / Referer / 浏览器历史 | 过渡期后可移除 query 兼容,或仅在 debug 开关下允许 |
| L-5 | app/Controllers/Admin/BaseController.php(__construct() 内 $uriPath 比较处) | rtrim($uriPath,'/') 之后仍比较 '/admin/verify/' → 永假死代码 | 删除该分支(同时在 H-2 修复中一并处理前缀问题) |
| L-6 | index.php:82-112 | 前置缓存分支手写 sessions.sqlite 查询 + `preg_match('/(?:^\ | [;}])user_id\ |
| L-7 | 全库 error_log 前缀 | 风格不一:'[attachment] download_count…'、'Post::insert 补偿 extern 失败…'、'SplitDB cron_trigger: …'、'[plugin-permission] …' | 统一为 [flinthub][模块] 消息,便于日志聚合与告警规则 |
| L-8 | web.config(包根 + data/ + protected/) | IIS 遗留配置(README 已声明支持 IIS,但项目审计文档已将其标注为「IIS 遗留」) | 明确是否继续支持 IIS;若不支持,从包中移除并在文档声明 |
四、按维度的审计结论(含「通过项」,均附证据)
一、安全
① 输入 / 输出
| 检查项 | 结论 | 证据 |
|---|
| SQL 注入(拼 SQL) | ✅ 未发现用户输入拼接 | ① 「SQL 关键字 + 字符串拼接变量」全库仅 1 处误报(PluginUpdater.php:44 常量前缀);② 所有动态片段均为 intval/占位符:Thread.php:353-380、Category.php:285-304、User.php:49/62、Notification.php:185/226、Tag.php:234;③ Model::buildWhere 是解析式白名单(只允许「列 运算符 :占位符」AND 连接,越界抛异常,Model.php:214-250);④ sanitizeOrderBy 同为解析式白名单(Model.php:272-296);⑤ 无用户可控 ORDER BY(全库仅 2 处 ORDER BY {$orderBy},均经白名单,Model.php:38/59) |
| XSS(裸输出未转义) | ✅ 未发现核心视图裸输出 | ① <?= $var 在 app/Views 0 命中(全部走 <?php echo $this->e(...) 或模板 {$var} 自动转义);② :raw 在核心视图 0 使用(仅引擎定义 TemplateCompiler.php:100-101);③ 抽查 errors/404.php:13、auth/*.php、blog/index.php:33/59/63、search/index.php:28/46 均见 $this->e();④ 富文本出口先净化:Content::formatPostContent(Content.php:567-580 纯文本分支先 htmlspecialchars)与 Content::sanitizeHtml(:15-27 DOMDocument 白标签) |
| CSRF(写操作校验) | ✅ 覆盖完整(102 处) | 前台:ThreadController:101/415/504、PostController:70/208、AuthController:78/212/306/338/420/511、ProfileController:29、MessageController:34/69/109、BlogController:122/192/254/322、ThemeController:23/172、NotificationController:27、ApiController:54/166/228;后台:19 个控制器全部命中(UpdateController 14 处、DatabaseController 12 处、SettingsController 8 处、MaintenanceController 6 处、MarketController 7 处…)+ 公共方法 Admin/BaseController:56。Token 实现:Csrf.php:24-120(30 分钟 TTL、hash_equals、滑动续期、敏感操作 consume())。唯一豁免:AuthController:303 登出(低风险幂等,注释说明)与 ThemeController 偏好翻转(校验失败静默回落不写库)——均可接受 |
| 文件上传(白名单/随机名/穿越) | ✅ 三层齐备 | ① finfo 实测 MIME + fileinfo 不可用即拒绝(Upload.php:480-504);② 扩展名白名单 + 强随机名(红线 4,Upload.php:516 move_uploaded_file);③ 路径收敛层 Upload::path()/absPath() 拒绝 \0 与 ..(Upload.php:87-90、:230-233);④ 下载侧同样收敛(AttachmentController 用 Upload::path(),失败 404) |
| SSRF(远程请求) | ✅ 五层防护 | ApiController.php:250-280:DNS 解析 → 内网/保留 IP 拒绝 → 以解析出的 IP 替换 host 发起(防 DNS Rebinding)→ 逐跳重定向每跳复检(fetchHopByHop)→ 5MB 上限 + MIME 白名单(:288-303);插件统一出口 Plugin::http() 另带 net:fetch 权限校验 + 禁跟随重定向(Plugin.php http(),注释说明防 301 打内网) |
② 权限 / 认证
| 检查项 | 结论 | 证据 |
|---|
| 越权(改别人的东西) | ✅ 未发现可直接越权 | 删帖:ThreadController:495-539 复合鉴权(管理员 ‖ 回帖作者 ‖ 楼主)+ 校验 post.thread_id 与 URL {id} 一致(防跨主题越权删)+ requireAdminVerified();改帖:ThreadController:409-440 Permission::canView/canPost;附件:AttachmentController:63-77 按版块 canView+canAttach,无有效归属即 403(禁默认放行);私信:Message.php:159 删除条件含 sender_id=? OR receiver_id=? |
| 后台接口鉴权 | ✅ 三重门禁 | Admin/BaseController.php:16-44:Auth::isAdmin() → 后台二次密码验证(admin_verify_timeout 动态 + 钳制 60s~7d)→ 超时跳转;验证接口自身限流 5 次/15 分钟(VerifyController.php:20);前台管理员操作需二次验证(ThreadController:417/525) |
| Session / Token | ✅ 未见会话固定/泄露 | password_hash(PASSWORD_DEFAULT)+password_verify(User.php:176/599、AuthController:105);登录、改密、恢复登录态均 session_regenerate_id(true)(AuthController:126、ProfileController:76、RememberMe:98);session.use_strict_mode=1(install.php:247)+ SessionHandler 实现 validateId;Cookie 全 HttpOnly + SameSite=Lax(install.php:42-46、AuthController:581-582、RememberMe:108-112);RememberMe 只存 sha256(token) 且滚动换新(RememberMe:68-87);登出清 Cookie(AuthController:320) |
③ 敏感信息
| 检查项 | 结论 | 证据 |
|---|
| 密钥 / 密码硬编码 | ✅ 包内无硬编码凭据 | ① `(password\ |
| 日志输出敏感数据 | ✅ 未发现 | 全库 error_log 关键字抽样(password/token/$_POST)仅 3 处命中,全部为异常消息而非数据(RememberMe:157/209、AuthController:469);Csrf/Auth/Points 等敏感路径日志均只记 ID 与错误原因;审计日志 AuditLog 记录动作+对象而非凭据 |
附加安全面(超出提纲但重要)
| 检查项 | 结论 | 证据 |
|---|
| 反序列化 / 动态执行 | ✅ 0 命中 | `unserialize\ |
| 备份 zip 解包(Zip-Slip) | ✅ 已防 | DatabaseController.php:821-825(getNameIndex → 拒 ../绝对路径/盘符)+ 后缀白名单(常量 :795,判定 :827-833),逐条目流式解包而非 extractTo |
| 备份下载 | ✅ 已防 | :975-1000 basename() 校验 + realpath 前缀(isWithin) |
| 数据库整包导入 | ✅ 双确认 | :936-941 CSRF + 服务端 confirm_token(不依赖 JS confirm)+ SQLite 文件头校验(:965-967) |
| CLI 脚本 Web 直访 | ✅ 403 兜底 | cli/_guard.php(22/24 脚本 require)+ upgrade_site_mode.php:30-33 内联守卫(双轨见 L-2) |
| 定时入口鉴权 | ✅ fail-closed | cron_trigger.php:53-66:CRON_KEY 未配置 → 503 拒绝(不再「留空即放行」)+ hash_equals + 非阻塞文件锁 + queue_mode 模式守卫 |
| 升级器验签 | ✅ fail-closed | Updater.php:930-971(缺签名/算法不符/公钥缺失/openssl 不可用/异常 path → 全拒);allowUnsigned 默认关(见 M-1) |
二、数据
| 检查项 | 结论 | 证据 |
|---|
| 跨库事务真原子 | ⚠️ 非原子(已知) | extern/桶行/索引行三步跨文件,代码自述 + 补偿仅覆盖异常路径 → M-2 |
| 并发(超卖 / 重复) | ✅ 核心扣减原子 | 积分扣减 = 条件 UPDATE SET points = points - :p WHERE id=:id AND points >= :p + rowCount()===0 判定余额不足(Points.php:249-259);嵌套事务感知(:246,在外层事务内不自行 commit/rollback);计数原子自增(User.php:158/166 post_count ± 1、BlogController:136 comment_count + 1);投票 ON CONFLICT(idCol,user_id) DO UPDATE(Vote.php:187);附件下载计数原子(AttachmentController download_count = download_count + 1) |
| 幂等(重复操作多扣/多发) | 🟡 基本可用,无幂等键 | 发帖/回帖走全局 ID 取号(IDGenerator::nextId),无 per-request 幂等键;重复提交靠 CSRF 一次性消费(ThreadController:532/555、ProfileController:80)+ 频率限制兜底。后台危险操作有服务端二次确认(confirm_token)。结论:非高危,建议关键写操作(发帖/兑换/打卡类插件)引入幂等键 |
| 迁移工具无损 | 🟡 可用,无自动化校验 | cli/migrate*.php 统一 INSERT OR REPLACE(migrate.php:245/458)→ 可重复执行;migrate_uploads_by_month.php:423-425 用「UPDATE … WHERE 旧值=:old」精确改写;migrate.php:112/121 表名来自源库自身 SHOW TABLES 遍历(非用户输入,且 CLI-only)。缺失:迁移前后行数/校验和对账(仅 migrate_plugins.php:123/175 做了 count 比对) |
| 备份可恢复 | ✅ 可恢复且有防护 | 备份 = wal_checkpoint(TRUNCATE) + 整目录 zip(DatabaseController:659-686,不依赖 3.27 的 VACUUM INTO);恢复路径见上(Zip-Slip 防护 + basename + realpath + 文件头 + 双确认);.sqlite 单库导入按文件名映射(mapBackupToTarget) |
| 索引缺失 / 慢查询 | ✅ 已知缺口已补 | idx_reply_index_status 已建(Schema.php:685 声明 + :874-877 创建,对应待办 P-2);配套 runtimeIncr 增量(见性能);其余大表索引齐备(users_username/email、topic_index 组合序、notifications(user_id,is_read,created_at)、audit_logs_action…Schema.php:325-625) |
| 全表扫 | ✅ 未见热路径全表扫 | 前导通配 LIKE '%...' 仅出现在一次性 CLI 迁移脚本(migrate_uploads_by_month.php:448/470/545/552、backfill_excerpts.php:90),非请求路径;列表查询用 keyset 游标(Thread.php:353-380)与 IN 批量 |
| N+1 | 🟡 2 处(见 M-5),主体已批量化 | 见 M-5 |
三、架构
| 检查项 | 结论 | 证据 |
|---|
| 分层:业务/数据绕过门面 | 🟡 大量直连,门面未强制 | ① Models 直接静态调用 SplitDB 23+ 处(Category.php:109/183/266/291/303、User.php:237/271/330/354/663/719、Post.php:74/78/122/343/350/373、Thread.php:61/84/120/271/301/319…);② Controllers 直接 Database::getInstance() 25+ 处(AuthController 7 处、ThreadController:119/474、ApiController:364、后台多处);③ 旁路创建 PDO 16 处(index.php:96、Settings.php:1031、DatabaseController:479/545/632、多个 CLI)。评价:作为轻量单体可接受(Model 仍提供 fillable 白名单 + 列名正则 + PRAGMA 列校验,Model.php:101-165),但「换引擎只换底层」不成立 |
| 插件 / 核心边界 | ✅ 单向、有校验 | 核心通过 Plugin::hook() 分发(Plugin.php:673-728:realpath 前缀校验 + 独立闭包作用域 + 单钩子异常隔离);插件能力收敛为受控门面(Plugin::db() 自动补 .htaccess/web.config 防下载 :113-151;Plugin::http() 统一外呼 + SSRF;Permission 白名单强制模式 :165-249,granted() :165 / requirePermission() :235);核心无反向依赖插件代码 |
| 耦合:插件间直接调用 | ⚠️ 存在约定式弱契约 | 跨插件/插件对核心的通信除钩子参数外,还依赖全局变量:AttachmentController.php:84-95 约定插件置 $GLOBALS['attachment_download_error'] 拦截下载。虽在核心侧有显式读取,但契约靠约定而非类型/返回值 → 建议改为钩子参数引用或返回结构($params 传引用已是支持的机制) |
| 耦合:核心 / 插件边界清晰度 | ✅ 清晰 | plugin.json 声明 hooks/permissions,admin_route_register 需 route:admin 权限(Plugin.php:686-689),插件写全局设置需 system:settings 且核心反查调用栈判别来源(Plugin.php:188-233 detectCaller() + Settings.php:261 越权日志) |
| 扩展性:加功能是否动核心 | ✅ 不必 | 43 个钩子全表已文档化(docs/插件开发规范.md §9);路由、导航、视图插入点齐备;新增插件零侵入 |
| 扩展性:换引擎能否只换底层 | ❌ 不能(架构级) | 见「分层」证据:SplitDB 静态门面被 Models 直接调用 23+ 处,SQL 直写贯穿 Controllers/Helpers/CLI(可程序化替换量级见项目自估:待办事宜/FlintHub_MySQL版本改造方案.md §0「核心层 3~5 工作日 + 后台模块 2~3 + 迁移器 2~3 + 插件兼容 1~2 ≈ 10~15 人日」)。若要保留换引擎能力,需先抽 Repository/Storage 接口层 |
四、性能
⚠️ 本环境无 shell(Windows 缺 Git Bash),无法运行 PHP 或压测。以下为静态证据 + 项目自带审计数据,页面毫秒数未经本次复测。
| 检查项 | 结论 | 证据 |
|---|
| 首屏耗时 | ❓ 未复测(引用自审数据) | 项目自审(docs/性能审计报告_2026-10-05.md):游客命中前置缓存 6ms、游客未命中 1.6~3.0s、登录用户 1.1~2.6s、纯 API 0.72~0.87s(Windows php -S、PHP 8.2.9,含 Windows 特有的 SQLite 关闭 ~635ms 税) |
| 瓶颈定位 | 🟡 已知三类(部分已优化) | ① SQLite 连接关闭(Windows 特有,Linux 生产显著更小);② 插件钩子执行(跨平台,自审 ≈265ms/请求);③ 模板渲染(自审 0.4~1.6s)。本次确认已落地两项最大收益优化:后台路由按请求路径跳过注册(index.php:379-394,注释自估省 ≈36ms/前台请求)、钩子索引 + 符号判定磁盘缓存(Plugin.php:32-62、:738 起,注释自估原 ≈80ms/请求 → 命中仅 2 次 stat) |
| 数据库查询次数 | ✅ 已批量化 + 增量统计 | 统计改增量:Thread.php:159-162 runtimeIncr('total_threads')、Post.php:153-157 runtimeIncr('total_posts')(对应待办 P-1 已修,原每次发帖/回帖 4 次全量 COUNT + 4 次写);漂移由 worker.php:41、worker2.php:68/86、cron_trigger.php:178 保留全量重建校正(设计正确);批量取数用 IN + $marks(HomeController:109/120、DashboardController:48-66) |
| 慢查询 | 🟡 无热路径全表扫;有 1 处冷启动放大 | 见「数据·全表扫」;Category::getLatestThreads 冷快照时每版块一次查询(M-5) |
| 缓存命中率 | ✅ 设计良好 | 游客前置静态缓存(index.php:82-112 会话探测 + :112-161 命中即输出,含维护模式禁用、形如数组参数防御、会话 cookie 精确判定);PageCache 失效由发帖/回帖触发;配置走 settings_cache.php 文件缓存;快照文件 Category 缓存最新帖;App/Helpers/Cache.php 通用缓存 |
| 失效策略 | ✅ 正确 | 发帖/回帖 PageCache::invalidate();版块统计 Category::invalidateCategoryStats()(Admin/BaseController.php:127-133);快照随写入刷新(refreshLatestSnapshot) |
五、代码质量
| 检查项 | 结论 | 证据 |
|---|
| 命名一致性 | ✅ 良好 | app\ PSR-4 严格对应文件名(Autoloader.php:20-33);类名 PascalCase、方法 camelCase、DB 列 snake_case;插件命名空间与目录约束已写入规范(本包不含插件) |
| 错误处理一致性 | 🟡 大体一致,边界各异 | 主体模式统一:业务异常 try/catch + error_log + 不中断主流程(钩子隔离 Plugin.php:717-724);关键失败 throw 由上层补偿(Post.php:112-140)。不一致点见 L-2 / L-3 |
| 日志规范 | 🟡 前缀不统一 | 见 L-7;无敏感数据(安全项已确认) |
| 复制粘贴 / 该抽的抽了吗 | ✅ 主要重复已抽取 | Admin/BaseController::batchMoveIds/batchMoveThreads(:56-130)消除了 Thread/Blog 后台重复;Upload::path()/makeRelPath()/url() 收敛了全站上传路径拼接(AttachmentController 注释记录该收敛修复了「分月目录被 basename 打散」的回归)。仍存在注释自述的三处同款骨架(index.php / cron_trigger.php / config.php 的 display_errors 抑制 + SITE_URL 预置),因涉及老 config.php 兼容而刻意复制,可接受 |
| 死代码 / 废弃残留 | ✅ 基本干净,少量残留 | ✅ 全库 TODO/FIXME/HACK/XXX 0 命中;var_dump/print_r 0 命中;.bak 0 命中。残留:web.config(IIS,L-8)、BaseController.php:37 死比较(L-5)、upgrade_site_mode.php 注释「用后建议删除或移出 Web 根」 |
六、兼容性
| 检查项 | 结论 | 证据 |
|---|
| 大小写(Linux 敏感) | ✅ 核心安全 | 核心自动加载按类名原样拼路径 + realpath 前缀校验(Autoloader.php:20-33),类名与文件名一致即无大小写问题;插件分支有 3 条候选路径兜底(:44-48 $pluginDir / camelToSnake / 全小写),历史上 plugins/adslot 命名空间写成 Plugin\AdSlot 在 Linux 上 500 的坑正源于此(规范 §11.34,本包不含插件,故仅记录) |
| 换行符 | 🟡 1 个文件 CRLF | 证据:全库模式 ^<\?php\r$ 仅命中 cli/backfill_excerpts.php;其余 PHP 均 LF → L-1 |
| BOM | ✅ | 未见 BOM;且各处 JSON 解析带 BOM 容错(Plugin::decodeJson) |
| 文件属主 / 权限 | ❓ 无法核验 | 本环境无 shell,无法读取 POSIX 权限位;INSTALL.md §3 已给建议(目录 755 / 文件 644,data/与protected/ 770) |
| PHP 8.0 / 8.2 兼容 | ✅ | 全库 0 命中 8.1+ 语法(readonly、enum、never、属性 #[...]、?->);特性使用下限 = PHP 7+ 常规语法 + SQLite UPSERT(3.24) |
| 废弃特性 | 🟡 1 处不一致 | curl_close 已从 ApiController.php:535 删除并注明 PHP 8.5 弃用,但 Updater.php:723、Market.php:402/799 仍保留(8.0~8.4 无害)→ L-3;finfo_close 已按 8.5 弃用删除(Upload.php:483 注释);session_set_save_handler 用对象式(8.4 兼容,init.php 注释);strftime 仅用于 SQL(非 PHP 函数) |
| 无 Composer | ✅ 保持 | 包根无 composer.json、无 vendor/;自研 Autoloader(app/Core/Autoloader.php) |
| JS 库过期 | ✅ 本地 vendor 且版本明确 | assets/lib/:fhstate.js(Alpine.js v3.15.12,含版权头)、fhajax.js(htmx 本地重打包)、qrcode.min.js;assets/js/:marked.min.js、turndown.js + turndown-plugin-gfm.js、editor.js、pow.js、lightbox.js、auth.js、thread.js。全部本地,无 CDN 依赖(利于内网/离线部署与 CSP 收紧) |
五、本次审计的限制(不当作通过项)
- 无法运行代码:本机缺 Git Bash →
run_terminal_command 不可用,未执行任何 PHP、未发任何 HTTP 请求、未做压测。所有「性能」结论为静态推断或引用项目自审文档,页面毫秒数未经本次复测。 - 未做动态渗透:无可用实例(镜像无
config.php/运行数据),越权/CSRF/SSRF 结论均为代码级取证,未做黑盒验证。 - 范围边界:仅审计核心包。
plugins/(57 个插件,含支付中心、红包、商城、OIDC 等高风险面)不在本包内,其安全需单独审计(项目 docs/ 下已有多个插件专项审计报告)。 - 工具偏差已修正:审计中发现
code_search 的 -c(计数)与 -l(仅列文件名)参数会产生假阴性(如对 csrf 全库返回 0 命中),已全部改用纯匹配模式重跑,计数类结论均基于重跑结果。 - 未穷尽:Views 模板目录未逐文件通读(采用模式化检索 + 抽样精读),理论上可能遗漏个别视图的转义疏漏。
六、修复优先级建议
| 优先级 | 项目 | 理由 |
|---|
| P0(立即) | H-2 BASE_PATH 下后台二次验证死循环(H-1 已按 §七 R-1 降级) | H-2 会让二级目录部署的站长彻底进不去后台,且修法确定、改动极小 |
| P1(本版次) | M-1 abs() 路径防护、M-3 .md/.conf 下载屏蔽 + 绝对路径占位化、M-4 MIME 换行剥离、M-6 最小 CLI 测试集 | 均为小改动、收益明确(M-1/M-4 是纵深防御补齐,M-3 是一行 Nginx 规则,M-6 是后续所有改动的安全网) |
| P2(下版次) | M-2 孤儿扫描挂 cron、M-5 N+1 收敛、L-1~L-8 打磨项 | 不影响正确性,属工程卫生与可维护性 |
已完成项确认(相对 待办事宜/FlintHub_下版本待办清单.md):
- ✅ P-1 统计刷新不对称 →
runtimeIncr 已落地(Thread.php:162、Post.php:157)+ cron/worker 全量校正保留 - ✅ P-2
reply_index 缺 status 单列索引 → 已建(Schema.php:685/877) - ✅ 后台路由性能(每请求 31 插件钩子)→ 已按路径跳过(
index.php:379-394) - ⛔ D-2(
.md/.conf 可下载)→ 仍未修(M-3) - ⛔ B-2(无自动化测试)→ 仍未做(M-6)
- ⛔ S-3(MIME 头 CRLF)→ 仍未修(M-4)
七、第二轮复核(2026-10-14,补漏与修订)
复核范围:按同六个维度对镜像做第二轮完整复核,重点补第一轮已声明的薄弱面(视图层穷尽性、备份完整性、令牌与约束、限流覆盖、搜索索引一致性)。全部结论同样附 文件:行号。
R-1 修订:H-1 降级(原文保留备案)
见 §一 H-1 表下的「②轮修订 R-1」:该目录为审计用干净快照、不被服务;install.php 在真实站点具备四道门(已安装判定 3 项依据 / CSRF / 环境门禁 / 协议强制)。H-1 由「高风险」降为「部署注意项」,无需处置。
N-1 🟡 新增:内联 <script> 中 json_encode() 未统一加 JSON_HEX_*(一致性缺陷,当前不可直接利用)
| 项 | 内容 |
|---|
| 证据(未加 HEX) | auth/login.php:62、:67;admin/blog_manage.php:93、:109;admin/threads.php:110、:127;admin/update.php:54;layouts/main.php:596 |
| 证据(已规范加 HEX,可作对照) | layouts/main.php:84、:88、:594-595;blog/edit.php:83;_components/right_sidebar.php:125、:129;admin/groups.php:42;admin/threads.php:13、admin/blog_manage.php:13 |
| 评估 | 未加 HEX 的位置其数据源为语言包文本 / 布尔常量 / 版本号(非用户可控字符串),故当前不可直接利用;但一旦将来把动态数据(如站点名、版本备注、用户名)放进同类插值点,</script> 即可闭合标签 → XSS。属防御标准未统一 |
| 建议 | 在 TemplateCompiler 暴露统一入口 $this->jsJson($v)(固定 `JSON_HEX_TAG\ |
N-2 🟡 新增:CSP 两处不一致,且仍含 unsafe-eval/unsafe-inline
| 项 | 内容 |
|---|
| 证据 | 动态页:app/Core/Controller.php:80 → default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';<br>游客缓存页:app/Helpers/PageCache.php:107 → 同款但缺 object-src / base-uri / frame-ancestors |
| 评估 | ① 同一站点「缓存命中页」与「动态页」安全策略不同(缓存页少了 3 条硬化指令);② 两处均保留 'unsafe-eval' 'unsafe-inline' → 对脚本注入的拦截能力基本归零(对应待办 B-4,仍未修)。其余头齐备:X-Content-Type-Options / X-Frame-Options: SAMEORIGIN / Referrer-Policy(Controller.php:75-77、PageCache.php:104-106),HSTS 仅在 HTTPS 下发送(Controller.php:83、PageCache.php:110) |
| 建议 | ① 抽出单一 SecurityHeaders::apply() 供两处共用(消除策略漂移);② 中期计划:Alpine 换 CSP build 去 unsafe-eval、脚本标签加 nonce 去 unsafe-inline |
N-3 🟠 新增(数据完整性):备份/恢复只覆盖 data/,不含上传目录与 config.php
| 项 | 内容 |
|---|
| 证据 1 | app/Controllers/Admin/DatabaseController.php:713-733 handleBackup():① 对 sqliteFiles() 逐个 wal_checkpoint(TRUNCATE);② Database::closeAll() 释放句柄;③ zipDataDir() 打包 —— 目标是 ShardRouter::dataPath()(即 data/) |
| 证据 2 | :778-796 zipDataDir():new RecursiveDirectoryIterator(rtrim(ShardRouter::dataPath(),'/\\')) 全量遍历 data/(含 meta/、bucket/、extern/、队列库、sessions)→ data/ 侧是完整的 |
| 证据 3 | 备份存储目录为 protected/backups/(:27,不在 data/ 内)→ 无「备份包自我嵌套」问题 ✅;轮转 :737-758 保留最近 10 份、删除前经 realpath + isWithin() 校验 ✅ |
| 缺口 | ① assets/uploads/(头像、附件、博客封面)完全不在备份包内 —— 恢复后这些文件不会回来;② 根 config.php 不在包内 —— 若在新机器/重装后恢复,缺失 MAIL_PASS_KEY 会导致库内 SMTP 密码密文无法解密(Mailer.php:50-51/78 依赖该常量);③ 后台界面未明示备份覆盖范围 |
| 建议 | ① 备份包追加 assets/uploads/(体积可控,建议压缩),或提供「完整备份(含上传)」与「仅数据库」两档并在 UI 标注;② 备份内附带 config.php 的脱敏副本(或至少提示「恢复前需确认 config.php 的 MAIL_PASS_KEY 与备份时一致」);③ 在后台「数据库」页写明「本次备份包含:data/ 全量;不包含:上传文件、config.php」 |
N-4 ✅ 复核确认(第一轮存疑点已逐行核实,无问题)
| 项 | 结论 | 证据 |
|---|
| blog 列表「裸 echo」 | 安全 | app/Views/blog/index.php:42-45 先 $username=$this->e(...)、$title=$this->e(...)、$excerpt=$this->e(mb_substr(strip_tags(...))),:54/56/65 再 echo 即为已转义值 |
$csrfToken 直接 echo | 安全 | blog/detail.php:16 $csrfToken = $this->e($this->getData('csrfToken')) → :37/94 输出已转义 |
| 令牌随机性 | 安全 | 全部 random_bytes:AuthController:266/357/524/574、RememberMe:84、Csrf:20/90、PoW:41、Upload:212(文件名 bin2hex(random_bytes(16)))、Captcha:25/35;无 mt_rand/uniqid 生成凭据 |
| 密码重置 / 邮件验证流程 | 安全 | token 32 字节;password_resets 具备 expires_at + used 单向消费(AuthController:410-411 查询条件含 used=0 AND expires_at > :now);成功即置 used=1(:437)并清理过期(:439);签发新令牌时失效旧令牌(:355);邮件验证另设 email_verify_token_sent_at 时限(:484-486) |
| 唯一约束(并发重复兜底) | 齐备 | Schema.php:302/304(username/email UNIQUE)、:505 viewed_replies、:540 category_permissions、:556/691 tags(name)、:567 thread_tags、:579 thread_votes、:589 post_votes;SearchIndexStore.php:96 uk_search_index |
| 限流覆盖 | 13 类动作 | 登录 20/900s(AuthController:53)、注册 3/h(:205)、找回密码 3/h(:339)、重发验证 3/h(:512)、发帖 5/600s(PostController:72)、回帖 3/30s(ThreadController:108)、私信 10/min(MessageController:110)、搜索 8/30s(SearchController:26-31)、投票 30/min(ApiController:57)、上传 5/min(:180)、远程拉图 20/min(:240)、验证码 30/min(:610-614)、后台二次验证 5/15min(VerifyController:20);阈值均支持后台配置 |
| 搜索索引一致性 | 同步到位 | 主题删除 → Thread.php:813 SearchIndexStore::deleteIndex(1,$id);回复/编辑后重索引(PostController:155/220、ThreadController:483、Admin/ThreadController:57/98);Search.php:324-330 封装删除;季度文件逐分片容错(SearchIndexStore:264-279) |
| 备份下载 / 轮转路径安全 | 安全 | handleDownload basename() + realpath/isWithin;轮转只删 forum_backup_* 且经同一前缀校验 |
N-5 仍未能验证(诚实声明,不当作通过)
- 运行时行为:无 PHP 执行环境(缺 Git Bash),压测/计时/HTTP 冒烟全部未做。
- 视图穷尽性:
app/Views 未逐行通读;本轮采用「模式化检索(echo $x[...] / json_encode / :raw / <?=)+ 命中点回读」策略,已把第一轮存疑的 blog 列表逐行核实;理论上仍可能存在个别未命中模式的插值点。 - 加密实现细节:仅确认
Mailer 强制使用 MAIL_PASS_KEY 且不硬编码(Mailer.php:50-51/78),未逐行审 AES-256-GCM 的 IV/tag 处理。 - 插件层:
plugins/ 不在本包(57 个插件含支付/红包/OIDC 等高风险面),需单独审计。 - 环境相关:IIS
web.config 行为、时区/date() 依赖的 php.ini 配置、POSIX 文件属主与权限位,均无法在本机核验。 - 会话 GC 与登录态并发:
SessionHandler 的 GC 与 RateLimiter 文件锁在高并发下的行为未实测。
N-6 修订后的问题计数
| 等级 | 第一轮 | 第二轮修订后 |
|---|
| 🔴 高 | 2(H-1、H-2) | 1(H-2;H-1 → ⚪ 部署注意项) |
| 🟠 中 | 6(M-1…M-6) | 7(+N-3 备份覆盖不全) |
| 🟡 低 | 8(L-1…L-8) | 10(+N-1 json_encode 一致性、+N-2 CSP 不一致) |
| ✅ 复核确认为安全 | — | blog 列表裸 echo、csrfToken 输出、令牌随机性、密码重置/邮件验证流程、唯一约束、限流覆盖、搜索索引一致性、备份路径安全 |
本报告全部结论可经 文件:行号 复核;如需,可按「六、修复优先级」逐条开工,每条均附验证方式。第二轮复核的修订与新增见「七」。