指南
文件上传
应用程序在某个环节需要允许用户上传文件(无论是为了使用还是仅用于存储)是非常普遍的需求。虽然这看起来很简单,但由于文件上传处理方式可能带来的潜在风险,该功能的实现方式至关重要。
请看这个简短的示例,以便更直观地理解我们所指的内容。
假设这是一个允许用户上传头像的应用程序:
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 等软件漏洞,并通过引导式训练任务在真实的应用程序模拟中进行实践。