前言
在Web应用开发中,文件上传、下载和读取功能是常见需求。然而,不安全的文件访问实现可能导致严重的任意文件读取/写入漏洞,攻击者可能借此读取服务器上的敏感配置文件、数据库凭据,甚至系统文件,造成严重的数据泄露。
常见的文件访问安全风险
1. 任意路径遍历(Path Traversal)
路径遍历是最常见的文件访问安全问题,攻击者通过../、..\\等序列来访问目录外的文件。
java
// 危险代码示例 - 存在路径遍历漏洞
@GetMapping("/files")
public ResponseEntity<Resource> getFile(@RequestParam String filename) {
// 极度危险!用户可以直接访问任意文件
File file = new File("/uploads/" + filename);
return ResponseEntity.ok()
.body(new FileSystemResource(file));
}
// 攻击示例:
// GET /files?filename=../../../../etc/passwd
// GET /files?filename=..\\..\\..\\windows\\system32\\config\\sam
2. 文件类型绕过
不充分的文件类型验证可能导致恶意文件上传。
java
// 不安全的文件类型检查
@PostMapping("/upload")
public String upload(@RequestParam MultipartFile file) {
String filename = file.getOriginalFilename();
if (filename.endsWith(".jpg") || filename.endsWith(".png")) {
// 危险:仅检查文件名后缀,攻击者可以上传
// shell.php.jpg 或 double-header.php%00.jpg
}
}
3. 符号链接攻击
符号链接可能被用来绕过访问限制,指向系统敏感文件。
4. 文件包含漏洞
不当的文件包含可能导致代码执行。
Spring Boot 安全文件访问实现
1. 路径白名单验证
java
import org.springframework.util.StringUtils;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
@Service
public class SecureFileService {
// 允许访问的基础目录
private static final Set<Path> ALLOWED_BASE_PATHS = new HashSet<>(Arrays.asList(
Paths.get("/app/uploads").normalize(),
Paths.get("/app/public").normalize()
));
// 允许的文件扩展名
private static final Set<String> ALLOWED_EXTENSIONS = new HashSet<>(Arrays.asList(
"jpg", "jpeg", "png", "gif", "pdf", "txt", "doc", "docx"
));
public boolean isPathSafe(String requestedPath) {
if (!StringUtils.hasText(requestedPath)) {
return false;
}
try {
// 规范化路径,解析所有 . 和 ..
Path normalizedPath = Paths.get(requestedPath).normalize();
// 检查是否在允许的基础目录内
for (Path basePath : ALLOWED_BASE_PATHS) {
if (normalizedPath.startsWith(basePath)) {
// 检查文件扩展名
String extension = getFileExtension(normalizedPath.toString());
return ALLOWED_EXTENSIONS.contains(extension.toLowerCase());
}
}
return false;
} catch (Exception e) {
return false;
}
}
private String getFileExtension(String filename) {
int lastDot = filename.lastIndexOf('.');
return lastDot > 0 ? filename.substring(lastDot + 1) : "";
}
}
2. 安全的文件访问控制器
java
@RestController
@RequestMapping("/api/files")
@Validated
public class SecureFileController {
private final SecureFileService fileService;
public SecureFileController(SecureFileService fileService) {
this.fileService = fileService;
}
@GetMapping("/download/**")
public ResponseEntity<Resource> downloadFile(HttpServletRequest request) {
try {
// 提取请求路径中的文件路径部分
String requestURI = request.getRequestURI();
String filePath = requestURI.substring("/api/files/download/".length());
// 安全验证
if (!fileService.isPathSafe(filePath)) {
return ResponseEntity.badRequest()
.body((Resource) new StringResource("Invalid file path"));
}
Path path = Paths.get(filePath).normalize();
Resource resource = new UrlResource(path.toUri());
if (!resource.exists() || !resource.isReadable()) {
return ResponseEntity.notFound().build();
}
// 检查文件大小限制
long fileSize = resource.contentLength();
if (fileSize > 100 * 1024 * 1024) { // 100MB limit
return ResponseEntity.badRequest()
.body((Resource) new StringResource("File too large"));
}
String contentType = Files.probeContentType(path);
if (contentType == null) {
contentType = "application/octet-stream";
}
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(contentType))
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + path.getFileName() + "\"")
.body(resource);
} catch (Exception e) {
return ResponseEntity.internalServerError().build();
}
}
}
3. 安全的文件上传实现
java
import org.springframework.web.multipart.MultipartFile;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang3.RandomStringUtils;
@Service
public class SecureFileUploadService {
private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
private static final Path UPLOAD_DIR = Paths.get("/app/uploads").toAbsolutePath().normalize();
@PostConstruct
public void init() {
try {
Files.createDirectories(UPLOAD_DIR);
} catch (IOException e) {
throw new RuntimeException("Could not create upload directory", e);
}
}
public String uploadFile(MultipartFile file) {
// 1. 基本验证
if (file.isEmpty()) {
throw new IllegalArgumentException("File is empty");
}
if (file.getSize() > MAX_FILE_SIZE) {
throw new IllegalArgumentException("File size exceeds limit");
}
// 2. 文件名验证和处理
String originalFilename = file.getOriginalFilename();
if (!isValidFilename(originalFilename)) {
throw new IllegalArgumentException("Invalid filename");
}
// 3. 文件类型验证(使用魔术字节,而不仅仅是扩展名)
if (!isValidFileType(file)) {
throw new IllegalArgumentException("Invalid file type");
}
// 4. 生成安全的文件名
String extension = FilenameUtils.getExtension(originalFilename);
String safeFilename = generateSafeFilename(extension);
try {
Path targetLocation = UPLOAD_DIR.resolve(safeFilename);
// 确保文件在允许的目录内
if (!targetLocation.normalize().startsWith(UPLOAD_DIR)) {
throw new SecurityException("Attempted path traversal attack");
}
Files.copy(file.getInputStream(), targetLocation, StandardCopyOption.REPLACE_EXISTING);
return safeFilename;
} catch (IOException e) {
throw new RuntimeException("Failed to store file", e);
}
}
private boolean isValidFilename(String filename) {
if (filename == null || filename.trim().isEmpty()) {
return false;
}
// 检查危险字符
if (filename.contains("..") || filename.contains("/") ||
filename.contains("\\") || filename.contains(":")) {
return false;
}
// 检查文件名长度
return filename.length() <= 255;
}
private boolean isValidFileType(MultipartFile file) throws IOException {
String filename = file.getOriginalFilename();
String extension = FilenameUtils.getExtension(filename).toLowerCase();
// 允许的文件类型
Set<String> allowedExtensions = Set.of("jpg", "jpeg", "png", "gif", "pdf", "txt");
if (!allowedExtensions.contains(extension)) {
return false;
}
// 文件头验证(魔术字节)
byte[] fileBytes = file.getBytes();
return isValidFileHeader(fileBytes, extension);
}
private boolean isValidFileHeader(byte[] fileBytes, String extension) {
if (fileBytes.length < 4) {
return false;
}
// 简化的文件头验证, 可以使用 Apache Tika库来判断
switch (extension) {
case "jpg":
case "jpeg":
return fileBytes[0] == (byte) 0xFF && fileBytes[1] == (byte) 0xD8;
case "png":
return fileBytes[0] == (byte) 0x89 && fileBytes[1] == 0x50 &&
fileBytes[2] == 0x4E && fileBytes[3] == 0x47;
case "pdf":
return fileBytes[0] == 0x25 && fileBytes[1] == 0x50 &&
fileBytes[2] == 0x44 && fileBytes[3] == 0x46;
default:
return true; // 对于文本文件,放宽检查
}
}
private String generateSafeFilename(String extension) {
String randomPart = RandomStringUtils.randomAlphanumeric(16);
String timestamp = String.valueOf(System.currentTimeMillis());
return String.format("%s_%s.%s", timestamp, randomPart, extension);
}
}
4. 配置安全过滤器
java
@Component
public class FileSecurityFilter implements Filter {
private static final Set<String> DANGEROUS_PATHS = Set.of(
"..", "../", "..\\", "%2e%2e%2f", "%2e%2e\\",
"etc/passwd", "windows/system32", "boot.ini"
);
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
String requestURI = httpRequest.getRequestURI();
String queryString = httpRequest.getQueryString();
// 检查路径遍历攻击
if (containsPathTraversal(requestURI, queryString)) {
httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST,
"Invalid request - potential path traversal");
return;
}
chain.doFilter(request, response);
}
private boolean containsPathTraversal(String uri, String queryString) {
String fullRequest = uri;
if (queryString != null) {
fullRequest += "?" + queryString;
}
String lowerCaseRequest = fullRequest.toLowerCase();
return DANGEROUS_PATHS.stream()
.anyMatch(lowerCaseRequest::contains);
}
}
高级安全措施
1. 使用虚拟文件系统
java
@Service
public class VirtualFileSystemService {
// 将文件映射到虚拟路径,隐藏真实文件系统结构
private final Map<String, FileInfo> virtualFileMap = new ConcurrentHashMap<>();
@PostConstruct
public void init() {
// 初始化虚拟文件映射
scanAndMapFiles(Paths.get("/app/uploads"), "/virtual/files");
}
private void scanAndMapFiles(Path realPath, String virtualBasePath) {
try {
Files.walk(realPath)
.filter(Files::isRegularFile)
.forEach(realFile -> {
String relativePath = realPath.relativize(realFile).toString();
String virtualPath = virtualBasePath + "/" + relativePath;
virtualFileMap.put(virtualPath, new FileInfo(realFile, virtualPath));
});
} catch (IOException e) {
log.error("Failed to scan files", e);
}
}
public Optional<Resource> getFileByVirtualPath(String virtualPath) {
FileInfo fileInfo = virtualFileMap.get(virtualPath);
if (fileInfo == null) {
return Optional.empty();
}
try {
Resource resource = new UrlResource(fileInfo.getRealPath().toUri());
return Optional.of(resource);
} catch (Exception e) {
return Optional.empty();
}
}
@Data
@AllArgsConstructor
private static class FileInfo {
private Path realPath;
private String virtualPath;
}
}
2. 文件访问权限控制
java
@Service
public class FileAccessControlService {
public boolean canAccessFile(String userId, String virtualPath, String action) {
// 基于用户角色的文件访问控制
UserInfo userInfo = getUserInfo(userId);
FileInfo fileInfo = getFileInfo(virtualPath);
if (fileInfo == null) {
return false;
}
// 检查文件所有权
if (userInfo.getRole() == UserRole.ADMIN) {
return true; // 管理员可以访问所有文件
}
// 普通用户只能访问自己的文件
return fileInfo.getOwnerId().equals(userId);
}
public void logFileAccess(String userId, String virtualPath, String action, boolean success) {
FileAccessLog log = FileAccessLog.builder()
.userId(userId)
.virtualPath(virtualPath)
.action(action)
.success(success)
.timestamp(LocalDateTime.now())
.userAgent(getCurrentUserAgent())
.clientIp(getClientIp())
.build();
fileAccessLogRepository.save(log);
}
}
3. 文件完整性检查
java
@Component
public class FileIntegrityChecker {
public String calculateFileHash(Path filePath) throws IOException {
byte[] fileBytes = Files.readAllBytes(filePath);
return DigestUtils.sha256Hex(fileBytes);
}
public boolean verifyFileIntegrity(Path filePath, String expectedHash) throws IOException {
String actualHash = calculateFileHash(filePath);
return MessageDigest.isEqual(
actualHash.getBytes(StandardCharsets.UTF_8),
expectedHash.getBytes(StandardCharsets.UTF_8)
);
}
public void generateIntegrityReport() {
// 定期扫描上传目录,生成文件完整性报告
Map<String, String> fileHashes = new HashMap<>();
try {
Files.walk(Paths.get("/app/uploads"))
.filter(Files::isRegularFile)
.forEach(file -> {
try {
String hash = calculateFileHash(file);
fileHashes.put(file.toString(), hash);
} catch (IOException e) {
log.error("Failed to calculate hash for file: " + file, e);
}
});
// 保存或比较完整性报告
saveIntegrityReport(fileHashes);
} catch (IOException e) {
log.error("Failed to scan upload directory", e);
}
}
}
4. 应用配置文件
yaml
# application.yml
spring:
servlet:
multipart:
max-file-size: 10MB
max-request-size: 10MB
enabled: true
file:
upload:
base-path: /app/uploads
max-size: 10485760 # 10MB
allowed-extensions: jpg,jpeg,png,gif,pdf,txt,doc,docx
allowed-mime-types:
- image/jpeg
- image/png
- image/gif
- application/pdf
- text/plain
- application/msword
- application/vnd.openxmlformats-officedocument.wordprocessingml.document
security:
enable-path-traversal-protection: true
enable-file-integrity-check: true
enable-access-logging: true
总结
通过本文的介绍,我们了解了Spring Boot应用中文件访问的主要安全风险和相应的防护措施。
文件安全是一个持续的过程,需要定期审查和更新安全策略。通过实施上述措施,您可以显著提高Spring Boot应用的文件访问安全性,有效防范任意文件访问漏洞。