Seluruh pertahanan pada section ini — validasi, hashing, parameterized query — hanya berguna jika benar-benar terpasang dan tetap bekerja setiap kali kode berubah. Kabar baiknya: keamanan bisa diuji dengan tooling yang sudah kita kenal dari section testing. Test yang sama, assertion yang sama, hanya pertanyaannya berbeda: bukan “apakah fitur bekerja”, melainkan “apakah pintu tertutup bagi penyerang”. Chapter ini mempraktikkan tiga jenisnya pada FastAPI, lalu mengaitkannya dengan pipeline CI.

Menguji Autentikasi sebagai Test #

Endpoint berikut sederhana: satu route pencarian pengguna yang menolak request tanpa token sah. Kita akan menyerangnya lewat TestClient — tanpa menjalankan server sungguhan:

python
import sqlite3
from typing import Annotated

from fastapi import FastAPI, Header, HTTPException
from fastapi.testclient import TestClient

TOKEN_VALID = "YOUR_API_TOKEN"

app = FastAPI()
conn = sqlite3.connect(":memory:", check_same_thread=False)
conn.execute("CREATE TABLE pengguna (id INTEGER PRIMARY KEY, username TEXT, peran TEXT)")
conn.execute("INSERT INTO pengguna VALUES (1, 'budi', 'viewer')")
conn.commit()


@app.get("/api/pengguna")
def cari_pengguna(
    cari: str = "",
    x_api_token: Annotated[str | None, Header()] = None,
):
    if x_api_token != TOKEN_VALID:
        raise HTTPException(status_code=401, detail="Token tidak valid")
    baris = conn.execute(
        "SELECT username, peran FROM pengguna WHERE username = ?", (cari,)
    ).fetchall()
    return [{"username": b[0], "peran": b[1]} for b in baris]


client = TestClient(app)

print("Tanpa token    ->", client.get("/api/pengguna").status_code)
print("Token salah   ->", client.get("/api/pengguna", headers={"X-API-Token": "TOKEN_PALSU"}).status_code)
print("Token benar   ->", client.get("/api/pengguna", headers={"X-API-Token": TOKEN_VALID}).status_code)

payload = "' OR '1'='1' -- "
response = client.get("/api/pengguna", params={"cari": payload}, headers={"X-API-Token": TOKEN_VALID})
print("Input hostile ->", response.status_code, response.json())

Output yang Dihasilkan:

text
Tanpa token    -> 401
Token salah   -> 401
Token benar   -> 200
Input hostile -> 200 []

Tiga baris pertama membuktikan gerbang autentikasi: tanpa token dan dengan token palsu, server menjawab 401 Unauthorized; hanya token sah yang menuju logika aplikasi. Baris terakhir mengirim payload SQL injection klasik dari Chapter SQL Injection sebagai parameter pencarian — endpoint menjawab 200 dengan daftar kosong, karena query di dalamnya memakai placeholder ? sehingga payload diperlakukan sebagai teks biasa yang tidak cocok dengan username mana pun.

Menguji Ketahanan sebagai Test Suite #

Kombinasi tersebut layak menjadi test permanen agar tidak tergerus refactor. Simpan sebagai test_keamanan.py:

python
import sqlite3
from typing import Annotated

from fastapi import FastAPI, Header, HTTPException
from fastapi.testclient import TestClient

TOKEN_VALID = "YOUR_API_TOKEN"

app = FastAPI()
conn = sqlite3.connect(":memory:", check_same_thread=False)
conn.execute("CREATE TABLE pengguna (id INTEGER PRIMARY KEY, username TEXT, peran TEXT)")
conn.execute("INSERT INTO pengguna VALUES (1, 'budi', 'viewer')")
conn.execute("INSERT INTO pengguna VALUES (2, 'siti', 'viewer')")
conn.commit()


@app.get("/api/pengguna")
def cari_pengguna(
    cari: str = "",
    x_api_token: Annotated[str | None, Header()] = None,
):
    if x_api_token != TOKEN_VALID:
        raise HTTPException(status_code=401, detail="Token tidak valid")
    baris = conn.execute(
        "SELECT username, peran FROM pengguna WHERE username = ?", (cari,)
    ).fetchall()
    return [{"username": b[0], "peran": b[1]} for b in baris]


client = TestClient(app)


def test_endpoint_dilindungi():
    response = client.get("/api/pengguna")
    assert response.status_code == 401

    response = client.get("/api/pengguna", headers={"X-API-Token": "TOKEN_PALSU"})
    assert response.status_code == 401

    response = client.get("/api/pengguna", headers={"X-API-Token": TOKEN_VALID})
    assert response.status_code == 200


def test_input_hostile_tidak_meretas():
    payload = "' OR '1'='1' -- "
    response = client.get(
        "/api/pengguna", params={"cari": payload}, headers={"X-API-Token": TOKEN_VALID}
    )
    assert response.status_code == 200  # tidak crash (bukan 500)
    assert response.json() == []  # data tidak bocor
    jumlah = conn.execute("SELECT COUNT(*) FROM pengguna").fetchone()[0]
    assert jumlah == 2  # data tidak berubah

Lalu jalankan pytest:

bash
python3 -m pytest test_keamanan.py -q

Output yang Dihasilkan:

text
..                                                                       [100%]
2 passed in 1.08s

Perintah tersebut menjalankan kedua test; lama run dan lebar baris [100%] berbeda-beda di tiap mesin. Test kedua menegakkan tiga klaim sekaligus: program tidak crash (200, bukan 500), data tidak bocor (hasil kosong), dan database tidak berubah (jumlah baris tetap). Pola ini berlaku untuk kelas kerentanan apa pun — kirim input hostile, lalu assert ketiga hal tersebut.

Fuzzing dan Property-Based Testing #

Daripada menebak input jahat satu per satu, fuzzing mengirim ribuan input acak dan memantau crash. Library hypothesis mengotaknya dalam gaya property-based test:

python
# ILUSTRATIF - membutuhkan: pip install hypothesis
from hypothesis import given, strategies as st

@given(st.text())
def test_cari_tidak_crash(kata: str):
    response = client.get(
        "/api/pengguna", params={"cari": kata}, headers={"X-API-Token": TOKEN_VALID}
    )
    assert response.status_code == 200

Pemindaian di CI dan Checklist Review #

Test keamanan paling berharga ketika berjalan otomatis pada setiap commit — persis fungsi pipeline yang dibahas pada Chapter Continuous Integration. Satu job tambahan menjalankan pip-audit dari Chapter Dependency Security:

yaml
# .github/workflows/security.yml (ilustratif)
name: security
on: [push, pull_request]
jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install -r requirements.txt pip-audit
      - run: pip-audit

Terakhir, tidak semua pertanyaan keamanan bisa dijawab mesin. Gunakan checklist saat mereview pull request:

  • Apakah setiap input dari luar tervalidasi atau di-escape sesuai konteks?
  • Apakah masih ada f-string SQL, pickle.loads() dari data luar, atau verify=False?
  • Apakah secrets dibaca dari environment, bukan tertulis di kode?
  • Apakah autentikasi dan otorisasi diperiksa pada setiap endpoint yang sensitif?
  • Apakah pesan error ke pengguna bebas detail internal?
  • Apakah dependensi baru benar-benar diperlukan dan berasal dari sumber tepercaya?

Enam pertanyaan tersebut adalah seluruh isi section ini dalam bentuk pemeriksaan menit terakhir. Keamanan bukan gerbang terakhir sebelum rilis, melainkan kebiasaan yang melekat pada setiap baris kode — dan kebiasaan itulah yang telah kita bangun sepanjang sepuluh chapter ini.