[开发]FlintHub 修复方案报告(scope 收敛版)

👑Lv.11 元老 🌞 骨干会员
2026-10-24 00:30:32

本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:暂存20260913/核查分析修复报告.md。

FlintHub 修复方案报告(scope 收敛版)

日期:2026-09-10

背景:基于 FlintHub_vs_flatbb_对比报告.md 的逐项核查,按用户评估收敛为 仅保留 2 项真实有价值的改动:

1. client_ip(限定 AuditLog 的 XFF 信任) —— 安全缺陷修复

2. 上传图片按日期分目录存储 —— 工程优化

本报告仅含分析与方案,未改动任何代码,待指令动手。


一、Scope 收敛说明(砍掉哪些、为何)

对比报告里的其它项经核查已排除/降级,此处不再展开做方案,仅留结论:

原报告项结论原因
后台安全自检探针放弃phpstudy 单机面板,vhost 规则自控,风险低,投入产出不划算
SSRF IPv6 盲区放弃书面上成立,但主机普遍无 IPv6 私网路由,可利用性≈0,属加固完备性非漏洞
CSP nonce 化放弃模板并非"零内联"(main.php 有 3 处内联 script),全量改造回归风险高,值不值
上传原图缩放(1600px)不做纯存储优化,中小规模到不了阈值;本次仅做日期分目录"顺手优化"
启动时幂等 schema已部分实现Schema::ensureColumnPatches() 已每请求自动补列,无需重复劳动
热门分时间衰减前提不成立论坛只有 latest/highlighted,无 hot 排序,属新功能非优化


二、改动 1:AuditLog 的 client IP —— 受信代理校验

2.1 现状与缺陷

AuditLog::getClientIp() 现状:

private static function getClientIp(): string
{
    $ip = $_SERVER['REMOTE_ADDR'] ?? '';          // 直连 IP 兜底
    if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
        $ips  = \explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
        $first = \trim($ips[0]);                  // ← 问题:无条件取第一个
        if (\filter_var($first, \FILTER_VALIDATE_IP)) {
            $ip = $first;                          // ← 攻击者可直接伪造
        }
    }
    return $ip;
}

缺陷:只要攻击者带上 X-Forwarded-For: 任意IP,$first 就被接受并写入 audit_logs.ip。这是可伪造审计 IP、嫁祸他人的真实安全缺陷。

2.2 修复方案(最小、不引入配置负担)

核心思路:只有「直连来源 REMOTE_ADDR 落在受信代理段内」时才采信 XFF;否则一律回退 REMOTE_ADDR。审计展示天然接受"反代下记录的是代理 IP",不求精确——你要的是防伪造,不是穿透反代取真实 IP。

推荐实现(改动集中在 AuditLog.php 内部,10-15 行):

/** 受信反代(直连来源在此段内,才采信 X-Forwarded-For) */
private const TRUSTED_PROXIES = [
    '127.0.0.1',                       // 本机反代(nginx/caddy)
    '::1',
    '10.0.0.0/8', '172.16.0.0/12',
    '192.168.0.0/16', '100.64.0.0/10', // CGNAT
];

private static function isTrustedProxy(string $ip): bool
{
    if (\filter_var($ip, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV4) || \filter_var($ip, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) {
        foreach (self::TRUSTED_PROXIES as $range) {
            if (self::ipInRange($ip, $range)) return true;
        }
    }
    return false;
}

private static function getClientIp(): string
{
    $remote = $_SERVER['REMOTE_ADDR'] ?? '';
    if ($remote && self::isTrustedProxy($remote)          // 直连是受信代理
        && !empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {      // 才读 XFF
        $first = \trim((string)\strtok($_SERVER['HTTP_X_FORWARDED_FOR'], ','));
        if (\filter_var($first, \FILTER_VALIDATE_IP)) {
            return $first;
        }
    }
    return $remote;
}

其中 ipInRange() 用纯整数位运算实现 IPv4 CIDR 匹配(无需扩展,~10 行);IPv6 因普通环境用不上 ULA 场景,可只处理 ::1 字面量,不必实现完整 IPv6 CIDR 算法(避免过度工程)。

2.3 行为影响

场景改前改后
直连(无反代)REMOTE_ADDR 或伪造 XFF恒 REMOTE_ADDR(XFF 直接忽略)✓
本机 nginx 反代 + 真实 XFF取 XFF 第一个取 XFF 第一个(127.0.0.1 在受信段)✓ 行为不变
攻击者伪装 XFF 直连被接受(伪造成功)忽略,记 REMOTE_ADDR ✓ 缺陷修复

2.4 注意事项

  • 不引入 config.php 配置项 / web 后台设置:默认内置常见代理段已足够。若确有第三方反代(如 Cloudflare),届时再加,不为假想需求加开关。
  • 不修 RateLimiter:其 REMOTE_ADDR 策略是保守安全的,不在本次 scope。
  • 该方案不触碰 AuditLog::log() 其它逻辑,仅替换 getClientIp() 私有实现,回归面极小。

三、改动 2:上传按日期分目录存储

3.1 现状

Upload.php:

$filename = \bin2hex(\random_bytes(16)) . '.' . $ext;
$destination = \rtrim(UPLOAD_PATH, '/\\') . \DIRECTORY_SEPARATOR . $filename;  // 平铺
// ...
'url' => UPLOAD_URL . $filename,   // 完整 URL 落库

所有图片/附件平铺在 assets/uploads/,文件多后 opendir 变慢。

3.2 关键约束(决定方案形态)

盘点所有消费 filename / 磁盘文件的点,分两类:

A. 依赖"扁平文件名 + UPLOAD_PATH 拼接"做安全校验/删除 —— 不能往 filename 塞直接子路径:

B. 依赖"完整 URL"(正文图/封面)——天然兼容子路径,无需改:

  • 正文 UPLOAD_URL . filename 完整落库 → 塞 2026/09/xxx.png 也能正确访问
  • 头像/封面 UPLOAD_URL . filename → 同样兼容

结论:日期分目录不能靠"把 2026/09/ 塞进 attachment.filename",否则 A 类拼接/白名单全断。正确做法是分层:

3.3 修复方案

思路:Upload::file() 写盘时落到日期子目录,但返回给调用方的 filename 仍保持扁平随机名;通过新增的解析函数在需要定位磁盘绝对路径的点,把"扁平名 → 实际相对子路径"还原。URL 返回带日期前缀子路径。

两种落地方案任选,推荐方案 A(写周期固定 subPath,磁盘稳定)。


方案 A(推荐):固定 YYYY/MM/ 子目录 + 绝对路径定位函数

  1. Upload::file() 写盘改日期子目录

```php

$filename = \bin2hex(\random_bytes(16)) . '.' . $ext;

$subPath = \date('Y/m'); // 2026/09

$destDir = \rtrim(UPLOAD_PATH, '/\\') . '/' . $subPath;

if (!\is_dir($destDir)) @\mkdir($destDir, 0755, true);

$destination = $destDir . '/' . $filename;

```

缩略图 makeThumbnail() 同样落到 $destDir . '/thumb_' . $filename(当前 thumb 拼 UPLOAD_PATH . $thumbName,需同步改为 $destDir . '/thumb_...')。

  1. 返回相对子路径,供 URL 与定位用:

```php

return [

'success' => true,

'filename' => $filename, // 保持扁平(入库不变)

'rel' => $subPath . '/' . $filename, // 2026/09/xxx.png

'url' => UPLOAD_URL . $subPath . '/' . $filename,

...

];

```

  • url 带子路径 → A 类正文图、封面、下载链接直接可用(本体无需改)。
  • filename 保持扁平 → attachment 表、删除白名单、basename 全都不用改。
  1. 新增 Upload::resolvePath($filename) 定位磁盘绝对路径(供删除/下载接力):

反解逻辑:先查 business attachments.filename 拿到扁平名后,glob(UPLOAD_PATH.'/'.$filename) 或 glob(UPLOAD_PATH.'/'.date('Y/m').'/'.$filename) 匹配到真实文件返回其绝对路径。用于 Thread hardDelete、User deleteWithCascade、AttachmentController。

取舍说明:不把 subPath 写进 attachment.filename 的原因——删除白名单是跨桶/跨 controller 的硬校验面,把子路径塞进去会让 Thread.php L677、User.php L582 的白名单正则全部失效,改动面从 1 个文件膨胀到 4+ 处,且引入 path-穿越风险。用"写周期固定 + 定位函数"把改动收拢到 Upload 内部,符合 memory 中"修改彻底但同步调用点"的工程约定,且改动点最少。


方案 B(轻量备选):仅写盘分平铺目录,不改任何返回字段

Upload::file() 写盘落 date('Y/m')/ 子目录,但 filename url 全部保持现状(仍返回 UPLOAD_URL.filename 平铺 URL)。这样磁盘物理分了目录,但 URL 指向旧平铺路径 → 不兼容,需同时改历史 URL,实际不可行,故不采用。仅留作反面说明。

结论:采用方案 A。URL 带子路径、filename 扁平、删除/下载走定位函数,三者自洽。

3.4 兼容性分析

资产改前(扁平 URL)改后(子路径 URL)是否需迁移
存量 attachment/正文图/头像/封面uploads/xxx.png—不迁移:旧 URL/旧文件原地不动,新写入才走日期目录
存量 删除/下载/孤儿清理按扁平拼接新逻辑 resolvePath() 需同时兜底"平铺旧文件"(glob 匹配不到子目录时回退 UPLOAD_PATH.'/'.$filename)需兜底旧路径
新增 上传—subPath/filename无
附件表 filename 字段扁平保持扁平(不变)无

关键点:resolvePath() 和删除/下载逻辑必须兼容存量平铺文件——glob 子目录命中则用子路径,否则回退旧平铺路径。这保证改动后存量文件删除/下载仍正常。

3.5 涉及文件清单(预估)

文件改动量级
app/Helpers/Upload.phpfile() 写日期子目录;返回 rel;新增 resolvePath();makeThumbnail() 落对应子目录中
app/Models/Thread.phphardDelete 附件删除改走 resolvePath(),回退旧路径小
app/Models/User.phpdeleteWithCascade 附件删除改走 resolvePath()小
app/Controllers/AttachmentController.php下载路径拼接改走 resolvePath()小
app/Helpers/Settings.php孤儿清理引用匹配补两层 glob(不破坏现有 basename 逻辑)小

3.6 验证方式

  • 上传图片 → 确认落盘在 assets/uploads/2026/09/,预览 URL 正常
  • 删除/下载附件 → resolvePath 正确定位到子目录文件,旧平铺文件也仍可删可下
  • 跑 cli/smoke_frontend_flow.php 确认无回归

四、执行顺序建议

  1. 先做改动 1(AuditLog):独立、安全、改动面 15 行内,风险最低,立即消除伪造审计 IP 缺陷。
  2. 再做改动 2(上传分目录):涉及 5 个文件,重点回归删除/下载路径与存量兼容。
  3. 每个改动完成即跑 cli/smoke_frontend_flow.php 确认无回归(符合 memory 工程约定)。

_本报告为分析 + 方案,未改动任何代码。待用户指令后动手。_

轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| 浏览 7 次 | 回复 0 次

全部回复 (0)

暂无回复

请 登录