指南

文件上传

应用程序在某个环节需要允许用户上传文件(无论是为了使用还是仅用于存储)是非常普遍的需求。虽然这看起来很简单,但由于文件上传处理方式可能带来的潜在风险,该功能的实现方式至关重要。

请看这个简短的示例,以便更直观地理解我们所指的内容。

假设这是一个允许用户上传头像的应用程序:

public string UploadProfilePicture(FormFile uploadedFile)
{
    // 生成保存上传文件的路径
    var path = $"./uploads/avatars/{request.User.Id}/{uploadedFile.FileName}";  

    // 保存文件
    var localFile = File.OpenWrite(path);
    localFile.Write(uploadedFile.ReadToEnd());
    localFile.Flush();
    localFile.Close();

    // 更新头像
    UserProfile.UpdateUserProfilePicture(request.User, path)

    return path;
}

这是一个非常基础的上传功能,但同时也容易受到路径遍历攻击。

根据应用程序的具体实现,攻击者可能会上传其他页面或脚本(例如 .asp、.aspx 或 .php 文件),从而能够直接调用并执行任意代码。这还可能导致现有文件被覆盖。

问题 1 - 保存到本地磁盘而非外部数据存储

随着云服务的普及,应用程序多以容器形式交付,高可用性架构已成为标准,因此应尽可能避免将上传的文件写入应用程序的本地磁盘。

文件应尽可能上传到中央存储(如块存储或数据库)。这可以从根本上避免此类安全漏洞。

问题 2 - 未验证文件扩展名

在许多文件上传漏洞被利用的案例中,攻击者往往依赖于上传特定扩展名的文件。因此,强烈建议为可上传的文件建立“白名单”机制。

请务必使用语言或框架提供的方法来获取文件扩展名,以避免空字节注入等问题。

验证上传文件的内容类型(Content-Type)可能很有诱惑力,但这样做会使系统变得非常脆弱,因为不同操作系统对特定文件使用的内容类型可能存在差异。此外,由于内容类型仅仅是扩展名的映射,它并不能真正反映文件本身的性质。

问题 3 - 未防止路径遍历

文件上传的另一个常见问题是容易受到路径遍历攻击。这是一个独立且复杂的话题,与其在此总结,不如查看关于 路径遍历 的完整指南。

更多示例

下面我们提供了更多安全与不安全文件上传的示例,供您参考。

C# - 不安全

public string UploadProfilePicture(IFormFile uploadedFile)
{
    // 生成保存上传文件的路径
    var path = $"./uploads/avatars/{request.User.Id}/{uploadedFile.FileName}";

    // 保存文件
    var localFile = File.OpenWrite(path);
    localFile.Write(uploadedFile.ReadToEnd());
    localFile.Flush();
    localFile.Close();

    // 更新头像
    UserProfile.UpdateUserProfilePicture(request.User, path)

    return path;
}

C# - 安全

public List<string> AllowedExtensions = new() { ".png", ".jpg", ".gif"};

public string UploadProfilePicture(IFormFile uploadedFile)
{
    // 注意:最佳做法是避免将文件保存到本地磁盘。
    var basePath = Path.GetFullPath("./uploads/avatars/");

    // 通过不使用提供的文件名来防止路径遍历。同时也是为了避免文件名冲突。
    var newFileName = GenerateFileName(uploadedFile.FileName);

    // 生成上传文件的保存路径
    var canonicalPath = Path.Combine(basePath, newFileName);

    // 确保没有意外保存到基础文件夹之外
    if(!canonicalPath.StartsWith(basePath))
    {
        return BadRequest("尝试将文件保存到上传文件夹之外");
    }

    // 确保仅保存允许的扩展名
    if(!IsFileAllowedExtension(uploadedAllowedExtensions))
    {
        return BadRequest("不允许的扩展名");
    }

    // 保存文件
    var localFile = File.OpenWrite(canonicalPath);
    localFile.Write(uploadedFile.ReadToEnd());
    localFile.Flush();
    localFile.Close();

    // 更新个人资料图片
    UserProfile.UpdateUserProfilePicture(request.User, canonicalPath)

    return path;

public bool GenerateFileName(string originalFileName) {
    return $"{Guid.NewGuid()}{Path.GetExtension(originalFileName)}";
}

public bool IsFileAllowedExtension(string fileName, List<string> extensions) {
    return extensions.Contains(Path.GetExtension(fileName));
}

‍

Java - 不安全

@Controller
public class FileUploadController {

   @RequestMapping(value = "/files/upload", method = RequestMethod.POST)
   @ResponseBody
   public ResponseEntity<String> uploadFile(@RequestParam("file") MultipartFile file, @AuthenticationPrincipal User user) {

       try {

           String uploadPath = "./uploads/avatars/" + principal.getName() + "/" + file.getOriginalFilename();

           File transferFile = new File(uploadPath);
           file.transferTo(transferFile);

       } catch (Exception e) {
           return new ResponseEntity<>("上传错误", HttpStatus.INTERNAL_SERVER_ERROR);
       }

       return new ResponseEntity<>(uploadPath, HttpStatus.CREATED);
   }
}

Java - 安全

@Controller
public class FileUploadController {

    @RequestMapping(value = "/files/upload", method = RequestMethod.POST)
    @ResponseBody
    public ResponseEntity<String> uploadFile(@RequestParam("file") MultipartFile file, @AuthenticationPrincipal User user) {

        try {
            String baseFolder = Paths.get("./uploads/avatars/").normalize();
            String uploadPath = Paths.get(baseFolder.toString() +
GenerateFileName(file.getOriginalFilename())).normalize();
           // 确保扩展名是允许的类型
            if(!IsAllowedExtension(file.getOriginalFilename()) {
                return new ResponseEntity<>("不允许的扩展名", HttpStatus.FORBIDDEN);
            }

            // 确保文件不会保存到上传根目录之外
           if(!uploadPath.toString().startsWith(baseFolder.toString()))            {
                return new ResponseEntity<>("不允许将文件保存到基础文件夹之外。", HttpStatus.FORBIDDEN);
           }

            File transferFile = new File(uploadPath.toString());
            file.transferTo(uploadPath.toString());

        } catch (Exception e) {
            return new ResponseEntity<>("上传错误", HttpStatus.INTERNAL_SERVER_ERROR);
        }

        return new ResponseEntity<>(uploadPath, HttpStatus.CREATED);
    }

    private string GenerateFileName(String fileName) {
        return UUID.randomUUID().toString() + "." + FilenameUtils.getExtension(fileName);
    }

    private boolean IsAllowedExtension(String fileName) {
        String[] allowedExtensions = {"jpg", "png", "gif"};
        String extension = FilenameUtils.getExtension(filename);
        return allowedExtensions.contains(extension);
    }
}

Python - Flask - 不安全

@app.route('/files/upload', methods=['POST'])
def upload_file():

    file = request.files['file']

    savedFilePath = os.path.join("./uploads/avatars/", file.filename)
    file.save(savedFilePath)

    return savedFilePath

Python - Flask - 安全

@app.route('/files/upload', methods=['POST'])
def upload_file():

    file = request.files['file']
    baseFolder = os.path.normpath("./uploads/avatars/")
    savedFilePath = os.path.normpath(os.path.join(baseFolder, generate_file_name(file.filename)))

    # 确保扩展名在允许的集合中
    if not is_extension_allowed(file.filename):
        return "不允许此扩展名"

    # 确保我们要保存到的文件不在基础文件夹之外
    if not savedFilePath.startsWith(baseFolder):
        return "尝试将文件保存到基础文件夹之外"

    file.save(savedFilePath)

    return savedFilePath

def generate_file_name(filename):
    return str(uuid.uuid4()) + os.path.splitext(filename)[1]

def is_extension_allowed(filename):
    return os.path.splitext(filename)[1] in (".png", ".jpg", ".gif")
标语

实用安全编码

探索安全编码指南,了解并缓解 OWASP Top 10 等软件漏洞,并通过引导式训练任务在真实的应用程序模拟中进行实践。

更多指南
预约演示