Skip to main content

Keamanan & Hardening — Core API (merchant_core_api)

Semua item di bawah telah diimplementasikan dan diverifikasi per audit keamanan 2026-05-29–2026-06-05.


Autentikasi & Otorisasi

  • Role check ketat pada route internal dashboard: Middleware withInternalDashboardAccess memvalidasi PrimaryRole pengguna setelah JWT valid. Hanya role superadmin, admin, finance, dan operations yang diizinkan mengakses route /internal/*. Role support, viewer, partner_admin, serta seluruh merchant staff di-reject 403 — privilege escalation dari merchant staff ke fungsi administratif tidak dimungkinkan.

  • Cancel device-login aman dari token leakage: Endpoint cancel device-login mendukung metode POST canonical dengan token di JSON body sebagai jalur utama. Metode GET dipertahankan hanya untuk backward-compatibility (link email/WA yang sudah beredar). Semua response dilengkapi Cache-Control: no-store, no-cache, must-revalidate dan Pragma: no-cache untuk mencegah token bocor via proxy atau browser cache.

  • Internal notification endpoint fail-closed: Empat handler notifikasi internal menggunakan fungsi authenticateInternalRequest() yang konsisten. Pattern fail-closed memastikan kunci kosong selalu ditolak — misconfiguration tidak menghasilkan auth bypass secara diam-diam.

  • Validasi kunci internal saat startup: Config.Validate() mewajibkan INTERNAL_NOTIFICATION_API_KEY tidak kosong sebelum proses dapat berjalan. Misconfiguration terdeteksi di fase startup, bukan saat request pertama masuk.

  • Handler event merchant internal menggunakan auth fail-closed: internal_merchant_event_handler menggunakan authenticateInternalRequest() yang identik dengan standar handler notifikasi — tidak ada inkonsistensi antar handler internal.

  • Endpoint transaction status internal dilindungi auth fail-closed: Dua endpoint internal yang dapat memicu FCM push notification dan Slack alert sama-sama menggunakan authenticateInternalRequest() — keduanya tidak dapat dipanggil tanpa kunci yang valid.

  • Redaksi data sensitif KYC untuk non-owner: Respons profil meredaksi NIK, nomor rekening bank, NPWP, dan foto dokumen KYC untuk pengguna yang bukan owner. Field is_owner diambil dari tabel merchant_users sebagai sumber kebenaran — bukan dari klaim token.

  • Verifikasi email OTP tidak bisa di-redirect ke email lain (IDOR): VerifyEmailOTP tidak lagi menerima email dari request body. Email diambil dari token berdasarkan user_id — attacker tidak bisa memasukkan email orang lain untuk mengambil alih verifikasi.

  • Forgot password tidak bisa jadi oracle enumerasi email: handleDashboardForgotPassword mengembalikan HTTP 200 di semua kondisi (email ditemukan, tidak ditemukan, maupun error server) dengan pesan generik. Attacker tidak dapat membedakan apakah email terdaftar atau tidak.

  • Refresh token rotation atomik: Urutan operasi refresh token adalah: (1) generate token baru, (2) RotateRefreshToken dengan WHERE guard hash token lama, (3) baru issue access token ke client. Token baru tidak dikirim ke client jika proses rotate gagal di langkah 2 — tidak ada kondisi race yang menghasilkan token ganda.

  • Seluruh endpoint referensi dilindungi autentikasi: Route /references/* dibungkus middleware withAuthenticatedSubject. Endpoint /address-by-nik memiliki rate limit tambahan 10 request per menit per IP untuk mencegah penyalahgunaan lookup data kependudukan.

  • Endpoint metrik Prometheus tidak terekspos ke internet: Endpoint /metrics hanya dapat diakses dari port internal terpisah — tidak dapat dijangkau dari jaringan publik.

  • Validasi issuer token: ParseAccessTokenSubject memvalidasi klaim iss pada setiap token. Token yang diterbitkan oleh service lain meskipun menggunakan secret yang sama akan ditolak.

  • Pencegahan session fixation pada login OTP: VerifyLoginOTP merevoke seluruh sesi platform lama sebelum menerbitkan token baru — session fixation tidak dimungkinkan.

  • Akses shipping receipt berbasis kepemilikan yang akurat: loadEligibleShippingForReceipt menggunakan JOIN ke merchant_users sebagai sumber kebenaran kepemilikan. Owner yang bergabung via transfer akun tidak lagi diblokir secara keliru.

  • PIN gate membedakan kondisi disabled vs misconfigured: requireRecentPinAuth mengembalikan 503 (bukan 200 atau 403) jika PinGateEnabled=true tetapi pinService==nil — operator mendapat sinyal yang jelas bahwa ada misconfiguration, bukan kesan fitur berfungsi normal. Setiap bypass gate dicatat via slog.Error.

  • Google Play review bypass OTP terkontrol ketat: Mode bypass OTP untuk reviewer Google Play hanya aktif jika GOOGLE_PLAY_TESTER_PHONE dan GOOGLE_PLAY_TESTER_OTP_CODE keduanya terisi secara eksplisit di environment — mengosongkan salah satu menonaktifkan bypass. Setiap login via jalur ini dicatat ke audit log dengan auth_provider=google_play_tester_bypass.

  • Merchant suspended diblokir sebelum RBAC dievaluasi: Middleware withMerchantRoleResolverMulti memverifikasi MerchantStatus aktif sebelum melanjutkan ke pengecekan role. Merchant yang di-suspend tidak dapat melewati layer RBAC.

  • Phone enumeration via endpoint resolve dicegah: handleResolvePhone memiliki rate limiter 10 request per menit per IP. Respons yang berbeda berdasarkan keberadaan nomor tidak dapat dieksploitasi untuk membuat daftar nomor terdaftar.

  • IP spoofing untuk bypass rate limit tidak dimungkinkan: clientIP hanya membaca header X-Forwarded-For jika RemoteAddr berasal dari CIDR proxy yang dipercaya (trustedProxyCIDRs). Client tidak bisa memalsukan IP untuk menghindari rate limit.

  • State konfigurasi OTP internal tidak terekspos publik: Endpoint /health/otp-runtime dibungkus withInternalDashboardAccess — hanya operator dashboard dengan role yang diizinkan yang dapat melihat state konfigurasi OTP runtime.

  • Endpoint dashboard trend dan ranking terproteksi: handleGetInternalDashboardTxnTrend dan handleGetInternalDashboardMerchantRanking memiliki authenticateInternalRequest di awal handler sebelum logika bisnis apapun berjalan.

  • Tidak ada timing side-channel pada pengecekan credential: AuthenticateCredential selalu menjalankan operasi dummy bcrypt pada path "not-found" — waktu respons seragam antara credential valid dan tidak valid, sehingga enumerasi via timing attack tidak dimungkinkan.

  • Seluruh akses QRIS config dilindungi autentikasi dan ownership: Setiap jalur akses ke endpoint konfigurasi QRIS dibungkus middleware authentication dan merchant-ownership — tidak ada jalur akses yang terlewat.

  • Cancel login challenge tidak bisa mengusir sesi yang sah: CancelLoginChallenge memeriksa status verified sebelum melakukan revoke. Challenge yang sudah berhasil diverifikasi tidak dapat di-cancel oleh pihak manapun untuk mengusir sesi yang sah.

  • Warning saat PIN enforcement dinonaktifkan di production: slog.Warn dicatat saat startup jika AppEnv=production dan PIN_GATE_ENABLED=false — operator tidak dapat tidak menyadari bahwa enforcement PIN sedang dimatikan.

  • Guard kuantitas pada append sales order item: Handler item sales order memvalidasi Quantity >= 1 dan Quantity <= 99 per item, serta memeriksa existingOther + Quantity terhadap limit per perangkat sebelum append — request berulang tidak dapat membypass batas kuantitas.


Validasi Input & Pencegahan Injeksi

  • Upload file divalidasi via magic byte, bukan ekstensi: Handler upload foto KYC dan avatar menggunakan http.DetectContentType() untuk mendeteksi tipe file dari konten aktual. File berbahaya yang diberi ekstensi .jpg tidak akan melewati validasi.

  • Batas kuantitas dan kalkulasi harga server-side: Handler item sales order membatasi Quantity maksimum 99 per item, dan kalkulasi subtotal/total header di-recompute di order_service (bukan dari nilai yang dikirim client). Nilai harga tidak dapat dimanipulasi via payload.

  • MDR rate selalu dari rule server-side: Field MdrRate tidak dapat dikirim dari client dalam request pembuatan merchant. Server selalu menggunakan aturan BI dari sisi server — nilai MDR tidak bisa dimanipulasi via payload.

  • Status merchant hanya bisa diubah ke nilai yang diizinkan: handleUpdateMerchantStatus memvalidasi target_status terhadap allowlist server-side. Nilai status arbitrer tidak dapat ditulis ke database.

  • Magic byte validation konsisten di seluruh handler upload: Handler payment proof dan terminal receipt juga menggunakan http.DetectContentType() — validasi tipe file berbasis konten diterapkan secara konsisten, bukan hanya di handler tertentu.

  • Query KYC tidak menggunakan string concatenation untuk nama kolom: kyc/postgres.go menggunakan dua query terpisah per kolom alih-alih membangun SQL dengan concatenation nama kolom — SQL injection via nama kolom tidak dimungkinkan.

  • Deteksi duplikasi staff tidak bergantung pada string fragil: Deteksi duplikasi undangan staff menggunakan errors.As ke pgconn.PgError dengan kode 23505 (unique violation) secara eksplisit, bukan pencocokan string nama constraint yang dapat berubah.

  • Payload reasons dibatasi ukurannya: Array reasons dibatasi 10 item, setiap item maksimum 64 karakter, dan reason_note maksimum 500 karakter. Kolom JSONB tidak dapat di-inflate via payload besar.

  • FCM push token aman dari path traversal: Token FCM di-encode dengan url.PathEscape sebelum di-append ke URL. Token yang mengandung karakter / tidak dapat menyebabkan path traversal.

  • User ID aman dari inject query parameter: userID di-encode dengan url.Values sebelum dijadikan query string. Karakter & dalam nilai tidak dapat menyisipkan parameter tambahan.

  • Upload bukti pengiriman divalidasi tipe file: Handler upload bukti pengiriman menggunakan http.DetectContentType — validasi berbasis konten konsisten dengan handler upload lainnya.

  • URL foto dibatasi ke domain CDN yang dikonfigurasi: Fungsi validatePhotoURL memverifikasi bahwa host URL cocok dengan MinioPublicBaseURL dari konfigurasi. URL foto dari domain sembarang tidak dapat diterima.

  • Avatar URL divalidasi saat write untuk mencegah stored XSS: avatar_url hanya diterima jika menggunakan skema HTTPS dan host dari domain CDN yang diizinkan. Skema javascript: dan domain eksternal ditolak.

  • Parameter rentang waktu query dibatasi: GetTransactionTrend membatasi parameter days maksimum 365 hari. Query generate_series dengan rentang ratusan tahun tidak dapat dibuat.

  • Validasi kepemilikan di store layer, bukan hanya caller: GetSubmissionForOwner menyertakan subjectID di klausa WHERE di level store — kepemilikan divalidasi di lapisan yang lebih dalam, bukan hanya mengandalkan caller.

  • Versi terms of service divalidasi terhadap allowlist: Field versi terms divalidasi terhadap daftar nilai yang diizinkan ({'1.0','1.1','2.0'}). Nilai arbitrer tidak dapat tersimpan di kolom terms_accepted_version.

  • HTTP request ke shipping provider dibatasi ke domain resmi: komerce/client.go memvalidasi prefix https:// dan allowlist domain sebelum membuat HTTP request — SSRF via manipulasi parameter URL tidak dimungkinkan.

  • Batas nilai quantity dan harga pada kalkulasi subtotal: Quantity dibatasi 1–10.000 dan unit_price maksimum 1 miliar. Integer overflow pada kalkulasi subtotal tidak dimungkinkan.

  • Validasi UUID ketat menggunakan parsing, bukan regex longgar: isLikelyUUID menggunakan uuid.Parse atau regexp ketat untuk validasi. String berbentuk UUID yang mengandung karakter non-hex tidak lolos.

  • Audit log dilindungi dari bloat via URL panjang: request_path di tabel audit log di-truncate ke maksimum 2.048 karakter sebelum INSERT — URL yang sangat panjang tidak dapat membuat tabel audit membengkak.


Pencegahan Memory Exhaustion & DoS

  • HTTP server terlindungi dari Slow-Loris: Server dikonfigurasi dengan ReadHeaderTimeout: 10s, ReadTimeout: 30s, WriteTimeout: 60s, dan IdleTimeout: 120s — koneksi yang sengaja dibiarkan lambat tidak dapat menghabiskan seluruh goroutine pool.

  • Request body dibatasi 1 MB: Fungsi decodeJSONBody menggunakan io.LimitReader dengan cap 1 MB sebelum decode JSON. Request body yang sangat besar tidak dapat menyebabkan OOM (out-of-memory).

  • Background worker tidak mati permanen setelah panic: Empat background worker memiliki restart loop dengan backoff 30 detik setelah panic. Metric worker_panics_total tersedia untuk kebutuhan alerting.

  • Query transaksi dibatasi jumlah barisnya: Parameter limit pada handler transaksi merchant dibatasi maksimum 100 baris per request. Dump seluruh tabel transaksi dalam satu query tidak dimungkinkan.


Keamanan Secret & Konfigurasi

  • Konfigurasi MinIO fail-fast jika tidak lengkap: MINIO_ENDPOINT dan MINIO_ACCESS_KEY tidak memiliki nilai fallback hardcoded. Config.Validate() gagal saat startup jika salah satu dari keduanya kosong di production.

  • Kunci API service eksternal wajib ada saat startup: Config.Validate() memproteksi FCMServiceAPIKey, WhatsAppServiceAPIKey, EmailServiceAPIKey, dan AuthServiceAPIKey dengan fail-fast di production — service tidak dapat berjalan dengan kunci yang hilang.

  • Security response headers ditambahkan ke semua response: Middleware menambahkan X-Content-Type-Options: nosniff, X-Frame-Options: DENY, dan Referrer-Policy: strict-origin-when-cross-origin ke seluruh HTTP response untuk mencegah MIME sniffing, clickjacking, dan referrer leakage.

  • CORS fail-closed saat konfigurasi tidak di-set: CORS tidak menerima request cross-origin dari domain sembarang jika CORS_ALLOWED_ORIGINS tidak dikonfigurasi. Nilai CORS_ALLOWED_ORIGINS=https://kesles.com di-set di environment production.

  • Content-Security-Policy dan HSTS aktif: Middleware withSecurityHeaders menambahkan Content-Security-Policy, Strict-Transport-Security dengan max-age=63072000 includeSubDomains, dan Permissions-Policy ke semua response.

  • Path fallback pembuatan kode merchant dihapus: Fungsi buildMerchantCode legacy yang menggunakan time.Now().UnixNano() sebagai fallback dihapus atau diubah menjadi panic. Path non-deterministik tidak dapat dipanggil secara tidak sengaja.

  • WhatsApp messaging mendukung idempotency key: Fungsi SendTextMessage menerima parameter idempotencyKey — caller yang memerlukan perlindungan retry dapat meneruskan key yang stabil untuk mencegah pengiriman pesan duplikat.


Integritas Data & Pencegahan Race Condition

  • Quota invite staff diproteksi dari concurrent acceptance: InsertMerchantUserGuarded menggunakan SELECT FOR UPDATE atomik untuk pengecekan kuota sebelum insert. Concurrent acceptance undangan tidak dapat melebihi batas jumlah staff yang dikonfigurasi.

  • Akses data transaksi partner divalidasi secara eksplisit: handlePartnerMerchantTransactions memanggil partnerStore.HasMerchantAccess secara eksplisit sebelum mendelegasikan ke store layer — validasi kepemilikan tidak hanya mengandalkan store.

  • Slack webhook tidak memblokir tanpa batas waktu: Fungsi postOnce menggunakan http.NewRequestWithContext dengan timeout eksplisit — panggilan ke webhook Slack tidak dapat memblokir goroutine secara permanen.

  • Audit log KYC mengembalikan seluruh event, bukan hanya yang pertama: Return statement dipindahkan ke luar loop iterasi — hasil audit log tidak lagi terpotong di item pertama.

  • Rate limit OTP staff invitation bersifat atomik: IncrementAttempts OTP undangan staff dipindahkan sebelum operasi bcrypt compare. UPDATE RETURNING atomik memblokir tanpa delay begitu limit tercapai — tidak ada window antara pengecekan dan penambahan counter.

  • Endpoint verifikasi undangan staff memiliki rate limit dan pesan error generik: handleVerifyStaffInvitation memiliki rate limiting per IP dan mengembalikan pesan error generik. DoS via pemicuan ErrTooManyAttempts pada seluruh undangan aktif tidak dimungkinkan.

  • Error internal tidak bocor ke client mobile: Error internal dari LoadActiveMerchantContext dicatat di server via slog. Client mobile menerima pesan generik tanpa detail teknis atau skema database.

  • OTP check mengembalikan 503 bukan 403 saat store tidak terinisialisasi: HasRecentVerifiedOTP mengembalikan ErrDatabaseNotConfigured saat db==nil. Handler mengembalikan 503 — memberi sinyal misconfiguration yang jelas kepada operator, bukan respons authorization yang menyesatkan.

  • Cancel challenge tidak dapat mengusir sesi yang sudah terverifikasi: CancelLoginChallenge memeriksa status verified sebelum merevoke challenge — challenge yang sudah berhasil tidak dapat dibatalkan untuk mengusir pengguna yang sah.

  • Redis rate limiter menggunakan pipeline atau Lua script untuk atomisitas: redis_repository.go menggunakan pipeline atau Lua script untuk operasi INCR + EXPIRE secara atomik. Orphan key tanpa TTL tidak terjadi jika EXPIRE gagal setelah INCR berhasil.

  • Endpoint info undangan staff memiliki rate limit dan menyembunyikan data sensitif: Endpoint GET /staff/invite/{id} memiliki rate limiting 10 request per menit per IP. inviter_name dan merchant_id tidak lagi terekspos di respons publik endpoint ini.

  • Handler store yang tidak terinisialisasi mengembalikan error eksplisit: GetRevenueByDay, GetTopMerchants, dan legaldocuments/store.go mengembalikan slog.Warn dan error 503 saat store tidak terinisialisasi — tidak ada fallback diam-diam ke data hardcoded atau respons kosong yang menyesatkan.


Keamanan File Upload

  • Upload ke MinIO aman dari path traversal: Upload menggunakan path.Clean dan menolak path yang mengandung ../. Key dengan komponen traversal tidak dapat mengakses prefix tenant lain di object storage.

  • Error registration tidak membocorkan skema database: Fungsi writeManualRegistrationError mencatat detail error ke server log dan mengembalikan string "internal error" generik ke caller HTTP — skema database tidak bocor melalui response.


Penguatan Tambahan (2026-06-08)

  • Error upstream tidak bocor ke mobile client (notification prefs): handleMeNotificationPrefs mencatat detail error dari firebase_service ke slog.Error di server. Mobile client hanya menerima pesan generik — IP internal, nama tabel, dan detail error upstream tidak pernah bocor ke respons HTTP.

  • OTP staff invitation: kode OTP tidak bocor ke header: Idempotency key untuk pengiriman OTP undangan staff menggunakan hex(SHA-256(merchantID + phone + OTP)[:16]) — OTP plaintext tidak lagi dikirim sebagai bagian dari header ke whatsapp_service. Uniqueness tetap terjaga karena input yang sama menghasilkan hash yang sama.

  • VerifyOTP tidak mengungkap keberadaan challenge: Kedua path kegagalan di VerifyOTP — "challenge tidak ada" dan "kode OTP salah" — mengembalikan kode error yang sama (otp_invalid / 401). Detail perbedaan dicatat via slog.Warn di server. Attacker tidak dapat membedakan apakah requestID masih valid untuk targeting brute-force.