D
P
0
← Semua artikel Read in English

WordPress & PHP di Produksi

Guard Form yang Cuma Tahu `bot` atau `bukan`: Orang Sungguhan Disuruh Menunggu Email yang Tidak Pernah Dikirim

· · 5 menit baca
Guard Form yang Cuma Tahu `bot` atau `bukan`: Orang Sungguhan Disuruh Menunggu Email yang Tidak Pernah Dikirim

Pada 1 September 2026 saya membuka lagi handler klaim di sebuah situs keanggotaan yang sudah lama saya pegang. Saya sedang menyisir ulang satu kelas kerusakan yang dulu saya nyatakan tertutup, dan ingin tahu apakah dia benar-benar tertutup.

Latarnya begini. Tiap form auth di situs ini membawa penanda waktu bernama mbr_ts yang tertanam langsung di HTML, jadi umur sebuah form dihitung dari kapan halamannya dirender, bukan dari kapan orangnya mulai mengetik. Kalau halaman itu sempat disajikan dari salinan yang sudah lama tersimpan, form yang diterima pengunjung bisa sudah lewat jendela waktunya sebelum satu huruf pun masuk. Jendelanya sendiri sudah pernah saya lebarkan.

Yang tidak pernah saya periksa adalah apa yang terjadi sesudah guard-nya menjawab tidak.

Satu boolean untuk dua cerita yang berbeda

Tiap form auth di situs ini mengirim POST ke admin-post.php dengan sebuah action, wp_nonce_field, sekumpulan field guard dari mbr_form_guard_fields($action), dan field Turnstile dari mbr_turnstile_field(). Bagian yang menilai kiriman itu memeriksa lebih dari satu hal, tapi hasil akhirnya diperas jadi satu jawaban ya atau tidak lewat mbr_form_is_bot().

Namanya jujur soal apa yang dia kembalikan. Masalahnya justru di sana. Form yang kedaluwarsa dan kiriman yang memang dari bot keluar dari fungsi itu dalam bentuk yang persis sama, dan handler klaim cuma punya satu cabang untuk menanganinya. Cabang itu mengarah ke claim=check-inbox, yang di berkas yang sama adalah layar sukses.

Bentuknya kira-kira seperti ini, saya sederhanakan supaya intinya kelihatan, bukan kutipan berkasnya apa adanya:

// satu cabang untuk semua sebab penolakan
if (mbr_form_is_bot($action)) {
    wp_safe_redirect(add_query_arg('claim', 'check-inbox', $back));
    exit;
}

Untuk bot, cabang itu memang pintar. Dia pergi merasa berhasil dan tidak ada yang tersimpan. Untuk orang sungguhan yang formnya kebetulan sudah tua, cabang yang sama berubah jadi bencana kecil yang sangat sunyi: dia mengisi formulir klaim, menekan kirim, membaca layar yang bilang cek kotak masuk, lalu menunggu email yang tidak akan pernah dikirim.

Kehilangan itu sunyi dalam arti yang harfiah. Tidak ada yang bisa dilihat siapa pun kecuali orang yang menunggu.

Modul yang sudah memisahkannya lebih dulu

Bagian yang paling tidak enak dari temuan ini adalah pemisahan yang saya butuhkan itu sudah ada di kode yang sama, cuma tidak di tempat ini. Modul leads sudah memakai mbr_form_guard_reason(), fungsi yang mengembalikan alasan penolakan, bukan sekadar ya atau tidak.

Jadi perbaikannya bukan menulis logika baru. Cukup memanggil fungsi yang sudah ada, lalu menambah satu cabang. Bentuk yang disederhanakan:

// alasan yang dulu melebur jadi satu boolean
$reason = mbr_form_guard_reason($action);
 
if ($reason === 'stale') {
    // orang sungguhan, halamannya kelamaan terbuka
} elseif ($reason === 'bot') {
    // tidak perlu diberi tahu apa-apa
}

Di catatan saya, itu benar-benar pekerjaan lima menit. Yang mahal bukan perbaikannya, melainkan jarak antara saat cabangnya mulai salah dan saat ada yang berpikir untuk memeriksanya.

Yang terukur setelah alasannya dipisah

Saya tidak mau menutup ini berdasarkan pembacaan kode saja, jadi saya kirim beberapa form dengan umur dan bentuk berbeda, lalu mencatat status yang keluar dari guard-nya.

umur 30 detik    -> accepted
umur 13 jam      -> stale
umur 1 detik     -> bot
honeypot terisi  -> bot

Form berumur 30 detik diterima. Form berumur 13 jam keluar sebagai stale. Form yang dikirim satu detik setelah halamannya terbuka keluar sebagai bot, dan honeypot yang terisi juga keluar sebagai bot. Sebelum alasannya dipisah, stale dan bot keluar dari guard dalam bentuk yang sama, jadi keduanya jatuh ke cabang yang sama. Sekarang stale mendarat di claim=stale, dengan pesan bahwa sesinya sudah kedaluwarsa dan permintaan untuk memuat ulang halaman lalu mengirim ulang.

Satu catatan praktis kalau kamu menguji hal semacam ini sendiri di situs hidup. Satu IP cuma boleh mengirim lima klaim di dalam rentang 1800 detik, jadi urutan tesnya harus diatur pelan. Kalau tidak, yang menjawab kiriman berikutnya bukan lagi guard yang sedang kamu uji, melainkan rate-limit, dan hasil bacaannya jadi kabur tanpa kamu sadari.

Cacat yang sama, dan bagian yang sengaja tidak saya samakan

Begitu bentuk cacatnya jelas, saya cari bentuk yang sama di tempat lain. Handler lupa kata sandi, mbr_handle_lostpw(), punya cacat yang persis sebangun: form yang lebih tua dari 12 jam mendarat di sent=1 dengan pesan bahwa email sudah dikirim, dan tidak ada email yang dikirim.

Di sinilah saya hampir membuat kesalahan kedua, yang jenisnya lebih halus daripada kesalahan pertama. Setelah menemukan satu jalur penolakan yang berbohong, refleksnya adalah membuat semua jalur penolakan jujur sekalian. Di form lupa kata sandi, refleks itu justru merusak.

Jawaban sukses pada honeypot yang terisi dan pada kiriman yang kena rate-limit memang harus tetap sukses. Bukan karena malas, tapi karena membedakan jawabannya di dua kasus itu memberi tahu penebak alamat mana yang terdaftar dan mana yang tidak. Yang salah di sana cuma stale, dan menjawab stale dengan jujur tidak membocorkan apa pun, karena pesannya soal halaman yang sudah tua, bukan soal alamat yang diketik. Jadi hanya stale yang saya pindahkan, ke auth=expired.

Pengujiannya saya jalankan di produksi terhadap handler aslinya, dan satu-satunya yang saya palsukan adalah jawaban jaringan dari Turnstile. Form segar berakhir di sent=1, form stale di auth=expired, kiriman bot di sent=1, dan tidak ada satu pun mail terkirim di ketiga kasus itu. Bagian terakhir yang paling saya perhatikan, karena itu artinya tes di situs hidup tidak meninggalkan jejak di kotak masuk siapa-siapa.

Sisanya menyisir pemanggil lain. mbr_form_is_bot() masih dipanggil di empat tempat lagi, dan keempatnya saya periksa satu per satu. Register, login, dan reset semuanya mengarah ke error yang terlihat, jadi tidak ada orang yang hilang di sana.

Bedakan alasannya, jangan seragamkan jawabannya

Fungsi yang mengembalikan boolean memaksa pemanggilnya menulis satu cabang untuk semua sebab penolakan. Selama sebabnya setara, itu tidak terasa. Begitu salah satunya ternyata orang sungguhan, cabang tunggal itu berubah dari penyederhanaan jadi kehilangan yang tidak tercatat di mana pun.

Yang saya bawa dari kasus ini ada empat, dan yang ketiga itu yang paling gampang kelewat: