獲取文件擴展名是上傳驗證、文件處理、路由分發的高頻操作。看似簡單,實則暗藏「多點文件名、無擴展名、大小寫、Unicode、路徑遍歷」等陷阱。本文對比 5 種主流方法,給出生產環境推薦方案與安全加固清單。
| 方法 | 代碼 | 核心函數 | 返回值示例 | 缺陷 |
|---|---|---|---|---|
| 1. pathinfo (推薦) | `pathinfo($filename, PATHINFO_EXTENSION)` | C 內置 | `'jpg'` / `''` | 無明顯缺陷,處理多點、無擴展名、路徑分隔符 |
| 2. SplFileInfo (OO) | `(new SplFileInfo($filename))->getExtension()` | SPL 類 | `'jpg'` / `''` | 需實例化對象,微較慢;但可復用對象獲取更多信息 |
| 3. strrchr + substr | `substr(strrchr($filename, '.'), 1)` | 字符串函數 | `'jpg'` / `false`/`''` | 無點返回 false/空串;目錄含點誤判;無路徑分隔符處理 |
| 4. explode 取末尾 | `end(explode('.', $filename))` | 數組函數 | `'jpg'` / `'filename'` | 無擴展名返回全名;目錄含點誤判;修改內部指針副作用 |
| 5. 正則提取 | `preg_match('/\.([^.]+)$/', $filename, $m) ? $m[1] : ''` | PCRE | `'jpg'` / `''` | 性能最差;需轉義;易寫錯邊界 |
<?php
$testCases = [
'image.jpg' => 'jpg',
'archive.tar.gz' => 'gz', // 多點:取最後一個
'.htaccess' => '', // 隱藏文件:無擴展名
'README' => '', // 無擴展名
'path/to/file.png' => 'png', // 帶路徑
'path.with.dots/file' => '', // 目錄含點
'file.' => '', // 以點結尾
'FILE.JPG' => 'JPG', // 大小寫保持
'文件.中文名.pdf' => 'pdf', // Unicode 文件名
'test.php.jpg' => 'jpg', // 雙擴展名攻擊向量
'../etc/passwd' => '', // 路徑遍歷
'con.jpg' => 'jpg', // Windows 保留名
];
foreach ($testCases as $file => $expected) {
$r1 = pathinfo($file, PATHINFO_EXTENSION);
$r2 = (new SplFileInfo($file))->getExtension();
$r3 = substr(strrchr($file, '.'), 1) ?: '';
$r4 = end(explode('.', $file));
$r5 = preg_match('/\.([^.]+)$/', $file, $m) ? $m[1] : '';
$ok = ($r1===$expected && $r2===$expected && $r3===$expected && $r4===$expected && $r5===$expected) ? '✓' : '✗';
echo "$ok | $file => p:$r1 s:$r2 r:$r3 e:$r4 p:$r5 (exp:$expected)\n";
}
關鍵發現:
<?php
$files = array_fill(0, 1000000, 'very/long/path/to/file.name.with.dots.jpg');
$start = microtime(true);
foreach ($files as $f) pathinfo($f, PATHINFO_EXTENSION);
echo "pathinfo: ".(microtime(true)-$start)."s\n";
$start = microtime(true);
foreach ($files as $f) (new SplFileInfo($f))->getExtension();
echo "SplFileInfo: ".(microtime(true)-$start)."s\n";
$start = microtime(true);
foreach ($files as $f) substr(strrchr($f, '.'), 1) ?: '';
echo "strrchr: ".(microtime(true)-$start)."s\n";
$start = microtime(true);
foreach ($files as $f) end(explode('.', $f));
echo "explode: ".(microtime(true)-$start)."s\n";
$start = microtime(true);
foreach ($files as $f) preg_match('/\.([^.]+)$/', $f, $m) ? $m[1] : '';
echo "preg_match: ".(microtime(true)-$start)."s\n";
典型結果 (PHP 8.2):
pathinfo: 0.18s
SplFileInfo: 0.42s (對象創建開銷)
strrchr: 0.12s (最快,但邊界錯誤)
explode: 0.35s (數組創建開銷)
preg_match: 0.68s (最慢)
function getExtension(string $filename): string {
// 1. 基礎名 (去除目錄)
$basename = basename($filename);
// 2. pathinfo 核心
$ext = pathinfo($basename, PATHINFO_EXTENSION);
// 3. 統一小寫 (可選,根據業務)
return strtolower($ext);
}
function getFileInfo(string $path): array {
$info = new SplFileInfo($path);
return [
'basename' => $info->getBasename(),
'extension' => strtolower($info->getExtension()),
'filename' => $info->getFilename(), // 不含擴展名
'dirname' => $info->getPath(),
'realpath' => $info->getRealPath(),
'size' => $info->getSize(),
'mtime' => $info->getMTime(),
'is_file' => $info->isFile(),
'mime' => mime_content_type($path), // 需 fileinfo 擴展
];
}
function validateUploadExtension(string $filename, array $allowedExts): bool {
$ext = getExtension($filename);
if ($ext === '') return false; // 必須有擴展名
// 白名單 (小寫比較)
$allowed = array_map('strtolower', $allowedExts);
if (!in_array($ext, $allowed, true)) return false;
// 黑名單 (雙重保險)
$dangerous = ['php','phtml','php3','php4','php5','php7','php8','phar','pht','htaccess','exe','bat','sh','js','html','htm'];
if (in_array($ext, $dangerous, true)) return false;
// MIME 類型二次驗證 (需 $_FILES['tmp_name'])
// $mime = mime_content_type($tmpPath);
// $allowedMimes = ['image/jpeg'=>'jpg', 'image/png'=>'png', ...];
// return isset($allowedMimes[$mime]) && $allowedMimes[$mime] === $ext;
return true;
}
| 陷阱 | 風險 | 防禦 |
|---|---|---|
| 雙擴展名 `shell.php.jpg` | Apache `AddHandler` 可能按倒數第二擴展名解析 | 白名單驗證 + 重命名存儲 (UUID + 真實擴展名) + 禁用腳本執行權限 |
| 大小寫繞過 `shell.PhP` | Windows 大小寫不敏感 | 統一 `strtolower` 比較 |
| Null 字節注入 `shell.php%00.jpg` | 舊版 PHP `move_uploaded_file` 截斷 | PHP 5.3.4+ 已修復,仍建議 `str_replace(chr(0), '', $filename)` |
| Unicode 視覺欺騙 `photo.jpg` (隱藏 RTL 字符) | 顯示為 jpg 實為 php | `preg_match('/^[\w.-]+$/', $basename)` 限制字符集 |
| Windows 保留名 `con`, `prn`, `aux`, `nul` | 無法創建/刪除文件 | 重命名存儲,避免直接使用用戶文件名 |
| 路徑遍歷 `../../../etc/passwd` | 讀寫任意文件 | `basename()` + `realpath()` 驗證在允許目錄內 |
文件擴展名獲取是「簡單功能、複雜邊界」的典型代表。建立標準工具函數庫,配合單元測試覆蓋所有邊界用例,將安全驗證前置,才能在惡意上傳攻擊面前立於不敗之地。