Files
FeedLite/upload.php
T
laoyang a850d479f0 安全:修复上传接口三个真实漏洞(可执行文件落盘 / 限流可绕过 / 真上传无限流) (#5)
## 用户提问

「上传视频和图片的接口,有没有限制?会不会所有人通过 POST 都能随意上传?」

## 结论:防护大多到位,但存在三个真实漏洞

已核实有效的:CSRF、扩展名白名单、MIME 白名单、filesize 实测大小、
is_uploaded_file、类型开关、总量上限、uploads 目录 .htaccess 禁直连禁执行、
附件只走 media.php 代理。

但以下三个是真问题:

### 1. 🔴 可执行文件能写进 uploads/(最严重)

MIME 只校验**前端上报的字符串**,从不检测文件真实内容。

实测:把 `<?php system($_GET[c]); ?>` 改名成 .png、POST 里写
`mime=image/png`,一路通过所有检查写入磁盘。已确认真实文件内容:

    00000000: 3c3f 7068 7020 7379 7374 656d 2824 5f47  <?php system($_G

虽然 uploads/.htaccess 禁了脚本执行,但那是**唯一一道防线** ——
换 nginx、改配置、目录迁移,任何一步疏忽就是 RCE。

修法:新增 fb_upload_detect_mime(),用 finfo 读文件头魔数
(getimagesize 兜底),在 **move_uploaded_file 之前**校验真实类型。
不合格返回 415 并拒绝落盘。

### 2. 🔴 伪造 X-Forwarded-For 可无限绕过所有限流

fb_client_ip() 无条件信任 CF-Connecting-IP / X-Forwarded-For / X-Real-IP,
而这些头客户端随便写。

实测(upload_per_hour=2,连发 6 次、每次换个 XFF):

    200,200,200,200,200,200   ← 上限 2 却全放行

影响面不止上传:fb_ip_hash() 用同一个函数,所以发帖限流、验证码频率、
所有基于 IP 的限制全部失效。

修法:默认只用 REMOTE_ADDR(客户端改不了)。新增 trusted_proxies 配置,
只有请求确实来自受信任代理/CDN 时才解析转发头,且只取**最右一跳**
(最左边是用户自己塞的,取最左等于把伪造的当前者)。

### 3. 真上传(action=put)没有限流

fb_rate_hit(upload) 只在领凭证时记一次,真正写盘那一段完全没有
fb_rate_check。拿到一个凭证后可反复 POST 覆盖写入。

修法:真上传独立限流 upload_put(与领凭证分开计数,避免互相挤占),
落地成功后才 fb_rate_hit。

## 验证:fb_upload_security.py,19 项

正常代码 **19/19**,覆盖:
- 无 CSRF 被拒 / 正常访客能传
- 限流生效(上限 3 连发 10 次 → 429 x8)
- 三种转发头都无法绕过
- PHP 伪装落地被拒(415,检测到 text/x-php)+ 未落盘
- .php/.phtml/.svg 全拒
- 存储关闭被拒
- 谎报 size 后落地仍用 filesize 拦住 + 未落盘
- uploads 防护文件完备(Require all denied / -Indexes / 禁脚本扩展)
- media.php 代理正常

## 反向验证(关键)

把三处修复全部回退后重跑,出现 4 项 FAIL:

    OPEN  改 XFF 不能绕过限流      上限2/6次: 200,200,200,200,200,200 放行 6
    OPEN  三种转发头都无法绕过      放行 9
    OPEN  伪装内容落地被拒         put 200
    OPEN  伪装内容没有落盘         磁盘=True      ← PHP 代码真的写进去了

证明测试确实能抓到,不是假通过。

## 测试踩的坑(写进注释)

- 领凭证必须带 ext/size/mime,缺了会返回「不支持的文件格式」,
  容易误判成「格式校验生效了」
- key 是 local/2026/11/x.png,实际落盘在 uploads/2026/11/x.png,
  直接拿 key 拼 FB_ROOT 判断「是否落盘」永远为 false → 假通过
- php -S 内置服务器不读 .htaccess,不能用 HTTP 状态验目录防护,
  改为检查防护文件内容(线上 Apache 实测 /uploads/ → 403)

## 回归

全新安装 33/33、邮件专项 73/73、配置守护 6/6。
2026-11-03 06:56:11 +08:00

248 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 附件上传接口(两种存储方式都走「两步」,前端代码完全一致)
* ------------------------------------------------------------------
* 存储方式由后台设置决定(attachment.storage):off / local / oss。
* 无论 local 还是 oss,浏览器都【不直连存储】,一律两步走本站:
*
* 第一步:POST upload.php kind=&ext=&size=&mime=&csrf=
* → 服务器生成对象键,返回 host=upload.php?action=put 与随传字段。
* 第二步:浏览器把文件 multipart POST 到 upload.php?action=put,服务器校验后落地:
* · local → 存到 uploads/YYYY/MM/ 下;
* · oss → 服务器用实例 RAM 角色的临时凭证,代理 PUT 到【内网 Endpoint】的私有桶。
*
* ★ 为什么 OSS 也要经服务器中转:桶是私有的、且只用内网 Endpoint(外网不可达),
* 浏览器既拿不到可直连的 URL、也连不上内网地址,所以「浏览器直传」在此架构下不成立。
* 读取同理,走 media.php 代理(见该文件)。
*
* ★ 前台地址与存储方式无关:local 与 oss 返回的 url 都是 media.php?k=...,
* 页面/前端 JS 看不到 uploads/ 真实路径,也看不到任何 OSS 痕迹。
*
* 两种方式共用同一套校验:扩展名 / MIME 白名单、单文件大小、每 IP 频率、全站总量上限。
*/
require __DIR__ . '/lib/bootstrap.php';
/** 统一 JSON 输出 */
function fb_up_json($data, $code = 200) {
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
header('X-Content-Type-Options: nosniff');
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
if (strtoupper(isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : 'GET') !== 'POST') {
fb_up_json(['ok' => false, 'error' => 'method not allowed'], 405);
}
$mode = fb_store_mode();
$putAt = (isset($_GET['action']) && (string)$_GET['action'] === 'put');
if ($mode === 'off') {
fb_up_json(['ok' => false, 'error' => '附件上传未启用。'], 403);
}
if ($mode === 'oss' && !fb_oss_enabled()) {
fb_up_json(['ok' => false, 'error' => '对象存储未配置完整,请到后台「站点设置」补齐桶信息。'], 403);
}
if ($mode === 'local' && !fb_local_writable()) {
$ld = fb_local_diag();
fb_up_json(['ok' => false, 'error' => '本机附件目录不可写(uploads/)。' . ($ld['tip'] !== '' ? ' ' . $ld['tip'] : '')], 403);
}
if (!fb_csrf_ok(isset($_POST['csrf']) ? $_POST['csrf'] : '')) {
fb_up_json(['ok' => false, 'error' => '会话已过期,请刷新页面后重试。'], 400);
}
/* ==================================================================
* 接收文件本体并落地(local → 本机磁盘;oss → 服务器代理 PUT 到内网 OSS)
* ------------------------------------------------------------------
* 私有桶 + 内网 Endpoint 下浏览器无法直传,所以 OSS 模式也走这里:
* 浏览器把文件 POST 给本站,本站再用服务器身份 PUT 进 OSS。
* ================================================================== */
if ($putAt) {
if ($mode !== 'local' && $mode !== 'oss') {
fb_up_json(['ok' => false, 'error' => '当前存储方式不支持上传。'], 400);
}
$kind = strtolower(trim(isset($_POST['kind']) ? (string)$_POST['kind'] : ''));
$key = trim(isset($_POST['key']) ? (string)$_POST['key'] : '');
if ($kind !== 'image' && $kind !== 'video') { fb_up_json(['ok' => false, 'error' => '不支持的附件类型。'], 400); }
if (!fb_upload_kind_on($kind)) { fb_up_json(['ok' => false, 'error' => '该类型附件未开放上传。'], 403); }
if (!fb_upload_key_ok($key)) {
fb_up_json(['ok' => false, 'error' => '附件地址不合法,请重新上传。'], 400);
}
// key 的存储类型必须与当前 mode 一致(防止 local 会话塞 oss key 或反之)
$keyIsLocal = fb_attach_is_local($key);
if ($mode === 'local' && !$keyIsLocal) { fb_up_json(['ok' => false, 'error' => '附件地址与当前存储方式不符。'], 400); }
if ($mode === 'oss' && $keyIsLocal) { fb_up_json(['ok' => false, 'error' => '附件地址与当前存储方式不符。'], 400); }
$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);
}
$f = $_FILES['file'];
if (!isset($f['error']) || (int)$f['error'] !== UPLOAD_ERR_OK) {
fb_up_json(['ok' => false, 'error' => '文件上传失败(错误码 ' . (int)(isset($f['error']) ? $f['error'] : -1) . ')。'], 400);
}
if (!is_uploaded_file((string)$f['tmp_name'])) {
fb_up_json(['ok' => false, 'error' => '文件上传失败。'], 400);
}
$size = (int)@filesize($f['tmp_name']);
$maxB = (int)fb_upload_cfg($kind . '_max_bytes', 0);
if ($size <= 0) { fb_up_json(['ok' => false, 'error' => '文件为空。'], 400); }
if ($maxB > 0 && $size > $maxB) {
fb_up_json(['ok' => false, 'error' => '文件超过大小限制(' . fb_size_h($maxB) . ')。'], 400);
}
$mime = strtolower(trim(isset($_POST['mime']) ? (string)$_POST['mime'] : ''));
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/。
* 实测确认文件内容就是 `<?php system($_GET['c']); ?>`。
* 虽然 .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);
if ($cap > 0 && (fb_attach_total_bytes($pdo) + $size) > $cap) {
fb_up_json(['ok' => false, 'error' => '附件存储空间已满,暂时无法上传。'], 507);
}
if ($mode === 'local') {
$path = fb_local_path_of($key);
$dir = dirname($path);
if (!is_dir($dir)) { @mkdir($dir, 0755, true); }
if (!is_dir($dir) || !@move_uploaded_file((string)$f['tmp_name'], $path)) {
fb_up_json(['ok' => false, 'error' => '保存文件失败,请稍后重试。'], 500);
}
@chmod($path, 0644);
} else {
// OSS:服务器代理 PUT 到内网桶(流式,不整文件进内存)
if (!fb_oss_enabled()) {
fb_up_json(['ok' => false, 'error' => '对象存储未配置完整。'], 403);
}
list($ok, $ossErr, $ossStatus) = fb_oss_put_file($key, (string)$f['tmp_name'], $mime);
if (!$ok) {
fb_up_json(['ok' => false, 'error' => '写入对象存储失败:' . $ossErr], 502);
}
}
// 登记「待入库」:让上传后、提交表单前的缩略图预览能通过 media.php 鉴权
fb_attach_pending_mark($key, $kind, $mime, $size);
fb_up_json([
'ok' => true,
'key' => $key,
'url' => fb_attach_url($key),
'size' => $size,
'mime' => $mime,
]);
}
/* ==================================================================
* 领取上传凭证
* ================================================================== */
$kind = strtolower(trim(isset($_POST['kind']) ? (string)$_POST['kind'] : ''));
if ($kind !== 'image' && $kind !== 'video') {
fb_up_json(['ok' => false, 'error' => '不支持的附件类型。'], 400);
}
if (!fb_upload_kind_on($kind)) {
fb_up_json(['ok' => false, 'error' => '该类型附件未开放上传。'], 403);
}
$ext = fb_upload_norm_ext(isset($_POST['ext']) ? (string)$_POST['ext'] : '');
$size = (int)(isset($_POST['size']) ? $_POST['size'] : 0);
$mime = strtolower(trim(isset($_POST['mime']) ? (string)$_POST['mime'] : ''));
if ($mime === '') { $mime = fb_upload_mime_of_ext($ext); }
$maxBytes = (int)fb_upload_cfg($kind . '_max_bytes', 0);
if ($ext === '' || !fb_upload_ext_ok($kind, $ext)) {
fb_up_json(['ok' => false, 'error' => '不支持的文件格式。'], 400);
}
if (!fb_upload_mime_ok($kind, $mime)) {
fb_up_json(['ok' => false, 'error' => '不支持的文件类型。'], 400);
}
if ($size <= 0) {
fb_up_json(['ok' => false, 'error' => '文件为空。'], 400);
}
if ($maxBytes > 0 && $size > $maxBytes) {
fb_up_json(['ok' => false, 'error' => '文件超过大小限制(' . fb_size_h($maxBytes) . ')。'], 400);
}
$limit = (int)fb_upload_cfg('upload_per_hour', 60);
if (!fb_rate_check('upload', $limit)) {
fb_up_json(['ok' => false, 'error' => '上传过于频繁,请稍后再试。'], 429);
}
// 全站总量上限:达到后不再发放凭证
$pdo = fb_db();
$cap = (int)fb_upload_cfg('total_cap_bytes', 0);
if ($cap > 0 && (fb_attach_total_bytes($pdo) + $size) > $cap) {
fb_up_json(['ok' => false, 'error' => '附件存储空间已满,暂时无法上传,请稍后再试。'], 507);
}
/* ---------- 领取上传凭证:local 与 oss 都指向本站 action=put ----------
* 私有桶 + 内网 Endpoint 下浏览器无法直传 OSS,所以两种模式统一为:
* 第一步(这里):服务器生成对象键、返回本站上传地址与随传字段;
* 第二步:浏览器把文件 multipart POST 到 upload.php?action=put,
* 由服务器落地(本机磁盘 / 或代理 PUT 到内网 OSS)。
* 前端 app.js 因此对两种模式走完全相同的代码路径。
*/
if ($mode === 'local') {
$key = fb_local_make_key($ext);
} else {
$key = fb_oss_make_key($ext);
}
fb_rate_hit('upload');
fb_up_json([
'ok' => true,
'credential' => [
'host' => 'upload.php?action=put',
'key' => $key,
'url' => fb_attach_url($key),
],
'fields' => [
'csrf' => fb_csrf_token(),
'key' => $key,
'kind' => $kind,
'mime' => $mime,
],
'mime' => $mime,
'kind' => $kind,
]);