文件上傳是Web開發高風險操作,涉及類型偽造、路徑遍歷、腳本執行、大小繞過等安全隱患。本文提供完整的前後端代碼,重點講解安全驗證、隨機命名、目錄隔離、錯誤處理等生產級要點。
<form action="upload.php" method="post" enctype="multipart/form-data" id="uploadForm">
<input type="hidden" name="MAX_FILE_SIZE" value="5242880"> <!-- 5MB 客戶端提示 -->
<div class="form-group">
<label>選擇圖片 (JPG/PNG/GIF, <5MB)</label>
<input type="file" name="image" id="image" accept="image/jpeg,image/png,image/gif" required>
<div class="preview" id="preview"></div> <!-- JS預覽 -->
</div>
<button type="submit" class="btn btn-primary">上傳</button>
<div class="progress" style="display:none"><div class="progress-bar" id="progressBar"></div></div>
</form>
<script>
// 簡單預覽與進度條 (需配合 XMLHttpRequest 或 fetch)
document.getElementById('image').addEventListener('change', function(e) {
const file = e.target.files[0];
if (file) {
document.getElementById('preview').innerHTML = '<img src="'+URL.createObjectURL(file)+'" style="max-width:200px">';
}
});
</script>
<?php
// === 配置區 ===
const UPLOAD_ROOT = __DIR__ . '/uploads'; // 存儲根目錄(非Web可訪問目錄更安全)
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
const ALLOWED_MIME = ['image/jpeg', 'image/png', 'image/gif'];
const ALLOWED_EXT = ['jpg', 'jpeg', 'png', 'gif'];
// === 初始化 ===
header('Content-Type: application/json; charset=utf-8');
$response = ['code' => 0, 'msg' => '', 'data' => null];
// 請求方法檢查
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$response['msg'] = '僅支持POST請求';
exit(json_encode($response));
}
// 文件上傳檢查
if (!isset($_FILES['image']) || $_FILES['image']['error'] !== UPLOAD_ERR_OK) {
$errors = [
UPLOAD_ERR_INI_SIZE => '超過php.ini upload_max_filesize限制',
UPLOAD_ERR_FORM_SIZE => '超過表單MAX_FILE_SIZE限制',
UPLOAD_ERR_PARTIAL => '文件部分上傳',
UPLOAD_ERR_NO_FILE => '未選擇文件',
UPLOAD_ERR_NO_TMP_DIR => '缺少臨時目錄',
UPLOAD_ERR_CANT_WRITE => '寫入磁盤失敗',
UPLOAD_ERR_EXTENSION => '擴展中斷上傳',
];
$response['msg'] = $errors[$_FILES['image']['error'] ?? '未知上傳錯誤'];
exit(json_encode($response));
}
$file = $_FILES['image'];
// 1. 文件大小二次檢查 (防繞過前端)
if ($file['size'] > MAX_SIZE) {
$response['msg'] = '文件大小超過限制 (5MB)';
exit(json_encode($response));
}
// 2. MIME類型檢測 (finfo 比 $_FILES['type'] 可靠)
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
if (!in_array($mime, ALLOWED_MIME, true)) {
$response['msg'] = '文件類型不允許,僅支持 JPG/PNG/GIF';
exit(json_encode($response));
}
// 3. 擴展名驗證 (雙重保險)
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($ext, ALLOWED_EXT, true)) {
$response['msg'] = '文件擴展名不允許';
exit(json_encode($response));
}
// 4. 圖片完整性驗證 (防偽裝腳本)
$imgInfo = @getimagesize($file['tmp_name']);
if (!$imgInfo || !in_array($imgInfo[2], [IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_GIF])) {
$response['msg'] = '非法圖片文件或文件損壞';
exit(json_encode($response));
}
// 5. 生成安全存儲路徑
$dateDir = date('Y/m/d'); // 按日期分目錄,避免單目錄文件過多
$saveDir = UPLOAD_ROOT . '/' . $dateDir;
if (!is_dir($saveDir)) {
if (!mkdir($saveDir, 0755, true)) {
$response['msg'] = '創建存儲目錄失敗';
exit(json_encode($response));
}
// 安全:禁止腳本執行
file_put_contents($saveDir . '/.htaccess', "SetHandler default-handler\nOptions -ExecCGI\nphp_flag engine off");
}
$filename = bin2hex(random_bytes(16)) . '.' . $ext; // 32位隨機十六進制名
$savePath = $saveDir . '/' . $filename;
$webPath = '/uploads/' . $dateDir . '/' . $filename; // Web訪問路徑
// 6. 移動文件 (move_uploaded_file 防止臨時文件殘留)
if (!move_uploaded_file($file['tmp_name'], $savePath)) {
$response['msg'] = '文件保存失敗';
exit(json_encode($response));
}
// 7. 設置權限 (僅讀,防篡改)
chmod($savePath, 0644);
// 8. 可選:生成縮略圖、記錄數據庫、掃描病毒...
// generate_thumb($savePath);
// db_insert('uploads', ['path'=>$webPath, 'mime'=>$mime, 'size'=>$file['size']]);
// 成功響應
$response['code'] = 1;
$response['msg'] = '上傳成功';
$response['data'] = [
'url' => $webPath,
'path' => $savePath,
'size' => $file['size'],
'mime' => $mime,
'width' => $imgInfo[0],
'height' => $imgInfo[1],
];
echo json_encode($response);
| 風險 | 防護措施 | 代碼體現 |
|---|---|---|
| 類型偽造 | finode + getimagesize 雙重驗證 | $finfo->file() + getimagesize() |
| 腳本執行 | 存儲目錄禁用PHP/腳本執行 + 隨機文件名 | .htaccess + random_bytes() |
| 路徑遍歷 | basename + 固定目錄結構,不使用用戶輸入路徑 | $dateDir 固定格式 |
| 大小繞過 | 服務端二次檢查 $_FILES['size'] + php.ini 限制 | MAX_SIZE 常量 |
| 文件殘留 | move_uploaded_file 自動清理臨時文件 | move_uploaded_file() |
| 併發衝突 | 隨機文件名 (16字節 = 2^128 空間) | bin2hex(random_bytes(16)) |