Fitur “unduh lampiran” atau “lihat foto profil” yang menerima nama file dari pengguna punya satu pertanyaan maut di baliknya: apakah path yang terbentuk masih berada di folder yang diizinkan? Penyerang memakai urutan ../ untuk memanjat keluar folder tersebut — serangan bernama path traversal — dan membaca file mana pun yang bisa dijangkau proses aplikasi. Chapter ini mendemonstrasikan pelariannya secara nyata, lalu membangun pola pertahanannya.
Anatomi Serangan #
Fungsi os.path.join() terlihat menyatukan dua path dengan rapi, tetapi ../ di sisi kanan tidak sekadar menjadi nama folder. Perhatikan path yang terbentuk dari input pengguna:
import os
base = "/var/www/app/uploads"
nama_file = "../../../../etc/passwd"
path_gabung = os.path.join(base, nama_file)
print("Hasil os.path.join :", path_gabung)
print("Setelah dinormalkan :", os.path.normpath(path_gabung))
print("Masih di dalam base? ", os.path.normpath(path_gabung).startswith(base))Output yang Dihasilkan:
Hasil os.path.join : /var/www/app/uploads/../../../../etc/passwd
Setelah dinormalkan : /etc/passwd
Masih di dalam base? FalseEmpat kali ../ memanjat empat tingkat direktori: dari /var/www/app/uploads keluar hingga ke root, lalu masuk ke /etc/passwd. Fungsi os.path.normpath() menyelesaikan ../ secara matematis dan memperlihatkan tujuan sebenarnya — inilah yang akan dibaca open() ketika sistem operasi mengeksekusinya. File passwd hanya contoh; konfigurasi aplikasi, kunci, dan source code berada dalam jangkauan yang sama.
Pola Aman: resolve dan is_relative_to #
Pertahanannya berdiri di satu kalimat: selesaikan path final, lalu pastikan path final tersebut masih berada di dalam base directory. Class pathlib.Path menyediakan keduanya melalui resolve() dan is_relative_to() (tersedia sejak Python 3.9):
from pathlib import Path
BASE = Path("/var/www/app/uploads").resolve()
def path_aman(nama_file: str) -> Path | None:
kandidat = (BASE / nama_file).resolve()
if kandidat.is_relative_to(BASE):
return kandidat
return None
for nama in ["laporan-2025.pdf", "../../../../etc/passwd", "sub/foto.png"]:
hasil = path_aman(nama)
print(f"{nama!r:20} -> {hasil if hasil is not None else 'DITOLAK'}")Output yang Dihasilkan:
'laporan-2025.pdf' -> /var/www/app/uploads/laporan-2025.pdf
'../../../../etc/passwd' -> DITOLAK
'sub/foto.png' -> /var/www/app/uploads/sub/foto.pngMari kita bedah kode di atas:
- Baris 3 (
BASE = Path(...).resolve()): base directory diselesaikan sekali di awal menjadi bentuk absolut, sehingga pembandingnya selalu path penuh dan konsisten. - Baris 7 (
kandidat = (BASE / nama_file).resolve()): operator/milikPathmenggabungkan path, laluresolve()menyelesaikan seluruh..dan.— termasuk yang disembunyikan input. - Baris 8 (
kandidat.is_relative_to(BASE)): method ini mengembalikanTruehanya jika path final memang berada di bawahBASE. Path yang memanjat keluar otomatis gagal. - Baris 10 (
return None): penolakan dikembalikan sebagaiNone— bukan exception — sehingga pemanggil tinggal memeriksa hasilnya sebelum membuka file.
Satu jebakan klasik: pemeriksaan string path.startswith("/var/www/app/uploads") bisa tertipu oleh folder saudara bernama /var/www/app/uploads-rahasia, karena prefix stringnya sama. Pemeriksaan is_relative_to() tidak tertipu karena membandingkan komponen path, bukan awalan teks.
Symlink, Whitelist, dan Pengiriman File #
Pemeriksaan string bisa tertipu satu hal lagi: symlink. Tautan di dalam base directory yang menunjuk ke luar lolos dari pemeriksaan nama, tetapi tidak dari resolve() yang mengikuti tautan sampai tujuan akhirnya:
from pathlib import Path
base = Path("/tmp/demo-uploads")
rahasia = Path("/tmp/demo-rahasia.txt")
base.mkdir(exist_ok=True)
(base / "asli.txt").write_text("data publik")
rahasia.write_text("data rahasia")
link = base / "tautan.txt"
if link.is_symlink() or link.exists():
link.unlink()
link.symlink_to(rahasia)
kandidat = base / "tautan.txt"
print("resolve() mengikuti symlink :", kandidat.resolve())
print("Masih di dalam base? :", kandidat.resolve().is_relative_to(base.resolve()))
# pembersihan file demo
link.unlink()
(base / "asli.txt").unlink()
base.rmdir()
rahasia.unlink()Output yang Dihasilkan:
resolve() mengikuti symlink : /tmp/demo-rahasia.txt
Masih di dalam base? : FalseMethod resolve() membongkar tujuan sebenarnya /tmp/demo-rahasia.txt, dan pemeriksaan is_relative_to menolaknya meskipun tautannya sendiri berada di dalam uploads.
Untuk kasus yang lebih sederhana — daftar file yang bisa diunduh memang terbatas — pendekatan whitelist lebih aman lagi: aplikasi menyimpan set nama file sah, dan input hanya boleh persis sama dengan salah satu anggotanya, persis seperti allowlist pada Chapter Validasi dan Sanitasi Input. Terakhir, saat mengirim file di Flask, fungsi send_file() menerima path hasil pemeriksaan kita; jangan meneruskan input mentah, dan jangan memakai send_file(os.path.join(base, nama)) tanpa validasi. Dengan pola ini, nama file dari pengguna kembali menjadi data yang tidak bisa memanjat ke mana-mana.