PHP中如何实现文件上传 | 验证·存储·安全完全指南
在PHP中实现文件上传功能时,编写"能运行"的代码很容易,但考虑安全性·验证·错误处理的健壮实现出人意料地复杂。本文系统地阐述了适合生产环境使用的实现方法,从<code>$_FILES</code>的结构开始,涵盖MIME类型验证·扩展名白名单·安全存储路径·文件名清理等内容。
理解 <code>$_FILES</code> 的结构
当你在 HTML 表单中使用 <code>enctype="multipart/form-data"</code> 提交文件时,PHP 会将上传信息存储在 <code>$_FILES</code> 超全局变量中。如果表单的 <code>name</code> 属性是 <code>userfile</code>,<code>$_FILES['userfile']</code> 将是一个包含以下 5 个键的关联数组。
// $_FILES の構造
[
'name' => 'photo.jpg', // クライアント側のファイル名
'type' => 'image/jpeg', // クライアントが申告するMIMEタイプ
'tmp_name' => '/tmp/phpA1B2C3', // サーバーの一時保存パス
'error' => 0, // エラーコード(0 = 成功)
'size' => 204800, // ファイルサイズ(バイト)
]
作为重要的一点,<code>type</code>是客户端发送的值,可能被伪造。必须在服务器端使用 <code>finfo</code> 来验证MIME类型。
UPLOAD_ERR_* 错误代码列表和解决方案
<code>$_FILES['userfile']['error']</code> 包含以下任意一个常数值。重要的是在上传处理初期检查此值,并适当处理错误。
| 常数 | 值 | 含义 | 解决方案 |
|---|---|---|---|
UPLOAD_ERR_OK |
0 | 上传成功 | 继续处理 |
UPLOAD_ERR_INI_SIZE |
1 | 超过了 php.ini 中的 upload_max_filesize | 检查 php.ini 配置 |
UPLOAD_ERR_FORM_SIZE |
2 | 超过 HTML 表单的 MAX_FILE_SIZE | 检查表单设置 |
UPLOAD_ERR_PARTIAL |
3 | 文件只部分传输 | 提示重新上传 |
UPLOAD_ERR_NO_FILE |
4 | 未选择文件 | 提示文件选择 |
UPLOAD_ERR_NO_TMP_DIR |
6 | 临时目录不存在 | 检查服务器设置 |
UPLOAD_ERR_CANT_WRITE |
7 | 磁盘写入失败 | 检查磁盘空间和权限 |
UPLOAD_ERR_EXTENSION |
8 | 由 PHP 扩展停止的上传 | 验证扩展模块配置 |
function getUploadErrorMessage(int $errorCode): string
{
return match ($errorCode) {
UPLOAD_ERR_INI_SIZE => 'ファイルサイズがサーバーの上限を超えています。',
UPLOAD_ERR_FORM_SIZE => 'ファイルサイズがフォームの上限を超えています。',
UPLOAD_ERR_PARTIAL => 'ファイルが完全にアップロードされませんでした。再度お試しください。',
UPLOAD_ERR_NO_FILE => 'ファイルが選択されていません。',
UPLOAD_ERR_NO_TMP_DIR => 'サーバーエラー: 一時ディレクトリが見つかりません。',
UPLOAD_ERR_CANT_WRITE => 'サーバーエラー: ファイルの書き込みに失敗しました。',
UPLOAD_ERR_EXTENSION => 'サーバーエラー: 拡張機能によりアップロードが拒否されました。',
default => '不明なエラーが発生しました。',
};
}
MIME 类型验证(使用 finfo)
客户端发送的 <code>$_FILES['userfile']['type']</code> 可能被伪造,因此服务器必须从实际文件内容确定 MIME 类型。使用 PHP 的 <code>finfo_file()</code>(或 <code>FileInfo</code> 类)。
function validateMimeType(string $tmpPath, array $allowedMimes): bool
{
$finfo = new finfo(FILEINFO_MIME_TYPE);
$detectedMime = $finfo->file($tmpPath);
return in_array($detectedMime, $allowedMimes, true);
}
// 使用例: 画像ファイルのみ許可
$allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!validateMimeType($_FILES['userfile']['tmp_name'], $allowedMimes)) {
throw new RuntimeException('許可されていないファイル形式です。');
}
<code>finfo</code> 通过读取文件的前几个字节(魔数)来判定 MIME 类型,因此可以检测伪造扩展名的攻击。但是,文本文件(CSV、JSON 等)可能被判定为 <code>text/plain</code>,因此建议与扩展名检查结合使用。
扩展名白名单
除了 MIME 类型验证外,还应对文件扩展名进行白名单检查。白名单(仅列出允许的内容)比黑名单(禁止 .php、.exe 等)更安全。
function validateExtension(string $originalName, array $allowedExtensions): bool
{
// pathinfo() で拡張子を取得し、小文字に正規化
$extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
return in_array($extension, $allowedExtensions, true);
}
// 使用例
$allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
if (!validateExtension($_FILES['userfile']['name'], $allowedExtensions)) {
throw new RuntimeException('許可されていない拡張子です。');
}
// MIMEタイプと拡張子の対応を明示的にマッピングする方法
$mimeToExtensions = [
'image/jpeg' => ['jpg', 'jpeg'],
'image/png' => ['png'],
'image/gif' => ['gif'],
'image/webp' => ['webp'],
];
$finfo = new finfo(FILEINFO_MIME_TYPE);
$detectedMime = $finfo->file($_FILES['userfile']['tmp_name']);
$extension = strtolower(pathinfo($_FILES['userfile']['name'], PATHINFO_EXTENSION));
if (
!isset($mimeToExtensions[$detectedMime]) ||
!in_array($extension, $mimeToExtensions[$detectedMime], true)
) {
throw new RuntimeException('ファイル形式と拡張子が一致しません。');
}
文件大小限制的代码示例
PHP 级别的大小检查使用 <code>$_FILES['userfile']['size']</code> 执行。但是,超过 php.ini 中 <code>upload_max_filesize</code> 限制的文件永远不会到达 <code>$_FILES</code>,而会导致 <code>UPLOAD_ERR_INI_SIZE</code> 错误。因此,重要的是先检查错误代码。
const MAX_UPLOAD_SIZE = 10 * 1024 * 1024; // 10 MiB
function validateFileSize(int $fileSize, int $maxSize = MAX_UPLOAD_SIZE): bool
{
return $fileSize > 0 && $fileSize <= $maxSize;
}
// 使用例
if (!validateFileSize($_FILES['userfile']['size'])) {
$maxMiB = MAX_UPLOAD_SIZE / (1024 * 1024);
throw new RuntimeException("ファイルサイズが上限({$maxMiB} MiB)を超えています。");
}
// アップロード処理を関数にまとめた例
function processUpload(array $file): array
{
// 1. エラーコード確認
if ($file['error'] !== UPLOAD_ERR_OK) {
throw new RuntimeException(getUploadErrorMessage($file['error']));
}
// 2. サイズチェック
if (!validateFileSize($file['size'])) {
throw new RuntimeException('ファイルサイズが上限を超えています。');
}
// 3. MIMEタイプチェック
$allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!validateMimeType($file['tmp_name'], $allowedMimes)) {
throw new RuntimeException('許可されていないファイル形式です。');
}
// 4. 拡張子チェック
$allowedExts = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
if (!validateExtension($file['name'], $allowedExts)) {
throw new RuntimeException('許可されていない拡張子です。');
}
return $file;
}
安全的存储位置(public 之外)
上传的文件应存储在文档根目录(如 <code>public_html</code> 或 <code>public</code>)之外。存储在 <code>public</code> 目录中会带来被直接通过 URL 访问或作为 PHP 文件执行的风险。
// 推奨ディレクトリ構成
// /var/www/
// ├── public/ ← ドキュメントルート(外部からアクセス可能)
// │ └── index.php
// └── storage/ ← public の外(外部から直接アクセス不可)
// └── uploads/
// 定数で保存先を明示
define('UPLOAD_DIR', dirname(__DIR__) . '/storage/uploads/');
// ディレクトリが存在しない場合は作成
if (!is_dir(UPLOAD_DIR)) {
mkdir(UPLOAD_DIR, 0755, true);
}
// ダウンロード提供時は PHP 経由でストリーミング
function serveFile(string $filename): void
{
$filePath = UPLOAD_DIR . basename($filename);
if (!file_exists($filePath)) {
http_response_code(404);
exit;
}
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($filePath);
header('Content-Type: ' . $mimeType);
header('Content-Length: ' . filesize($filePath));
header('Content-Disposition: attachment; filename="' . basename($filename) . '"');
readfile($filePath);
exit;
}
ファイル名のサニタイズ(uniqid使用)
クライアントが送信したファイル名をそのまま使用するのは危険です。ディレクトリトラバーサル(../../etc/passwd のようなパス)や、特殊文字を含むファイル名によるOSコマンドインジェクションのリスクがあります。uniqid() を使ってユニークなファイル名を生成し、元のファイル名は別途データベースなどに記録する方法が安全です。
function generateSafeFilename(string $originalName): string
{
// 拡張子のみ元のファイルから引き継ぐ
$extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
// uniqid() + mt_rand() でユニークな名前を生成
// more_entropy=true で精度を上げる
$uniqueName = uniqid('upload_', true);
// さらにランダム性を加える場合は bin2hex(random_bytes(8)) を使う
// $uniqueName = bin2hex(random_bytes(16));
return $uniqueName . '.' . $extension;
}
// 使用例
$safeFilename = generateSafeFilename($_FILES['userfile']['name']);
$savePath = UPLOAD_DIR . $safeFilename;
// is_uploaded_file() で正規のアップロードか確認してから移動
if (!is_uploaded_file($_FILES['userfile']['tmp_name'])) {
throw new RuntimeException('不正なファイルアップロードを検出しました。');
}
if (!move_uploaded_file($_FILES['userfile']['tmp_name'], $savePath)) {
throw new RuntimeException('ファイルの保存に失敗しました。');
}
// 元のファイル名、保存名、MIMEタイプをDBに記録
// db_insert(['original_name' => $_FILES['userfile']['name'], 'saved_name' => $safeFilename, ...]);
php.ini 設定(upload_max_filesize / post_max_size)
要在PHP中接受文件上传,必须正确配置php.ini(或 .htaccess / 本地php.ini设置)。重要的是不仅要更改<code>upload_max_filesize</code>,还要一起更改<code>post_max_size</code>。
; php.ini の設定
; ファイルアップロードを有効化
file_uploads = On
; 1ファイルあたりの上限(M = MiB 単位)
upload_max_filesize = 20M
; POSTリクエスト全体の上限
; upload_max_filesize より大きく設定する(フォームデータのオーバーヘッド分)
post_max_size = 25M
; 最大実行時間(大きなファイルのアップロードに対応)
max_execution_time = 300
; 最大入力時間(アップロードの読み込み時間)
max_input_time = 300
; メモリ上限(post_max_size より大きくする)
memory_limit = 128M
您可以使用 <code>phpinfo()</code> 或 <code>ini_get()</code> 验证设置的应用。
// 現在の設定値を確認
echo ini_get('upload_max_filesize'); // 例: "20M"
echo ini_get('post_max_size'); // 例: "25M"
// バイト単位に変換するユーティリティ
function convertToBytes(string $value): int
{
$value = trim($value);
$last = strtolower($value[-1]);
$num = (int) $value;
return match ($last) {
'g' => $num * 1024 * 1024 * 1024,
'm' => $num * 1024 * 1024,
'k' => $num * 1024,
default => $num,
};
}
总结:安全文件上传处理检查清单
- 使用 UPLOAD_ERR_* 检查错误代码
- 使用 <code>finfo</code> 在服务器端判定 MIME 类型
- 使用白名单方式验证文件扩展名
- 使用 <code>$_FILES['userfile']['size']</code> 检查文件大小
- 保存位置设置在public目录之外
- 使用 <code>is_uploaded_file()</code> 验证正规上传
- 使用 <code>move_uploaded_file()</code> 移动文件
- 使用 <code>uniqid()</code> 或 <code>random_bytes()</code> 生成文件名
- 在 php.ini 中正确配置 <code>upload_max_filesize</code> 和 <code>post_max_size</code>
本文中可用的测试文件
- <a href="/ja/files/threshold/" class="text-primary-600 dark:text-primary-400 hover:underline">边界值测试文件列表</a> — 最适合验证 upload_max_filesize 设置值前后
- <a href="/ja/files/threshold/10mb/" class="text-primary-600 dark:text-primary-400 hover:underline">10MB 边界值测试集合</a> — 正好·直前·直后的 3 文件集合
- <a href="/ja/files/broken/" class="text-primary-600 dark:text-primary-400 hover:underline">损坏文件·非法文件列表</a> — 使用 MIME 类型伪装·扩展名伪装文件测试验证
- <a href="/ja/files/images/png/" class="text-primary-600 dark:text-primary-400 hover:underline">PNG 测试图像列表</a> — 用于图像上传的 MIME 类型验证
相关文章
- <a href="/ja/blog/how-to-test-upload-limit/" class="text-primary-600 dark:text-primary-400 hover:underline">如何正确测试文件上传大小限制</a>
- <a href="/ja/blog/file-validation-checklist/" class="text-primary-600 dark:text-primary-400 hover:underline">Web表单文件验证实现检查清单</a>
- <a href="/ja/blog/multipart-form-data-overhead/" class="text-primary-600 dark:text-primary-400 hover:underline">准确计算 multipart/form-data 开销</a>
- <a href="/ja/blog/s3-upload-limit/" class="text-primary-600 dark:text-primary-400 hover:underline">AWS S3·CloudFront 文件上传上限汇总</a>