Skip to content

安全限制

概述

文件上传是 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 禁止在上传目录执行 PHP
apache
# /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 -Indexes
nginx
# 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;
}

参考链接