文件处理是Web开发中最常见的需求之一,用户头像上传、Excel批量导入、附件下载、日志文件读取......几乎每个系统都绕不开。
但文件操作也是最容易出问题的环节。随便搜一下"文件上传漏洞",能看到的案例多到吓人:任意文件上传Getshell、路径穿越下载敏感文件、大文件耗尽内存导致宕机......
这篇文章不讲那些高大上的理论,就用最朴实的代码,一步步带你实现一个安全、健壮、功能完整的PHP文件处理模块。从文件上传、下载,到CSV读取、大文件处理,所有代码都经过实际检验。
一、文件上传:从入门到安全
1.1 最简单的文件上传表单
先写一个最基础的上传页面 upload.html:
html
<!DOCTYPE html>
<html>
<head>
<title>文件上传</title>
</head>
<body>
<form action="upload.php" method="post" enctype="multipart/form-data">
<input type="file" name="file" required>
<button type="submit">上传</button>
</form>
</body>
</html>
注意:enctype="multipart/form-data" 是必须的,否则文件传不过来。
1.2 基础接收代码(千万不要直接用!)
php
<?php
// upload.php - 极度不安全的版本,仅用于演示
$targetDir = 'uploads/';
$targetFile = $targetDir . $_FILES['file']['name'];
if (move_uploaded_file($_FILES['file']['tmp_name'], $targetFile)) {
echo '上传成功';
} else {
echo '上传失败';
}
?>
这段代码能跑,但谁用谁踩坑:
-
文件名直接拼接,用户可以传 ../../etc/passwd 路径穿越
-
没限制文件类型,用户可以传 .php 一句话木马
-
没限制文件大小,用户可以传几个G撑爆磁盘
-
没检查文件内容,图片马可以绕过简单检查
1.3 安全的上传实现
下面是一个相对安全的版本,我会逐行解释为什么这么写:
php
<?php
// upload.php - 安全版本
session_start();
// 1. 登录检查(根据需要开启)
// if (!isset($_SESSION['user_id'])) {
// die('请先登录');
// }
// 2. 配置参数
$targetDir = 'uploads/';
$maxFileSize = 5 * 1024 * 1024; // 5MB
$allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf'];
$allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'pdf'];
// 3. 创建目录(如果不存在)
if (!file_exists($targetDir)) {
mkdir($targetDir, 0755, true);
}
// 4. 基础检查
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
die('非法请求');
}
if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
$errors = [
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 => '扩展阻止了上传'
];
$errorMsg = $errors[$_FILES['file']['error']] ?? '未知错误';
die('上传失败:' . $errorMsg);
}
$file = $_FILES['file'];
// 5. 大小检查
if ($file['size'] > $maxFileSize) {
die('文件不能超过5MB');
}
// 6. 获取真实文件类型(用finfo,别信$_FILES['type'])
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!in_array($mimeType, $allowedTypes)) {
die('文件类型不允许:' . $mimeType);
}
// 7. 获取扩展名并检查
$extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($extension, $allowedExtensions)) {
die('文件扩展名不允许');
}
// 8. 生成安全的新文件名
$newFilename = uniqid() . '_' . bin2hex(random_bytes(8)) . '.' . $extension;
$targetPath = $targetDir . $newFilename;
// 9. 移动文件
if (move_uploaded_file($file['tmp_name'], $targetPath)) {
// 保存到数据库(如果需要)
// $pdo->prepare("INSERT INTO files (user_id, filename, original_name, size, mime_type) VALUES (?, ?, ?, ?, ?)")->execute([$userId, $newFilename, $file['name'], $file['size'], $mimeType]);
echo '上传成功,文件名:' . $newFilename;
} else {
die('移动文件失败');
}
?>
关键点解释:
-
用
finfo检测真实MIME类型:$_FILES['file']['type'] 是客户端提供的,可以随便伪造。一个 .php 文件伪装成 image/jpeg 太简单了。 -
文件名重命名:永远不要用用户提供的文件名,可能包含路径穿越(../../)或恶意脚本。用 uniqid() + 随机字符串生成新名字,扩展名从原文件提取后校验。
-
检查上传错误码:$_FILES['file']['error'] 会告诉我们上传过程中是否出错(比如超过 post_max_size)。
-
大小检查:除了代码里检查,还要在 php.ini 设置 upload_max_filesize 和 post_max_size。
1.4 php.ini 相关配置
bash
; 允许的最大上传文件大小
upload_max_filesize = 10M
; 允许的最大POST数据大小(必须大于upload_max_filesize)
post_max_size = 12M
; 允许同时上传的文件数
max_file_uploads = 20
; 脚本最大执行时间(上传大文件时需要)
max_execution_time = 300
; 脚本最大消耗内存
memory_limit = 128M
二、文件下载:安全地输出文件
2.1 基础下载(同样有坑)
php
<?php
// download.php - 不安全的版本
$file = $_GET['file'];
readfile('uploads/' . $file);
这段代码的问题:
-
用户可以传 ../../config.php 下载敏感文件
-
没有检查文件是否存在
-
输出时没有设置正确的Header,浏览器可能直接显示乱码
2.2 安全的下载实现
php
<?php
// download.php - 安全版本
session_start();
// 1. 登录检查
if (!isset($_SESSION['user_id'])) {
die('请先登录');
}
// 2. 获取并验证文件名
$filename = $_GET['file'] ?? '';
if (empty($filename) || strpos($filename, '/') !== false || strpos($filename, '\\') !== false) {
die('非法文件名');
}
// 3. 构建完整路径
$baseDir = __DIR__ . '/uploads/';
$filepath = $baseDir . $filename;
// 4. 安全检查:确保文件在uploads目录内
$realPath = realpath($filepath);
if ($realPath === false || strpos($realPath, $baseDir) !== 0) {
die('文件不存在或非法路径');
}
// 5. 检查文件是否存在
if (!file_exists($realPath)) {
die('文件不存在');
}
// 6. 获取文件信息
$fileSize = filesize($realPath);
$fileExt = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
// 7. 根据扩展名设置MIME类型
$mimeTypes = [
'pdf' => 'application/pdf',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'txt' => 'text/plain',
'csv' => 'text/csv',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
];
$contentType = $mimeTypes[$fileExt] ?? 'application/octet-stream';
// 8. 设置下载头
header('Content-Description: File Transfer');
header('Content-Type: ' . $contentType);
header('Content-Disposition: attachment; filename="' . basename($filename) . '"');
header('Content-Length: ' . $fileSize);
header('Cache-Control: private, max-age=0, must-revalidate');
header('Pragma: public');
// 9. 清空输出缓冲区
ob_clean();
flush();
// 10. 输出文件内容
readfile($realPath);
exit;
?>
关键点:
-
路径规范化:用 realpath() 获取实际路径,并检查是否在 uploads 目录内,防止 ../../ 攻击。
-
正确的MIME类型:根据扩展名设置对应类型,避免浏览器乱码。未知类型用 application/octet-stream。
-
Header设置:Content-Disposition: attachment 强制下载,而不是在浏览器里打开。
2.3 防盗链实现
如果只允许本站访问下载链接:
php
// 在 download.php 开头添加
$referer = $_SERVER['HTTP_REFERER'] ?? '';
$allowedDomain = 'yourdomain.com';
if (!strpos($referer, $allowedDomain)) {
die('非法访问');
}
三、大文件处理:别把内存撑爆
3.1 逐行读取大文件
处理几百MB甚至GB级别的日志文件时,file_get_contents() 和 file() 会一次性把文件读入内存,直接导致内存溢出。
正确做法是逐行读取:
php
<?php
function readLargeFile($filepath, $callback) {
$handle = fopen($filepath, 'r');
if ($handle === false) {
throw new Exception('无法打开文件');
}
$lineNumber = 0;
while (($line = fgets($handle)) !== false) {
// 处理每一行
$callback($line, $lineNumber);
$lineNumber++;
// 可选:每处理1000行释放一下内存
if ($lineNumber % 1000 == 0) {
gc_collect_cycles();
}
}
fclose($handle);
}
// 使用示例
readLargeFile('logs/access.log', function($line, $num) {
echo "第{$num}行: " . htmlspecialchars($line) . "<br>";
});
?>
3.2 分块读取
如果需要对文件内容做更精细的控制,可以指定每次读取的字节数:
php
function readInChunks($filepath, $chunkSize = 8192) {
$handle = fopen($filepath, 'rb');
if ($handle === false) {
return;
}
while (!feof($handle)) {
$chunk = fread($handle, $chunkSize);
// 处理chunk
yield $chunk;
}
fclose($handle);
}
// 使用生成器逐块处理
foreach (readInChunks('largefile.zip') as $chunk) {
// 可以边读边写,或者计算hash等
$hash = hash_update($ctx, $chunk);
}
3.3 大文件下载优化
如果用户下载大文件,直接用 readfile() 也可能占用大量内存。更好的做法是用 fpassthru() 或手动分块输出:'
php
function downloadLargeFile($filepath) {
$handle = fopen($filepath, 'rb');
if ($handle === false) {
return false;
}
// 设置Headers
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . basename($filepath) . '"');
header('Content-Length: ' . filesize($filepath));
// 一次输出8KB
while (!feof($handle)) {
echo fread($handle, 8192);
flush(); // 确保立即发送
}
fclose($handle);
}
四、CSV文件处理:批量导入导出的核心
4.1 安全读取CSV
php
<?php
function importCsv($filepath) {
$handle = fopen($filepath, 'r');
if ($handle === false) {
throw new Exception('无法打开文件');
}
// 读取标题行(可选)
$headers = fgetcsv($handle);
if ($headers === false) {
fclose($handle);
throw new Exception('空文件或格式错误');
}
$data = [];
$rowNumber = 1; // 跳过标题行
while (($row = fgetcsv($handle)) !== false) {
$rowNumber++;
// 检查列数是否匹配
if (count($row) !== count($headers)) {
// 可以记录错误日志,跳过这一行
error_log("第{$rowNumber}行列数不匹配");
continue;
}
// 组合关联数组
$item = array_combine($headers, $row);
// 数据清洗
foreach ($item as $key => $value) {
// 去除首尾空格
$value = trim($value);
// 防止CSV注入(如果值以=+-@开头,可能被Excel执行公式)
if (in_array(substr($value, 0, 1), ['=', '+', '-', '@'])) {
$value = "'" . $value; // 加单引号阻止执行
}
$item[$key] = $value;
}
// 业务验证
if (empty($item['email']) || !filter_var($item['email'], FILTER_VALIDATE_EMAIL)) {
error_log("第{$rowNumber}行邮箱无效");
continue;
}
$data[] = $item;
}
fclose($handle);
return $data;
}
?>
CSV注入防护 :当CSV文件在Excel中打开时,如果单元格以 =、+、-、@ 开头,Excel会将其视为公式执行。攻击者可以在CSV里写 =CMD|'/C calc'!A0 之类的恶意代码。所以必须转义这些特殊字符。
4.2 生成CSV下载
php
<?php
function exportCsv($data, $filename = 'export.csv') {
// 设置Headers
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
// 创建输出流
$output = fopen('php://output', 'w');
// 添加BOM(解决中文乱码)
fprintf($output, chr(0xEF).chr(0xBB).chr(0xBF));
// 写入标题行
if (!empty($data)) {
fputcsv($output, array_keys($data[0]));
}
// 写入数据
foreach ($data as $row) {
// 清洗特殊字符
$row = array_map(function($value) {
// 防止CSV注入
if (in_array(substr($value, 0, 1), ['=', '+', '-', '@'])) {
return "'" . $value;
}
return $value;
}, $row);
fputcsv($output, $row);
}
fclose($output);
exit;
}
?>
五、文件管理:增删改查
5.1 列出目录下的文件
php
<?php
function listFiles($dir) {
$files = [];
$handle = opendir($dir);
if ($handle === false) {
return $files;
}
while (($file = readdir($handle)) !== false) {
// 跳过 . 和 ..
if ($file == '.' || $file == '..') {
continue;
}
$path = $dir . '/' . $file;
$stat = stat($path);
$files[] = [
'name' => $file,
'path' => $path,
'size' => $stat['size'],
'size_formatted' => formatBytes($stat['size']),
'modified' => date('Y-m-d H:i:s', $stat['mtime']),
'is_dir' => is_dir($path)
];
}
closedir($handle);
// 按修改时间倒序排序
usort($files, function($a, $b) {
return $b['modified'] <=> $a['modified'];
});
return $files;
}
function formatBytes($bytes, $precision = 2) {
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= pow(1024, $pow);
return round($bytes, $precision) . ' ' . $units[$pow];
}
?>
5.2 删除文件
php
<?php
function deleteFile($filepath, $baseDir) {
// 安全检查:确保文件在指定目录内
$realPath = realpath($filepath);
$baseDir = realpath($baseDir);
if ($realPath === false || $baseDir === false || strpos($realPath, $baseDir) !== 0) {
return false;
}
if (is_file($realPath)) {
return unlink($realPath);
} elseif (is_dir($realPath)) {
// 如果是目录,递归删除(谨慎使用)
return rrmdir($realPath);
}
return false;
}
// 递归删除目录(危险操作,建议仅用于临时目录)
function rrmdir($dir) {
$files = array_diff(scandir($dir), ['.', '..']);
foreach ($files as $file) {
$path = $dir . '/' . $file;
is_dir($path) ? rrmdir($path) : unlink($path);
}
return rmdir($dir);
}
?>
六、安全加固:文件处理必读
6.1 上传目录权限设置
bash
# 设置上传目录权限
chmod 0755 uploads/
# 禁止执行PHP脚本
# 在Apache中配置:
<Directory /path/to/uploads>
php_flag engine off
</Directory>
# Nginx中配置:
location /uploads {
location ~ \.php$ {
return 403;
}
}
6.2 .htaccess 防护
在 uploads 目录下放一个 .htaccess 文件:
apache
# 禁止访问所有PHP文件
<FilesMatch "\.php$">
Order Allow,Deny
Deny from all
</FilesMatch>
# 禁止直接访问
Options -Indexes
6.3 图片重新压缩
对于图片上传,即使验证了MIME类型,还可能存在图片马(隐藏恶意代码的图片)。更安全的做法是使用GD库或Imagick重新生成图片:
php
<?php
function processImage($sourcePath, $targetPath, $maxWidth = 800, $maxHeight = 800) {
list($width, $height, $type) = getimagesize($sourcePath);
// 根据类型创建画布
switch ($type) {
case IMAGETYPE_JPEG:
$source = imagecreatefromjpeg($sourcePath);
break;
case IMAGETYPE_PNG:
$source = imagecreatefrompng($sourcePath);
break;
case IMAGETYPE_GIF:
$source = imagecreatefromgif($sourcePath);
break;
default:
return false;
}
// 计算缩放尺寸
$ratio = min($maxWidth/$width, $maxHeight/$height);
$newWidth = (int)($width * $ratio);
$newHeight = (int)($height * $ratio);
// 创建新图像
$newImage = imagecreatetruecolor($newWidth, $newHeight);
// 保持PNG透明度
if ($type == IMAGETYPE_PNG) {
imagealphablending($newImage, false);
imagesavealpha($newImage, true);
}
// 缩放
imagecopyresampled($newImage, $source, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height);
// 保存
switch ($type) {
case IMAGETYPE_JPEG:
imagejpeg($newImage, $targetPath, 90);
break;
case IMAGETYPE_PNG:
imagepng($newImage, $targetPath, 9);
break;
case IMAGETYPE_GIF:
imagegif($newImage, $targetPath);
break;
}
imagedestroy($source);
imagedestroy($newImage);
return true;
}
?>
这样生成的新图片不包含原文件的任何元数据和可能隐藏的恶意代码,是最彻底的防护方式。
七、实战:完整的文件管理器示例
最后,把这些知识点整合成一个简单的文件管理器,包含上传、列表、下载、删除功能:
php
<?php
// filemanager.php
session_start();
// 检查登录
if (!isset($_SESSION['user_id'])) {
header('Location: login.php');
exit;
}
$baseDir = __DIR__ . '/uploads/' . $_SESSION['user_id'] . '/';
// 创建用户专属目录
if (!file_exists($baseDir)) {
mkdir($baseDir, 0755, true);
}
// 处理上传
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['file'])) {
// 这里嵌入前面第1节的安全上传代码
// ...
}
// 处理删除
if (isset($_GET['delete'])) {
$filename = basename($_GET['delete']); // 防止路径遍历
$filepath = $baseDir . $filename;
if (file_exists($filepath) && is_file($filepath)) {
unlink($filepath);
}
header('Location: filemanager.php');
exit;
}
// 获取文件列表
$files = [];
$handle = opendir($baseDir);
while (($file = readdir($handle)) !== false) {
if ($file != '.' && $file != '..' && is_file($baseDir . $file)) {
$files[] = $file;
}
}
closedir($handle);
?>
<!DOCTYPE html>
<html>
<head>
<title>文件管理器</title>
<style>
body { font-family: Arial; padding: 20px; }
.upload-form { margin-bottom: 20px; padding: 15px; background: #f5f5f5; }
.file-list { border: 1px solid #ddd; }
.file-item { padding: 10px; border-bottom: 1px solid #eee; display: flex; }
.file-item:hover { background: #f9f9f9; }
.file-name { flex: 1; }
.file-actions { width: 150px; }
.file-actions a { margin-left: 10px; }
</style>
</head>
<body>
<h1>文件管理器</h1>
<div class="upload-form">
<form method="post" enctype="multipart/form-data">
<input type="file" name="file" required>
<button type="submit">上传</button>
</form>
</div>
<div class="file-list">
<?php foreach ($files as $file):
$filepath = $baseDir . $file;
$size = filesize($filepath);
$modified = date('Y-m-d H:i:s', filemtime($filepath));
?>
<div class="file-item">
<div class="file-name">
<?php echo htmlspecialchars($file); ?>
(<?php echo round($size/1024, 2); ?> KB) - <?php echo $modified; ?>
</div>
<div class="file-actions">
<a href="download.php?file=<?php echo urlencode($file); ?>" target="_blank">下载</a>
<a href="?delete=<?php echo urlencode($file); ?>" onclick="return confirm('确定删除?')">删除</a>
</div>
</div>
<?php endforeach; ?>
<?php if (empty($files)): ?>
<div class="file-item">暂无文件</div>
<?php endif; ?>
</div>
</body>
</html>
八、总结
文件处理看似简单,但涉及的安全点非常多:
-
上传:校验真实类型、大小、文件名重命名、目录权限
-
下载:路径验证、正确Header、防盗链
-
大文件:分块处理、内存控制
-
CSV:注入防护、编码处理
-
删除:权限控制、路径检查
-
图片:重新压缩彻底清除恶意代码
