Spring Boot实现文件访问安全

前言

在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应用的文件访问安全性,有效防范任意文件访问漏洞。

相关推荐
IMPYLH14 小时前
Lua 的 setmetatable 函数
开发语言·笔记·后端·游戏引擎·lua
Victor35614 小时前
Redis(170)如何使用Redis实现分布式限流?
后端
Victor35615 小时前
Redis(171)如何使用Redis实现分布式事务?
后端
Tsonglew15 小时前
Python 自由线程实现原理深度解析
后端·python
锋行天下1 天前
公司内网部署大模型的探索之路
前端·人工智能·后端
码事漫谈1 天前
C++异常安全保证:从理论到实践
后端
码事漫谈1 天前
C++对象生命周期与析构顺序深度解析
后端
张较瘦_1 天前
SpringBoot3 | SpringBoot中Entity、DTO、VO的通俗理解与实战
java·spring boot·后端
LucianaiB1 天前
从 0 到 1 玩转 N8N——初识 N8N(入门必看)
后端