Mengandalkan file.getOriginalFilename() atau header Content-Type dari request HTTP untuk memvalidasi berkas unggahan adalah celah keamanan fatal. Nilai Content-Type dikirimkan sepenuhnya oleh klien dan dapat dimanipulasi menggunakan perkakas seperti cURL, Burp Suite, atau skrip otomatisasi. Penyerang dapat mengunggah skrip berbahaya (misalnya PHP atau JSP webshell) dengan ekstensi .jpg dan header image/jpeg, yang kemudian dieksekusi oleh web server jika disimpan pada direktori publik.
Untuk mengamankan endpoint unggahan berkas di Spring Boot dari eksekusi arbitrary file dan berkas polyglot (berkas biner yang valid sebagai dua format berbeda), sistem harus menginspeksi signature biner atau magic bytes secara langsung dari InputStream sebelum berkas disimpan.
1. Kerapuhan Validasi Konvensional
Dua metode validasi umum yang tidak memadai:
- Validasi Ekstensi String: Mengambil substring setelah titik terakhir pada nama berkas. Celah:
shell.php.jpg,shell.jsp%00.png, atau file berkas biner berbahaya yang hanya diganti namanya menjadi.pdf. - Validasi
multipartFile.getContentType(): Mengambil headerContent-Typedari MIME part. Celah: Nilai ini ditentukan sepenuhnya oleh HTTP client header dan tidak memverifikasi isi byte payload sebenarnya.
Solusi yang benar adalah membaca byte penanda di awal berkas (magic numbers), seperti FF D8 FF untuk JPEG atau 89 50 4E 47 0D 0A 1A 0A untuk PNG, lalu mencocokkannya dengan format MIME yang diizinkan.
2. Konfigurasi Batas Multipart di application.yml
Cegah serangan Denial of Service (DoS) berbasis resource exhaustion dengan membatasi ukuran request dan berkas pada layer servlet container Spring Boot:
spring:
servlet:
multipart:
enabled: true
max-file-size: 5MB
max-request-size: 10MB
file-size-threshold: 2MB
Properti file-size-threshold menginstruksikan servlet container untuk menahan berkas di memori jika ukurannya di bawah 2MB, dan menulis sementara ke temporary directory jika melebihinya, mencegah konsumsi RAM berlebih.
3. Dependensi Apache Tika
Gunakan Apache Tika Core untuk mendeteksi tipe MIME berdasarkan signature biner tanpa memerlukan dependensi parser format berat:
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-core</artifactId>
<version>2.9.2</version>
</dependency>
4. Implementasi Custom ConstraintValidator
Buat custom constraint annotation agar validasi dapat dideklarasikan secara deklaratif pada parameter controller.
Definisi Anotasi @ValidFile
package com.example.upload.security;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
@Documented
@Target({ElementType.PARAMETER, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = FileSecurityValidator.class)
public @interface ValidFile {
String message() default "Tipe berkas tidak valid atau berbahaya";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
String[] allowedTypes() default {"image/jpeg", "image/png", "application/pdf"};
}
Implementasi Validator dengan Tika InputStream
Validasi dilakukan langsung dari InputStream milik MultipartFile. Tika menginspeksi byte awal menggunakan buffer bertanda (mark/reset) sehingga seluruh isi berkas tidak perlu dibaca ke heap memory ataupun ditulis ke storage terlebih dahulu.
package com.example.upload.security;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import org.apache.tika.Tika;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;
import java.util.List;
public class FileSecurityValidator implements ConstraintValidator<ValidFile, MultipartFile> {
private final Tika tika = new Tika();
private List<String> allowedTypes;
@Override
public void initialize(ValidFile constraintAnnotation) {
this.allowedTypes = Arrays.asList(constraintAnnotation.allowedTypes());
}
@Override
public boolean isValid(MultipartFile file, ConstraintValidatorContext context) {
if (file == null || file.isEmpty()) {
return true; // Tangani kebutuhan mandatory lewat @NotNull terpisah
}
try (InputStream inputStream = file.getInputStream()) {
// Tika membaca magic bytes langsung dari stream tanpa write ke disk
String detectedMimeType = tika.detect(inputStream);
boolean isAllowed = allowedTypes.contains(detectedMimeType);
if (!isAllowed) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(
"MIME type biner tidak diizinkan: " + detectedMimeType
).addConstraintViolation();
return false;
}
return true;
} catch (IOException e) {
return false;
}
}
}
5. Mitigasi Path Traversal dan Penyimpanan Aman
Jangan pernah menyimpan berkas menggunakan nilai file.getOriginalFilename() secara langsung. Karakter manipulatif seperti ../../etc/cron.d/malicious dapat menimpa berkas konfigurasi sistem.
package com.example.upload.service;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.*;
import java.util.Map;
import java.util.UUID;
@Service
public class FileStorageService {
private final Path rootLocation = Paths.get("/var/app/uploads").toAbsolutePath().normalize();
private static final Map<String, String> MIME_TO_EXT = Map.of(
"image/jpeg", ".jpg",
"image/png", ".png",
"application/pdf", ".pdf"
);
public String store(MultipartFile file, String verifiedMimeType) throws IOException {
String extension = MIME_TO_EXT.get(verifiedMimeType);
if (extension == null) {
throw new IllegalArgumentException("Ekstensi tidak terdaftar untuk MIME: " + verifiedMimeType);
}
// Generate nama file acak menggunakan UUID untuk eliminasi path traversal
String secureFilename = UUID.randomUUID().toString() + extension;
Path destinationFile = this.rootLocation.resolve(secureFilename).normalize();
// Validasi absolut path boundary check
if (!destinationFile.getParent().equals(this.rootLocation)) {
throw new SecurityException("Target penyimpanan di luar direktori yang diizinkan");
}
Files.copy(file.getInputStream(), destinationFile, StandardCopyOption.REPLACE_EXISTING);
return secureFilename;
}
}
6. Unit Test: Verifikasi File Ekstensi Palsu
Pengujian unit menggunakan MockMultipartFile untuk memastikan bahwa file teks/skrip berekstensi .jpg dengan header palsu akan ditolak oleh validator.
package com.example.upload.security;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockMultipartFile;
import jakarta.validation.ConstraintValidatorContext;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.*;
class FileSecurityValidatorTest {
private FileSecurityValidator validator;
private ConstraintValidatorContext context;
@BeforeEach
void setUp() {
validator = new FileSecurityValidator();
ValidFile annotation = mock(ValidFile.class);
when(annotation.allowedTypes()).thenReturn(new String[]{"image/jpeg", "image/png"});
validator.initialize(annotation);
context = mock(ConstraintValidatorContext.class);
ConstraintValidatorContext.ConstraintViolationBuilder builder =
mock(ConstraintValidatorContext.ConstraintViolationBuilder.class);
when(context.buildConstraintViolationWithTemplate(anyString())).thenReturn(builder);
}
@Test
void shouldRejectFakeJpgFileContainingTextScript() {
// Payload teks/shell dengan nama .jpg dan header image/jpeg
byte[] fakeJpgPayload = "<?php echo 'webshell'; ?>".getBytes(StandardCharsets.UTF_8);
MockMultipartFile fakeFile = new MockMultipartFile(
"file",
"avatar.jpg",
"image/jpeg",
fakeJpgPayload
);
boolean isValid = validator.isValid(fakeFile, context);
assertFalse(isValid, "Validator harus menggagalkan file teks yang menyamar sebagai JPG");
}
@Test
void shouldAcceptRealPngFile() {
// Magic byte valid untuk format PNG (8 byte signature)
byte[] validPngHeader = new byte[] {
(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52
};
MockMultipartFile validFile = new MockMultipartFile(
"file",
"photo.png",
"image/png",
validPngHeader
);
boolean isValid = validator.isValid(validFile, context);
assertTrue(isValid, "Validator harus menerima byte signature PNG yang valid");
}
}
7. Batasan dan Rekomendasi Tambahan
- Polyglot Files: Berkas polyglot dapat memiliki magic bytes gambar yang sah namun tetap membawa payload eksekusi pada metadata atau bagian data biner lainnya. Untuk keamanan aset media gambar, proses ulang gambar menggunakan pustaka grafis (seperti
Graphics2DatauImageIO) untuk me-render ulang byte dan menghapus payload tersembunyi. - Execution Permissions: Simpan seluruh berkas unggahan di luar web application context root, dan pastikan direktori penyimpanan diatur dengan permission
chmod 644(tanpa flag execute). Lebih aman lagi jika berkas diteruskan ke object storage terisolasi (seperti Amazon S3 atau Google Cloud Storage). - Serving Response: Saat mengembalikan berkas kepada pengguna, selalu set header
X-Content-Type-Options: nosniffdanContent-Disposition: attachmentuntuk mencegah browser mengeksekusi berkas SVG/HTML berbahaya.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!