Aplikasi yang menerima nama file dari pengguna—unggah, unduh, atau pembacaan berbasis parameter—sering kali membangun path dengan menggabungkan input pengguna ke direktori dasar. Tanpa pemeriksaan ketat, penyerang dapat menyisipkan ../ untuk keluar dari direktori yang dituju dan membaca file di luar area yang diizinkan, misalnya /etc/passwd atau file konfigurasi aplikasi. Serangan ini disebut path traversal atau directory traversal, dan termasuk dalam daftar OWASP Top 10 karena dampaknya yang luas. Pada chapter ini kita akan melihat bagaimana payload ../../etc/passwd lolos saat path dibandingkan sebagai string mentah, lalu mempraktikkan tiga lapis mitigasi: normalize+startsWith, whitelist nama file, serta validasi karakter.
Anti-pattern: String Concat tanpa Normalize #
Cara paling intuitif membaca file berdasarkan input pengguna adalah File(baseDir).resolve(namaFile). Sayangnya, resolve tidak membersihkan referensi ..—/data/uploads resolve ../../etc/passwd menghasilkan /data/uploads/../../etc/passwd, yang setelah sistem operasi memprosesnya menjadi /etc/passwd. Pemeriksaan startsWith("/data/uploads") pada string mentah akan true karena prefix cocok—penyerang lolos.
val pathRentan = File("/data/uploads").resolve("../../etc/passwd")
// pathRentan.toString() == "/data/uploads/../../etc/passwd"
// pathRentan.toPath().startsWith("/data/uploads") == true (BUG!)
// Path yang sebenarnya saat OS memproses: /etc/passwdMitigasi yang benar: panggil .normalize().toAbsolutePath() sebelum membandingkan. Normalize membersihkan .. dan . menjadi path kanonik; toAbsolutePath memastikan path relatif diubah ke absolut.
Mitigasi 1: Normalize dan startsWith #
Pola yang direkomendasikan: simpan path base yang sudah dinormalisasi, resolve input, normalize hasil resolve, lalu bandingkan prefix dengan startsWith. Path hasil yang berada di luar base dir akan ditolak.
// path_traversal.kt
// ../etc/passwd attack. Mitigation: normalize + startsWith. Whitelist paths.
import java.io.File
import java.nio.file.Path
import java.nio.file.Paths
// --- Anti-pattern: gunakan input pengguna langsung sebagai path ---
fun bacaFileTidakAman(namaFile: String): String {
val path = File("/data/uploads").resolve(namaFile)
// Bila namaFile = "../../etc/passwd", path menjadi /data/etc/passwd
return if (path.exists()) path.readText() else "(tidak ada)"
}
// --- Mitigasi 1: normalize + startsWith check ---
fun bacaFileAman(namaFile: String): String {
val baseDir = Paths.get("/data/uploads").normalize().toAbsolutePath()
val resolved = baseDir.resolve(namaFile).normalize().toAbsolutePath()
if (!resolved.startsWith(baseDir)) {
return "(penolakan: path di luar base dir)"
}
val file = resolved.toFile()
return if (file.exists() && file.isFile) file.readText() else "(tidak ada)"
}
// --- Mitigasi 2: whitelist nama file yang diizinkan ---
val NAMA_FILE_DIIZINKAN: Set<String> = setOf("budi-foto.jpg", "siti-cv.pdf", "laporan-2026.txt")
fun bacaFileWhitelist(namaFile: String): String {
// Bersihkan path separators yang mencoba escape
val namaBersih = namaFile.substringAfterLast('/').substringAfterLast('\\')
if (namaBersih !in NAMA_FILE_DIIZINKAN) return "(penolakan: nama file tidak diizinkan)"
val path = File("/data/uploads").resolve(namaBersih)
return if (path.exists()) path.readText() else "(tidak ada)"
}
// --- Mitigasi 3: tolak karakter berbahaya secara eksplisit ---
fun validasiNamaFile(input: String): Boolean {
if (input.isBlank()) return false
if (input.length > 64) return false
// Tolak path separator, parent reference, null byte, dan karakter shell
val forbidden = setOf('/', '\\', ':', '*', '?', '"', '<', '>', '|', '\u0000')
if (input.any { it in forbidden }) return false
if (input.contains("..")) return false
// Whitelist karakter yang diizinkan
return input.all { it.isLetterOrDigit() || it in ".-_ " }
}
fun main() {
// Simulasi: kita tidak benar-benar menulis ke /data/uploads, jadi output hanya
// menunjukkan hasil pemeriksaan keamanan, bukan isi file.
// Anti-pattern demo
println("=== Anti-pattern: path traversal lolos ===")
val payload = "../../etc/passwd"
val pathRentan = File("/data/uploads").resolve(payload)
println("Input pengguna : $payload")
println("Path hasil : $pathRentan")
println("Raw startsWith('/data/uploads')? ${pathRentan.toPath().startsWith("/data/uploads")}")
println("Setelah normalize: ${pathRentan.toPath().normalize().toAbsolutePath()}")
println("Catatan: startsWith tanpa normalize dapat menipu karena membandingkan string mentah.")
println()
// Mitigasi 1: normalize + startsWith
println("=== Mitigasi 1: normalize + startsWith ===")
for (input in listOf("budi.txt", "../etc/passwd", "..\\..\\windows\\system32", "sub/dalam/ok.txt")) {
val baseDir = Paths.get("/data/uploads").normalize().toAbsolutePath()
val resolved = baseDir.resolve(input).normalize().toAbsolutePath()
val aman = resolved.startsWith(baseDir)
println("input=$input -> path=$resolved, diizinkan? $aman")
}
println()
// Mitigasi 2: whitelist
println("=== Mitigasi 2: whitelist nama file ===")
for (input in listOf("budi-foto.jpg", "../etc/passwd", "laporan-2026.txt", "rahasia.txt")) {
println("bacaFileWhitelist('$input'): ${bacaFileWhitelist(input).take(40)}")
}
println()
// Mitigasi 3: validasi nama file
println("=== Mitigasi 3: validasi nama file ===")
for (input in listOf("budi.txt", "../etc/passwd", "foto budi.jpg", "data:;rm", "file\u0000.txt")) {
println("validasiNamaFile(${input.replace("\u0000", "<NUL>")}) = ${validasiNamaFile(input)}")
}
}Output yang Dihasilkan:
=== Anti-pattern: path traversal lolos ===
Input pengguna : ../../etc/passwd
Path hasil : /data/uploads/../../etc/passwd
Raw startsWith('/data/uploads')? true
Setelah normalize: /etc/passwd
Catatan: startsWith tanpa normalize dapat menipu karena membandingkan string mentah.
=== Mitigasi 1: normalize + startsWith ===
input=budi.txt -> path=/data/uploads/budi.txt, diizinkan? true
input=../etc/passwd -> path=/data/etc/passwd, diizinkan? false
input=..\..\windows\system32 -> path=/data/uploads/..\..\windows\system32, diizinkan? true
input=sub/dalam/ok.txt -> path=/data/uploads/sub/dalam/ok.txt, diizinkan? true
=== Mitigasi 2: whitelist nama file ===
bacaFileWhitelist('budi-foto.jpg'): (tidak ada)
bacaFileWhitelist('../etc/passwd'): (penolakan: nama file tidak diizinkan)
bacaFileWhitelist('laporan-2026.txt'): (tidak ada)
bacaFileWhitelist('rahasia.txt'): (penolakan: nama file tidak diizinkan)
=== Mitigasi 3: validasi nama file ===
validasiNamaFile(budi.txt) = true
validasiNamaFile(../etc/passwd) = false
validasiNamaFile(foto budi.jpg) = true
validasiNamaFile(data:;rm) = false
validasiNamaFile(file<NUL>.txt) = falseMari kita bedah bagian kunci dari kode di atas:
bacaFileTidakAman: anti-pattern.File("/data/uploads").resolve("../../etc/passwd")menghasilkan path yang masih mengandung... Tidak ada pemeriksaan apakah hasil resolve berada di luar base dir.bacaFileAman(Mitigasi 1): memanggil.normalize().toAbsolutePath()pada base dir dan pada path hasil resolve.resolved.startsWith(baseDir)membandingkan path yang sudah kanonik—/etc/passwdtidak dimulai dengan/data/uploads, sehingga penolakan terjadi.bacaFileWhitelist(Mitigasi 2): membersihkan input dengansubstringAfterLast('/')dansubstringAfterLast('\\')untuk mengambil hanya nama file tanpa path. Nama file kemudian dicocokkan denganNAMA_FILE_DIIZINKAN. Payload seperti../../etc/passwdmenjadipasswdsetelah pembersihan, danpasswdtidak ada di whitelist.validasiNamaFile(Mitigasi 3): menolak karakter berbahaya seperti/,\,:,*,?,",<,>,|, dan null byte\u0000. Karakter..ditolak secara eksplisit. Sisa karakter harus termasuk whitelist (huruf, angka,.,-,_, spasi).- Output anti-pattern menunjukkan jebakan:
Raw startsWith('/data/uploads')? true—startsWithtanpa normalize dapat menipu karena membandingkan string mentah. Setelah normalize, path yang sebenarnya adalah/etc/passwd.
Mitigasi 2: Whitelist Nama File #
Bila aplikasi hanya perlu membaca file dari kumpulan terbatas (mis. foto profil yang sudah diunggah), whitelist nama file adalah pendekatan paling kuat. Daftar nama yang diizinkan disimpan eksplisit; input yang tidak ada di daftar ditolak tanpa peduli formatnya.
val namaBersih = namaFile.substringAfterLast('/').substringAfterLast('\\')
if (namaBersih !in NAMA_FILE_DIIZINKAN) return "(penolakan)"Pembersihan dengan substringAfterLast('/') memastikan hanya segmen terakhir (nama file) yang dipakai. passwd tidak ada di whitelist, sehingga penolakan terjadi—bahkan tanpa perlu pemeriksaan path kanonik.
Mitigasi 3: Validasi Karakter #
Bila aplikasi perlu menerima nama file dinamis (mis. hasil upload dengan nama asli pengguna), lakukan validasi karakter ketat. Tolak path separator, parent reference, null byte, dan karakter shell yang dapat memicu command injection bila nama file dipakai di subproses.
| Karakter | Alasan penolakan |
|---|---|
/ \ | Path separator—dapat keluar direktori |
.. | Parent reference—dapat keluar direktori |
\u0000 | Null byte—dapat memutus string di C library |
: | Pemisah stream NTFS (Windows); ASCII shell |
* ? | Wildcard shell |
< > " | Karakter terlarang di Windows filename |
| ` | ` |
Tabel di atas merangkum karakter umum yang perlu ditolak. Untuk kompatibilitas lintas platform, terapkan blacklist ini sebagai lapis pertama, lalu whitelist karakter yang diizinkan sebagai lapis kedua.
Kombinasi Tiga Lapis #
Pertahanan terbaik adalah kombinasi:
fun bacaFileTripleAman(namaFile: String): String {
if (!validasiNamaFile(namaFile)) return "(penolakan: karakter)"
val namaBersih = namaFile.substringAfterLast('/')
if (namaBersih !in NAMA_FILE_DIIZINKAN) return "(penolakan: whitelist)"
val baseDir = Paths.get("/data/uploads").normalize().toAbsolutePath()
val resolved = baseDir.resolve(namaBersih).normalize().toAbsolutePath()
if (!resolved.startsWith(baseDir)) return "(penolakan: path)"
return resolved.toFile().readText()
}Tiga lapis ini saling melengkapi: validasi karakter menolak payload paling jelas, whitelist membatasi ke kumpulan nama yang diketahui, normalize+startsWith menangkap kasus edge yang lolos dua lapis pertama.
Referensi Lanjutan #
- OWASP Path Traversal — artikel OWASP tentang vektor path traversal dan teknik mitigasinya
- OWASP Attacks — katalog vektor serangan OWASP yang berkaitan dengan manipulasi path
Penutup #
Path traversal adalah kerentanan klasik yang tetap relevan. Kombinasi normalize+startsWith, whitelist nama file, dan validasi karakter menutup celah pada hampir semua skenario. Chapter berikutnya akan membahas dependency vulnerability management—bagaimana mengelola pustaka pihak ketiga yang mungkin membawa celah keamanan ke aplikasi kita.