<form action="" method="post" enctype="multipart/form-data">
檔案上傳:<input type="file" name="file" id="file">
<input type="submit" id="send" value="提交">
</form>
<script>
document.getElementById('send').onclick = function(e) {
const fileInput = document.getElementById('file');
if (!fileInput.value) { // 僅檢查文件名非空
alert('請選擇檔案');
return false; // 阻止提交
}
// 可選:客戶端檢查副檔名/大小 (易偽造)
const allowed = ['.jpg', '.jpeg', '.png', '.gif', '.pdf'];
const ext = fileInput.value.substring(fileInput.value.lastIndexOf('.')).toLowerCase();
if (!allowed.includes(ext)) {
alert('僅允許圖片/PDF 檔案');
return false;
}
// 檢查大小 (HTML5 File API)
if (fileInput.files[0].size > 5 * 1024 * 1024) { // 5MB
alert('檔案不得超過 5MB');
return false;
}
return true; // 允許提交
};
</script>
<?php
// 判斷是否有文件上傳 (關鍵:檢查 tmp_name 或 error 碼)
if (!isset($_FILES['file']) || !is_uploaded_file($_FILES['file']['tmp_name'])) {
die('請選擇上傳檔案');
}
$file = $_FILES['file'];
// 錯誤碼檢查 (UPLOAD_ERR_OK === 0)
if ($file['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 => '被 PHP 擴展阻止',
];
die($errors[$file['error']] ?? '未知上傳錯誤');
}
// 大小限制 (雙重保險)
$maxSize = 5 * 1024 * 1024; // 5MB
if ($file['size'] > $maxSize) {
die('檔案過大');
}
?>
不可信 `$_FILES['file']['type']` (瀏覽器根據副檔名猜測,極易偽造)。
<?php
$allowedMimes = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'application/pdf',
];
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']); // 真實 MIME
if (!in_array($mime, $allowedMimes, true)) {
die('檔案類型不允許: ' . htmlspecialchars($mime));
}
// 可選:副檔名與 MIME 一致性檢查
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$mimeToExt = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
'application/pdf' => 'pdf',
];
if (($mimeToExt[$mime] ?? '') !== $ext) {
die('副檔名與檔案內容不符');
}
?>
<?php
function detectMimeByMagic(string $path): string {
$fp = fopen($path, 'rb');
$header = fread($fp, 16); // 讀前 16 字節足夠大多數格式
fclose($fp);
$signatures = [
// [偏移 => [十六進制特徵 => MIME]]
0 => [
'FFD8FF' => 'image/jpeg',
'89504E470D0A1A0A' => 'image/png',
'474946383761' => 'image/gif', // GIF87a
'474946383961' => 'image/gif', // GIF89a
'52494646' => 'image/webp', // RIFF....WEBP (需進一步確認)
'25504446' => 'application/pdf', // %PDF
],
8 => [ // WebP 在偏移 8 處有 WEBP
'57454250' => 'image/webp',
],
];
$hex = bin2hex($header);
foreach ($signatures as $offset => $patterns) {
$sub = substr($hex, $offset * 2); // 十六進制字串偏移
foreach ($patterns as $sig => $mime) {
if (str_starts_with($sub, $sig)) return $mime;
}
}
return 'application/octet-stream';
}
// 使用
$mime = detectMimeByMagic($file['tmp_name']);
if (!in_array($mime, $allowedMimes)) die('類型不允許');
?>
<?php
// 1. 生成安全文件名 (防覆蓋、防路徑遍歷、防執行)
$ext = $mimeToExt[$mime] ?? 'bin';
$safeName = bin2hex(random_bytes(16)) . '.' . $ext; // 32位隨機十六進制
// 2. 存儲目錄 (非 Web 可訪問目錄最佳,或配置 Web 服務器禁止腳本執行)
$uploadDir = __DIR__ . '/../storage/uploads/'; // 絕對路徑
if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);
$destPath = $uploadDir . $safeName;
// 3. 移動文件 (is_uploaded_file 已驗證來源)
if (!move_uploaded_file($file['tmp_name'], $destPath)) {
die('文件保存失敗');
}
// 4. 設置權限 (去除執行權限)
chmod($destPath, 0644);
// 5. 記錄資料庫
// INSERT INTO uploads (original_name, stored_name, mime, size, user_id) VALUES ...
echo "上傳成功: {$safeName}";
?>
用戶提交表單
↓
前端 JS 檢查 (體驗優化,可繞過)
↓
後端 PHP:
1. $_FILES 存在性 + is_uploaded_file()
2. error 碼檢查
3. 大小限制
4. 【核心】finfo/magic bytes 真實 MIME 偵測
5. 副檔名一致性檢查
6. 生成安全隨機文件名
7. move_uploaded_file() 到非執行目錄
8. chmod 0644
9. 入庫記錄
↓
返回結果