Keamanan Siber

Rate Limiting untuk Aplikasi Web Kecil — Perlambat Kegagalan Berulang Tanpa Mengunci Pengguna Asli

Rate Limiting untuk Aplikasi Web Kecil — Perlambat Kegagalan Berulang Tanpa Mengunci Pengguna Asli

Ada orang yang akan mencoba masuk ke aplikasimu dengan password yang salah, ribuan kali. Kalau form login-mu ada di internet terbuka, itu bukan lagi hipotesis; itu yang pada akhirnya tertarik oleh endpoint yang tidak dijaga. Pertanyaannya bukan apakah kamu harus menahan, tapi bagaimana caranya. Jawaban yang mudah masing-masing merusak sesuatu: memblokir IP selamanya menghukum koneksi bersama, mengunci akun setelah tiga kali percobaan mengubah reset password jadi senjata, dan mengabaikannya membuat worker PHP-mu terus menjawab skrip yang memang tidak akan pernah berhasil login.

Ini adalah rate limiting untuk aplikasi kecil: kontrak HTTP yang sebenarnya sudah ada, pilihan tentang apa yang harus dihitung, dua tempat di mana kamu bisa menegakkan batas itu, dan kesalahan yang membuat limiter lebih buruk daripada tidak punya limiter sama sekali. Aku menulisnya memakai stack yang benar-benar dipakai situs ini — Nginx di depan PHP-FPM lewat Unix socket, dengan MariaDB di belakangnya — dan setiap perintah serta potongan konfigurasi di bawah dicek langsung di mesin itu, bukan diambil dari ingatan. Kalau sebuah angka bergantung pada trafikmu sendiri dan bukan pada aturan, aku menyebutnya begitu; aku tidak mengarang angka.

Yang sebenarnya sudah disediakan HTTP

Ada status code untuk keperluan ini, dan umurnya sudah cukup tua sampai terasa membosankan. RFC 6585 mendefinisikan 429 Too Many Requests untuk kasus ketika klien mengirim terlalu banyak request dalam rentang waktu tertentu. Responsnya SHOULD memuat penjelasan mengenai kondisinya, dan MAY memuat header Retry-After yang memberi tahu berapa lama harus menunggu. Kode ini terdaftar di IANA HTTP status code registry dengan RFC 6585 sebagai rujukannya, jadi ini bukan karangan vendor yang tidak pernah dikenal klien.

Field itu sendiri didefinisikan di RFC 9110, bagian 10.2.3: nilainya bisa berupa HTTP-date atau sejumlah detik, dan field yang sama dipakai bersama 503 Service Unavailable untuk menyatakan berapa lama sebuah layanan diperkirakan tidak tersedia. Itulah seluruh kontraknya — 429 plus Retry-After — dan klien yang paham HTTP bisa patuhi tanpa perlu tahu apa pun tentang aplikasimu.

Ada dua detail di RFC 6585 yang layak dibaca dua kali. Spesifikasi itu menyatakan secara eksplisit bahwa RFC ini tidak mendefinisikan bagaimana origin server mengidentifikasi pengguna, maupun bagaimana request dihitung: per-resource, per-server, atau lintas sekumpulan server, dengan kunci berupa kredensial atau cookie stateful — semuanya dibiarkan terbuka. Semua hal menarik di artikel ini terjadi di dalam celah itu. Detail kedua terasa lebih canggung: respons dengan status 429 tidak boleh disimpan cache, dan catatan keamanan di RFC itu menyebutkan bahwa ketika server sedang diserang, menjawab setiap request dengan 429 juga memakan sumber daya. RFC ini menyatakan terus terang bahwa server tidak diwajibkan memakai 429 sama sekali, dan bahwa membuang koneksi bisa lebih tepat. Limiter yang menghabiskan lebih banyak CPU daripada serangan yang dilayaninya bukan limiter, melainkan beban kerja kedua.

Pilih kunci dulu, baru pilih angkanya

Sebelum angka mana pun bisa dipilih, kamu harus memutuskan apa yang dihitung: alamat klien, akun yang jadi sasaran, atau sesi yang dipakai.

Penghitungan berdasarkan IP paling gampang dipasang dan paling gampang keliru. Ia menghukum semua orang di balik alamat bersama — jaringan kampus, NAT operator seluler, kantor — demi perilaku satu klien. Ia juga gagal terbuka ke arah yang paling penting: OWASP Credential Stuffing Prevention Cheat Sheet mencatat bahwa toolkit credential stuffing umumnya memakai jaringan proxy untuk menyebarkan request ke banyak alamat berbeda, dan ini mengalahkan baik IP block-list maupun rate limiting, karena volume request per-IP tetap rendah meskipun serangan besarnya besar.

Penghitungan berdasarkan akun menutup lubang itu. OWASP Authentication Cheat Sheet menyatakannya langsung: penghitung login gagal sebaiknya dikaitkan ke akun itu sendiri, bukan ke alamat IP sumber, supaya penyerang tidak bisa mengelakinya dengan berpindah-pindah alamat. Dokumen yang sama memperingatkan balasan baliknya: ketika merancang account lockout, harus diperhatikan agar tidak dipakai untuk melakukan denial of service dengan mengunci akun pengguna lain. Penyerang yang tahu endpoint-mu berbasis akun bisa mengunci pelanggan yang dikenalnya dengan sengaja. OWASP mencantumkan hal-hal yang harus diseimbangkan: banyaknya percobaan gagal sebelum terkunci, observation window, durasi lockout, dan apakah durasi itu tumbuh secara eksponensial setiap kali gagal.

Karena itu bentuk yang masuk akal adalah dua batas, bukan satu: batas kasar di edge dengan kunci alamat melindungi sumber daya server dari traffic massal, sedangkan batas yang lebih halus di dalam aplikasi dengan kunci akun melindungi satu akun agar tidak ditebak. Masing-masing kunci tidak cukup sendiri, dan keduanya hidup di tempat berbeda dengan biaya berbeda saat menyala.

Taruh batas di tempat penolakan request paling murah

Menolak request sebelum sampai ke PHP jauh lebih murah daripada menerimanya, menjalankan query, menghitung hash password, lalu menjawab 429 juga. Nginx bisa melakukannya sendiri lewat ngx_http_limit_req_module, yang membatasi laju pemrosesan request per kunci dengan metode leaky bucket dan menyimpan statusnya di shared memory zone.

http {
    limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;

    server {
        location = /login {
            limit_req zone=login burst=3 nodelay;
            limit_req_status 429;
            limit_req_log_level warn;
            limit_req_dry_run on;
        }
    }
}

Beberapa detail di sana tidak sesederhana kelihatannya. Status default untuk request yang ditolak adalah 503, bukan 429, jadi limit_req_status 429 yang mengubah penolakan modul menjadi sesuatu yang bisa dibaca klien sebagai "pelan-pelan" dan bukan "situsnya rusak". Batas di bawah satu request per detik ditulis per menit, jadi rate=5r/m berarti lima per menit; burst=3 membiarkan lonjakan singkat lolos, dan nodelay menerima request burst itu langsung tanpa menahannya. limit_req_dry_run on, yang baru ada sejak Nginx 1.17.1, menghitung persis seperti biasanya tanpa benar-benar menolak apa pun — cara yang jujur untuk mengetahui apakah sebuah ambang cocok dengan trafikmu, sebelum ambang itu benar-benar menolak pembaca. Modul ini juga menyediakan $limit_req_status dengan nilai seperti PASSED, DELAYED, dan REJECTED.

State-nya sendiri tidak tak terbatas. Dokumentasi menyebutkan bahwa satu state memakan 128 byte di platform 64-bit, jadi zona sebesar satu megabyte menampung sekitar delapan ribu state; state yang paling lama tidak dipakai dibuang ketika zona penuh, dan jika state baru sama sekali tidak bisa dibuat, request dihentikan dengan error. Kasus terakhir itu penting: zona yang penuh tidak melambat dengan rapi, melainkan menggagalkan request.

Masalah proxy yang diam-diam menggabungkan semua pengunjung

Ini adalah kesalahan yang akan pernah kulakukan kalau aku tidak mengecek. $binary_remote_addr adalah alamat peer yang membuka koneksi — benar untuk server yang terekspos langsung, salah untuk apa pun yang berada di belakang proxy atau tunnel. Situs ini dijangkau lewat Cloudflare tunnel yang mengakhiri TLS di sisi atas dan berbicara dengan origin lewat HTTP biasa dari localhost. Aku mengirim satu request ke URL publik dengan path unik, lalu membaca access log origin:

curl -s -o /dev/null -w '%{http_code}\n' "https://adammuiz.com/__rl_probe_1792602801617611643"
sudo grep __rl_probe /var/log/nginx/access.log

Log mencatat request itu datang dari 127.0.0.1. Setiap pengunjung di internet sampai ke Nginx itu dengan alamat yang sama. Limiter yang memakai kunci $binary_remote_addr akan memasukkan seluruh pembaca ke satu ember, dan beberapa pembaca pertama yang datang akan mengunci semua yang datang setelahnya — denial of service yang kamu sebabkan sendiri dan di log-nya terlihat persis seperti serangan.

Perbaikannya adalah memberi tahu Nginx siapa yang boleh bertindak atas nama klien, dan header mana yang perlu dipercaya. Nginx melakukannya dengan ngx_http_realip_module, yang secara default tidak aktif:

set_real_ip_from 127.0.0.1;
real_ip_header CF-Connecting-IP;

set_real_ip_from mendeklarasikan alamat mana yang dipercaya boleh memberikan alamat pengganti, dan real_ip_header memberi tahu field header mana yang dibaca. Untuk traffic yang diproksi Cloudflare, dokumentasi resmi Cloudflare memberikan konfigurasi Nginx persis seperti itu dan menunjuk ke daftar alamat Cloudflare yang dipublikasikan di Cloudflare IP addresses untuk dipakai di set_real_ip_from, karena daftar itu berubah dan harus disegarkan. Referensi header Cloudflare menambahkan bahwa CF-Connecting-IP hanya dikirim pada hop dari edge Cloudflare ke origin.

Jebakan yang sama ada di PHP: $_SERVER['REMOTE_ADDR'] juga merupakan alamat peer, jadi limiter di level aplikasi harus memakai alamat klien yang sudah dipulihkan, bukan alamat socket. Prinsipnya adalah prinsip yang sudah kujelaskan sebelumnya di artikel tentang client IP di balik reverse proxy: percaya koneksinya dulu sebelum percaya headernya, dan percaya header hanya kalau koneksi itu sendiri datang dari pihak yang sudah kamu percaya.

Menghitung di dalam aplikasi

Limiter di edge melindungi server. Ia tidak bisa memberi tahu bahwa satu akun tertentu sudah gagal dua belas kali, karena ia tidak tahu akun mana yang sedang dicoba. Pemeriksaan itu milik aplikasi, di mana kuncinya bisa berupa akun, bukan alamat.

Penghitungan punya beberapa model. Fixed window menghitung request di dalam interval yang sejajar — menit pertama window, menit kedua, dan seterusnya. Sederhana dan murah, dengan satu kelemahan yang sudah bisa diperkirakan: karena window-nya sejajar, klien bisa mengirim jatah penuh di akhir satu window dan lagi di awal window berikutnya. Sliding window menghitung request dalam N detik terakhir tanpa peduli di mana batas window-nya jatuh, sehingga menghilangkan lonjakan di batas itu dengan konsekuensi harus menyimpan lebih banyak riwayat. Token bucket mengisi ulang anggaran secara bertahap dan membiarkan klien menghabiskannya lebih cepat ketika saldonya sudah terkumpul — itulah yang oleh Nginx disebut leaky bucket. Untuk endpoint login di server kecil, fixed window biasanya sudah cukup, asal kamu tahu bahwa batas itu ada.

Berikut penghitung fixed window di PHP di atas MariaDB, memakai upsert supaya increment dan pembacaan terjadi pada baris yang sama:

<?php
final class FixedWindowLimiter
{
    public function __construct(private readonly PDO $pdo)
    {
    }

    public function hit(string $key, int $limit, int $windowSeconds): array
    {
        $start = (int) floor(time() / $windowSeconds) * $windowSeconds;
        $startSql = date('Y-m-d H:i:s', $start);

        $stmt = $this->pdo->prepare(
            'INSERT INTO rate_limit_counters (bucket_key, window_start, hits)
             VALUES (:key, :start, 1)
             ON DUPLICATE KEY UPDATE hits = hits + 1'
        );
        $stmt->execute(['key' => $key, 'start' => $startSql]);

        $stmt = $this->pdo->prepare(
            'SELECT hits FROM rate_limit_counters
             WHERE bucket_key = :key AND window_start = :start'
        );
        $stmt->execute(['key' => $key, 'start' => $startSql]);
        $hits = (int) $stmt->fetchColumn();

        return [
            'allowed'     => $hits <= $limit,
            'hits'        => $hits,
            'remaining'   => max(0, $limit - $hits),
            'retry_after' => max(0, ($start + $windowSeconds) - time()),
        ];
    }
}

Tabel di belakangnya sengaja dibuat membosankan:

CREATE TABLE rate_limit_counters (
  bucket_key   VARCHAR(191) NOT NULL,
  window_start DATETIME     NOT NULL,
  hits         INT UNSIGNED NOT NULL DEFAULT 0,
  PRIMARY KEY (bucket_key, window_start)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Aku menjalankan pola ini pada database scratch di MariaDB 11.8.6 — bukan pada database produksi — dengan batas lima percobaan per enam puluh detik, dan hasilnya sesuai dengan deskripsi di atas: lima percobaan pertama diizinkan dengan sisa angka turun ke nol, percobaan keenam sampai kedelapan ditolak, dan kunci yang berbeda mulai lagi dari satu. Pola upsert-nya mengikuti perilaku INSERT ... ON DUPLICATE KEY UPDATE yang terdokumentasi di knowledge base MariaDB.

Dua keputusan tetap milikmu. Hitung hanya kegagalan: kalau login yang berhasil ikut menaikkan penghitung, kantor yang dipakai bersama atau keluarga yang memakai satu akun akan mengunci dirinya sendiri saat pemakaian normal, dan angka yang sebenarnya kamu pedulikan adalah jumlah tebakan salah. Dan jangan biarkan limiter jadi amplifier: kalau setiap request yang ditolak juga menulis ke database, banjir request ditolak akan membuat database-mu menjadi bendera yang tumbang. Itu salah satu alasan untuk tetap menempatkan pemeriksaan yang mahal di edge, dan membiarkan penghitung di level aplikasi hanya menangani request yang sudah lolos.

Menjawab dengan cara yang bisa dipatuhi klien

Ketika batas tercapai, responsnya harus membawa dua hal yang dibutuhkan klien: statusnya dan lama tunggunya. Halaman MDN untuk 429 menjelaskan kontrak yang sama dari sisi klien — Retry-After boleh disertakan untuk memberi tahu berapa lama harus menunggu, dan pembatasannya bisa berlaku untuk seluruh server atau per resource.

if (!$result['allowed']) {
    http_response_code(429);
    header('Retry-After: ' . $result['retry_after']);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode([
        'error'       => 'too_many_requests',
        'retry_after' => $result['retry_after'],
    ]);
    exit;
}

Ada dua hal yang sebaiknya tidak dilakukan respons itu. Ia tidak boleh membocorkan apakah akun itu ada, jadi limiter tidak boleh memberi jawaban yang lebih pelan untuk alamat yang tidak terdaftar. Dan ia tidak boleh memberi tahu pengguna sah bahwa ada orang yang sedang menebak passwordnya, karena informasi itulah yang justru dicari oleh penyerang. OWASP membuat poin serupa dalam konteks lockout: memberitahu pengguna tentang percobaan login gagal dengan password yang salah biasanya tidak pantas, sedangkan password yang benar lalu faktor kedua yang gagal adalah situasi yang berbeda.

Di sisi klien, perilaku yang benar adalah membaca Retry-After lalu menunggu, bukan mencoba ulang dalam loop rapat. Klien yang mengabaikannya lalu mencoba lagi seketika sebenarnya tidak dihambat apa pun selain kapasitasmu menyerap request ulang itu. Backoff dengan jitter adalah jawaban yang biasa dipakai ketika tidak ada waktu tunggu eksplisit.

Satu nuansa soal ekosistem: sebagian server mengiklankan state kuota lewat header RateLimit-*. Nama-nama itu sampai hari ini masih dirumuskan dalam IETF Internet-Draft (draft-ietf-httpapi-ratelimit-headers), yang menurut datatracker masih terdaftar sebagai Active Internet-Draft tanpa intended RFC status, dan pemeriksaan terhadap IANA HTTP field name registry pada 22 Oktober 2026 tidak menemukan entri terdaftar untuk nama-nama tersebut. Retry-After adalah bagian yang sudah dipahami semua klien, jadi itu bagian yang layak dikirim.

Yang tidak bisa dilakukan limiter

Limiter adalah satu lapisan, dan justru lapisan inilah yang paling rapi bila gagal. OWASP tegas bahwa memblokir alamat bisa menghentikan serangan yang sederhana, tetapi tidak boleh menjadi pertahanan utama atau satu-satunya, dan bahwa mitigasi tidak boleh bergantung pada satu batas volume yang mudah diprediksi; periode pendek (burst) dan periode panjang sama-sama perlu dipertimbangkan, karena penyerang dengan banyak alamat bisa menjaga setiap alamatnya tetap rendah secara individual. Panduan yang sama menyarankan respons berjenjang, misalnya meminta faktor tambahan ketika login datang dari perangkat baru, lokasi yang tidak biasa, atau satu alamat dengan volume login besar.

Jadi jawaban yang lengkap juga mencakup password hashing yang lambat, multi-factor authentication, dan CAPTCHA yang dipakai sebagai defense in depth, bukan sebagai pintu depan. Rate limiting membuat menebak mahal secara waktu. Ia tidak membuat menebak mustahil, dan ia tidak akan menyelamatkan sistem yang menganggap password lemah atau yang dipakai ulang sebagai hal yang bisa diterima.

Checklist yang muat untuk server kecil

  1. Tentukan yang kamu lindungi. Sumber daya server, satu akun, dan operasi yang mahal adalah tujuan berbeda yang butuh kunci berbeda.
  2. Mulai dalam mode dry-run. limit_req_dry_run dan $limit_req_status milik Nginx menunjukkan apa yang akan terjadi jika sebuah ambang ditegakkan, sebelum ambang itu benar-benar menolak siapa pun.
  3. Pulihkan alamat klien lebih dulu. Kalau ada proxy atau tunnel di depan, konfigurasi modul real-IP dan pakai alamat yang sudah dipulihkan sebagai kunci. Limiter dengan alamat yang salah lebih buruk daripada tidak ada limiter.
  4. Pakai dua kunci, bukan satu. Batas kasar di edge untuk volume traffic, batas halus di aplikasi untuk akunnya.
  5. Hitung kegagalan, bukan keberhasilan. Dan sebisa mungkin jauhkan penghitung dari jalur request.
  6. Jawab dengan 429 dan Retry-After. Lalu pastikan front-end milikmu sendiri menangani status itu, bukan menampilkan halaman rusak.
  7. Catat request yang ditolak. Limiter yang tak terlihat adalah limiter yang tak bisa kamu setel ulang — tapi log juga jalur tulis, jadi simpan jauh dari database yang sedang kamu lindungi.
  8. Jaga ukuran tindakan tetap sementara. Pemblokiran yang tak pernah kedaluwarsa itu antrean dukungan, dan panduan OWASP tegas bahwa mitigasi harus bisa dicabut ketika penyalahgunaan menurun.

Yang masih belum kupahami

Beberapa hal masih menjadi pertanyaan terbuka buatanku, bukan aturan yang sudah selesai. Tidak ada angka percobaan atau request per menit yang benar untuk semua situasi; sumber yang jujur untuk angka itu adalah trafik yang benar-benar kamu amati, dan itu salah satu alasan dry-run mode ada. Aku juga belum memutuskan bagaimana memperlakukan kelonggaran di batas fixed window — apakah batas yang jadi dua kali lipat di ujung window masih bisa diterima bergantung pada endpoint-nya dan seberapa besar kerugian yang bisa ditanggung pengguna. Dan interaksi antara limiter dengan lapisan-lapisan lain adalah bagian tempat aku paling berharap untuk belajar: pemicu MFA step-up, ambang CAPTCHA, dan titik ketika request yang diblokir sebaiknya dibuang saja alih-alih dijawab, seperti yang diperbolehkan RFC 6585.

Yang kupercaya itu lebih sempit tapi lebih berguna: protokolnya sudah punya kosakata yang tepat untuk ini, dan kegagalan yang umum terjadi bukan kegagalan protokol. Kegagalan itu adalah menghitung hal yang salah, menghitungnya di tempat yang salah, dan menghitungnya terhadap alamat yang tidak pernah benar-benar dilihat server.

Penutup

Rate limiting di server kecil pada dasarnya keputusan tentang identitas dan penempatan. Tentukan apakah yang kamu hitung adalah alamat atau akun, tentukan apakah penolakan terjadi sebelum atau sesudah PHP ikut bermain, dan pastikan alamat yang kamu hitung adalah alamat orangnya, bukan alamat proxy di depannya. Setelah itu pakai kontrak yang sudah diberikan HTTP: 429, penjelasan singkat, dan Retry-After. Itu desain yang bisa dipahami manusia dan bisa dipatuhi klien — standar yang lebih tinggi daripada yang kebanyakan kode pertahanan penuhi.

Referensi