[审计]FlintHub 核心包审计报告(`release/core/flinthub1.1.22`)

👑Lv.11 元老 🌞 骨干会员
2026-10-23 23:42:19

本文由 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,保留原分析供备案)

项内容
证据 1install.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/(在站点根内,必须对公网可读)」→ 该目录按设计对外公开
证据 4nginx-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() 缺路径穿越防护(依赖签名单点兜底)

项内容
证据 1app/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)
证据 2Updater.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\
证据 3nginx-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-1cli/backfill_excerpts.php(全文件)CRLF 换行(证据:全库 ^<\?php\r$ 仅命中此文件,其余 PHP 均为 LF)——违反 README/CHANGELOG 声明的「UTF-8 without BOM + LF」统一转 LF(可加一条打包前自检,防回归)
L-2cli/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-3app/Helpers/Updater.php:723、app/Helpers/Market.php:402,799 仍保留 curl_close($ch);而 ApiController.php:535 已删除并注明 PHP 8.5 弃用同一问题的处理不一致统一删除(8.5 前无副作用,删除零风险)
L-4cron_trigger.php:60-66CRON_KEY 优先读 X-Cron-Key 头,兼容 ?key= —— URL 参数会进入 access log / Referer / 浏览器历史过渡期后可移除 query 兼容,或仅在 debug 开关下允许
L-5app/Controllers/Admin/BaseController.php(__construct() 内 $uriPath 比较处)rtrim($uriPath,'/') 之后仍比较 '/admin/verify/' → 永假死代码删除该分支(同时在 H-2 修复中一并处理前缀问题)
L-6index.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-8web.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-closedcron_trigger.php:53-66:CRON_KEY 未配置 → 503 拒绝(不再「留空即放行」)+ hash_equals + 非阻塞文件锁 + queue_mode 模式守卫
升级器验签✅ fail-closedUpdater.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 收紧)


五、本次审计的限制(不当作通过项)

  1. 无法运行代码:本机缺 Git Bash → run_terminal_command 不可用,未执行任何 PHP、未发任何 HTTP 请求、未做压测。所有「性能」结论为静态推断或引用项目自审文档,页面毫秒数未经本次复测。
  2. 未做动态渗透:无可用实例(镜像无 config.php/运行数据),越权/CSRF/SSRF 结论均为代码级取证,未做黑盒验证。
  3. 范围边界:仅审计核心包。plugins/(57 个插件,含支付中心、红包、商城、OIDC 等高风险面)不在本包内,其安全需单独审计(项目 docs/ 下已有多个插件专项审计报告)。
  4. 工具偏差已修正:审计中发现 code_search 的 -c(计数)与 -l(仅列文件名)参数会产生假阴性(如对 csrf 全库返回 0 命中),已全部改用纯匹配模式重跑,计数类结论均基于重跑结果。
  5. 未穷尽: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

项内容
证据 1app/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 仍未能验证(诚实声明,不当作通过)

  1. 运行时行为:无 PHP 执行环境(缺 Git Bash),压测/计时/HTTP 冒烟全部未做。
  2. 视图穷尽性:app/Views 未逐行通读;本轮采用「模式化检索(echo $x[...] / json_encode / :raw / <?=)+ 命中点回读」策略,已把第一轮存疑的 blog 列表逐行核实;理论上仍可能存在个别未命中模式的插值点。
  3. 加密实现细节:仅确认 Mailer 强制使用 MAIL_PASS_KEY 且不硬编码(Mailer.php:50-51/78),未逐行审 AES-256-GCM 的 IV/tag 处理。
  4. 插件层:plugins/ 不在本包(57 个插件含支付/红包/OIDC 等高风险面),需单独审计。
  5. 环境相关:IIS web.config 行为、时区/date() 依赖的 php.ini 配置、POSIX 文件属主与权限位,均无法在本机核验。
  6. 会话 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 输出、令牌随机性、密码重置/邮件验证流程、唯一约束、限流覆盖、搜索索引一致性、备份路径安全


本报告全部结论可经 文件:行号 复核;如需,可按「六、修复优先级」逐条开工,每条均附验证方式。第二轮复核的修订与新增见「七」。

轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| Views 3 | Replies 0

All Replies (0)

No replies yet

Please Log in