准则

文件上传

通常情况下,应用程序会在某些时候需要允许用户在应用程序中的某个地方上传文件(无论是使用还是存储)。虽然这看起来很简单,但由于文件上传的处理方式存在潜在风险,因此如何实现这一功能可能相当关键。 

请看这个简单的例子,以便更直观地理解我们的意思。 

假设这是一个允许用户上传个人照片的应用程序:

public string UploadProfilePicture(FormFile uploadedFile)
{
    // Generate path to save the uploaded file at
    var path = $"./uploads/avatars/{request.User.Id}/{uploadedFile.FileName}";  

    // Save the file
    var localFile = File.OpenWrite(path);
    localFile.Write(uploadedFile.ReadToEnd());
    localFile.Flush();
    localFile.Close();

    // Update the profile picture
    UserProfile.UpdateUserProfilePicture(request.User, path)

    return path;
}

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

根据应用程序的具体实现,攻击者可以上传另一个页面/脚本(如 .asp、.aspx 或 .php 文件),从而可以直接调用并执行任意代码。这样还可以覆盖现有文件。 

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

随着云服务的使用越来越普遍,应用程序在容器中交付,高可用性设置已成为标准,而将上传文件写入应用程序本地磁盘的做法基本上应不惜一切代价避免。 

文件应尽可能上传到某种形式的中央存储(块存储或数据库)。在这种情况下,这可以避免整类安全漏洞。 

问题 2 - 无法验证扩展名 

在许多利用文件上传漏洞的案例中,它依赖于上传具有特定扩展名的文件的能力。因此,建议使用 "允许列表 "来列出可上传文件的扩展名。 

确保使用语言/框架提供的方法获取文件扩展名,以避免出现空字节注入等问题。 

验证上传的内容类型可能也很诱人,但这样做会使其变得非常脆弱,因为特定文件使用的内容类型在不同操作系统之间可能会有所不同。此外,由于内容类型纯粹是扩展名的映射,因此实际上也不会告诉你文件本身的任何信息。 

问题 3 - 无法阻止路径遍历

文件上传的另一个常见问题是容易受到路径遍历的攻击。这本身就是一个大问题,所以与其在此总结,不如看看路径遍历的完整指南。

更多实例

下面,我们还提供了一些安全和不安全文件上传的示例,供您参考。 

C# - 不安全

public string UploadProfilePicture(IFormFile uploadedFile)
{
    // Generate path to save the uploaded file at
    var path = $"./uploads/avatars/{request.User.Id}/{uploadedFile.FileName}";

    // Save the file
    var localFile = File.OpenWrite(path);
    localFile.Write(uploadedFile.ReadToEnd());
    localFile.Flush();
    localFile.Close();

    // Update the profile picture
    UserProfile.UpdateUserProfilePicture(request.User, path)

    return path;
}

C# - 安全

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

public string UploadProfilePicture(IFormFile uploadedFile)
{
    // NOTE: The best option is to avoid saving files to the local disk.
    var basePath = Path.GetFullPath("./uploads/avatars/");

    // Prevent path traversal by not utilizing the provided file name. Also needed to avoid filename conflicts.
    var newFileName = GenerateFileName(uploadedFile.FileName);

    // Generate path to save the uploaded file at
    var canonicalPath = Path.Combine(basePath, newFileName);

    // Ensure that we did not accidentally save to a folder outside of the base folder
    if(!canonicalPath.StartsWith(basePath))
    {
        return BadRequest("Attempted to save file outside of upload folder");
    }

    // Ensure only allowed extensions are saved
    if(!IsFileAllowedExtension(uploadedAllowedExtensions))
    {
        return BadRequest("Extension is not allowed");
    }

    // Save the file
    var localFile = File.OpenWrite(canonicalPath);
    localFile.Write(uploadedFile.ReadToEnd());
    localFile.Flush();
    localFile.Close();

    // Update the profile picture
    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<>("Upload error", 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();
           // Make sure that the extension is an allowed type
            if(!IsAllowedExtension(file.getOriginalFilename()) {
                return new ResponseEntity<>("Extension not allowed", HttpStatus.FORBIDDEN);
            }

            // Make sure that the file is not saved outside of the upload root
           if(!uploadPath.toString().startsWith(baseFolder.toString()))            {
                return new ResponseEntity<>("Files are not allowed to be saved outside of the base folder.", HttpStatus.FORBIDDEN);
           }

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

        } catch (Exception e) {
            return new ResponseEntity<>("Upload error", 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 "This extension is not allowed"

# 确保我们要保存的文件不在基本文件夹之外
if not savedFilePath.startsWith(baseFolder):
return "Attempted to save file outside of base folder"

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")