本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:暂存20260913/审计结果20260821.md。
安全与代码审计结果(2026-08-21)
审计范围:第三方对本次「自适应验证码防线」修改新增文件进行审计反馈,本文件依据实际代码现状逐条复核,区分「已确认」「已修复/不成立」「需加固」三类,并给出处置建议。
复核依据:
app/Controllers/ApiController.php、app/Controllers/AuthController.php、app/Helpers/PoW.php、app/Helpers/RateLimiter.php、app/Helpers/Content.php、app/Helpers/Csrf.php、app/Models/User.php、app/SplitDB/Schema.php、app/Views/auth/login.php、app/Views/auth/register.php、app/Views/layouts/main.php、assets/js/pow.js、index.php状态说明:指令为「先出文档,等指令再改」。以下结论仅用于记录与对齐,尚未对代码做任何修改。
一、修复优先级总览
| # | 位置 | 问题 | 复核结论 | 优先级 |
|---|---|---|---|---|
| 1 | main.php 语言切换链接 | REQUEST_URI 拼链接未转义 → 反射型 XSS | ✅ 已确认 | 高 |
| 2 | fetchImage() / fetchHopByHop() | SSL 证书校验关闭,支持 MITM 投毒 | ✅ 已确认(取舍) | 高/中 |
| 3 | RateLimiter::writeIpRecords() | 抢锁失败直接放行 → 高并发下限流失效 | ✅ 已确认 | 中/高 |
| 4 | RateLimiter IP 来源 | 依赖 REMOTE_ADDR,反代下误判 | ✅ 已确认 | 中 |
| 5 | RateLimiter 文件 GC | 无过期缓存回收,磁盘堆积 | ✅ 已确认 | 中 |
| 6 | register.php | checkPasswordStrength 未定义防兜底 | ✅ 已确认 | 低/中 |
| 7 | login.php | 锁定态 captchaImg 重复事件绑定 | ✅ 已确认 | 低/中 |
| 8 | main.php 登出表单 | csrf 隐藏域未 $this->e() 转义 | ✅ 已确认(低危) | 低 |
| 9 | main.php 抽屉 | 视图层直接跑 SQL 聚合查询 | ✅ 已确认(架构) | 低/中 |
| 10 | PoW::validate() 重放 | session 未 write_close 强制落盘 | ⚠️ 理论面,需加固 | 低 |
| 11 | PoW::issue() | /api/pow 无自身限流 | ✅ 已确认 | 中 |
| 12 | Schema::bucketFromPath() | realpath 范围未强制核验 | ⚠️ 正则已强约束,建议加固 | 低 |
| 13 | Schema::ensureProtectionFiles() | 仅生成 Apache/IIS 防护,Nginx 靠人工 | ✅ 需文档提醒 | 中 |
| 14 | Schema::seedDefaults() | 批量 INSERT 未显式事务 | ✅ 已确认 | 低/中 |
| 15 | Schema PRAGMA | 运行时 table_info 检查 | ✅ 已确认(架构) | 低 |
| 16 | ApiController::vote() | CSRF 拼进 HTML <script> | ⚠️ 建议改用 json_encode/HX-Trigger | 低 |
| 17 | postBody() 正文输出 | XSS(正文经净化,实际已兜底) | ⚠️ 已由 sanitizeHtml 覆盖 | 中→已缓解 |
| 18 | 邮箱验证 Token 哈希不一致 | 审计报告重大指控 | ❌ 不成立,已复核一致 | — |
二、逐条复核详情
2.1 「高」修复:main.php 语言切换链接反射型 XSS — ✅ 已确认
代码依据(app/Views/layouts/main.php L142-154,移动端同理):
$i18nUri = (string)($_SERVER['REQUEST_URI'] ?? '/');
$i18nBase = (string)preg_replace('/([?&])lang=[^&]*&?/', '$1', $i18nUri);
...
REQUEST_URI 未经 htmlspecialchars 转义即拼入 href。若 url() 内部不自动转义(需确认 app/Core/Controller::url()),攻击者可构造 /?module=" autofocus onfocus=alert(1) 之类的 URI 造成属性注入/反射型 XSS。
处置建议:输出前统一 $this->e($this->url(...));或在进入链接前对 preg_replace 后的 $i18nBase 做防属性注入清洗(剔除 " ' < >)。
2.2 「高/中」:fetchImage() / fetchHopByHop() SSL 校验关闭 — ✅ 已确认
代码依据(ApiController.php L299-302、L340-341):
'ssl' => [ 'verify_peer' => false, 'verify_peer_name' => false, 'timeout' => 15 ],
// cURL 分支
CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => 0,为满足「Host 替换为 IP + 保留原始 SNI」的 SSRF 方案,关闭了证书校验。这使中间人在该链路可伪造证书、篡改图片数据或重定向响应。
补充判断(审计中一项不成立):审计称 foreach ($resolvedIps as $ip) ... $targetIp = $ip; 存在「内网 IP 排在最后可被绕过」。经复核 —— isBlockedIp($ip) 命中时调用 jsonError(),而 jsonError() → json() → exit,任一解析 IP 为内网即直接终止,不会继续到 $targetIp = $ip;,因此不存在该绕过路径。
处置建议:
- 可尝试在 cURL 分支启用
CURLOPT_SSL_VERIFYPEER+ 指定 CA,将 URL host 恢复为原始域名(依赖 DNS),与「IP 替换」方案二选一; - 若必须保留 IP 直连方案,至少对 cURL 使用
CURLOPT_SSL_VERIFYPEER=false时配合CURLOPT_CAINFO或友好头Strict-Transport-Security场景降级; - 建议在注释中说明这是 SSRF 与 TLS 完整性之间的主动取舍,并记录约定。
2.3 「中/高」:RateLimiter::writeIpRecords() 抢锁失败直接放行 — ✅ 已确认
代码依据(app/Helpers/RateLimiter.php L85-99):
$fh = @fopen($file, 'c+b');
if ($fh === false) return; // 降级放行
if (@flock($fh, LOCK_EX | LOCK_NB)) { ... } // 抢锁失败不写入
fclose($fh);当并发爆破时,非阻塞锁失败即放弃写入,timestamp 未落盘 → 下一次检查仍判定未超限,限流「漏放」。
处置建议:
- 抢锁失败时采用有限次退避重试(
usleep抖动 2~3 次); - 仍失败则回退 Session 级强制记数(已有 Session 记录为底线,不可再放行),把「完全放行」改为「降级但仍有边界」;
- 长期建议迁移至 Redis/APCu+文件混合。
2.4 「中」:RateLimiter IP 来源依赖 REMOTE_ADDR — ✅ 已确认
代码依据(RateLimiter.php L113/L148/L227):IP 统一取自 $_SERVER['REMOTE_ADDR']。
若站点部署于 Nginx 反代 / Cloudflare / ALB 之后,REMOTE_ADDR 恒为代理服务器地址,所有用户共享同一限流池 → 正常用户被误锁或限制被反转绕过。
处置建议:引入受信代理链位的 IP 提取函数(读取 HTTP_X_FORWARDED_FOR 并校验直连方为受信代理),在框架初始化处统一注入或封装 clientIp()。
2.5 「中」:RateLimiter 文件缓存无 GC — ✅ 已确认
代码依据(RateLimiter.php readIpRecords/writeIpRecords):每个 _ip_rate_{action}_{ip} 生成独立 ip_*.cache,无过期回收。Bots/Crawler 扫量时可堆积数十万小文件,拖慢 inode 检索。
处置建议:追加 GC,低概率(如 1%)触发过期文件清理或滚动窗口复用同一文件;长期建议换 Redis。
2.6 「低/中」:register.php 引用 checkPasswordStrength 无兜底 — ✅ 已确认
代码依据(app/Views/auth/register.php L58-60):
document.getElementById('regPassword').addEventListener('input', function(){
checkPasswordStrength(this.value);
});全局 checkPasswordStrength 在 register 页未显式声明/引入。若布局未载入含该函数的 JS,将持续抛 ReferenceError 并中断后续脚本。
处置建议:调用前 typeof checkPasswordStrength === 'function' 判定;或确认其在 auth.js 中全局定义且 register 页必加载。
2.7 「低/中」:login.php 锁定态下 captchaImg 重复事件绑定 — ✅ 已确认
代码依据(app/Views/auth/login.php L71 与 L78):
- L71:
$locked && $lockRemaining > 0条件块内绑定一次 - L78:条件块外始终再绑定一次
锁定态时同一元素被绑定 2 个 click → 每次点击触发 2 次验证码刷新,后发覆盖先发导致「验证码永远错误」。
处置建议:删除条件块内(L71)的冗余绑定,仅保留 L78 单一绑定。
2.8 「低」:main.php 登出表单 csrf 未转义 — ✅ 已确认
代码依据(app/Views/layouts/main.php L206、L324、L392):
未用 $this->e()。当前 Token 为 bin2hex(random_bytes(32))(见 Csrf.php L17)——纯 hex,实际无注入风险,但风格上不一致。
处置建议:统一补 $this->e(...),与其它表单保持一致。
2.9 「低/中」:main.php 抽屉视图层跑 SQL 聚合 — ✅ 已确认(架构)
代码依据(app/Views/layouts/main.php L106-107):
(new Blog())->query(
"SELECT strftime('%Y-%m', created_at) as month, COUNT(*) as cnt FROM blogs GROUP BY ..."
);主布局对全站页面加载即触发此全表分组统计,随文章量增长有性能压力,且违反 MVC 分层。
处置建议:下沉到 Controller/Service 并加 TTL 缓存,或模型层提供缓存方法后由控制器传入 View。
2.10 「中/低」:PoW::validate() 重放 — ⚠️ 理论面,需加固
代码依据(app/Helpers/PoW.php L85):
unset($_SESSION['pow_challenge'], ...); // 一次性逻辑正确,但 PHP 默认是在脚本结束时才写回 Session。若 validate() 后控制器在 session_write_close()/正常结束前 exit/致命错误,擦除状态可能未落盘,同 challenge+nonce 可再复用。
处置建议:在确认 challenge 存在后先 session_write_close() 强制落盘;或引入全局一次性 nonce 列表(Redis/文件)。
2.11 「中」:PoW::issue() 触发 mode 判定但 /api/pow 自身无限流 — ✅ 已确认
代码依据(app/Helpers/PoW.php L40,ApiController::pow() L483-491):pow() 仅无脑签发 challenge,issue() 内部 RateLimiter::isLimited 是只读判定。攻击者可用脚本批量刷 /api/pow 冲击 Session/DB,不触发任何限流计数。
处置建议:为 /api/pow 增加 RateLimiter::hit('pow', ...);issue() 同时做读前判定即可。
2.12 「低」:Schema::bucketFromPath() 路径范围 — ⚠️ 正则已强约束,建议加固
代码依据(app/SplitDB/Schema.php L748-760):要求 bucket/(active|archive)/(\d{4}Q\d)/(\d+)\.sqlite,bucketPath 必须完全命中该锚定正则,含 .. 或非此结构均会抛异常。因此目录穿越实际难以达成。审计建议补 realpath 范围校验属于纵深防御。
处置建议:可在拼接前用 realpath($root) 做基底比对,低成本加固。
2.13 「中」:Schema::ensureProtectionFiles() 仅覆盖 Apache/IIS — ✅ 需文档提醒
代码依据(Schema.php L97 起):仅写 .htaccess 与 web.config。Nginx 依赖 nginx-server.conf 的 location deny 人工配置,无法由 PHP 自动生成,若漏配则 data/*.sqlite 可被直下。
处置建议:在 bootstrap() 检测 Web 服务器类型为 Nginx 时,向日志输出高亮安全告警;或在 data/ 根额外写一个 index.html/index.php(<?php http_response_code(403); die; ?>)作为 Nginx 默认兜底(最简单有效)。
2.14 「低/中」:Schema::seedDefaults() 批量 INSERT 未显式事务 — ✅ 已确认
代码依据(Schema.php L165 起的多个 INSERT OR IGNORE 循环 execute):每 execute 为独立事务,多进程并发 bootstrap() 时可能触发 SQLite SQLITE_BUSY。
处置建议:beginTransaction() / commit() 包裹批量写入。
2.15 「低」:Schema 运行时 PRAGMA table_info — ✅ 已确认(架构)
代码依据(Schema.php L578/L620):动态升级路径反复执行 PRAGMA table_info,打断 SQLite 语句缓存。建议仅在显式 Upgrade 操作时触发,不放入常驻检查。
2.16 「低」:ApiController::vote() 把 CSRF 拼进 htmx HTML <script> — ⚠️ 建议优化
代码依据(ApiController.php L103):
. '';Token 为纯 hex,无攻击面,但硬编码进 HTML 脚本块属坏味道。建议改用 json_encode 输出,或经响应头 HX-Trigger/全局事件由前端统一更新。
2.17 「中→已缓解」:postBody() 正文输出 XSS
代码依据(ApiController.php L544 结合 Content.php):
echo Content::formatPostContent(Content::decode($thread['content'] ?? ''));审计最初指控「未严格净化注入 DOM」。经复核 formatPostContent()(Content.php L270-296)对含标记内容调用 sanitizeHtml(),后者基于 DOMDocument 白名单标签/属性 + style 白名单过滤 + 协议白名单(Content.php L15-136),已有 XSS 兜底。但该净化能力高度依赖 sanitizeHtml,建议:
- 补充完善的单元测试覆盖恶意输入;
- 输出上下文本就为
htmx innerHTML,务必确保sanitizeHtml永不劣化(回归保护)。
2.18 「重大指控」:邮箱验证 Token 哈希不一致 — ❌ 不成立(已复核一致)
审计原文:认为 resendVerification() 把 $token 明文写入 DB,而 verifyEmail() 用哈希比对,导致功能失效。
复核结论:该指控与当前代码不符。
- 写入侧:所有路径均经
User::setVerifyToken()(app/Models/User.phpL278-284),其内部统一对 token 做hash('sha256', $token)后入库:
```php
public function setVerifyToken(int $userId, string $token): void {
$this->execute('UPDATE users SET email_verify_token = :token, ...',
[':token' => hash('sha256', $token), ...]);
}
```
- 读取侧:
verifyEmail()(AuthController.phpL358)$hashedToken = hash('sha256', $token)后按明文 token 哈希比对 —— 与写入侧同一标准(SHA-256)。
因此「写入明文、读取哈希」的失配并不存在,verifyEmail/resendVerification 端到端一致。若审计观察到入口路由缺失注册,是其它层面的问题(见下)。
注:本次审计未发现
verifyEmail/resendVerification路由在index.php之外被调用的阻断性问题;GET /verify-email/{token}、GET /resend-verification均已注册(index.phpL151/L153)。
三、附:登录锁定(AuthController::login)复核
代码依据(AuthController.php L47-130):
- 锁 key:
_login_lock_{md5(username)},存settings表 JSON,跨 Session 生效(不依赖 Session),更换 session id 不可绕过 —— 设计合理; - 用
INSERT ... ON CONFLICT DO UPDATE单条原子 UPSERT,比「先 SELECT 再 UPDATE」更抗并发,审计中「非原子读写导致并发覆盖」此项部分不成立; - 但带来的副作用是:将粒度为「用户/IP」的临时锁写入全局
settings表,高频爆破下确实会造成该表膨胀与读写放大(合理性存疑)。 usleep(250000)用于防时间侧信道枚举(密码时间差),password_verify对不存在用户使用 dummy hash 统一耗时 —— 较好做法。
处置建议:保持原子 UPSERT 不动;将锁从 settings 迁至专用 login_attempts 表或缓存介质,避免污染全局配置表。
四、审计报告的 pow.js 与 index.php 诉求复核
| 诉求 | 现状 | 复核结论 |
|---|---|---|
pow.js SHA-256 << 24 符号溢出 | L107 逐字节 `<<24 | <<16 |
solveNonce 主线程阻塞 | L57 for(;i<1e7;) 同步 hash | 高难度/低端设备会卡 UI。建议 Web Worker 化(已列入后续优化)。 ⚠️ 待优化 |
index.php 会话正则 `user_id\ | i:` | L43 `preg_match('/(?:^ |
index.php 重复 DB 连接 | L32-36 与 L73 两处 require config + SQLite 读 | 前置缓存的绝大多数开销在磁盘;可把会话检查延迟到缓存 miss 后再执行。 ⚠️ 待优化 |
五、结论与下一步
- 必须优先处理(建议先改):#1 语言切换 XSS、#3 RateLimiter 抢锁放行、#2 fetchImage SSL 取舍、#4 IP 来源。
- 低成本加固(可顺带做):#6/#7 前端函数兜底与重复绑定、#8 登出转义、#13 Nginx index 兜底。
- 审计误判:#18 邮箱 token 不一致(不成立)、fetchImage 多 IP 绕过(不成立)、login 锁并发覆盖(部分不成立)。
- 架构/性能项(#5 GC、#9 视图 SQL、#11 pow 限流、#14 事务、#15 PRAGMA、pow.js Web Worker)可纳入后续迭代。
当前仅整理归档,未改动任何代码。请在 UI 按需下达修复指令(可逐条确认,也可按优先级批量处理)。