diff --git a/lib/attach.php b/lib/attach.php index 9a36e2a..999cce0 100644 --- a/lib/attach.php +++ b/lib/attach.php @@ -226,6 +226,41 @@ function fb_upload_ext_ok($kind, $ext) { } /** MIME 白名单 */ +/** + * 探测文件的**真实** MIME(不信任前端上报)。 + * + * 🔴 为什么必须有:mime 是客户端 POST 上来的字符串,随便写。 + * 把 PHP 代码改名成 .png、上报 mime=image/png,就能写进 uploads/。 + * 虽然 .htaccess 禁了脚本执行,但那是唯一一道防线 —— + * 换 nginx、改配置、目录迁移,任何一步疏忽就是 RCE。 + * + * 优先用 fileinfo(读文件头魔数);没装则用 getimagesize 兜底 + * (只认图片,视频降级为「放行」并记录,不阻断正常视频)。 + * + * @return string 探测到的 mime;无法判断返回空串 + */ +function fb_upload_detect_mime($path) { + if (!is_file($path)) { return ''; } + if (function_exists('finfo_open')) { + $fi = @finfo_open(FILEINFO_MIME_TYPE); + if ($fi) { + $m = @finfo_file($fi, $path); + @finfo_close($fi); + if (is_string($m) && $m !== '' && $m !== 'application/octet-stream') { + return strtolower(trim($m)); + } + } + } + // 兜底:图片用 getimagesize(读真实尺寸,伪造难度高) + if (function_exists('getimagesize')) { + $info = @getimagesize($path); + if (is_array($info) && !empty($info['mime'])) { + return strtolower(trim((string)$info['mime'])); + } + } + return ''; +} + function fb_upload_mime_ok($kind, $mime) { $list = (array)fb_upload_cfg($kind . '_mimes', []); return in_array(strtolower(trim((string)$mime)), array_map('strtolower', $list), true); diff --git a/lib/util.php b/lib/util.php index ee1c968..39cb93a 100644 --- a/lib/util.php +++ b/lib/util.php @@ -22,17 +22,94 @@ function fb_fmt_time($ts) { return date('Y-m-d H:i', $ts); } -/** 访客 IP(兼容反向代理 / CDN) */ +/** + * 访客 IP。 + * + * 🔴🔴 这里曾经是**无条件信任** X-Forwarded-For / X-Real-IP / CF-Connecting-IP: + * + * $keys = ['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR']; + * foreach ($keys as $k) { ... return 第一个合法 IP ... } + * + * 而这些请求头是**客户端可以随便写的**。任何人加一个 + * `X-Forwarded-For: 1.2.3.4` 就换了个「IP」,于是: + * - 上传限流形同虚设(实测:上限 2 次,改头后 6 次全放行) + * - 发帖限流、验证码频率、所有基于 IP 的限制全部失效 + * + * 正确做法: + * 1. 默认只用 REMOTE_ADDR(客户端改不了) + * 2. 只有请求**确实来自受信任的反向代理/CDN** 时,才解析转发头 + * 3. 解析时取**最右一跳**——那是最近一层代理写进去的, + * 最左边的(用户自己塞的)最不可信。取最左等于把伪造的当前者。 + * + * 受信任来源通过后台配置 `trusted_proxies` 指定(逗号分隔 IP 或 CIDR)。 + * 空 = 不信任任何转发头(默认,最安全)。 + */ function fb_client_ip() { - $keys = ['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR']; - foreach ($keys as $k) { - if (!empty($_SERVER[$k])) { - $v = explode(',', (string)$_SERVER[$k]); - $v = trim($v[0]); - if (filter_var($v, FILTER_VALIDATE_IP)) { return $v; } - } + $remote = isset($_SERVER['REMOTE_ADDR']) ? trim((string)$_SERVER['REMOTE_ADDR']) : ''; + if (!filter_var($remote, FILTER_VALIDATE_IP)) { $remote = ''; } + + // 没有配置受信任代理 → 只能用 REMOTE_ADDR,转发头一律忽略 + $trusted = fb_trusted_proxies(); + if (!$trusted || $remote === '' || !fb_ip_in_ranges($remote, $trusted)) { + return $remote !== '' ? $remote : '0.0.0.0'; } - return '0.0.0.0'; + + // 来自受信任代理,才读转发头;且只取最右一跳 + $candidates = ['HTTP_CF_CONNECTING_IP', 'HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_FOR']; + foreach ($candidates as $k) { + if (empty($_SERVER[$k])) { continue; } + $parts = array_filter(array_map('trim', explode(',', (string)$_SERVER[$k]))); + if (!$parts) { continue; } + $last = end($parts); // ★ 最右一跳 + if (filter_var($last, FILTER_VALIDATE_IP)) { return $last; } + } + return $remote; +} + +/** 受信任的反向代理列表(后台配置,逗号分隔 IP / CIDR)@return string[] */ +function fb_trusted_proxies() { + static $cache = null; + if ($cache !== null) { return $cache; } + $raw = (string)fb_cfg('trusted_proxies', ''); + $out = []; + foreach (preg_split('/[\s,]+/', $raw) as $one) { + $one = trim($one); + if ($one !== '') { $out[] = $one; } + } + $cache = $out; + return $cache; +} + +/** + * IP 是否落在某个 CIDR / 精确 IP 列表里。支持 IPv4 与 IPv6 精确匹配, + * IPv6 只做精确比较(不做前缀,够用且不易出错)。 + */ +function fb_ip_in_ranges($ip, array $ranges) { + $ipBin = @inet_pton($ip); + if ($ipBin === false) { return false; } + foreach ($ranges as $r) { + if (strpos($r, '/') === false) { + $b = @inet_pton($r); + if ($b !== false && $b === $ipBin) { return true; } + continue; + } + list($net, $bits) = explode('/', $r, 2); + $netBin = @inet_pton(trim($net)); + if ($netBin === false) { continue; } + $bits = (int)$bits; + // 只处理同族(长度一致)的情况 + if (strlen($netBin) !== strlen($ipBin)) { continue; } + if ($bits <= 0) { return true; } + $maxBits = strlen($netBin) * 8; + if ($bits > $maxBits) { $bits = $maxBits; } + $bytes = intdiv($bits, 8); + $rem = $bits % 8; + if ($bytes > 0 && substr($netBin, 0, $bytes) !== substr($ipBin, 0, $bytes)) { continue; } + if ($rem === 0) { return true; } + $mask = 0xFF << (8 - $rem) & 0xFF; + if ((ord($netBin[$bytes]) & $mask) === (ord($ipBin[$bytes]) & $mask)) { return true; } + } + return false; } /** 访客 IP 的加盐哈希(不保存明文 IP) */ @@ -392,6 +469,7 @@ function fb_setting_keys() { 'footer.show_credit', 'footer.credit_url', 'installed_at', 'attachment.storage', + 'trusted_proxies', 'upload.image_on', 'upload.video_on', 'upload.image_max_per_post', 'upload.image_max_bytes', 'upload.video_max_per_post', 'upload.video_max_bytes', diff --git a/upload.php b/upload.php index 8309398..4fc1018 100644 --- a/upload.php +++ b/upload.php @@ -80,6 +80,17 @@ if ($putAt) { $ext = fb_upload_key_ext($key); if (!fb_upload_ext_ok($kind, $ext)) { fb_up_json(['ok' => false, 'error' => '不支持的文件格式。'], 400); } + /* 🔴 真上传(action=put)必须独立限流。 + * 原先只有「领凭证」那一步记 fb_rate_hit, + * 拿到一个新凭证后可以反复 POST action=put 覆盖写入,次数完全不受限。 + * 这里用同一个每小时额度,但独立计数(upload_put), + * 避免领凭证与落地互相挤占导致正常用户被误拦。 + */ + $putLimit = (int)fb_upload_cfg('upload_per_hour', 60); + if (!fb_rate_check('upload_put', $putLimit)) { + fb_up_json(['ok' => false, 'error' => '上传过于频繁,请稍后再试。'], 429); + } + if (empty($_FILES['file']) || !is_array($_FILES['file'])) { fb_up_json(['ok' => false, 'error' => '没有收到文件。'], 400); } @@ -102,6 +113,27 @@ if ($putAt) { if ($mime === '') { $mime = fb_upload_mime_of_ext($ext); } if (!fb_upload_mime_ok($kind, $mime)) { fb_up_json(['ok' => false, 'error' => '不支持的文件类型。'], 400); } + /* 🔴 关键:用文件真实内容再验一次 MIME,不信前端上报。 + * + * 事故:把 PHP 代码改名成 .png、POST 里写 mime=image/png, + * 就能一路通过所有检查写进 uploads/。 + * 实测确认文件内容就是 ``。 + * 虽然 .htaccess 禁了脚本执行,但那是唯一一道防线 —— + * 换 nginx / 改配置 / 目录迁移,任何一步疏忽就是 RCE。 + * + * 这里在**写盘之前**用 finfo 读文件头魔数, + * 真实类型必须也在白名单里,否则直接拒绝。 + */ + $realMime = fb_upload_detect_mime((string)$f['tmp_name']); + if ($realMime !== '' && !fb_upload_mime_ok($kind, $realMime)) { + fb_up_json([ + 'ok' => false, + 'error' => '文件内容与声明的类型不符(检测到 ' . $realMime . '),已拒绝。', + ], 415); + } + + fb_rate_hit('upload_put'); // 真正落地一次才计数 + // 二次确认总量上限(防止并发绕过) $pdo = fb_db(); $cap = (int)fb_upload_cfg('total_cap_bytes', 0);