Hình dung nhận tệp như một thợ thẩm định ở quầy cầm đồ: người lạ đưa tới một món đồ dán đầy nhãn do chính họ viết — "hàng thật", "bên trong là ảnh", "vô hại thôi". Người giỏi nghề tin món đồ, không tin cái nhãn. Nhận tệp từ người dùng là chỗ gặp nhau của tiện dụng và rủi ro, và 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 và @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". Cái quầy từ chối món đồ quá to mà không nói một lời thì cũng bằng không.

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 — cái nhãn địa chỉ do người gửi tự viết — 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 — gắn số hiệu kho của chính bạn — và 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 — cái sticker "bên trong là ảnh". Đổi virus.exe thành Content-Type: image/png là xong.

Kiểm bằng nội dung thật — mở hộp ra xem:

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 — món "vô hại" mà thật ra cài kíp. 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 — xử món hàng khả nghi ở xa cái quầy chính. 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* và hai dấu nháy đơn — cú pháp lạ nhưng đúng đặc tả.

Nếu chỉ thử một thứ sau bài này, đẩy một món đồ quá khổ qua quầy trong 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.

Mẫu số chung

Mọi thứ client nói về một tệp tải lên đều là một lời khai, không phải sự thật: tên tệp, Content-Type, phần đuôi — tất cả đến từ phía kẻ tấn công điều khiển được, nên bạn kiểm chính các byte, không kiểm cái nhãn: số hiệu đầu tệp, thử giải mã ảnh, đổi sang một cái tên do chính bạn chọn. Đây là ranh giới mọi đầu vào đều thù địch — cùng một tư thế với tiêm SQL, với bài IDOR và bài validation — và tải tệp có một lưỡi dao sắc mà phần đông bỏ sót: "ảnh" tự nó cũng là một lời khai, và SVG là một tài liệu XML chạy được, nên một tệp được phục vụ có thể chạy script với cookie của người dùng bạn. Ở ranh giới tin cậy, bạn thôi tin và bắt đầu kiểm — và kiểm món đồ, không kiểm cái nhãn.

Điều thứ hai: một giới hạn tài nguyên phải hỏng ồn ào và sớm. Cái 413-với-thân-rỗng là một giới hạn có bật nhưng chẳng nói gì cho người gọi — gần như vô dụng ngang với không có giới hạn — và nó lọt qua một handler có phạm vi vì multipart vỡ trước khi request được khớp vào controller, bên dưới chỗ handler của bạn nhìn tới. Các bài học chồng lên nhau: chặn trần tài nguyên kẻo thành DoS (getBytes() trên tệp 500 MB nhân mười người gọi là 5 GB heap), đọc-theo-luồng mọi thứ có thể lớn thay vì nạp hết vào bộ nhớ (đúng cái đừng-nạp-tất-cả của bài tệp-I/O và bài phân trang), và biết mỗi kiểu lỗi có thể phát sinh ở đâu trong đường ống để cách xử lý của bạn bắt được cả những cái nổ sớm — một cái chốt từ chối trong im lặng là một cái chốt mà người gọi không làm gì được.

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