Validasi input tahan balapan berarti validasi Anda tetap benar walaupun dua atau lebih request masuk hampir bersamaan, state berubah di tengah proses, atau klien melakukan retry agresif. Banyak endpoint tampak aman saat diuji satu per satu, tetapi gagal saat dipukul paralel: voucher ter-claim dua kali, reset password dipakai ulang, upload ganda lolos, atau rate limit bisa diakali.
Masalah utamanya adalah validasi yang hanya hidup di level aplikasi sering mengasumsikan state stabil dari awal sampai akhir request. Padahal di sistem nyata, state bisa berubah di antara langkah “cek” dan “simpan”. Ini mirip dengan analogi dari konteks Linux on the Sega 32X: sinkronisasi minim membuat komponen mudah melihat keadaan yang tidak lagi konsisten. Di backend modern, gejala yang sama muncul ketika beberapa worker, node, atau request membaca state lama lalu mengambil keputusan yang saling bertabrakan.
Mengapa validasi biasa gagal saat ada race condition
Pola bug paling umum adalah check-then-act:
- Request membaca state saat ini.
- Aplikasi menyimpulkan aksi masih valid.
- Request lain mengubah state yang sama.
- Request pertama tetap menulis hasil berdasarkan asumsi lama.
Contoh sederhana:
if voucher.status == "available":
mark_voucher_as_claimed(voucher, user_id)
Secara logika terlihat benar. Namun jika dua request membaca available pada waktu yang hampir sama, keduanya bisa lolos dari pengecekan sebelum salah satu perubahan tersimpan. Kalau proteksinya hanya if di aplikasi, validasi itu tidak tahan balapan.
Inti masalah: validasi yang benar secara fungsional belum tentu aman secara konkurensi. Endpoint kritis harus didesain untuk kondisi balapan, bukan hanya jalur normal.
Prinsip desain validasi input tahan balapan
1. Bedakan validasi bentuk data vs validasi state
Ada dua kelas validasi yang sering tercampur:
- Validasi bentuk data: tipe, panjang, format email, ukuran file, enum, required field.
- Validasi state/bisnis: voucher belum dipakai, token reset masih aktif, user belum melebihi kuota, file belum pernah diunggah dengan checksum yang sama.
Validasi bentuk data cocok di level aplikasi dan bisa dilakukan lebih awal. Validasi state yang menyentuh resource bersama harus diasumsikan rawan race condition dan biasanya butuh bantuan database, lock, atau mekanisme idempoten.
2. Jangan percaya hasil baca yang tidak diikat ke write
Kalau aplikasi membaca lalu menulis dalam dua operasi terpisah tanpa proteksi, selalu anggap ada celah. Solusinya bisa berupa:
- transaksi dengan isolasi yang tepat,
UPDATE ... WHERE ...yang atomik,- unique constraint,
- optimistic locking,
- idempotency key,
- atau kombinasi beberapa teknik.
3. Proteksi terakhir harus ada di lapisan yang benar-benar otoritatif
Untuk state persisten, lapisan paling otoritatif biasanya database. Cache, rate limiter in-memory, atau flag di aplikasi membantu, tetapi tidak boleh menjadi satu-satunya penjaga integritas data. Jika satu node aplikasi mati atau ada request masuk ke node lain, proteksi yang hanya hidup lokal mudah bocor.
Level aplikasi vs level database
Validasi di level aplikasi
Kelebihan:
- cepat menolak input yang jelas salah,
- pesan error lebih ramah,
- mengurangi beban database,
- mudah menggabungkan rule lintas field.
Keterbatasan:
- tidak cukup untuk menjamin konsistensi saat ada request paralel,
- rentan terhadap kondisi time-of-check to time-of-use (TOCTOU),
- sering gagal di arsitektur multi-worker atau multi-instance.
Validasi di level database
Kelebihan:
- lebih dekat ke sumber kebenaran data,
- bisa memaksa invariant secara atomik,
- lebih kuat terhadap request paralel dan retry.
Keterbatasan:
- error lebih teknis jika tidak diterjemahkan,
- rule kompleks kadang sulit diekspresikan hanya dengan constraint,
- bisa menambah kontensi jika desain query buruk.
Praktiknya, gunakan keduanya: aplikasi untuk validasi awal dan pengalaman API yang baik, database untuk jaminan akhir terhadap state kritis.
Pola proteksi yang paling sering dipakai
Unique constraint untuk mencegah duplikasi
Jika hanya boleh ada satu hasil untuk kombinasi tertentu, nyatakan secara eksplisit di database. Contoh:
- satu voucher hanya bisa di-claim sekali,
- satu email reset token hanya boleh punya satu token aktif,
- satu idempotency key hanya boleh diproses sekali per pengguna atau per endpoint.
Daripada mengecek dulu lalu insert, lebih aman langsung melakukan operasi yang akan gagal jika duplikat terjadi.
-- contoh konsep, sesuaikan dengan database Anda
CREATE UNIQUE INDEX uniq_voucher_claim
ON voucher_claims (voucher_id);
Dengan ini, dua request paralel mungkin sama-sama mencoba insert, tetapi hanya satu yang berhasil. Aplikasi tinggal menangani error duplicate sebagai respons bisnis yang benar, bukan menganggapnya kegagalan acak.
Transaksi untuk mengikat beberapa langkah menjadi satu unit
Kalau operasi melibatkan beberapa perubahan yang harus konsisten bersama, bungkus dalam transaksi. Misalnya:
- memverifikasi token reset,
- mengubah password,
- menandai token sebagai sudah dipakai,
- mencabut sesi lama.
Tanpa transaksi, satu langkah bisa sukses sementara langkah lain gagal, meninggalkan state separuh jalan.
Optimistic locking untuk state yang sering dibaca, jarang bentrok
Pada optimistic locking, record menyimpan versi atau timestamp. Saat update, aplikasi hanya menulis jika versi yang dibaca masih sama. Jika ternyata sudah berubah, update ditolak dan request harus retry atau dikembalikan sebagai konflik.
BEGIN;
row = SELECT balance, version FROM accounts WHERE id = :id;
UPDATE accounts
SET balance = :new_balance,
version = version + 1
WHERE id = :id AND version = :old_version;
-- jika affected_rows = 0, berarti ada update lain yang mendahului
COMMIT;
Pola ini cocok jika konflik relatif jarang dan Anda ingin menghindari lock panjang. Namun untuk resource yang sangat diperebutkan, unique constraint atau update atomik sering lebih sederhana.
Idempotency key untuk retry aman dan anti double-submit
Endpoint seperti pembayaran, klaim voucher, atau submit form penting sebaiknya menerima idempotency key. Klien mengirim kunci unik untuk satu aksi logis. Server menyimpan hasil berdasarkan kunci itu. Jika request yang sama dikirim ulang akibat timeout, refresh, atau klik ganda, server mengembalikan hasil yang sama alih-alih memproses ulang.
Hal penting dalam desain idempotency key:
- ikat ke identitas pengguna atau scope tertentu,
- simpan status request:
processing,succeeded,failed, - validasi bahwa payload untuk key yang sama tidak berubah,
- tentukan masa retensi yang masuk akal.
Update atomik lebih aman daripada read-then-write
Untuk kuota atau counter, hindari pola:
current = SELECT used FROM quota WHERE user_id = :user;
if current < limit:
UPDATE quota SET used = current + 1 WHERE user_id = :user;
Lebih aman gunakan update bersyarat yang atomik:
UPDATE quota
SET used = used + 1
WHERE user_id = :user
AND used < limit;
-- jika affected_rows = 1, berhasil
-- jika 0, kuota habis atau state sudah berubah
Dengan cara ini, keputusan dan perubahan state terjadi dalam satu operasi logis di storage.
Kasus nyata pada endpoint kritis
1. Auth: login, perubahan password, dan pencabutan sesi
Pada endpoint auth, bug balapan sering muncul ketika sistem:
- mengubah password tetapi sesi lama tidak segera dicabut,
- membatasi percobaan login hanya di memori satu instance,
- memproses beberapa OTP atau challenge yang sebenarnya harus saling menonaktifkan.
Pendekatan yang lebih aman:
- simpan counter rate limit di storage bersama, bukan memori lokal saja,
- saat password berubah, lakukan pencabutan sesi/token dalam transaksi atau alur yang konsisten,
- untuk OTP, tandai token sebagai used dengan update atomik atau unique constraint pada konsumsi token.
Contoh bug alur reset password:
- User meminta reset dan menerima token.
- Dua request
POST /reset-passworddikirim hampir bersamaan. - Keduanya memeriksa token masih aktif.
- Keduanya mengubah password karena penandaan token dipakai tidak atomik.
Perbaikannya: konsumsi token harus terjadi sekali saja, misalnya dengan:
UPDATE reset_tokens SET used_at = now() WHERE token_hash = :hash AND used_at IS NULL AND expires_at > now(), atau- tabel konsumsi token dengan unique constraint per token.
Jika affected_rows = 0, token dianggap sudah dipakai, kadaluarsa, atau invalid.
2. Reset password: jangan validasi token di luar transaksi lalu gunakan belakangan
Kesalahan umum adalah memisahkan verifikasi token dan update password ke dua langkah tanpa pengikat. Jika harus ada beberapa perubahan, lakukan dalam satu transaksi sehingga token tidak bisa “dipakai dua kali” oleh request paralel.
BEGIN;
updated = UPDATE reset_tokens
SET used_at = now()
WHERE token_hash = :hash
AND used_at IS NULL
AND expires_at > now();
if updated == 0:
ROLLBACK;
return invalid_or_expired;
UPDATE users
SET password_hash = :new_hash
WHERE id = :user_id;
-- opsional: revoke refresh tokens / sessions
COMMIT;
3. Claim voucher atau promo: resource diperebutkan
Voucher adalah contoh klasik. Ada dua model umum:
- Voucher unik per kode: satu kode hanya boleh diklaim sekali.
- Kuota kampanye: total klaim dibatasi secara global atau per user.
Untuk voucher unik, gunakan unique constraint atau update bersyarat pada baris voucher. Untuk kuota kampanye, gunakan counter atomik atau representasi claim yang dijaga constraint.
Bug alur yang sering terjadi:
- Aplikasi cek user belum pernah claim.
- Aplikasi cek sisa kuota masih ada.
- Dua request paralel lolos.
- Keduanya insert claim.
- Kuota jadi minus atau user punya dua claim.
Perbaikan bisa berupa kombinasi:
- unique constraint pada
(campaign_id, user_id), - update atomik untuk mengurangi sisa kuota,
- transaksi agar pengurangan kuota dan pencatatan claim konsisten.
4. Upload: anti duplikasi, anti klik ganda, anti state setengah jadi
Upload sering dianggap aman karena file besar dan proses lambat, padahal justru mudah terkena retry dan double-submit. Risiko umum:
- metadata file tercatat dua kali,
- objek storage berhasil tersimpan tetapi database gagal update,
- satu file diproses oleh beberapa worker secara bersamaan.
Praktik yang lebih aman:
- pisahkan status upload:
initiated,uploaded,processing,ready,failed, - gunakan idempotency key pada pembuatan upload session,
- jika perlu deduplikasi, simpan checksum dan pertimbangkan unique constraint sesuai kebutuhan bisnis,
- pastikan worker pemrosesan memeriksa status dengan update atomik sebelum mulai bekerja.
Contoh klaim kerja oleh worker:
UPDATE uploads
SET status = 'processing', worker_id = :worker
WHERE id = :id AND status = 'uploaded';
-- hanya satu worker yang berhasil mengambil pekerjaan
5. Rate limit: jangan hanya menghitung, pastikan increment aman
Rate limit yang hanya membaca counter lalu menulis balik rentan dilompati. Untuk endpoint sensitif seperti login, OTP, reset password, atau voucher, gunakan increment atomik di storage bersama. Jika memakai Redis atau sistem sejenis, fokusnya bukan sekadar cepat, tetapi operasi hitung-dan-cek harus konsisten untuk semua instance aplikasi.
Kesalahan umum:
- counter per proses aplikasi, bukan per cluster,
- window reset yang bisa dipukul paralel saat rollover,
- key terlalu umum sehingga satu user bisa mengunci user lain, atau sebaliknya terlalu sempit sehingga abuse lolos.
Rate limit juga bukan pengganti integritas database. Ia menurunkan frekuensi abuse, tetapi invariant utama tetap harus dijaga oleh storage yang otoritatif.
Contoh pseudocode: dari bug ke desain yang lebih aman
Contoh bug: claim voucher dengan check-then-act
function claimVoucher(userId, voucherCode):
voucher = db.findVoucherByCode(voucherCode)
if voucher == null or voucher.claimed:
return error("voucher tidak tersedia")
existing = db.findClaim(userId, voucher.id)
if existing != null:
return error("sudah pernah claim")
db.insertClaim(userId, voucher.id)
db.markVoucherClaimed(voucher.id)
return success()
Masalahnya ada banyak:
- dua request bisa lolos dari pengecekan yang sama,
- insert claim dan mark claimed tidak atomik,
- jika satu langkah gagal, state bisa tidak sinkron.
Versi lebih aman
function claimVoucher(userId, voucherCode, idemKey):
begin transaction
idem = db.findIdempotency(userId, idemKey, "claim-voucher")
if idem exists and idem.status == "succeeded":
commit
return idem.response
if idem not exists:
db.insertIdempotency(userId, idemKey, "claim-voucher", "processing")
voucher = db.findVoucherByCodeForUpdate(voucherCode)
if voucher == null:
db.markIdempotencyFailed(userId, idemKey, "not_found")
rollback
return error("voucher tidak ditemukan")
inserted = db.insertClaimIfAbsent(userId, voucher.id)
if inserted == 0:
db.markIdempotencyFailed(userId, idemKey, "already_claimed")
rollback
return error("sudah pernah claim")
updated = db.updateVoucherAsClaimedIfAvailable(voucher.id)
if updated == 0:
rollback
return error("voucher tidak tersedia")
response = success("claim berhasil")
db.markIdempotencySucceeded(userId, idemKey, response)
commit
return response
Implementasi persisnya akan berbeda tergantung database dan framework, tetapi idenya konsisten:
- retry aman lewat idempotency key,
- resource kritis diikat dalam transaksi,
- operasi insert/update dijaga oleh constraint atau kondisi atomik,
- hasil antar langkah tidak bergantung pada asumsi state yang sudah basi.
Kapan memakai lock pesimistis, kapan optimistic locking?
Pilih optimistic locking jika:
- konflik jarang,
- read lebih sering daripada write,
- retry masih masuk akal,
- Anda ingin menghindari lock yang menahan transaksi lama.
Pilih lock pesimistis atau operasi atomik jika:
- resource sangat diperebutkan,
- aksi harus menang sekali tanpa ambigu,
- biaya konflik tinggi,
- aturan bisnis sulit dipulihkan dengan retry di sisi klien.
Untuk banyak endpoint abuse-prone, operasi atomik plus constraint sering lebih sederhana daripada lock eksplisit yang panjang. Lock yang terlalu lama meningkatkan kontensi, risiko timeout, dan deadlock.
Anti double-submit di sisi klien tetap berguna, tetapi bukan proteksi utama
Menonaktifkan tombol submit setelah klik pertama, menampilkan spinner, atau mencegah form terkirim dua kali tetap berguna untuk mengurangi noise. Namun itu hanya lapisan kenyamanan. Pengguna bisa membuka dua tab, browser bisa retry, mobile network bisa mengulang request, dan penyerang bisa mengirim request paralel langsung ke API.
Karena itu, anti double-submit di UI harus dipasangkan dengan:
- idempotency key di server,
- constraint database untuk aksi yang harus unik,
- transaksi atau update atomik untuk perubahan state penting.
Checklist audit endpoint kritis
Saat mengaudit endpoint, jangan berhenti pada “sudah ada validasi”. Gunakan daftar berikut:
- Apa resource yang diperebutkan? Token, voucher, saldo, kuota, slot upload, sesi, OTP.
- Apa invariant yang wajib benar? Misalnya “satu token hanya dipakai sekali” atau “satu user satu claim”.
- Apakah invariant itu dipaksa oleh database? Unique constraint, foreign key, check constraint, conditional update.
- Apakah ada pola check-then-act? Jika iya, asumsikan rawan race sampai terbukti aman.
- Apakah retry aman? Jika timeout terjadi, apakah request yang sama bisa diproses dua kali?
- Apakah endpoint menerima request paralel dari user yang sama? Uji dengan dua tab atau skrip konkurensi.
- Apakah perubahan multi-langkah dibungkus transaksi?
- Apakah ada penanganan conflict yang jelas? Misalnya duplicate key diterjemahkan menjadi respons bisnis yang benar.
- Apakah rate limit tersentralisasi? Bukan hanya per proses.
- Apakah logging cukup untuk membedakan retry sah dan abuse?
Observability: cara mendeteksi abuse dan race di produksi
Bug konkurensi sering tidak terlihat di lingkungan lokal karena trafik rendah. Anda perlu observability yang memang dirancang untuk mendeteksi pola balapan dan abuse.
Log yang perlu ada
- request_id dan idempotency_key,
- user_id atau actor_id,
- resource_id yang diperebutkan,
- hasil operasi atomik:
affected_rows, duplicate constraint, conflict, timeout, deadlock, - transisi state: dari status lama ke status baru,
- asal request: IP, user-agent, device fingerprint jika relevan dan sesuai kebijakan privasi.
Metric yang berguna
- jumlah conflict per endpoint,
- rasio duplicate key error,
- jumlah retry dengan idempotency key yang sama,
- rate request paralel per user/resource,
- deadlock dan lock wait timeout,
- selisih antara “permintaan sukses di aplikasi” dan “baris benar-benar berubah di database”.
Alert yang layak dipasang
- lonjakan duplicate claim atau duplicate upload metadata,
- kenaikan tajam reset password attempt pada akun yang sama,
- banyak request dengan payload berbeda tetapi idempotency key sama,
- spike pada
affected_rows = 0untuk operasi yang biasanya sukses, - deadlock meningkat setelah deploy endpoint baru.
Tracing dan debugging
Jika menggunakan tracing terdistribusi, kaitkan span aplikasi dengan query penting yang mengubah state. Saat insiden terjadi, Anda ingin bisa menjawab:
- request mana yang membaca state lama,
- request mana yang menang lebih dulu,
- constraint apa yang menyelamatkan integritas,
- atau justru di langkah mana invariant bocor.
Cara menguji validasi input tahan balapan
Pengujian fungsional biasa tidak cukup. Tambahkan uji konkurensi sederhana:
- tembak endpoint yang sama 10-100 kali secara paralel dengan payload identik,
- uji dua request dengan user yang sama dan resource yang sama,
- sisipkan delay artifisial di antara langkah baca dan tulis untuk memperbesar peluang race,
- uji retry setelah timeout jaringan,
- uji deploy multi-instance bila memungkinkan.
Target uji bukan hanya “tidak error”, tetapi:
- tidak ada duplikasi data,
- tidak ada state setengah jadi,
- hasil akhir tetap memenuhi invariant bisnis.
Kesalahan yang sering terjadi
- Mengandalkan validasi framework saja untuk rule yang sebenarnya menyangkut state bersama.
- Menganggap cache atau rate limiter cukup untuk menjamin keunikan data.
- Memakai idempotency key tanpa memeriksa payload berubah.
- Menangkap error duplicate key tetapi tidak menerjemahkannya menjadi hasil bisnis yang benar.
- Membuat transaksi terlalu lebar sehingga lock lama dan konflik meningkat.
- Tidak mencatat
affected_rows, padahal itu sinyal penting bahwa state sudah berubah duluan.
Penutup
Validasi input tahan balapan bukan soal menambah lebih banyak if di controller. Yang dibutuhkan adalah desain endpoint yang mengasumsikan request bisa datang paralel, state bisa berubah cepat, dan klien bisa retry kapan saja. Dari analogi sistem dengan sinkronisasi minim seperti Linux on the Sega 32X, pelajarannya relevan untuk backend modern: jika Anda tidak mengikat pembacaan, keputusan, dan penulisan dengan mekanisme yang tepat, state akan mudah terlihat konsisten padahal sebenarnya tidak.
Untuk endpoint kritis seperti auth, reset password, claim voucher, upload, dan rate limit, kombinasikan validasi aplikasi dengan proteksi yang benar di database: transaksi, unique constraint, optimistic locking, operasi atomik, dan idempotency key. Setelah itu, lengkapi dengan observability dan uji konkurensi agar bug balapan tidak baru terlihat saat abuse sudah terjadi di produksi.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!