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:

python
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:

text
Hasil os.path.join  : /var/www/app/uploads/../../../../etc/passwd
Setelah dinormalkan  : /etc/passwd
Masih di dalam base?   False

Empat 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):

python
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:

text
'laporan-2025.pdf'   -> /var/www/app/uploads/laporan-2025.pdf
'../../../../etc/passwd' -> DITOLAK
'sub/foto.png'       -> /var/www/app/uploads/sub/foto.png

Mari kita bedah kode di atas:

  1. Baris 3 (BASE = Path(...).resolve()): base directory diselesaikan sekali di awal menjadi bentuk absolut, sehingga pembandingnya selalu path penuh dan konsisten.
  2. Baris 7 (kandidat = (BASE / nama_file).resolve()): operator / milik Path menggabungkan path, lalu resolve() menyelesaikan seluruh .. dan . — termasuk yang disembunyikan input.
  3. Baris 8 (kandidat.is_relative_to(BASE)): method ini mengembalikan True hanya jika path final memang berada di bawah BASE. Path yang memanjat keluar otomatis gagal.
  4. Baris 10 (return None): penolakan dikembalikan sebagai None — 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.

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:

python
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:

text
resolve() mengikuti symlink : /tmp/demo-rahasia.txt
Masih di dalam base?        : False

Method 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.