## 用户提问 「上传视频和图片的接口,有没有限制?会不会所有人通过 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。
248 lines
11 KiB
PHP
248 lines
11 KiB
PHP
<?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,
|
||
]);
|