用copilot修复php上传漏洞,要一起检查上传身份、文件内容、保存位置与访问方式。下面以仅允许png的头像入口为例,给出可运行校验测试和带会话校验的上传处理代码。

文件上传完成后,记得让Copilot同步校验访问路径
文件上传风险不止存在于表单校验。若用户能指定保存路径、上传目录允许脚本执行,或者下载接口绕过权限,单独限制后缀仍可能留下问题。先整理上传路由、存储目录、下载处理器及服务器配置。
本文只处理已登录用户上传PNG头像的场景,不扩展到压缩包、文档或任意图片格式。生产项目还需要配额、限流和必要的内容处理,这些要求应在任务开始时交代,不能靠补丁自行猜测。
另外最好额外加两层限制:单个账号最多能传多少个文件、累计总大小不能超过多少,而且用户替换新头像的时候,必须明确写好旧头像的删除逻辑,不然时间长了存储目录里会堆大量没人用的冗余文件。
立即学习“PHP免费学习笔记(深入)”;
后缀、MIME和存储隔离分别解决什么
原始文件名与请求中的Content-Type都由客户端提供,不能直接证明文件内容。服务端检测MIME、检查图片结构与尺寸,可以收紧允许输入的范围,但不是病毒扫描或绝对安全证明。
随机命名减少路径操控和覆盖风险,私有目录则避免上传物被当作站点脚本执行。身份校验和CSRF保护还要在读取文件前完成,防止未授权消耗存储资源。
在VS Code里把整套校验任务拆成4步落地执行
在项目副本中打开Copilot Chat,选择Agent工作方式。把上传控制器、表单和访问文件的代码加入上下文,明确只能审查测试项目,不连接对象存储生产桶。
要求先输出风险清单,逐项回答身份从哪里取得、支持哪些文件、最终路径谁决定、文件如何下载。缺少服务器配置时,标记待确认,不默认上传目录不可执行。
确认方案后让Copilot生成小范围补丁。检查保存路径是否只由服务端生成,错误响应是否泄露绝对路径,是否误删原有会话和权限条件。
先运行校验单元测试,再在测试Web服务发送真实multipart上传。逐条核对正常头像、伪装文本、超限文件和未登录请求;不得把命令行临时文件当成HTTP上传成功证据。
修复当前PHP头像上传入口,只允许登录用户上传PNG。 先检查认证、CSRF、上传错误、真实大小、扩展名、服务端MIME、图片尺寸和最终访问路径。 保存名使用随机值,存储目录必须在Web根目录之外;不要相信原始文件名和客户端MIME。 先给最小修改差异与测试,再列出Nginx/Apache和下载处理器需要人工核验的条件。 不更改生产存储、不开放任意后缀,不把MIME检测描述成病毒扫描。
完整上传处理器实现,附带可直接运行的校验测试代码
下面给出的代码文件,既可以直接在命令行跑单元测试,收到HTTP请求的时候也会自动走上传处理分支。
接入这套逻辑之前,要确保站点本身的登录流程已经正常把用户身份写入会话、能生成随机CSRF令牌,前端上传表单用avatar做文件字段名、csrf做令牌字段名,后台由管理员提前配置好站点根目录外的私有存储路径。
<?php
declare(strict_types=1);
const MAX_BYTES = 2 * 1024 * 1024;
function inspectPng(string $name, string $bytes): string {
if (strlen($bytes) < 1 || strlen($bytes) > MAX_BYTES ||
strtolower(pathinfo($name, PATHINFO_EXTENSION)) !== 'png') {
throw new InvalidArgumentException('Invalid size or extension');
}
$mime = (new finfo(FILEINFO_MIME_TYPE))->buffer($bytes);
$image = @getimagesizefromstring($bytes);
if ($mime !== 'image/png' || $image === false || $image[2] !== IMAGETYPE_PNG ||
$image[0] > 4096 || $image[1] > 4096) {
throw new InvalidArgumentException('Invalid PNG');
}
// 原始文件名不参与保存路径;返回随机服务端名称。
return bin2hex(random_bytes(16)) . '.png';
}
if (!in_array(PHP_SAPI, ['cli', 'phpdbg', 'wasm'], true)) {
// 接入项目时,user_id必须由登录流程写入服务端会话。
session_start();
header('Content-Type: application/json; charset=UTF-8');
try {
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !isset($_SESSION['user_id']) ||
!is_int($_SESSION['user_id']) || $_SESSION['user_id'] < 1 ||
!is_string($_SESSION['csrf'] ?? null) || strlen($_SESSION['csrf']) !== 64 ||
!is_string($_POST['csrf'] ?? null) ||
!hash_equals($_SESSION['csrf'], $_POST['csrf'])) {
http_response_code(403); throw new RuntimeException('Forbidden');
}
$file = $_FILES['avatar'] ?? null;
if (!is_array($file) || ($file['error'] ?? null) !== UPLOAD_ERR_OK ||
!is_string($file['tmp_name'] ?? null) || !is_string($file['name'] ?? null) ||
!is_uploaded_file($file['tmp_name'])) { throw new RuntimeException('Upload failed'); }
$bytes = file_get_contents($file['tmp_name'], false, null, 0, MAX_BYTES + 1);
if ($bytes === false) { throw new RuntimeException('Read failed'); }
$name = inspectPng($file['name'], $bytes);
// 管理员预建私有目录,并确保不在Web文档根目录下。
$configured = getenv('UPLOAD_PRIVATE_DIR');
$dir = is_string($configured) && $configured !== '' ? realpath($configured) : false;
$documentRoot = $_SERVER['DOCUMENT_ROOT'] ?? '';
$root = is_string($documentRoot) && $documentRoot !== '' ? realpath($documentRoot) : false;
if ($dir === false || $root === false || $dir === $root ||
str_starts_with($dir, rtrim($root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR) ||
!is_dir($dir) || !is_writable($dir)) { throw new RuntimeException('Storage unavailable'); }
if (!move_uploaded_file($file['tmp_name'], $dir . DIRECTORY_SEPARATOR . $name)) {
throw new RuntimeException('Move failed');
}
echo json_encode(['id' => $name], JSON_THROW_ON_ERROR);
} catch (Throwable $e) {
if (http_response_code() < 400) { http_response_code(400); }
echo '{"error":"Upload rejected"}'; // 不向用户泄露绝对路径。
}
exit;
}
// CLI教学夹具,不伪造HTTP上传成功。
$png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jp1sAAAAASUVORK5CYII=', true);
if ($png === false) { throw new RuntimeException('Fixture error'); }
$name = inspectPng('avatar.png', $png);
echo 'valid_png: PASS', PHP_EOL;
foreach (['fake_png'=>['a.png','not an image'], 'wrong_extension'=>['a.php',$png],
'oversize'=>['a.png',str_repeat('A',MAX_BYTES+1)]] as $case => [$filename,$data]) {
try { inspectPng($filename, $data); }
catch (InvalidArgumentException $e) { echo $case, ': PASS rejected', PHP_EOL; continue; }
throw new RuntimeException('FAIL: ' . $case);
}
if (!preg_match('/^[a-f0-9]{32}\.png$/D', $name)) { throw new RuntimeException('FAIL: name'); }
echo 'random_name: PASS', PHP_EOL;
echo 'Runtime: PHP ', PHP_VERSION, ' / ', PHP_SAPI, PHP_EOL;
php audit.php
本次实际环境是macOS上的PHP 8.5.10 WebAssembly,已执行内容与命名测试。会话、真实上传来源校验、文件移动和目录权限尚未在Web服务器联调;下图不代表已经完成生产上传验收。

头像上传和文档上传别共用同一份宽松的后缀白名单
| 业务 | 内容处理 | 提供访问 |
|---|---|---|
| PNG头像 | 尺寸限制,按需安全解码后重编码 | 隔离域或受控图片响应 |
| 合同文档 | 独立类型白名单、配额与内容检测 | 鉴权下载,默认附件方式 |
示例没有实现图片重编码与恶意内容扫描,不能据此接收所有业务文件。私有文件的下载也要重新鉴权,服务器不得仅凭猜到文件名就返回内容;文件标识应与数据库中的所属用户对应。
正式上线前重点排查四类校验失效场景
- 正常头像也被拒绝:检查fileinfo扩展、PHP上传限制和PNG策略是否一致。
- 请求中没有文件:核对表单编码及post_max_size,区分传输失败和内容不合格。
- 命令行移动测试失败:move_uploaded_file只接受真正的PHP上传文件,不要用普通临时文件绕过校验。
- 上传成功却能作为脚本访问:立即停止放行,复核真实存储路径与Web服务器映射,不能只靠后缀限制。



















