跳到内容

PHP中如何实现文件上传 | 验证·存储·安全完全指南

分类:PHP·实现

在PHP中实现文件上传功能时,编写"能运行"的代码很容易,但考虑安全性·验证·错误处理的健壮实现出人意料地复杂。本文系统地阐述了适合生产环境使用的实现方法,从$_FILES的结构开始,涵盖MIME类型验证·扩展名白名单·安全存储路径·文件名清理等内容。

Browser PHP-FPM /tmp uploads/ POST multipart/form-data tmp file write $_FILES populated move_uploaded_file()
图:$_FILES 的处理序列(POST → /tmp → move_uploaded_file)

理解 $_FILES 的结构

当你在 HTML 表单中使用 enctype="multipart/form-data" 提交文件时,PHP 会将上传信息存储在 $_FILES 超全局变量中。如果表单的 name 属性是 userfile$_FILES['userfile'] 将是一个包含以下 5 个键的关联数组。

// $_FILES の構造
[
    'name'     => 'photo.jpg',        // クライアント側のファイル名
    'type'     => 'image/jpeg',       // クライアントが申告するMIMEタイプ
    'tmp_name' => '/tmp/phpA1B2C3',   // サーバーの一時保存パス
    'error'    => 0,                  // エラーコード(0 = 成功)
    'size'     => 204800,             // ファイルサイズ(バイト)
]

作为重要的一点,type是客户端发送的值,可能被伪造。必须在服务器端使用 finfo 来验证MIME类型。

UPLOAD_ERR_* 错误代码列表和解决方案

$_FILES['userfile']['error'] 包含以下任意一个常数值。重要的是在上传处理初期检查此值,并适当处理错误。

常数 含义 解决方案
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)

客户端发送的 $_FILES['userfile']['type'] 可能被伪造,因此服务器必须从实际文件内容确定 MIME 类型。使用 PHP 的 finfo_file()(或 FileInfo 类)。

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('許可されていないファイル形式です。');
}

finfo 通过读取文件的前几个字节(魔数)来判定 MIME 类型,因此可以检测伪造扩展名的攻击。但是,文本文件(CSV、JSON 等)可能被判定为 text/plain,因此建议与扩展名检查结合使用。

扩展名白名单

除了 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 级别的大小检查使用 $_FILES['userfile']['size'] 执行。但是,超过 php.ini 中 upload_max_filesize 限制的文件永远不会到达 $_FILES,而会导致 UPLOAD_ERR_INI_SIZE 错误。因此,重要的是先检查错误代码。

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 之外)

上传的文件应存储在文档根目录(如 public_htmlpublic)之外。存储在 public 目录中会带来被直接通过 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设置)。重要的是不仅要更改upload_max_filesize,还要一起更改post_max_size

; 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

您可以使用 phpinfo()ini_get() 验证设置的应用。

// 現在の設定値を確認
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_* 检查错误代码
  • 使用 finfo 在服务器端判定 MIME 类型
  • 使用白名单方式验证文件扩展名
  • 使用 $_FILES['userfile']['size'] 检查文件大小
  • 保存位置设置在public目录之外
  • 使用 is_uploaded_file() 验证正规上传
  • 使用 move_uploaded_file() 移动文件
  • 使用 uniqid()random_bytes() 生成文件名
  • 在 php.ini 中正确配置 upload_max_filesizepost_max_size

本文中可用的测试文件

❓ 常见问题

PHP 上传文件大小上限的默认值是多少?
php.ini 中 upload_max_filesize 默认为 2MB,post_max_size 为 8MB。
在 PHP 中如何同时上传多个文件?
在 input 上加 multiple 属性,并把 name 写成数组形式 files[],即可通过 $_FILES['files'] 以数组形式接收。
PHP 文件上传在安全上需要注意什么?
关键在于:扩展名白名单校验、用 finfo_file() 确认 MIME 类型,以及把上传目录放在文档根目录之外。

常见问题

PHP上传文件大小限制的默认值是多少?

php.ini 中的 upload_max_filesize 默认值为 2MB。post_max_size 也设置为 8MB。

如何在PHP中同时上传多个文件?

input 标签添加 multiple 属性,并将 name 属性设置为数组格式(files[])。可以使用 $_FILES['files'] 将其作为数组接收。

PHP 文件上传中有哪些安全注意事项?

重要的是使用 finfo_file() 验证文件扩展名白名单、验证 MIME 类型,并将上传文件存储在文档根目录之外。