Perbaiki dan Verifikasi

Remediasi temuan disertai pengujian ulang untuk memverifikasi perbaikan.

Daftar isi

Daftar temuan yang telah diberi peringkat hanya bermanfaat apabila setiap entrinya ditutup dengan perbaikan yang terbukti. Topik ini mengerjakan tahap terakhir proyek, yaitu memperbaiki keempat temuan aplikasi lab Catatan Pegawai satu per satu, lalu menjalankan ulang empat pemeriksaan audit yang sama terhadap versi amannya. Hasil akhirnya berupa aplikasi lab yang lolos seluruh pemeriksaan serta catatan penutupan temuan yang dapat ditelusuri dari temuan, kontrol, hingga bukti uji ulangnya.

Prinsip remediasi yang dapat diverifikasi#

Remediasi (remediation) adalah pengerjaan perbaikan atas temuan, dan perbaikan yang baik selalu dapat dinyatakan sebagai kontrol yang terukur. Kontrol adalah mekanisme yang mencegah atau membatasi serangan, misalnya pemeriksaan kepemilikan atau pengodean keluaran, sehingga keberhasilannya dapat diuji ulang oleh pihak lain. Prinsip kerjanya sederhana, yaitu satu temuan ditutup dengan satu kontrol, dan kontrol tersebut dipasangkan dengan pemeriksaan yang membuktikannya. Keempat temuan lab terdiri atas IDOR (Insecure Direct Object References), reflected XSS yang memantulkan masukan pada respons, token reset yang dapat diprediksi, dan pesan error debug yang membocorkan detail kesalahan. Pemetaannya disajikan sebagai berikut.

TemuanKontrol perbaikanBukti pada uji ulang
IDOR (A01)Otorisasi berbasis pemilik pada /catatan/<id>Non-pemilik menerima 403
Reflected XSS (A05)Pengodean html.escape() pada pantulanPayload tampil sebagai teks terenkode
Token reset (A07)Token acak secrets.token_urlsafe() yang sekali pakai dan berkedaluwarsaPemakaian ulang token ditolak
Error debug (A10)Pesan generik ke klien, traceback ke log internalRespons tanpa jejak traceback

Prinsip kedua adalah memperbaiki penyebab, bukan gejalanya. Temuan IDOR diperbaiki dengan pemeriksaan otorisasi, bukan dengan mengacak pengenal catatan, karena pengenal yang sulit ditebak tetap dapat bocor melalui log dan tautan. Temuan error debug diperbaiki dengan memisahkan kebutuhan dua kelompok pembaca, yaitu pengguna yang hanya perlu tahu bahwa terjadi kesalahan dan operator yang memerlukan detail traceback untuk diagnosis.

Aplikasi lab versi aman#

Versi aman memuat struktur yang sama dengan versi rentan, sehingga perbandingannya berfokus pada perbedaan kontrolnya. Empat perbaikan ditandai komentar yang diawali kata “Perbaikan” di dalam kode. Pemeriksaan kepemilikan membandingkan pemilik catatan dengan pengguna sesi sebelum data dikirim dan menolak dengan 403 apabila tidak cocok. Pantulan pencarian disaring dengan html.escape() sehingga karakter < dan > berubah menjadi entitas HTML yang aman. Token reset dibangkitkan dari secrets.token_urlsafe(32) yang menghasilkan 256 bit acak, disimpan dengan masa berlaku 900 detik, dan hanya dapat digunakan satu kali. Penanganan error mencatat traceback, yaitu catatan rantai pemanggilan saat error terjadi, ke daftar log internal, sedangkan klien hanya menerima pesan generik. Simpan berkas berikut sebagai app-catatan-aman.py.

python app-catatan-aman.py
# tidak-dijalankan
"""Aplikasi lab "Catatan Pegawai" versi aman hasil remediasi.

Keempat temuan pada app-catatan.py telah diperbaiki: pemeriksaan kepemilikan
pada /catatan/<id>, pengodean html.escape pada pantulan pencarian, token reset
secrets.token_urlsafe yang kedaluwarsa dan sekali pakai, serta penanganan
error yang mencatat traceback secara internal tanpa mengirimkannya ke klien.
Jalankan sebagai server dengan `python3 app-catatan-aman.py`, lalu buka
http://127.0.0.1:8802/.
"""

import html
import secrets
import time
import traceback
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse

CATATAN = {
    "1": {"pemilik": "budi", "judul": "Tugas minggu ini",
          "isi": "Menyiapkan laporan pemeliharaan server bulan Maret."},
    "2": {"pemilik": "budi", "judul": "Pesan untuk tim",
          "isi": "Rapat koordinasi dipindah dari Rabu ke Kamis."},
    "3": {"pemilik": "citra", "judul": "Evaluasi pengadaan",
          "isi": "Daftar penawaran kertas sedang ditinjau ulang."},
    "4": {"pemilik": "citra", "judul": "Catatan pribadi",
          "isi": "Kata sandi sementara akun uji internal: lab-2026."},
}

SESI = {"sesi-budi": "budi", "sesi-citra": "citra"}

# Perbaikan 3: token reset acak, sekali pakai, dan memiliki kedaluwarsa.
UMUR_TOKEN_DETIK = 900
TOKEN_RESET = {}

# Perbaikan 4: traceback dicatat internal, tidak pernah dikirim ke klien.
LOG_INTERNAL = []


def buat_token_reset(pengguna):
    """Mengembalikan token acak 256-bit beserta masa berlakunya."""
    token = secrets.token_urlsafe(32)
    TOKEN_RESET[token] = {
        "pemilik": pengguna,
        "kedaluwarsa": time.time() + UMUR_TOKEN_DETIK,
        "terpakai": False,
    }
    return token


def pakai_token(token, pengguna):
    """Mengembalikan True hanya untuk token milik pemohon yang masih valid."""
    data = TOKEN_RESET.get(token)
    if data is None or data["terpakai"] or data["pemilik"] != pengguna:
        return False
    if time.time() > data["kedaluwarsa"]:
        return False
    data["terpakai"] = True
    return True


def halaman(judul, tubuh):
    return f"<!DOCTYPE html><html><head><title>{judul}</title></head><body>{tubuh}</body></html>"


class PenanganCatatanAman(BaseHTTPRequestHandler):
    """Handler HTTP dengan keempat perbaikan di atas."""

    protocol_version = "HTTP/1.1"

    def _kirim(self, kode, isi, jenis="text/html; charset=utf-8", cookie=None):
        data = isi.encode("utf-8")
        self.send_response(kode)
        self.send_header("Content-Type", jenis)
        self.send_header("Content-Length", str(len(data)))
        if cookie:
            self.send_header("Set-Cookie", cookie)
        self.end_headers()
        self.wfile.write(data)

    def _pengguna(self):
        kue = self.headers.get("Cookie", "")
        for pasangan in kue.split(";"):
            if "=" in pasangan:
                nama, nilai = pasangan.strip().split("=", 1)
                if nama == "sesi":
                    return SESI.get(nilai)
        return None

    def do_POST(self):
        if urlparse(self.path).path == "/masuk":
            panjang = int(self.headers.get("Content-Length") or 0)
            isi = self.rfile.read(panjang).decode("utf-8")
            nilai = parse_qs(isi).get("pengguna", [""])
            if nilai and nilai[0] in ("budi", "citra"):
                pengguna = nilai[0]
                self._kirim(200, f"masuk sebagai {pengguna}", "text/plain; charset=utf-8",
                            cookie=f"sesi=sesi-{pengguna}; Path=/")
            else:
                self._kirim(401, "pengguna tidak dikenal", "text/plain; charset=utf-8")
        else:
            self._kirim(404, "endpoint tidak ada", "text/plain; charset=utf-8")

    def do_GET(self):
        try:
            self._rute()
        except Exception:
            # Perbaikan 4: pesan generik untuk klien, detail masuk log internal.
            LOG_INTERNAL.append(traceback.format_exc())
            self._kirim(500, halaman("Error", "<p>Terjadi kesalahan internal. "
                                            "Kesalahan telah dicatat untuk operator.</p>"))

    def _rute(self):
        url = urlparse(self.path)
        if url.path == "/":
            pengguna = self._pengguna()
            if pengguna is None:
                self._kirim(401, halaman("Masuk", "<p>Anda belum masuk.</p>"))
                return
            daftar = "".join(
                f"<li>/catatan/{id} - {html.escape(catatan['judul'])}</li>"
                for id, catatan in CATATAN.items()
                if catatan["pemilik"] == pengguna
            )
            self._kirim(200, halaman("Beranda", f"<h1>Catatan {pengguna}</h1><ul>{daftar}</ul>"))
        elif url.path.startswith("/catatan/"):
            pengguna = self._pengguna()
            if pengguna is None:
                self._kirim(401, halaman("Masuk", "<p>Anda belum masuk.</p>"))
                return
            id_catatan = url.path.rsplit("/", 1)[1]
            # Perbaikan 1: validasi id, lalu otorisasi berbasis pemilik.
            if not id_catatan.isdigit() or id_catatan not in CATATAN:
                self._kirim(404, halaman("Tidak ditemukan", "<p>Catatan tidak ditemukan.</p>"))
                return
            catatan = CATATAN[id_catatan]
            if catatan["pemilik"] != pengguna:
                self._kirim(403, halaman("Ditolak", "<p>Akses ditolak: bukan pemilik catatan.</p>"))
                return
            tubuh = f"<h1>{html.escape(catatan['judul'])}</h1><p>{html.escape(catatan['isi'])}</p>"
            self._kirim(200, halaman("Catatan", tubuh))
        elif url.path == "/cari":
            # Perbaikan 2: pantulan dienkode dengan html.escape.
            q = parse_qs(url.query).get("q", [""])[0]
            aman = html.escape(q)
            tubuh = f"<h1>Pencarian</h1><p>Hasil untuk: {aman}</p>"
            self._kirim(200, halaman("Cari", tubuh))
        elif url.path == "/reset":
            pengguna = self._pengguna()
            if pengguna is None:
                self._kirim(401, halaman("Masuk", "<p>Anda belum masuk.</p>"))
                return
            token = buat_token_reset(pengguna)
            tubuh = f"<p>Token reset untuk {pengguna} telah dibuat dan berlaku 15 menit.</p>"
            self._kirim(200, halaman("Reset", tubuh), cookie=f"token={token}; Path=/")
        elif url.path == "/reset/pakai":
            pengguna = self._pengguna()
            token = parse_qs(url.query).get("token", [""])[0]
            if pengguna is not None and pakai_token(token, pengguna):
                self._kirim(200, halaman("Reset", "<p>Kata sandi baru telah dikirim.</p>"))
            else:
                self._kirim(400, halaman("Reset", "<p>Token tidak valid, kedaluwarsa, "
                                                  "atau sudah pernah dipakai.</p>"))
        else:
            self._kirim(404, halaman("Tidak ditemukan", "<p>Halaman tidak ada.</p>"))

    def log_message(self, format, *args):
        pass  # log akses tidak dicetak agar keluaran uji ulang tetap bersih


if __name__ == "__main__":
    ThreadingHTTPServer(("127.0.0.1", 8802), PenanganCatatanAman).serve_forever()

Perbaikan token layak diamati lebih dekat karena alurnya berubah, bukan sekadar nilainya. Server tidak lagi mencetak token pada halaman, tetapi mengirimkannya kepada pemohon melalui cookie, dan penukaran token dilakukan melalui endpoint GET /reset/pakai. Fungsi pakai_token() menolak token milik pengguna lain, token yang sudah digunakan, dan token yang melampaui masa berlakunya, lalu menandai token yang sah sebagai terpakai. Rangkaian ini menutup tiga jalur penyalahgunaan sekaligus, yaitu penebakan nilai, pemakaian ulang, dan pemakaian yang terlambat.

Uji ulang dengan pemeriksaan yang sama#

Verifikasi menuntut pemeriksaan yang identik dengan pemeriksaan yang menemukan masalah, karena pemeriksaan yang berbeda dapat menguji hal yang berbeda. Skrip berikut memuat daftar periksa yang sama dengan skrip audit, mulai dari urutan pemeriksaan hingga payload yang dikirim, tetapi menargetkan app-catatan-aman.py dan menuntut hasil sebaliknya. Simpan berkasnya sebagai uji-ulang.py, lalu jalankan dengan python3 uji-ulang.py dari folder yang sama.

python uji-ulang.py
"""Uji ulang empat pemeriksaan audit terhadap aplikasi lab versi aman.

Skrip ini menjalankan pemeriksaan yang sama dengan audit-otomatis.py pada
topik audit, tetapi menargetkan app-catatan-aman.py dan menuntut hasil
sebaliknya: setiap temuan harus berganti menjadi lolos. Jalankan dengan
`python3 uji-ulang.py` dari folder tempat app-catatan-aman.py berada.
"""

import importlib.util
import re
import threading
import urllib.error
import urllib.parse
import urllib.request
from http.server import ThreadingHTTPServer

BERKAS_APLIKASI = "app-catatan-aman.py"
DASAR = "http://127.0.0.1:8802"
PAYLOAD_XSS = "<script>alert(1)</script>"


def muat_aplikasi():
    """Memuat app-catatan-aman.py dari folder kerja tanpa menjalankan servernya."""
    spesifikasi = importlib.util.spec_from_file_location("app_catatan_aman", BERKAS_APLIKASI)
    modul = importlib.util.module_from_spec(spesifikasi)
    spesifikasi.loader.exec_module(modul)
    return modul


def minta(jalur, data=None, cookie=None):
    """Mengirim satu permintaan HTTP dan mengembalikan (status, isi, header)."""
    metode = "POST" if data is not None else "GET"
    permintaan = urllib.request.Request(DASAR + jalur, data=data, method=metode)
    if cookie:
        permintaan.add_header("Cookie", cookie)
    try:
        with urllib.request.urlopen(permintaan, timeout=5) as jawaban:
            return jawaban.status, jawaban.read().decode("utf-8"), jawaban.headers
    except urllib.error.HTTPError as e:
        return e.code, e.read().decode("utf-8"), e.headers


def masuk(pengguna):
    """Login sebagai pengguna lab dan mengembalikan nilai cookie sesinya."""
    data = urllib.parse.urlencode({"pengguna": pengguna}).encode("utf-8")
    _, _, header = minta("/masuk", data=data)
    return header.get("Set-Cookie", "").split(";", 1)[0]


def periksa_idor():
    """Budi mencoba membaca catatan 3 milik citra; permintaan wajib ditolak."""
    kuki = masuk("budi")
    status, isi, _ = minta("/catatan/3", cookie=kuki)
    if status == 403 and "Evaluasi pengadaan" not in isi:
        return "LOLOS — budi membaca catatan 3 milik citra -> 403 ditolak"
    return f"GAGAL — GET /catatan/3 -> {status}"


def periksa_xss():
    """Payload script dikirim ke pencarian; pantulan wajib terenkode."""
    q = urllib.parse.quote(PAYLOAD_XSS)
    status, isi, _ = minta(f"/cari?q={q}")
    terenkode = "&lt;script&gt;" in isi
    if status == 200 and terenkode and PAYLOAD_XSS not in isi:
        return "LOLOS — payload dipantulkan sebagai &lt;script&gt; yang aman"
    return f"GAGAL — status {status}, pantulan terenkode: {terenkode}"


def periksa_token():
    """Dua token diminta, lalu pemakaian ulangnya diuji."""
    kuki = masuk("budi")
    token = []
    for _ in range(2):
        _, _, header = minta("/reset", cookie=kuki)
        set_cookie = header.get("Set-Cookie", "")
        cari = re.search(r"token=([A-Za-z0-9_-]+)", set_cookie)
        if cari:
            token.append(cari.group(1))
    if len(token) != 2 or token[0] == token[1] or len(token[0]) < 40:
        return "GAGAL — token tidak acak atau tidak berbeda"
    kuki_lengkap = f"{kuki}; token={token[0]}"
    pertama, _, _ = minta("/reset/pakai?token=" + token[0], cookie=kuki_lengkap)
    kedua, _, _ = minta("/reset/pakai?token=" + token[0], cookie=kuki_lengkap)
    if pertama == 200 and kedua == 400:
        return "LOLOS — dua token acak 43 karakter, pemakaian ulang ditolak 400"
    return f"GAGAL — pakai pertama {pertama}, pakai ulang {kedua}"


def periksa_error():
    """Id non-angka dipicu; respons wajib generik dan tanpa traceback."""
    kuki = masuk("budi")
    status, isi, _ = minta("/catatan/abc", cookie=kuki)
    bocor = "Traceback (most recent call last)" in isi
    if status in (404, 500) and not bocor:
        return "LOLOS — /catatan/abc -> pesan generik tanpa traceback"
    return f"GAGAL — status {status}, traceback bocor: {bocor}"


def main():
    modul = muat_aplikasi()
    server = ThreadingHTTPServer(("127.0.0.1", 8802), modul.PenanganCatatanAman)
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    try:
        pemeriksaan = (
            ("IDOR pada catatan", periksa_idor),
            ("Reflected XSS", periksa_xss),
            ("Token reset", periksa_token),
            ("Error debug", periksa_error),
        )
        print("== Uji ulang lab Catatan Pegawai (versi aman) ==")
        lolos = 0
        for nama, fungsi in pemeriksaan:
            hasil = fungsi()
            if hasil is not None and hasil.startswith("LOLOS"):
                lolos += 1
            print(f"{nama:<18} : {hasil}")
        print(f"Total: {lolos} dari {len(pemeriksaan)} pemeriksaan lolos")
    finally:
        server.shutdown()
        server.server_close()


if __name__ == "__main__":
    main()
text Keluaran
== Uji ulang lab Catatan Pegawai (versi aman) ==
IDOR pada catatan  : LOLOS — budi membaca catatan 3 milik citra -> 403 ditolak
Reflected XSS      : LOLOS — payload dipantulkan sebagai &lt;script&gt; yang aman
Token reset        : LOLOS — dua token acak 43 karakter, pemakaian ulang ditolak 400
Error debug        : LOLOS — /catatan/abc -> pesan generik tanpa traceback
Total: 4 dari 4 pemeriksaan lolos

Setiap baris keluaran memasangkan satu temuan lama dengan bukti barunya. Permintaan IDOR kini berhenti dengan 403 dan isi catatan citra tidak muncul di respons. Payload XSS dipantulkan sebagai &lt;script&gt; yang diperlakukan browser sebagai teks biasa, bukan sebagai elemen. Dua token reset berbeda nilainya dengan panjang 43 karakter, dan penukaran ulang token pertama ditolak dengan 400, membuktikan sifat sekali pakainya. Permintaan id non-angka menerima pesan generik tanpa traceback, sedangkan detail kesalahannya tercatat pada log internal untuk operator.

Kesalahan umum#

Kesalahan berikut sering muncul ketika remediasi dikerjakan di bawah tekanan tenggat.

  • Memperbaiki gejala saja. Menyembunyikan pesan error dengan mematikan seluruh halaman debug menghilangkan petunjuk bagi operator, sehingga pesan generik dan log internal harus berjalan berpasangan.
  • Memeriksa satu jalur dan melupakan jalur lain. Objek yang sama sering dapat diakses dari beberapa endpoint, sehingga otorisasi perlu dipasang di satu tempat yang selalu dijalankan.
  • Menguji ulang hanya kasus aslinya. Perbaikan IDOR juga wajib diuji dengan id yang tidak ada dan id milik sendiri, agar pemeriksaan tidak menjadikan penolakan sembarangan sebagai keberhasilan.
  • Menutup temuan tanpa catatan. Temuan yang berubah status tanpa rujukan kontrol dan bukti uji ulang akan muncul kembali pada audit berikutnya.

Ringkasan#

  • Remediasi memasangkan satu temuan dengan satu kontrol yang terukur, lalu memverifikasinya dengan pemeriksaan yang identik dengan audit aslinya.
  • Keempat kontrol yang dipasang adalah otorisasi pemilik, pengodean html.escape(), token acak sekali pakai berkedaluwarsa, dan pesan error generik dengan log internal.
  • Uji ulang menghasilkan empat bukti perbaikan, yaitu 403 untuk non-pemilik, pantulan terenkode, penolakan pemakaian ulang token, dan respons tanpa traceback.

Referensi#