REST API kita kini mampu menyajikan data terpaginasi, tetapi dunia nyata tidak hanya mengirim teks: foto produk, lampiran PDF, dan bukti transfer datang sebagai berkas. Menerima berkas dari pihak yang tidak dipercaya adalah titik masuk paling rawan di aplikasi web — penyerang dapat mengirim nama berkas berbahaya, berkas raksasa untuk memenuhi memori, atau berkas untuk menimpa konfigurasi server. Pada Chapter ini kita bedah multipart/form-data, anotasi MultipartFile, tiga lapis validasi, lalu membuktikan serangan path traversal — dan penangkalnya — dalam simulasi Java murni.

Multipart/form-data dari Dalam #

Form HTML biasa mengirim pasangan kunci-nilai sebagai application/x-www-form-urlencoded — format yang tidak sanggup membawa data biner. Unggahan berkas memakai multipart/form-data: badan request dipecah menjadi beberapa part oleh pembatas (boundary), dan tiap part membawa header sendiri: nama field, nama berkas, tipe MIME. Servlet container sudah mem-parsing format ini sebelum request mencapai controller, sehingga kode kita hanya menerima objek MultipartFile yang rapi — ditampilkan apa adanya untuk Spring Boot 3.4/3.5:

java
@PostMapping("/api/produk/{id}/foto")
public ResponseEntity<Void> unggahFoto(@PathVariable long id,
        @RequestParam("berkas") MultipartFile berkas) throws IOException {
    if (berkas.isEmpty()) {
        return ResponseEntity.badRequest().build();
    }
    String namaTersimpan = penyimpan.simpan(id, berkas);
    return ResponseEntity.created(URI.create("/api/berkas/" + namaTersimpan)).build();
}

MultipartFile mengekspos getOriginalFilename(), getContentType(), getSize(), getInputStream(), dan transferTo(Path). Dari semuanya, hanya getOriginalFilename() yang datang langsung dari klien — di situlah masalahnya: nama itu boleh memuat karakter apa pun, termasuk pemisah direktori.

Validasi Berlapis Sebelum Menyimpan #

Unggahan yang tidak tervalidasi menyerang tiga sasaran sekaligus: memori (berkas raksasa), disk (mengisi penyimpanan), dan integritas sistem (menimpa berkas di luar direktori unggahan). Pertahanan berlapis menutup ketiganya — dan lapis pertama sebaiknya diletakkan di konfigurasi, bukan kode:

yaml
spring:
  servlet:
    multipart:
      max-file-size: 5MB       # tolak di batas servlet sebelum memori terisi
      max-request-size: 10MB   # batas total seluruh part dalam satu request

Batas di application.yml dijalankan container sebelum controller dipanggil. Setelah lolos batas global, service masih memeriksa tiga hal: ukuran per kasus (foto produk cukup 1 MB meski global 5 MB), daftar izin ekstensi (whitelist .pdf, .png, .jpg — bukan blacklist), dan signature isi bila perlu (ImageIO.read memastikan berkas benar-benar gambar). Whitelist menang karena daftar izin tertutup; daftar larangan selalu ketinggalan variasi baru.

Path Traversal dan Penangkalnya #

Serangan klasik unggahan bernama path traversal: nama berkas seperti ../../etc/konfig dipakai mentah-mentah sebagai path tujuan, sehingga operasi penyimpanan menulis ke luar direktori yang disiapkan. Variannya memakai path absolut (/etc/penting) — Path.resolve mengembalikan path absolut itu apa adanya, melewati basis sepenuhnya. Bandingkan dua versi penyimpanan berikut:

java
// RENTAN: nama asli klien dipakai langsung sebagai path tujuan
public String simpanRentan(MultipartFile berkas) throws IOException {
    Path target = Path.of("unggahan").resolve(berkas.getOriginalFilename());
    berkas.transferTo(target);   // "../../etc/konfig" lolos ke luar unggahan/!
    return target.toString();
}
java
// AMAN: normalisasi, uji batas direktori, lalu nama baru dari server
public String simpanAman(MultipartFile berkas) throws IOException {
    Path basis = Path.of("unggahan").toAbsolutePath().normalize();
    Path target = basis.resolve(berkas.getOriginalFilename()).normalize();
    if (!target.startsWith(basis)) {                     // pagar wajib
        throw new IllegalArgumentException("path keluar dari basis");
    }
    String namaBaru = UUID.randomUUID() + ".pdf";        // klien tidak menamai disk
    Files.copy(berkas.getInputStream(), basis.resolve(namaBaru));
    return namaBaru;
}

Dua baris versi aman menutup celah: normalize() menuntaskan seluruh .. dan . hingga path sesungguhnya terlihat, dan startsWith(basis) memastikan hasil akhir masih di dalam direktori penyimpanan. Nama acak buatan server melumpuhkan upaya menimpa berkas yang sudah ada — klien kehilangan kendali atas nama di disk.

Simulasi Lima Unggahan #

Karena MultipartFile membutuhkan servlet container, kita bangun penyimpanan mini dengan Java murni: lima unggahan dilewatkan ke versi rentan dan versi aman secara bergantian, memakai Path.resolve/normalize/startsWith yang sama seperti kode produksi:

import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;

public class SimpanUnggahan {

    static final long BATAS_BYTE = 1_048_576L; // 1 MiB
    // List (bukan Set) agar urutan cetak deterministik.
    static final List<String> EKSTENSI_DIIZINKAN = List.of(".pdf", ".png", ".jpg");
    static final Path BASIS = Path.of("unggahan");

    record Unggahan(String namaAsli, String tipeMime, long ukuran) { }

    // ==== Versi RENTAN: nama asli dipakai langsung sebagai path tujuan ====
    static String targetRentan(Unggahan u) {
        // Tanpa normalize, tanpa pemeriksaan batas direktori.
        return BASIS.resolve(u.namaAsli()).toString();
    }

    static String statusRentan(Unggahan u) {
        Path target = BASIS.resolve(u.namaAsli()).normalize();
        boolean diDalam = target.startsWith(BASIS);
        return diDalam ? "di dalam basis (kebetulan tidak bahaya)"
                : "KELUAR DARI BASIS (penulisan lolos ke luar unggahan/)";
    }

    // ==== Versi AMAN: periksa ukuran, ekstensi, dan batas direktori ====
    static class PenyimpanAman {

        private final Map<String, Long> tersimpan = new LinkedHashMap<>();
        private int urut = 0;

        String simpan(Unggahan u) {
            // Normalisasi menuntaskan semua "." dan ".." sebelum diuji batas.
            Path target = BASIS.resolve(u.namaAsli()).normalize();
            if (!target.startsWith(BASIS)) {
                return "DITOLAK: path keluar dari basis penyimpanan";
            }
            if (u.ukuran() > BATAS_BYTE) {
                return "DITOLAK: ukuran " + ukuranTeks(u.ukuran())
                        + " melebihi batas " + ukuranTeks(BATAS_BYTE);
            }
            String ekstensi = ekstensiDari(target.getFileName().toString());
            if (!EKSTENSI_DIIZINKAN.contains(ekstensi)) {
                return "DITOLAK: ekstensi " + ekstensi + " tidak ada di daftar izin";
            }
            // Nama baru dari server: penyerang tidak memilih nama akhir di disk.
            String namaBaru = String.format("berkas-%03d%s", ++urut, ekstensi);
            tersimpan.put(namaBaru, u.ukuran());
            return "DITERIMA sebagai " + namaBaru + " (" + ukuranTeks(u.ukuran()) + ")";
        }

        Map<String, Long> daftar() {
            return tersimpan;
        }
    }

    public static void main(String[] args) {
        PenyimpanAman penyimpan = new PenyimpanAman();
        Unggahan[] uji = {
                new Unggahan("laporan-q1.pdf", "application/pdf", 122_880L),
                new Unggahan("../../etc/konfig-penting", "text/plain", 200L),
                new Unggahan("/etc/penting", "text/plain", 200L),
                new Unggahan("skrip.bat", "application/octet-stream", 500L),
                new Unggahan("foto-liburan.jpg", "image/jpeg", 2_097_152L)
        };

        System.out.println("Basis penyimpanan: " + BASIS + "/  batas=" + ukuranTeks(BATAS_BYTE)
                + "  ekstensi=" + EKSTENSI_DIIZINKAN);
        System.out.println();

        for (int i = 0; i < uji.length; i++) {
            Unggahan u = uji[i];
            System.out.printf("Unggahan %d: namaAsli=\"%s\" tipe=%s ukuran=%s%n",
                    i + 1, u.namaAsli(), u.tipeMime(), ukuranTeks(u.ukuran()));
            System.out.println("  [rentan] target=" + targetRentan(u)
                    + " -> " + statusRentan(u));
            System.out.println("  [aman]   " + penyimpan.simpan(u));
            System.out.println();
        }

        System.out.println("Isi penyimpanan aman di akhir sesi:");
        for (Map.Entry<String, Long> e : penyimpan.daftar().entrySet()) {
            System.out.println("  " + e.getKey() + " (" + ukuranTeks(e.getValue()) + ")");
        }
    }

    // Ambil ekstensi kecil (lowercase) dari segmen nama terakhir.
    static String ekstensiDari(String nama) {
        int titik = nama.lastIndexOf('.');
        if (titik < 0) {
            return "";
        }
        return nama.substring(titik).toLowerCase(Locale.ROOT);
    }

    static String ukuranTeks(long bytes) {
        return bytes < 1024 ? bytes + " B" : (bytes / 1024) + " KB";
    }
}

Output yang Dihasilkan:

text
Basis penyimpanan: unggahan/  batas=1024 KB  ekstensi=[.pdf, .png, .jpg]

Unggahan 1: namaAsli="laporan-q1.pdf" tipe=application/pdf ukuran=120 KB
  [rentan] target=unggahan/laporan-q1.pdf -> di dalam basis (kebetulan tidak bahaya)
  [aman]   DITERIMA sebagai berkas-001.pdf (120 KB)

Unggahan 2: namaAsli="../../etc/konfig-penting" tipe=text/plain ukuran=200 B
  [rentan] target=unggahan/../../etc/konfig-penting -> KELUAR DARI BASIS (penulisan lolos ke luar unggahan/)
  [aman]   DITOLAK: path keluar dari basis penyimpanan

Unggahan 3: namaAsli="/etc/penting" tipe=text/plain ukuran=200 B
  [rentan] target=/etc/penting -> KELUAR DARI BASIS (penulisan lolos ke luar unggahan/)
  [aman]   DITOLAK: path keluar dari basis penyimpanan

Unggahan 4: namaAsli="skrip.bat" tipe=application/octet-stream ukuran=500 B
  [rentan] target=unggahan/skrip.bat -> di dalam basis (kebetulan tidak bahaya)
  [aman]   DITOLAK: ekstensi .bat tidak ada di daftar izin

Unggahan 5: namaAsli="foto-liburan.jpg" tipe=image/jpeg ukuran=2048 KB
  [rentan] target=unggahan/foto-liburan.jpg -> di dalam basis (kebetulan tidak bahaya)
  [aman]   DITOLAK: ukuran 2048 KB melebihi batas 1024 KB

Isi penyimpanan aman di akhir sesi:
  berkas-001.pdf (120 KB)

Mari kita bedah hasilnya:

  1. Unggahan 1 (laporan-q1.pdf, 120 KB): kedua versi menerima — di sinilah bahaya versi rentan: sistem terlihat bekerja normal untuk kasus biasa. Versi aman menamai ulang menjadi berkas-001.pdf; nomor urut server (di produksi UUID.randomUUID()) membuat penyerang tidak bisa memilih nama akhir.
  2. Unggahan 2 (../../etc/konfig-penting): keluaran versi rentan memperlihatkan target unggahan/../../etc/konfig-penting dengan status KELUAR DARI BASIS — transferTo pada kode nyata akan menulis keluar direktori unggahan. Versi aman menolak: normalize() menuntaskan ../.. menjadi ../etc/konfig-penting, lalu startsWith(BASIS) gagal.
  3. Unggahan 3 (/etc/penting): Path.resolve mengembalikan path absolut apa adanya — keluaran versi rentan menunjuk langsung ke /etc/penting. Pagar startsWith menutup varian ini tanpa kode khusus; pemeriksaan string contains("..") justru tidak akan menangkapnya.
  4. Unggahan 4 (skrip.bat) dan 5 (2 MB jpg): lolos pagar path namun ditahan lapis berikutnya — whitelist ekstensi menolak .bat, batas ukuran menolak 2048 KB. Urutan pemeriksaan di simpan(...) menempatkan pagar path paling awal karena penolakan paling murah sekaligus paling kritikal.
  5. ekstensiDari(target.getFileName().toString()): ekstensi diambil dari segmen nama setelah normalisasi, bukan dari string asli klien — nama ../../etc/x.pdf dievaluasi pada segmen terakhirnya saja.

Unggahan yang sah kini tersimpan aman, tetapi katalog yang sama masih dibaca berulang kali dari penyimpanan setiap menit. Mekanisme yang menyembunyikan hasil kerja berat di lapisan memori — cache deklaratif dengan @Cacheable — menjadi pokok bahasan Chapter Caching berikutnya.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

SimpanUnggahan.java

Unduh berkas
java
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;

public class SimpanUnggahan {

    static final long BATAS_BYTE = 1_048_576L; // 1 MiB
    // List (bukan Set) agar urutan cetak deterministik.
    static final List<String> EKSTENSI_DIIZINKAN = List.of(".pdf", ".png", ".jpg");
    static final Path BASIS = Path.of("unggahan");

    record Unggahan(String namaAsli, String tipeMime, long ukuran) { }

    // ==== Versi RENTAN: nama asli dipakai langsung sebagai path tujuan ====
    static String targetRentan(Unggahan u) {
        // Tanpa normalize, tanpa pemeriksaan batas direktori.
        return BASIS.resolve(u.namaAsli()).toString();
    }

    static String statusRentan(Unggahan u) {
        Path target = BASIS.resolve(u.namaAsli()).normalize();
        boolean diDalam = target.startsWith(BASIS);
        return diDalam ? "di dalam basis (kebetulan tidak bahaya)"
                : "KELUAR DARI BASIS (penulisan lolos ke luar unggahan/)";
    }

    // ==== Versi AMAN: periksa ukuran, ekstensi, dan batas direktori ====
    static class PenyimpanAman {

        private final Map<String, Long> tersimpan = new LinkedHashMap<>();
        private int urut = 0;

        String simpan(Unggahan u) {
            // Normalisasi menuntaskan semua "." dan ".." sebelum diuji batas.
            Path target = BASIS.resolve(u.namaAsli()).normalize();
            if (!target.startsWith(BASIS)) {
                return "DITOLAK: path keluar dari basis penyimpanan";
            }
            if (u.ukuran() > BATAS_BYTE) {
                return "DITOLAK: ukuran " + ukuranTeks(u.ukuran())
                        + " melebihi batas " + ukuranTeks(BATAS_BYTE);
            }
            String ekstensi = ekstensiDari(target.getFileName().toString());
            if (!EKSTENSI_DIIZINKAN.contains(ekstensi)) {
                return "DITOLAK: ekstensi " + ekstensi + " tidak ada di daftar izin";
            }
            // Nama baru dari server: penyerang tidak memilih nama akhir di disk.
            String namaBaru = String.format("berkas-%03d%s", ++urut, ekstensi);
            tersimpan.put(namaBaru, u.ukuran());
            return "DITERIMA sebagai " + namaBaru + " (" + ukuranTeks(u.ukuran()) + ")";
        }

        Map<String, Long> daftar() {
            return tersimpan;
        }
    }

    public static void main(String[] args) {
        PenyimpanAman penyimpan = new PenyimpanAman();
        Unggahan[] uji = {
                new Unggahan("laporan-q1.pdf", "application/pdf", 122_880L),
                new Unggahan("../../etc/konfig-penting", "text/plain", 200L),
                new Unggahan("/etc/penting", "text/plain", 200L),
                new Unggahan("skrip.bat", "application/octet-stream", 500L),
                new Unggahan("foto-liburan.jpg", "image/jpeg", 2_097_152L)
        };

        System.out.println("Basis penyimpanan: " + BASIS + "/  batas=" + ukuranTeks(BATAS_BYTE)
                + "  ekstensi=" + EKSTENSI_DIIZINKAN);
        System.out.println();

        for (int i = 0; i < uji.length; i++) {
            Unggahan u = uji[i];
            System.out.printf("Unggahan %d: namaAsli=\"%s\" tipe=%s ukuran=%s%n",
                    i + 1, u.namaAsli(), u.tipeMime(), ukuranTeks(u.ukuran()));
            System.out.println("  [rentan] target=" + targetRentan(u)
                    + " -> " + statusRentan(u));
            System.out.println("  [aman]   " + penyimpan.simpan(u));
            System.out.println();
        }

        System.out.println("Isi penyimpanan aman di akhir sesi:");
        for (Map.Entry<String, Long> e : penyimpan.daftar().entrySet()) {
            System.out.println("  " + e.getKey() + " (" + ukuranTeks(e.getValue()) + ")");
        }
    }

    // Ambil ekstensi kecil (lowercase) dari segmen nama terakhir.
    static String ekstensiDari(String nama) {
        int titik = nama.lastIndexOf('.');
        if (titik < 0) {
            return "";
        }
        return nama.substring(titik).toLowerCase(Locale.ROOT);
    }

    static String ukuranTeks(long bytes) {
        return bytes < 1024 ? bytes + " B" : (bytes / 1024) + " KB";
    }
}