D
P
0
← Semua artikel Read in English

WordPress & PHP di Produksi

Baris yang Diredupkan Bukan Otorisasi: Profil Bertuan Tetap Bisa Diklaim Lewat URL Langsung

· · 6 menit baca
Baris yang Diredupkan Bukan Otorisasi: Profil Bertuan Tetap Bisa Diklaim Lewat URL Langsung

Baris profil yang sudah bertuan di wizard klaim itu benar-benar tidak bisa diklik. Bukan link yang dimatikan lewat CSS, melainkan <div> redup dengan petunjuk gembok bertulis CLAIMED, dan yang memutuskan redup atau tidak adalah satu closure yang membaca dua meta sekaligus: owner id yang lebih besar dari nol, atau flag claimed yang bernilai 1. Dari sisi orang yang memakai formulirnya, profil itu memang tidak tersedia.

Pada 3 Juli 2026 saya menyalin slug salah satu baris redup itu ke URL wizard, lalu membukanya langsung tanpa lewat kotak pencarian. Step CONFIRM ter-render seperti biasa, lengkap dengan formulirnya. Ternyata peredupan di daftar pencarian itu satu-satunya cek yang ada di seluruh alur, dan handler submit menyimpan klaimnya tanpa satu pun pemeriksaan kepemilikan.

// Daftar hasil pencarian: baris diredupkan kalau salah satu meta ini benar.
$is_taken = ( (int) get_post_meta( $listing_id, '_dir_owner_id', true ) > 0 )
         || ( get_post_meta( $listing_id, '_dir_claimed', true ) === '1' );
 
if ( $is_taken ) {
    echo render_locked_row( $listing_id );   // <div> redup + gembok "CLAIMED"
} else {
    echo render_selectable_row( $listing_id ); // <a> yang bisa diklik
}

Handler yang menerima submit-nya tidak punya padanan baris mana pun di atas:

// Tidak ada satu pun turunan $is_taken di sini. Klaim langsung tersimpan.
$request_id = wp_insert_post( [
    'post_type'   => 'dir_request',
    'post_status' => 'pending',
] );
update_post_meta( $request_id, '_req_listing_id', $listing_id );
update_post_meta( $request_id, '_req_owner_id', get_current_user_id() );

Saya jalankan klaim atas profil yang sudah dimiliki orang lain itu sampai ujung: submit, konfirmasi lewat email, sampai persetujuan admin. Semua tahapnya lolos. Yang akhirnya menahan pemindahan kepemilikan cuma penjaga one-owner di baris paling belakang. Kepemilikan tidak pernah benar-benar berpindah, dan peredupan tadi terbukti kosmetik saja.

Kenapa ini tidak ketahuan

Seluruh QA sebelumnya menempuh jalur bahagia lewat UI, dan di jalur itu peredupan membuat rutenya tampak tidak bisa dilewati. Kalimat "UI-nya mencegah itu" terasa seperti coverage, padahal tidak pernah jadi coverage. UI hanya satu klien. Handler melayani setiap klien: URL langsung, salinan basi daftar dari CDN, curl, dan formulir yang dipost ulang.

Salinan basi itu bukan skenario karangan. Aturan cache berumur satu tahun sempat menyajikan daftar pencarian dalam keadaan sebelum profil diklaim, jadi pengunjung bisa mendapat daftar yang barisnya masih terlihat bisa dipilih padahal di database sudah tidak.

Predikat peredupannya sendiri juga bukan cerminan kepemilikan. Dua belas dari baris yang tampak sudah diklaim ternyata cuma memakai bendera seed demo, _dir_claimed diset oleh skrip seeding untuk memperagakan tiga state rendering, sementara owner id-nya kosong di semuanya. Tidak ada satu pun yang pernah benar-benar diklaim orang. Yang membuat baris itu redup adalah bendera, bukan pemilik.

Kelas yang sama, sesi yang sama

Di alur yang sama, _req_owner_id di-stamp dari sesi mana pun yang sedang login. Artinya admin yang sekadar menguji formulir diam-diam tercatat sebagai claimant, dan catatan projeknya mencatat akibat lanjutannya: persetujuan akan memberikan kepemilikan ke akun admin itu.

Perbaikannya menghapus kemungkinan menebak siapa claimant-nya dari konteks eksekusi. Ada satu resolver tunggal yang dipakai bersama oleh metabox dan proses persetujuan, jadi keduanya menjawab pertanyaan "siapa pemohonnya" dari sumber yang sama, ditambah penjagaan supaya sesi staf tidak pernah menjadi claimant.

Perbaikannya: tiga lapis

Lapis pertama ada di handler submit. Ia menurunkan ulang predikat yang sama dari data yang sama, dan kalau listing-nya sudah bertuan, permintaan ditolak dengan notice taken=1. Lapis kedua ada di step CONFIRM, yang sekarang merender state "Already claimed." tanpa formulir sama sekali. Lapis ketiga adalah penjaga one-owner di fungsi penaut kepemilikan, yang menolak menimpa pemilik yang berbeda dan sekaligus melepas profil sebelumnya milik user itu.

// Lapis 1: predikat yang sama, data yang sama, dijalankan ulang di server.
if ( dir_listing_is_taken( $listing_id ) ) {
    wp_safe_redirect( add_query_arg( 'taken', '1', $wizard_url ) );
    exit;
}
 
// Lapis 3: penaut kepemilikan menolak menimpa pemilik yang berbeda.
function dir_link_user_listing( $user_id, $listing_id ) {
    $owner = (int) get_post_meta( $listing_id, '_dir_owner_id', true );
    if ( $owner && $owner !== (int) $user_id ) {
        return false; // tidak ada pemindahan diam-diam
    }
    // ... lepas profil lama user, lalu pasang kepemilikan baru
}

Lapis ketiga dibuktikan langsung di situs live, dua lapis pertama diuji lewat POST dengan slug ditukar. Taut kedua yang hostile mengembalikan false dengan pemilik tidak berubah, sementara taut ulang oleh pemilik yang sama tetap mengembalikan true, jadi penjaganya idempoten dan bukan sekadar penolak buta. Daftar pencariannya sendiri terbukti merender 13 baris berstatus claimed dan 12 baris yang bisa dipilih, jadi 25 baris seluruhnya, mencerminkan state yang sama seperti di direktori.

Kasus tabrakannya sudah terverifikasi sehari sebelumnya, pada 2 Juli. Klaim user kedua atas profil yang sudah bertuan tetap boleh disetujui, tetapi hasilnya hanya penanda konflik pada klaim itu, tanpa pemindahan, pemiliknya tidak berubah, dan peringatan untuk staf muncul di metabox.

Dua hal sengaja tidak dibuat lebih informatif. State publik pasca-submit tetap buta akun, karena kalau ia membedakan akun yang ada dan yang tidak, ia berubah menjadi oracle enumerasi. Dan tracker klaim di halaman akun sengaja mengecualikan klaim yang ter-stamp ke pengguna lain serta klaim atas profil yang sudah bertuan, sementara klaim yang disetujui tetapi berkonflik tetap dirender sebagai masih dalam peninjauan, bukan hilang begitu saja.

Cara mengujinya yang berubah

Sesi 3 Juli itu berisi empat celah live yang saya temukan, semuanya di-root-cause, diperbaiki, direview adversarial, lalu dideploy dalam tiga commit. Review adversarial sebelum deploy menghasilkan delapan temuan terkonfirmasi yang seluruhnya diperbaiki lebih dulu, dan satu temuan yang dibantah.

Bentuk permintaan yang dulu lolos itu yang saya pakai untuk membuktikan tambalannya: POST dengan slug ditukar, memakai nonce yang benar-benar sah dari halaman yang sah.

# Nonce-nya valid. Yang tidak valid adalah kepemilikan atas listing yang dituju.
curl -s -i -X POST https://situs-klien.test/ownership/ \
  -d 'listing=profil-yang-sudah-bertuan' \
  -d '_wpnonce=<nonce dari halaman yang sah>' \
  -d 'email=penguji@contoh.test' | grep -i '^location:'
# Sekarang: Location: /ownership/?taken=1

Re-QA sesudahnya saya jalankan lewat klik UI dari kotak pencarian, klik hasilnya, lalu isi formulirnya, bukan dengan menembak URL langsung, supaya yang teruji adalah jalur yang sungguhan ditempuh orang. Seluruh rangkaiannya lulus, dan ketiga email verdict-nya dikonfirmasi diterima. Satu hal teknis yang perlu diperhitungkan saat menyusun urutan tes: submit klaim dibatasi 5 per 1800 detik per IP, jadi rata-ratanya satu submit tiap 1800 dibagi 5 sama dengan 360 detik, alias enam menit.

Lima jalur adversarial berikut sekarang dijalankan di setiap alur, bukan cuma di jalur bahagia:

  1. Buka setiap state bergerbang lewat URL langsung, lewati wizard-nya.
  2. Tukar nilai hidden field, slug, atau ID lewat devtools sebelum submit.
  3. Submit dengan email orang lain atau ID resource milik akun lain.
  4. Replay dari halaman yang tersimpan di cache, dengan nonce lama dan state daftar yang lama.
  5. Klik ulang link email sekali pakai yang sudah terpakai.

Enam pekan kemudian

Klien melaporkan efek samping dari lapis kedua. Setiap CTA di halaman harga menunjuk ke rute klaim, dan template klaim itu menampilkan "Already claimed." tanpa formulir dan tanpa jalan keluar menuju pemiliknya. Jadi halaman harga mengirim member yang sudah punya profil ke jalan buntu.

Itu bukan alasan untuk mencabut penjaganya. Tapi bagus untuk diingat bahwa lapis yang benar secara otorisasi tetap punya sisi produk, dan state penolakan butuh jalan keluar sama seperti state sukses.

Pelajaran

Aturan yang saya ambil dari sini cuma satu kalimat: setiap affordance UI yang berkata "tidak bisa" wajib punya kembaran cek server-side di handler, dengan predikat yang sama dan data yang sama. Baris redup berarti handler mengecek ulang kepemilikan. Tombol disabled berarti handler mengecek ulang state. Daftar terfilter berarti handler memvalidasi ulang pilihannya. Kalau cek di sisi handler-nya tidak ada, fiturnya belum terkirim, sebagus apa pun tampilan UI-nya.

Ini juga alasan kenapa nonce yang valid tidak pernah cukup. Nonce membuktikan permintaannya berasal dari halaman kita dan belum kedaluwarsa, bukan bahwa pengirimnya berhak atas baris yang dia sebut. Kepemilikan itu properti per baris, dan satu-satunya tempat ia boleh diputuskan adalah di server, diturunkan ulang dari data, tepat sebelum penulisan terjadi.