安全限制
概述
文件上传是 Web 应用中最常见的安全攻击面之一。正确的安全限制包括:文件类型白名单验证、文件重命名、存储目录隔离、禁止执行上传文件中的代码、病毒扫描等。每一个环节的疏忽都可能导致严重的安全漏洞。
适用场景
- 所有涉及文件上传的 Web 应用
- 用户内容管理系统
- 文件存储服务
- SaaS 平台的附件功能
基础概念
文件上传常见攻击
| 攻击类型 | 描述 | 防御方式 |
|---|---|---|
| 文件类型绕过 | 伪造扩展名上传恶意文件 | MIME 类型检测(finfo) |
| Webshell 上传 | 上传 PHP/JSP 可执行文件 | 上传目录禁止执行 |
| 路径遍历 | 通过文件名遍历服务器目录 | 文件名清洗和重命名 |
| 文件覆盖 | 覆盖已有系统文件 | 随机文件名 |
| DoS 攻击 | 上传超大文件耗尽资源 | 大小限制 |
| 双重扩展名 | shell.php.jpg 绕过检查 | 验证实际 MIME 类型 |
| 图片马 | 在图片中嵌入恶意代码 | 图片处理(重采样) |
安全警告
文件上传安全必须从多个层面进行防护,单一防护手段是不够的。
语法与代码示例
文件类型白名单
php
<?php
declare(strict_types=1);
class UploadSecurity
{
private const ALLOWED_IMAGES = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/bmp',
];
private const ALLOWED_DOCS = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
];
private const ALLOWED_EXTENSIONS = [
'jpg', 'jpeg', 'png', 'gif', 'webp',
'pdf', 'doc', 'docx', 'xls', 'xlsx',
'txt', 'csv',
];
/**
* 验证 MIME 类型(基于文件内容)
*/
public static function validateMime(string $tmpPath, array $allowedMimes): bool
{
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($tmpPath);
return in_array($mime, $allowedMimes, true);
}
/**
* 验证扩展名
*/
public static function validateExtension(string $originalName, array $allowedExts): bool
{
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
return in_array($ext, $allowedExts, true);
}
/**
* 双重验证:扩展名 + MIME 类型
*/
public static function validateFileType(
string $tmpPath,
string $originalName,
array $allowedMimes,
array $allowedExts
): bool {
return self::validateMime($tmpPath, $allowedMimes)
&& self::validateExtension($originalName, $allowedExts);
}
}
// 使用示例
$allowedMimes = UploadSecurity::ALLOWED_IMAGES;
$allowedExts = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
if (!UploadSecurity::validateFileType(
$_FILES['photo']['tmp_name'],
$_FILES['photo']['name'],
$allowedMimes,
$allowedExts
)) {
die('不支持的文件类型');
}文件名安全处理
php
<?php
declare(strict_types=1);
class FileNameSanitizer
{
/**
* 生成安全的随机文件名
*/
public static function generateSafeName(string $originalName): string
{
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
// 只保留安全扩展名
$safeExt = preg_replace('/[^a-z0-9]/', '', $ext);
if ($safeExt === '') {
$safeExt = 'bin';
}
// 使用随机名称
return date('Ymd_His') . '_' . bin2hex(random_bytes(8)) . '.' . $safeExt;
}
/**
* 清洗文件名(保留原始名称但去除危险字符)
*/
public static function sanitize(string $filename): string
{
// 去除路径信息
$filename = basename($filename);
// 替换危险字符
$filename = preg_replace('/[^\w\.\-]/', '_', $filename);
// 防止双重扩展名
$parts = explode('.', $filename);
if (count($parts) > 2) {
$ext = array_pop($parts);
$filename = implode('_', $parts) . '.' . $ext;
}
// 限制长度
$filename = substr($filename, 0, 100);
return $filename;
}
/**
* 检查文件名是否包含危险字符
*/
public static function isDangerous(string $filename): bool
{
$dangerous = ['..', '/', '\\', "\0", '%00', '<', '>', '|', '&', ';'];
foreach ($dangerous as $char) {
if (str_contains($filename, $char)) {
return true;
}
}
// 检查是否为可执行文件扩展名
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
$dangerousExts = ['php', 'phtml', 'php3', 'php4', 'php5', 'php7', 'pht',
'asp', 'aspx', 'jsp', 'cgi', 'pl', 'sh', 'py', 'rb',
'exe', 'bat', 'cmd', 'com', 'msi'];
return in_array($ext, $dangerousExts, true);
}
}存储目录隔离
bash
# Apache .htaccess 禁止在上传目录执行 PHPapache
# /var/www/html/uploads/.htaccess
<FilesMatch "\.(php|phtml|php[0-9]+|pht)$">
Require all denied
</FilesMatch>
# 禁止所有脚本执行
<FilesMatch "\.(php[0-9]*|phtml|pl|py|jsp|asp|cgi|sh|bash)$">
Deny from all
</FilesMatch>
# 设置默认 MIME 类型
ForceType application/octet-stream
# 禁止目录浏览
Options -Indexesnginx
# Nginx 配置
location /uploads/ {
# 禁止执行 PHP
location ~ \.php$ {
deny all;
return 403;
}
# 限制请求方法
limit_except GET HEAD POST {
deny all;
}
# 设置缓存和过期
expires 30d;
add_header Cache-Control "public, immutable";
}php
<?php
// PHP 安全检查:确保上传目录在 Web 根目录外或在受保护区域
class UploadDirectoryGuard
{
private string $webRoot;
private string $uploadDir;
public function __construct(string $webRoot, string $uploadDir)
{
$this->webRoot = realpath($webRoot);
$this->uploadDir = realpath($uploadDir);
}
public function isSecure(): array
{
$issues = [];
// 检查 .htaccess 是否存在
$htaccess = $this->uploadDir . DIRECTORY_SEPARATOR . '.htaccess';
if (!file_exists($htaccess)) {
$issues[] = '缺少 .htaccess 文件';
}
// 检查目录权限
$perms = fileperms($this->uploadDir) & 0x1FF;
if ($perms > 0755) {
$issues[] = '目录权限过大';
}
// 检查是否存在可执行文件
$this->checkExecutableFiles($this->uploadDir, $issues);
return $issues;
}
private function checkExecutableFiles(string $dir, array &$issues): void
{
$dangerousExts = ['php', 'phtml', 'pht', 'php3', 'php4', 'php5'];
$pattern = '*.' . implode(',*.', $dangerousExts);
$files = glob($dir . DIRECTORY_SEPARATOR . $pattern);
if (!empty($files)) {
$issues[] = '上传目录中存在危险文件: ' . implode(', ', array_map('basename', $files));
}
}
}实战示例
图片安全处理(防图片马)
php
<?php
declare(strict_types=1);
class SecureImageUploader
{
private string $uploadDir;
private int $maxWidth;
private int $maxHeight;
public function __construct(string $uploadDir, int $maxWidth = 2000, int $maxHeight = 2000)
{
$this->uploadDir = $uploadDir;
$this->maxWidth = $maxWidth;
$this->maxHeight = $maxHeight;
if (!is_dir($this->uploadDir)) {
mkdir($this->uploadDir, 0755, true);
}
}
/**
* 安全上传图片:通过 GD 重采样去除潜在恶意代码
*/
public function upload(array $file): array
{
// 1. 验证 MIME 类型
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
if (!in_array($mime, ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], true)) {
throw new RuntimeException("不支持的图片类型: {$mime}");
}
// 2. 验证图像有效性
$imageInfo = getimagesize($file['tmp_name']);
if ($imageInfo === false) {
throw new RuntimeException('不是有效的图片文件');
}
// 3. 限制图片尺寸
$width = $imageInfo[0];
$height = $imageInfo[1];
if ($width > $this->maxWidth || $height > $this->maxHeight) {
throw new RuntimeException("图片尺寸超限: {$width}x{$height}");
}
// 4. 通过 GD 重新创建图片(去除嵌入的恶意代码)
$safeImage = $this->sanitizeImage($file['tmp_name'], $mime);
if ($safeImage === null) {
throw new RuntimeException('图片处理失败');
}
// 5. 保存为安全格式
$ext = match ($mime) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
default => 'jpg',
};
$filename = bin2hex(random_bytes(16)) . '.' . $ext;
$destPath = $this->uploadDir . DIRECTORY_SEPARATOR . $filename;
$this->saveImage($safeImage, $destPath, $mime);
imagedestroy($safeImage);
chmod($destPath, 0644);
return [
'filename' => $filename,
'path' => $destPath,
'width' => $width,
'height' => $height,
'mime' => $mime,
'size' => filesize($destPath),
];
}
private function sanitizeImage(string $path, string $mime): ?\GdImage
{
return match ($mime) {
'image/jpeg' => imagecreatefromjpeg($path),
'image/png' => imagecreatefrompng($path),
'image/gif' => imagecreatefromgif($path),
'image/webp' => imagecreatefromwebp($path),
default => null,
};
}
private function saveImage(\GdImage $image, string $path, string $mime): void
{
$success = match ($mime) {
'image/jpeg' => imagejpeg($image, $path, 85),
'image/png' => imagepng($image, $path, 9),
'image/gif' => imagegif($image, $path),
'image/webp' => imagewebp($image, $path, 85),
default => false,
};
if (!$success) {
throw new RuntimeException('图片保存失败');
}
}
}病毒扫描集成
php
<?php
declare(strict_types=1);
class VirusScanner
{
private string $clamscanPath;
public function __construct(string $clamscanPath = '/usr/bin/clamscan')
{
$this->clamscanPath = $clamscanPath;
}
/**
* 使用 ClamAV 扫描文件
*/
public function scan(string $filePath): bool
{
if (!file_exists($this->clamscanPath)) {
// ClamAV 未安装,跳过扫描
return true;
}
$command = escapeshellarg($this->clamscanPath) . ' --no-summary ' . escapeshellarg($filePath);
$output = [];
$returnCode = 0;
exec($command, $output, $returnCode);
// 0: 无病毒, 1: 发现病毒, 其他: 错误
return $returnCode === 0;
}
/**
* 上传时集成病毒扫描
*/
public function scanUploadedFile(string $tmpPath): void
{
if (!$this->scan($tmpPath)) {
// 删除受感染文件
@unlink($tmpPath);
throw new RuntimeException('文件安全扫描未通过');
}
}
}
// 使用示例
$scanner = new VirusScanner();
try {
$scanner->scanUploadedFile($_FILES['document']['tmp_name']);
// 安全,继续处理
} catch (RuntimeException $e) {
echo "文件安全扫描未通过\n";
}注意事项
不要仅依赖扩展名
php
<?php
// 不好:仅检查扩展名
$ext = pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION);
if (!in_array($ext, ['jpg', 'png', 'gif'])) {
die('不允许的文件类型');
}
// 好:检查实际内容
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($_FILES['file']['tmp_name']);
if (!in_array($mime, ['image/jpeg', 'image/png', 'image/gif'], true)) {
die('不允许的文件类型');
}防止路径遍历
php
<?php
// 危险:用户可以提交包含 ../ 的文件名
$filename = $_FILES['file']['name']; // "../../etc/crontab"
move_uploaded_file($tmp, $uploadDir . '/' . $filename); // 遍历攻击!
// 安全:使用 basename() 或随机命名
$safeName = basename($filename); // 仅保留文件名
// 更安全:完全重命名
$newName = bin2hex(random_bytes(16)) . '.' . $ext;最佳实践
1. 多层防御
php
<?php
function secureUpload(array $file, string $uploadDir): string
{
// 层 1:检查 PHP 上传错误
if ($file['error'] !== UPLOAD_ERR_OK) {
throw new RuntimeException('上传错误');
}
// 层 2:验证上传来源
if (!is_uploaded_file($file['tmp_name'])) {
throw new RuntimeException('非法上传');
}
// 层 3:大小限制
if ($file['size'] > 5 * 1024 * 1024) {
throw new RuntimeException('文件过大');
}
// 层 4:MIME 类型验证
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
$allowed = ['image/jpeg', 'image/png'];
if (!in_array($mime, $allowed, true)) {
throw new RuntimeException('不支持的类型');
}
// 层 5:重命名文件
$ext = match ($mime) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
default => 'bin',
};
$newName = bin2hex(random_bytes(16)) . '.' . $ext;
// 层 6:安全保存
$destPath = $uploadDir . DIRECTORY_SEPARATOR . $newName;
if (!move_uploaded_file($file['tmp_name'], $destPath)) {
throw new RuntimeException('保存失败');
}
// 层 7:设置权限
chmod($destPath, 0644);
return $destPath;
}2. 存储目录不在 Web 根目录
php
<?php
// 最佳方案:将上传目录放在 Web 根目录之外
$uploadDir = '/var/uploads/appname/'; // Web 不可直接访问
$webAccessibleDir = '/var/www/html/public/thumbnails/'; // 仅存放处理后的缩略图
// 通过 PHP 脚本提供文件下载
function serveUploadedFile(string $filePath): void
{
if (!file_exists($filePath)) {
http_response_code(404);
exit;
}
$finfo = new finfo(FILEINFO_MIME_TYPE);
header('Content-Type: ' . $finfo->file($filePath));
header('Content-Length: ' . filesize($filePath));
header('Content-Disposition: inline; filename="' . basename($filePath) . '"');
readfile($filePath);
exit;
}