Kiat Menguji Keamanan Payment Gateway Sebelum Uang Sungguhan Lewat

Abd. Asis
Kiat Menguji Keamanan Payment Gateway Sebelum Uang Sungguhan Lewat

Sebuah platform pembayaran bisa terlihat sempurna di demo: tombol bayar bekerja, status berubah menjadi lunas, notifikasi terkirim. Lalu seseorang membuka terminal dan mengirim satu request curl ke endpoint callback dengan body yang dia susun sendiri. Kalau endpoint itu percaya begitu saja, pesanan sepuluh juta rupiah berubah menjadi lunas tanpa satu rupiah pun masuk.

Di aplikasi biasa, kesalahan seperti itu berarti bug. Di payment gateway, itu berarti kerugian langsung, sengketa dengan merchant, dan kemungkinan besar urusan hukum.

Karena itu pengujian keamanan payment gateway tidak bisa diperlakukan sebagai checklist yang diisi menjelang rilis. Dia harus jadi bagian dari cara kamu membangun, dengan skenario serangan yang ditulis lebih dulu sebelum fiturnya selesai.

Artikel ini menyusun kiat-kiat itu berdasarkan dokumen security assessment internal yang kami pakai, dirapikan menjadi urutan yang bisa langsung dikerjakan.

Prinsip yang membedakan payment gateway

Sebelum masuk ke daftar uji, ada empat prinsip yang membuat semua pengujian lain masuk akal.

Frontend tidak punya suara. Nominal, status, dan identitas merchant yang dikirim dari browser adalah klaim, bukan fakta. Sumber kebenaran hanya dua: permintaan yang kamu buat ke penyedia pembayaran, dan callback yang tanda tangannya valid.

Uang tidak punya undo. Kesalahan pada data biasa bisa diperbaiki dengan migrasi. Transaksi yang sudah settlement tidak bisa ditarik kembali tanpa prosedur refund yang melibatkan bank.

Setiap operasi harus idempoten. Jaringan putus, pengguna menekan tombol dua kali, dan webhook datang berulang adalah kondisi normal, bukan pengecualian. Operasi yang mengubah uang wajib aman dijalankan lebih dari sekali.

Status adalah mesin, bukan label. Perpindahan dari pending ke paid hanya sah lewat satu jalur, dan jalur itu harus dijaga di server.

Satu: uji manipulasi nominal

Ini pengujian pertama yang harus kamu tulis, karena paling sering ditemukan di integrasi buatan sendiri.

Skenarionya sederhana. Invoice asli bernilai Rp1.000.000. Kamu mengirim permintaan pembayaran dengan amount=1000, dan melihat apa yang terjadi.

Yang diuji bukan cuma apakah server menolak. Yang diuji adalah dari mana server mengambil nominal:

PHPPHP
// salah: nominal dari request customer
$amount = $request->integer('amount');

// benar: nominal dari invoice yang tersimpan, berdasarkan referensi
$invoice = Invoice::query()
    ->where('number', $request->string('invoice_number'))
    ->lockForUpdate()
    ->firstOrFail();

$amount = $invoice->outstanding_amount;

Kiat praktisnya: tulis test yang mengirim nominal lebih kecil, lebih besar, nol, negatif, dan nominal dengan desimal aneh. Kelimanya harus ditolak, dan penolakannya harus tercatat di log.

Hal yang sama berlaku untuk mata uang. Menerima currency=USD pada invoice rupiah bukan soal tampilan, itu soal nilai tukar yang tidak pernah kamu hitung.

Dua: uji keunikan dan idempotensi transaksi

Dua transaksi berbeda dengan referensi yang sama adalah bencana akuntansi. Pencegahan paling murah ada di level basis data, bukan di level kode aplikasi:

SQLSQL
create unique index payment_ref_unique
    on payment_transactions (provider, provider_reference);

Dengan constraint itu, percobaan menyimpan referensi ganda akan gagal, bukan diam-diam membuat baris kedua.

Di atasnya, uji idempotensi operasinya. Kirim callback yang sama dua kali, lalu pastikan:

  1. status pembayaran tidak berubah dua kali
  2. tidak ada dua baris transaksi
  3. saldo merchant tidak bertambah dua kali
  4. respons kedua tetap sukses, bukan error yang membuat penyedia mengirim ulang lagi

Poin terakhir sering dilupakan. Kalau callback duplikat dijawab dengan 500, penyedia akan menganggap pengiriman gagal dan mencoba lagi, biasanya dengan backoff selama berjam-jam.

Tiga: uji tanda tangan dan replay

Endpoint callback adalah alamat publik. Tanpa verifikasi kriptografis, siapa pun bisa mengumumkan bahwa pembayaran berhasil.

PHPPHP
$expected = hash_hmac('sha256', $rawBody, $secret);

if (! hash_equals($expected, $signatureFromHeader)) {
    abort(401, 'Signature tidak valid');
}

Dua detail teknis yang sering salah:

hash_equals dipakai, bukan ===. Perbandingan string biasa bisa membocorkan informasi lewat perbedaan waktu, dan itu cukup untuk menebak tanda tangan sedikit demi sedikit.

Body yang ditandatangani adalah body mentah, bukan hasil json_decode lalu di-encode ulang. Urutan kunci dan spasi bisa berbeda, dan tanda tangannya langsung tidak cocok.

Setelah tanda tangan valid, masih ada dua pengujian lagi:

Replay window. Kirim callback dengan timestamp dua jam lalu, tanda tangan valid. Harus ditolak. Jendela waktu yang wajar biasanya dua sampai lima menit.

Event ID unik. Kirim dua callback berbeda isi tapi dengan event_id yang sama. Yang kedua harus diabaikan, karena itu tanda penyedia mengirim ulang, bukan transaksi baru.

Empat: uji race condition

Ini pengujian yang paling jarang ditulis dan paling mahal akibatnya.

Skenarionya: pengguna menekan tombol bayar, jaringan lambat, dia menekan lagi. Atau dua worker memproses callback yang sama pada milidetik yang berdekatan.

Pola yang salah adalah cek-lalu-tulis:

PHPPHP
if ($transaction->status !== 'paid') {   // dua request lolos di sini
    $transaction->update(['status' => 'paid']);
    $merchant->increment('balance', $transaction->net_amount);
}

Dua request bisa sama-sama lolos pengecekan, dan saldo naik dua kali. Perbaikannya ada di level penguncian dan constraint:

PHPPHP
DB::transaction(function () use ($reference) {
    $transaction = PaymentTransaction::query()
        ->where('provider_reference', $reference)
        ->lockForUpdate()
        ->firstOrFail();

    if ($transaction->status === 'paid') {
        return; // idempoten, tidak ada perubahan
    }

    $transaction->markAsPaid();
    $transaction->merchant->increment('balance', $transaction->net_amount);
});

Cara mengujinya jangan pakai asumsi. Jalankan dua request paralel dengan curl di background, atau tulis test yang memanggil endpoint dua kali dari dua proses. Setelah itu, periksa satu angka: saldo merchant harus bertambah tepat satu kali.

Lima: uji transisi status

Setiap status punya jalur masuk yang sah. Yang perlu diuji adalah jalur yang tidak sah.

DariKeBoleh?
pendingpaidya, setelah verifikasi
pendingexpiredya, setelah batas waktu
paidrefundedya, dengan otorisasi
expiredpaidtidak
refundedpaidtidak
paidpaidya, tapi tanpa efek samping

Kiat praktisnya: tulis matriks ini sebagai test table-driven, satu baris satu kasus. Kalau ada transisi yang lolos padahal seharusnya ditolak, kamu sudah menemukan celah sebelum ada yang memakainya.

Yang juga wajib diuji: perubahan status harus berasal dari server, bukan dari parameter request. Kalau endpoint punya field status yang bisa dikirim client, itu temuan kritis, bukan saran.

Enam: uji otorisasi dan isolasi merchant

Di platform multi-merchant, pertanyaan paling penting bukan "apakah user sudah login", tapi "apakah user ini boleh melihat transaksi ini".

Bentuk serangannya IDOR: mengganti ID transaksi di URL dengan milik merchant lain.

CodeCODE
GET /api/transactions/8841   -> milik merchant B

Yang harus terjadi: 403 atau 404, bukan 200 dengan data merchant lain. Kiatnya, tulis test yang membuat dua merchant, dua transaksi, lalu mencoba mengakses silang dengan token masing-masing. Empat kombinasi, semuanya harus jelas hasilnya.

Refund punya pengujian sendiri, karena dia mengeluarkan uang. Yang diuji:

  • refund melebihi nominal transaksi harus ditolak
  • refund ganda untuk transaksi yang sama harus ditolak atau idempoten
  • refund oleh staf tanpa izin harus ditolak
  • refund untuk transaksi yang belum settlement harus mengikuti aturan yang kamu tetapkan, dan aturan itu harus diuji

Tujuh: uji autentikasi dan sesi

Bagian ini terasa biasa, tapi tetap menjadi pintu masuk paling sering di aplikasi keuangan.

Yang diujiHarapan
Penyimpanan sandiArgon2id atau bcrypt, bukan hash cepat
MFA adminAktif dan tidak bisa dilewati lewat API
Cookie sesiSecure, HttpOnly, SameSite
Brute forceDibatasi per akun dan per IP
Reset sandiToken sekali pakai, punya masa berlaku
Masa berlaku token APIAda, dan bisa dicabut
LogoutToken benar-benar tidak berlaku lagi
Enumerasi akunPesan error tidak membocorkan akun ada atau tidak

Dua yang paling sering gagal diuji: pencabutan token setelah logout, dan pembatasan percobaan login yang ternyata hanya berlaku di halaman, tidak di endpoint API.

Delapan: uji API dan webhook keluar

Platform pembayaran tidak cuma menerima callback, dia juga mengirim webhook ke merchant. Arah keluar ini punya risiko sendiri.

SSRF. Merchant mendaftarkan URL webhook http://169.254.169.254/latest/meta-data/ atau http://127.0.0.1:5432. Kalau servermu menurut, kamu baru saja membocorkan jaringan internal. Yang diuji: URL harus publik, skema harus HTTPS, dan resolusi DNS harus diperiksa, termasuk redirect.

Rate limit. Uji dengan 200 request per menit dari satu token. Kalau tidak ada pembatasan, satu integrasi yang salah bisa menghabiskan kapasitas untuk semua merchant.

Kebocoran error. Kirim payload rusak dan lihat responsnya. Stack trace, nama tabel, atau versi framework di body respons adalah temuan.

Validasi input. Nominal, email, nomor telepon, dan ID referensi harus divalidasi dengan aturan yang jelas. Uji juga input yang sangat panjang, karakter unicode, dan angka dengan notasi ilmiah.

Sembilan: uji infrastruktur

Ini bagian yang tidak bisa diuji dari dalam aplikasi, dan sering terlewat karena dianggap urusan orang lain.

  • HTTPS wajib. Coba akses lewat http://, harus redirect, bukan melayani.
  • TLS 1.2 ke atas. Uji dengan openssl s_client, pastikan TLS 1.0 dan 1.1 ditolak.
  • Database tidak boleh publik. Dari mesin luar, coba nc -vz host 5432. Kalau terbuka, itu temuan kritis, bukan saran.
  • Secret tidak ada di repo. Jalankan pemindai seperti Gitleaks pada seluruh riwayat, bukan hanya commit terakhir.
  • Firewall. Hanya port 80, 443, dan port SSH yang dibutuhkan yang terbuka.
  • Backup terenkripsi, dan restore-nya pernah diuji. Backup yang belum pernah direstore bukan backup, itu harapan.

Sepuluh: uji kebersihan data dan log

Data kartu, token, dan kunci API tidak boleh muncul di tempat yang bisa dibaca orang yang salah.

Yang diuji:

  1. Log aplikasi tidak memuat nomor kartu, CVV, atau token akses penuh.
  2. Payload mentah yang disimpan untuk audit dienkripsi atau dimasking.
  3. Kunci API hanya ditampilkan sekali saat dibuat, setelah itu hanya hint.
  4. Rotasi secret benar-benar bekerja, bukan hanya ada di dokumentasi.
  5. Data sensitif dienkripsi di level kolom atau disk.
  6. Akses ke basis data dibatasi dan tercatat.

Satu kebiasaan yang membantu: tambahkan test yang sengaja memicu error pada jalur pembayaran, lalu periksa isi file log. Kalau ada data sensitif di situ, itu temuan yang harus diperbaiki sebelum rilis.

Alat yang dipakai di tiap tahap

Tidak semua pengujian butuh alat khusus, tapi beberapa akan sangat mempercepat pekerjaan.

AlatKapan dipakai
Semgrepanalisis statis, mencari pola rawan di kode sendiri
OWASP ZAPpemindaian dinamis endpoint dan form
Burp Suitepengujian manual, terutama memodifikasi request
Trivymemindai image container dan dependency
Gitleaksmencari secret yang bocor di riwayat git
Nmapmemeriksa port yang terbuka dari luar
Snykmemantau kerentanan dependency berkelanjutan
CrowdSecdeteksi dan mitigasi penyusupan saat produksi

Urutan pemakaiannya masuk akal dimulai dari yang paling murah: Gitleaks dan Semgrep sebelum rilis pertama, ZAP dan Burp sebelum uji penetrasi, Trivy dan Snyk berkelanjutan, Nmap dan CrowdSec setelah aplikasi hidup.

Gate sebelum produksi

Bagian ini yang membuat assessment punya gigi. Tanpa kriteria lulus yang jelas, setiap temuan akan berakhir sebagai "nanti saja".

PersyaratanKriteria lulus
Temuan kritisnol
Temuan tingginol
Validasi pembayaranseluruh test kritis lulus
Verifikasi tanda tanganterbukti menolak yang tidak valid
Idempotensitidak ada duplikasi saat callback diulang
Autentikasiseluruh endpoint terlindungi
Otorisasitidak ada akses lintas merchant
Restore backupberhasil diuji di lingkungan bersih
Audit logperubahan status tercatat
Persetujuanreviewer menandatangani

Jangan menguji skenario serangan di produksi dengan data nyata. Pakai lingkungan staging dengan data sintetis, dan akun uji penyedia pembayaran. Kalau harus menguji di produksi, batasi pada hal yang tidak mengubah uang, misalnya header keamanan dan konfigurasi TLS.

Kiat yang jarang ditulis di panduan

Tulis test negatif lebih dulu. Sebelum fitur pembayaran selesai, tulis test untuk request yang seharusnya ditolak. Ini memaksa kamu memikirkan jalur serangan saat masih murah untuk diperbaiki.

Uji dengan data yang jelek. Nominal 0.1, 999999999, kode referensi dengan spasi, ID merchant milik tenant lain, dan callback dengan field yang hilang. Data rapi tidak menemukan bug.

Periksa yang tidak terlihat di layar. Banyak temuan ada di header respons, isi log, dan metadata file, bukan di halaman.

Jadikan temuan sebagai test permanen. Setiap temuan yang diperbaiki harus berubah menjadi regression test. Assessment yang diulang tanpa test akan menemukan bug yang sama dua kali.

Ukur, jangan nilai sendiri. Untuk severity, gunakan CVSS sebagai titik awal, lalu sesuaikan dengan dampak bisnis. Kebocoran daftar transaksi merchant lebih berbahaya daripada server yang versi patch-nya tertinggal satu, meski keduanya terlihat sama di daftar.

Penutup

Kalau kamu membangun payment gateway, anggap setiap endpoint yang menyentuh uang sebagai tempat yang sedang diuji orang lain, karena memang begitu keadaannya. Yang membedakan platform yang aman bukan jumlah fitur keamanan yang dipasang, tapi kebiasaan membuktikan bahwa setiap jalur penolakan benar-benar menolak.

Mulailah dari lima pengujian yang paling murah dan paling berdampak: manipulasi nominal, idempotensi callback, verifikasi tanda tangan, race condition pada saldo, dan akses lintas merchant. Kalau kelimanya lulus dengan bukti, bukan dengan asumsi, sisanya akan terasa jauh lebih mudah.

Abd. Asis
Ditulis oleh
Abd. Asis

Software Developer dari Madura. Menulis tentang PHP, Laravel, dan pengembangan web modern dalam Bahasa Indonesia.

Artikel Terkait

Tulisan lain yang mungkin menarik buatmu.