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

相关推荐
武子康5 小时前
Shippy:确定性工具、会话级 Sandbox 与 Live-Data Eval(4 类收敛 + 7 步实现方案 + 6 类评测指标)
前端·人工智能·后端
我叫黑大帅5 小时前
我为什么单一消费者的场景下,要用 Redis List 当消息队列?
redis·后端·面试
AskHarries5 小时前
文件上传系统
后端
止语Lab6 小时前
好的 DX 不等于少写代码——三种语言的摩擦力设计课
后端
吃饱了得干活6 小时前
别再手动解析 LLM 输出了!LangChain 四种结构化输出方案对比
后端·python·langchain
程序员天天困6 小时前
Arthas trace 命令怎么用?一行定位最慢那行代码
jvm·后端
Huiturn6 小时前
GPT 5.6 连续编码 10 小时,纯 Python 啃下 Word 二进制格式——doc2docx 实现拆解
后端
用户298698530146 小时前
Python 实现 Excel 与 Markdown 互转的实用指南
后端·python·excel
用户77283104908407 小时前
krono-job:零侵入、单二进制交付的分布式任务调度平台(开源)
后端
Conan在掘金7 小时前
鸿蒙报错速查:Function lacks ending return statement,返回路径漏 return 就炸,根因 + 真解法
后端