Nhận tệp từ người dùng là chỗ gặp nhau của tiện dụng và rủi ro. Bài này về cả hai.

Nhận tệp

@PostMapping("/tep")
Map<String, Object> tep(@RequestPart("tep") MultipartFile f) {
    return Map.of("ten", f.getOriginalFilename(),
                  "kichThuoc", f.getSize(),
                  "kieu", String.valueOf(f.getContentType()));
}
  {"ten":"nho.bin","kieu":"application/octet-stream","kichThuoc":500}

Không cần cấu hình gì — Spring Boot bật sẵn hỗ trợ multipart.

@RequestPart@RequestParam đều nhận được MultipartFile. Khác biệt: @RequestPart đi qua bộ chuyển đổi thông điệp nên đọc được phần JSON kèm theo, còn @RequestParam chỉ xử lý dạng chuỗi đơn giản.

Nhiều tệp thì dùng List<MultipartFile> hoặc MultipartFile[].

Giới hạn kích thước

spring:
  servlet:
    multipart:
      max-file-size: 1MB
      max-request-size: 2MB

Hai tham số khác nhau: max-file-size cho một tệp, max-request-size cho cả request. Tải mười tệp 500 KB thì mỗi tệp qua được nhưng tổng có thể vượt.

Mặc định của Spring Boot là 1MB và 10MB — đủ nhỏ để bất ngờ, đủ lớn để không ai nhận ra là mình chưa đặt.

Vượt trần: 413 với thân rỗng

  tệp 500 byte  -> 200 {"ten":"nho.bin",...}
  tệp 2 MB      -> 413
  thân response :                        <- RỖNG

Mã trạng thái đúng, nhưng client không nhận được thông điệp nào. Với API dùng JSON, một thân rỗng là thứ client không phân tích được — và người dùng nhận về "lỗi không xác định".

Chỗ khó chịu hơn: @RestControllerAdvice của bạn thường không bắt được lỗi này.

Lý do: multipart hỏng trước khi Spring biết request thuộc controller nào. Lúc đó chưa có handler, nên một @ControllerAdvice khai basePackages bị loại khỏi danh sách ứng viên, và ngoại lệ rơi xuống bộ xử lý mặc định.

Cách chữa là để advice ở phạm vi toàn cục và tự phân loại theo URI:

@RestControllerAdvice          // KHÔNG khai basePackages
class XuLyTaiLen {
    @ExceptionHandler(MaxUploadSizeExceededException.class)
    ResponseEntity<Map<String,String>> qua(MaxUploadSizeExceededException e) {
        return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
                .body(Map.of("thongDiep", "tệp vượt quá dung lượng cho phép"));
    }
}

Và một cảnh báo về cách kiểm: MockMvc không bắt được lỗi này. MockMultipartFile không đi qua bộ phân tích multipart thật, nên test xanh trong khi chạy thật vẫn 413 rỗng. Phải thử bằng curl trên container thật.

Đây là một trong những mục nằm trong CLAUDE.md của chính blog này, sau khi tôi mất khá lâu mới lần ra.

Bốn việc phải làm với tệp người dùng

Một: đừng tin getOriginalFilename()

Tên tệp do client gửi lên, và nó có thể là ../../etc/cron.d/backdoor.

String ten = UUID.randomUUID() + duoiFileAnToan(f.getOriginalFilename());
Path dich = goc.resolve(ten).normalize();
if (!dich.startsWith(goc)) throw new SecurityException("đường dẫn không hợp lệ");

Cách chắc nhất là không dùng tên gốc làm tên lưu. Sinh tên mới, giữ tên gốc trong CSDL để hiển thị.

Bước startsWith sau normalize() là bắt buộc — normalize() một mình không chặn được gì.

Hai: đừng tin getContentType()

Header này cũng do client đặt. Đổi virus.exe thành Content-Type: image/png là xong.

Kiểm bằng nội dung thật:

byte[] dau = Arrays.copyOf(f.getBytes(), 8);
// PNG: 89 50 4E 47 | JPEG: FF D8 FF | PDF: 25 50 44 46

Và với ảnh, cách chắc nhất là thử giải mã nó: ImageIO.read() trả null nghĩa là không phải ảnh.

Ba: SVG là mã, không phải ảnh

Đây là chỗ hay bị bỏ sót nhất. SVG là XML và chạy được JavaScript:

<svg xmlns="http://www.w3.org/2000/svg"><script>alert(document.cookie)</script></svg>

Người dùng tải lên "ảnh" này, bạn phục vụ nó ở cùng tên miền, và bất kỳ ai xem đều chạy mã đó với cookie của họ.

Ba cách xử lý: làm sạch bằng jsoup với safelist chỉ cho phép thẻ hình học; phục vụ từ tên miền khác để cùng lắm cũng không chạm được cookie chính; hoặc không nhận SVG.

Blog này chọn cách thứ nhất — có một SvgSanitizer riêng cho việc đó.

Bốn: đừng phục vụ tệp từ chính ứng dụng nếu tránh được

Content-Disposition: attachment buộc trình duyệt tải xuống thay vì hiển thị, và thêm X-Content-Type-Options: nosniff để nó không tự đoán kiểu.

Nhưng cách tốt hơn là đưa tệp lên kho đối tượng (S3, MinIO) và phục vụ qua URL có chữ ký, hoặc qua một tên miền riêng. Blog này lưu trên MinIO trong bucket riêng tư và phục vụ qua /media/**.

Tệp lớn: đừng gọi getBytes()

byte[] tatCa = f.getBytes();      // toàn bộ tệp vào heap

Với tệp 500 MB và mười người tải cùng lúc, đó là 5 GB. Dùng luồng:

try (var in = f.getInputStream()) {
    Files.copy(in, dich, StandardCopyOption.REPLACE_EXISTING);
}

Spring đã ghi phần vượt ngưỡng ra đĩa tạm (file-size-threshold, mặc định 0 nghĩa là ghi đĩa ngay), nên getInputStream() đọc từ đó chứ không giữ trong bộ nhớ.

Với tệp thật lớn, cân nhắc cho client tải thẳng lên kho đối tượng bằng URL có chữ ký. Ứng dụng chỉ cấp URL và ghi nhận kết quả — không byte nào đi qua nó.

Tải xuống

@GetMapping("/tai/{id}")
ResponseEntity<Resource> tai(@PathVariable String id) {
    var r = new InputStreamResource(kho.moLuong(id));
    return ResponseEntity.ok()
            .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"bao-cao.pdf\"")
            .header(HttpHeaders.CONTENT_TYPE, "application/pdf")
            .body(r);
}

Tên tệp có dấu tiếng Việt cần mã hoá theo RFC 5987, nếu không trình duyệt hiện ra ký tự hỏng:

String ten = "báo-cáo.pdf";
String maHoa = URLEncoder.encode(ten, UTF_8).replace("+", "%20");
.header(CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + maHoa)

Chú ý dấu sao trong filename*hai dấu nháy đơn — cú pháp lạ nhưng đúng đặc tả.

Thử ba mươi giây

head -c 50000000 /dev/urandom > /tmp/to.bin
curl -si -X POST http://localhost:8080/tep -F "tep=@/tmp/to.bin" | head -20

Ra 413 với thân rỗng nghĩa là client của bạn không biết chuyện gì đã xảy ra. Ra 500 nghĩa là còn tệ hơn — bạn đang có một handler bắt Exception như bài 14 đã nói.

Ngày mai: gọi HTTP từ Spring — và phép chờ mặc định là vô hạn.