Fitur “unduh lampiran berdasarkan nama berkas” terdengar polos: pengirim memilih nama, aplikasi membuka berkas di folder data, selesai. Namun satu string saja cukup bagi penyerang untuk mengubah nama itu menjadi ../../etc/passwd atau ..\\config\\server.xml — dan tiba-tiba aplikasi kita menjadi pembaca berkas untuk seluruh sistem. Path traversal (juga disebut directory traversal) adalah kelas serangan yang memanfaatkan data pengguna sebagai jalur berkas tanpa pembatasan; serangan ini konsisten masuk daftar OWASP Top 10 sebagai bagian kategori broken access control karena intinya: akses melewati batas yang seharusnya ada. Bab ini membedah mekanisme .. yang membuatnya bekerja, lalu membangun validator tiga lapis dengan Path.resolve(), Path.normalize(), dan Path.startsWith().
Bagaimana Titik Dua Membawa Keluar Direktori #
Sistem berkas memberi makna khusus kepada dua komponen nama: .. berarti “naik satu direktori” dan . berarti “direktori ini”. Keduanya sah dan berguna di jalur yang kita tulis sendiri — masalah muncul ketika komponen itu datang dari luar. String laporan/../../etc/passwd yang dijabarkan terhadap direktori data berjalan begini: masuk ke data/laporan, naik dua tingkat ke akar relatif, lalu menuju etc/passwd — jauh di luar data yang kita maksudkan.
Varian serangannya berlapis. Jalur absolut (/etc/passwd, C:\Windows\win.ini) melewati direktori basis sepenuhnya karena resolve terhadap jalur absolut menghasilkan jalur absolut itu sendiri. Karakter ter-encode (%2e%2e%2f adalah ../ dalam URL encoding) menembus validasi yang dilakukan sebelum decoding di lapisan web. Pemisah gaya Windows (..\\..\\) tidak dianggap pemisah oleh Linux, tetapi kode yang sama bisa saja berjalan di server Windows — atau di wadah yang memasang berkas lintas format. Dan symlink di dalam direktori basis bisa mengarah ke luar, sehingga jalur yang terlihat aman tetap mendarat jauh.
Validator dengan Normalize dan StartsWith #
Pertahanan yang benar memakai tiga operasi java.nio.file.Path secara berurutan: resolve untuk menjadikan masukan relatif terhadap basis, normalize untuk meringkas .. dan . menjadi jalur paling sederhana, lalu startsWith untuk memastikan hasil akhir masih berada di dalam basis. Program berikut menguji tujuh masukan — tiga sah dan empat upaya kabur:
import java.nio.file.Path;
public class PenjagaPath {
// Semua akses berkas pengguna dibatasi di bawah direktori ini.
private static final Path BASIS = Path.of("data");
// Hasil pemeriksaan: diterima atau ditolak beserta keterangannya.
record Hasil(boolean diterima, String keterangan) { }
public static void main(String[] args) {
String[] masukan = {
"teks.txt",
"laporan/penjualan.csv",
"laporan/../catatan.txt",
"../etc/passwd",
"laporan/../../etc/passwd",
"..\\..\\windows\\system32",
"/etc/passwd"
};
System.out.println("=== Validator path dengan basis: " + BASIS + " ===");
for (String m : masukan) {
laporkan(m, periksa(m));
}
}
static void laporkan(String masukan, Hasil hasil) {
System.out.printf("%-28s -> %s (%s)%n",
masukan, hasil.diterima() ? "DITERIMA" : "DITOLAK ", hasil.keterangan());
}
// Tiga lapis: tolak karakter berisiko, jabarkan terhadap basis,
// normalisasi, lalu pastikan hasil tetap di dalam basis.
static Hasil periksa(String masukan) {
if (masukan == null || masukan.isBlank()) {
return new Hasil(false, "masukan kosong");
}
if (masukan.contains("\\") || masukan.contains("\0")) {
return new Hasil(false, "karakter pemisah atau NUL tidak diizinkan");
}
Path dijabarkan = BASIS.resolve(masukan).normalize();
if (!dijabarkan.startsWith(BASIS)) {
return new Hasil(false, "keluar dari basis, terjabar menjadi " + dijabarkan);
}
return new Hasil(true, "jalur aman: " + dijabarkan);
}
}Output yang Dihasilkan:
=== Validator path dengan basis: data ===
teks.txt -> DITERIMA (jalur aman: data/teks.txt)
laporan/penjualan.csv -> DITERIMA (jalur aman: data/laporan/penjualan.csv)
laporan/../catatan.txt -> DITERIMA (jalur aman: data/catatan.txt)
../etc/passwd -> DITOLAK (keluar dari basis, terjabar menjadi etc/passwd)
laporan/../../etc/passwd -> DITOLAK (keluar dari basis, terjabar menjadi etc/passwd)
..\..\windows\system32 -> DITOLAK (karakter pemisah atau NUL tidak diizinkan)
/etc/passwd -> DITOLAK (keluar dari basis, terjabar menjadi /etc/passwd)Output memuat pelajaran lengkap dalam tujuh baris. Tiga masukan sah diterima, termasuk laporan/../catatan.txt — normalisasi meringkas .. yang tidak merugikan menjadi data/catatan.txt, sehingga masukan sah tidak terganggu. Empat upaya traversal ditolak dengan alasan yang berbeda-beda: ../etc/passwd dan laporan/../../etc/passwd sama-sama terjabar menjadi etc/passwd di luar basis; ..\\..\\windows\\system32 ditolak di lapis pertama karena mengandung pemisah gaya Windows; /etc/passwd tolak karena jalur absolut mengabaikan basis sepenuhnya.
Bedah baris-per-baris untuk method periksa:
- Baris 34-36 (
masukan.contains("\\")dancontains("\0")): lapis pertama menolak karakter yang bermakna berbeda lintas platform.\\adalah pemisah di Windows (di sana..\\..\\benar-benar menembus keluar), dan byte NUL bisa memotong string di lapisan sistem operasi. Menolak keduanya membuat validator tidak bergantung pada platform tempat kode berakhir berjalan. - Baris 37 (
BASIS.resolve(masukan)):resolvemenjadikan masukan relatif terhadap basis — kecuali masukan adalah jalur absolut, yang menggantikan hasil sepenuhnya. Perilaku kedua inilah yang membuat/etc/passwdtidak bermuara kedata/etc/passwd; validator kita justru mengandalkan langkah berikutnya untuk menangkapnya. - Baris 37 (
.normalize()): normalisasi meringkasdata/laporan/../../etc/passwdmenjadietc/passwdmurni lewat manipulasi teks — tanpa menyentuh sistem berkas, tanpa memerlukan berkas itu ada. Inilah alur utama pembongkaran traversal. - Baris 38-40 (
dijabarkan.startsWith(BASIS)): pemeriksaan terakhir membandingkan prefiks komponen path, bukan prefiks string.Path.startsWith(Path.of("data"))bernilai benar untukdata/teks.txttetapi salah untukdatabase/teks.txt— bandingkan denganString.startsWith("data")yang bernilai benar untuk keduanya. Gunakan selalu versiPath, bukan versiString.
Symlink dan Jalur Kanonik #
normalize() bekerja pada teks dan tidak melihat ke sistem berkas — kekuatannya (tidak perlu I/O) sekaligus batasnya: symlink di dalam basis tidak dilihatnya sama sekali. Bila direktori kerja bisa berisi symlink yang dibuat pihak lain (folder unggahan publik adalah contoh klasiknya), lapisan tambahan wajib dipasang: setelah validasi teks, buka berkas lewat toRealPath() yang menelusuri symlink sampai jalur kanonik, lalu ulangi pemeriksaan startsWith(BASIS.toRealPath()) terhadap jalur kanonik itu. Biayanya ada pemanggilan sistem berkas nyata, tetapi hanya lapisan inilah yang tahu ke mana symlink sungguhan mengarah.
Path kanonikBasis = BASIS.toRealPath(); // sekali, saat aplikasi mulai
Path kanonikBerkas = dijabarkan.toRealPath(); // menelusuri symlink nyata
if (!kanonikBerkas.startsWith(kanonikBasis)) {
// tolak akses - jalur nyata berada di luar basis
}Pertahanan path menutup kemampuan penyerang menyentuh berkas yang bukan haknya. Ancaman terakhir dalam daftar kita justru datang dari arah yang sama sekali berbeda: bantuan yang kita unduh sendiri. Bab berikutnya, Dependency Vulnerability Management, membahas bagaimana CVE di library pihak ketiga menembus aplikasi tanpa satu baris kode kita yang salah — dan bagaimana memindai, memperbarui, serta mengelolanya secara terjadwal.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
PenjagaPath.java
Unduh berkasimport java.nio.file.Path;
public class PenjagaPath {
// Semua akses berkas pengguna dibatasi di bawah direktori ini.
private static final Path BASIS = Path.of("data");
// Hasil pemeriksaan: diterima atau ditolak beserta keterangannya.
record Hasil(boolean diterima, String keterangan) { }
public static void main(String[] args) {
String[] masukan = {
"teks.txt",
"laporan/penjualan.csv",
"laporan/../catatan.txt",
"../etc/passwd",
"laporan/../../etc/passwd",
"..\\..\\windows\\system32",
"/etc/passwd"
};
System.out.println("=== Validator path dengan basis: " + BASIS + " ===");
for (String m : masukan) {
laporkan(m, periksa(m));
}
}
static void laporkan(String masukan, Hasil hasil) {
System.out.printf("%-28s -> %s (%s)%n",
masukan, hasil.diterima() ? "DITERIMA" : "DITOLAK ", hasil.keterangan());
}
// Tiga lapis: tolak karakter berisiko, jabarkan terhadap basis,
// normalisasi, lalu pastikan hasil tetap di dalam basis.
static Hasil periksa(String masukan) {
if (masukan == null || masukan.isBlank()) {
return new Hasil(false, "masukan kosong");
}
if (masukan.contains("\\") || masukan.contains("\0")) {
return new Hasil(false, "karakter pemisah atau NUL tidak diizinkan");
}
Path dijabarkan = BASIS.resolve(masukan).normalize();
if (!dijabarkan.startsWith(BASIS)) {
return new Hasil(false, "keluar dari basis, terjabar menjadi " + dijabarkan);
}
return new Hasil(true, "jalur aman: " + dijabarkan);
}
}