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
withInternalDashboardAccessmemvalidasiPrimaryRolepengguna setelah JWT valid. Hanya rolesuperadmin,admin,finance, danoperationsyang diizinkan mengakses route/internal/*. Rolesupport,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-revalidatedanPragma: no-cacheuntuk 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()mewajibkanINTERNAL_NOTIFICATION_API_KEYtidak 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_handlermenggunakanauthenticateInternalRequest()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_ownerdiambil dari tabelmerchant_userssebagai sumber kebenaran — bukan dari klaim token. -
Verifikasi email OTP tidak bisa di-redirect ke email lain (IDOR):
VerifyEmailOTPtidak lagi menerima email dari request body. Email diambil dari token berdasarkanuser_id— attacker tidak bisa memasukkan email orang lain untuk mengambil alih verifikasi. -
Forgot password tidak bisa jadi oracle enumerasi email:
handleDashboardForgotPasswordmengembalikan 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)
RotateRefreshTokendenganWHEREguard 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 middlewarewithAuthenticatedSubject. Endpoint/address-by-nikmemiliki rate limit tambahan 10 request per menit per IP untuk mencegah penyalahgunaan lookup data kependudukan. -
Endpoint metrik Prometheus tidak terekspos ke internet: Endpoint
/metricshanya dapat diakses dari port internal terpisah — tidak dapat dijangkau dari jaringan publik. -
Validasi issuer token:
ParseAccessTokenSubjectmemvalidasi klaimisspada setiap token. Token yang diterbitkan oleh service lain meskipun menggunakan secret yang sama akan ditolak. -
Pencegahan session fixation pada login OTP:
VerifyLoginOTPmerevoke seluruh sesi platform lama sebelum menerbitkan token baru — session fixation tidak dimungkinkan. -
Akses shipping receipt berbasis kepemilikan yang akurat:
loadEligibleShippingForReceiptmenggunakan JOIN kemerchant_userssebagai sumber kebenaran kepemilikan. Owner yang bergabung via transfer akun tidak lagi diblokir secara keliru. -
PIN gate membedakan kondisi disabled vs misconfigured:
requireRecentPinAuthmengembalikan 503 (bukan 200 atau 403) jikaPinGateEnabled=truetetapipinService==nil— operator mendapat sinyal yang jelas bahwa ada misconfiguration, bukan kesan fitur berfungsi normal. Setiap bypass gate dicatat viaslog.Error. -
Google Play review bypass OTP terkontrol ketat: Mode bypass OTP untuk reviewer Google Play hanya aktif jika
GOOGLE_PLAY_TESTER_PHONEdanGOOGLE_PLAY_TESTER_OTP_CODEkeduanya terisi secara eksplisit di environment — mengosongkan salah satu menonaktifkan bypass. Setiap login via jalur ini dicatat ke audit log denganauth_provider=google_play_tester_bypass. -
Merchant suspended diblokir sebelum RBAC dievaluasi: Middleware
withMerchantRoleResolverMultimemverifikasiMerchantStatusaktif sebelum melanjutkan ke pengecekan role. Merchant yang di-suspend tidak dapat melewati layer RBAC. -
Phone enumeration via endpoint resolve dicegah:
handleResolvePhonememiliki 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:
clientIPhanya membaca headerX-Forwarded-ForjikaRemoteAddrberasal 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-runtimedibungkuswithInternalDashboardAccess— hanya operator dashboard dengan role yang diizinkan yang dapat melihat state konfigurasi OTP runtime. -
Endpoint dashboard trend dan ranking terproteksi:
handleGetInternalDashboardTxnTrenddanhandleGetInternalDashboardMerchantRankingmemilikiauthenticateInternalRequestdi awal handler sebelum logika bisnis apapun berjalan. -
Tidak ada timing side-channel pada pengecekan credential:
AuthenticateCredentialselalu 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:
CancelLoginChallengememeriksa statusverifiedsebelum 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.Warndicatat saat startup jikaAppEnv=productiondanPIN_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 >= 1danQuantity <= 99per item, serta memeriksaexistingOther + Quantityterhadap 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.jpgtidak akan melewati validasi. -
Batas kuantitas dan kalkulasi harga server-side: Handler item sales order membatasi
Quantitymaksimum 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
MdrRatetidak 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:
handleUpdateMerchantStatusmemvalidasitarget_statusterhadap 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.gomenggunakan 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.Askepgconn.PgErrordengan kode23505(unique violation) secara eksplisit, bukan pencocokan string nama constraint yang dapat berubah. -
Payload reasons dibatasi ukurannya: Array
reasonsdibatasi 10 item, setiap item maksimum 64 karakter, danreason_notemaksimum 500 karakter. Kolom JSONB tidak dapat di-inflate via payload besar. -
FCM push token aman dari path traversal: Token FCM di-encode dengan
url.PathEscapesebelum di-append ke URL. Token yang mengandung karakter/tidak dapat menyebabkan path traversal. -
User ID aman dari inject query parameter:
userIDdi-encode denganurl.Valuessebelum 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
validatePhotoURLmemverifikasi bahwa host URL cocok denganMinioPublicBaseURLdari konfigurasi. URL foto dari domain sembarang tidak dapat diterima. -
Avatar URL divalidasi saat write untuk mencegah stored XSS:
avatar_urlhanya diterima jika menggunakan skema HTTPS dan host dari domain CDN yang diizinkan. Skemajavascript:dan domain eksternal ditolak. -
Parameter rentang waktu query dibatasi:
GetTransactionTrendmembatasi parameterdaysmaksimum 365 hari. Querygenerate_seriesdengan rentang ratusan tahun tidak dapat dibuat. -
Validasi kepemilikan di store layer, bukan hanya caller:
GetSubmissionForOwnermenyertakansubjectIDdi klausaWHEREdi 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 kolomterms_accepted_version. -
HTTP request ke shipping provider dibatasi ke domain resmi:
komerce/client.gomemvalidasi prefixhttps://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_pricemaksimum 1 miliar. Integer overflow pada kalkulasi subtotal tidak dimungkinkan. -
Validasi UUID ketat menggunakan parsing, bukan regex longgar:
isLikelyUUIDmenggunakanuuid.Parseatau regexp ketat untuk validasi. String berbentuk UUID yang mengandung karakter non-hex tidak lolos. -
Audit log dilindungi dari bloat via URL panjang:
request_pathdi tabel audit log di-truncate ke maksimum 2.048 karakter sebelumINSERT— 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, danIdleTimeout: 120s— koneksi yang sengaja dibiarkan lambat tidak dapat menghabiskan seluruh goroutine pool. -
Request body dibatasi 1 MB: Fungsi
decodeJSONBodymenggunakanio.LimitReaderdengan 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_totaltersedia untuk kebutuhan alerting. -
Query transaksi dibatasi jumlah barisnya: Parameter
limitpada 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_ENDPOINTdanMINIO_ACCESS_KEYtidak 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()memproteksiFCMServiceAPIKey,WhatsAppServiceAPIKey,EmailServiceAPIKey, danAuthServiceAPIKeydengan 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, danReferrer-Policy: strict-origin-when-cross-originke 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_ORIGINStidak dikonfigurasi. NilaiCORS_ALLOWED_ORIGINS=https://kesles.comdi-set di environment production. -
Content-Security-Policy dan HSTS aktif: Middleware
withSecurityHeadersmenambahkanContent-Security-Policy,Strict-Transport-Securitydenganmax-age=63072000 includeSubDomains, danPermissions-Policyke semua response. -
Path fallback pembuatan kode merchant dihapus: Fungsi
buildMerchantCodelegacy yang menggunakantime.Now().UnixNano()sebagai fallback dihapus atau diubah menjadi panic. Path non-deterministik tidak dapat dipanggil secara tidak sengaja. -
WhatsApp messaging mendukung idempotency key: Fungsi
SendTextMessagemenerima parameteridempotencyKey— 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:
InsertMerchantUserGuardedmenggunakanSELECT FOR UPDATEatomik untuk pengecekan kuota sebelum insert. Concurrent acceptance undangan tidak dapat melebihi batas jumlah staff yang dikonfigurasi. -
Akses data transaksi partner divalidasi secara eksplisit:
handlePartnerMerchantTransactionsmemanggilpartnerStore.HasMerchantAccesssecara eksplisit sebelum mendelegasikan ke store layer — validasi kepemilikan tidak hanya mengandalkan store. -
Slack webhook tidak memblokir tanpa batas waktu: Fungsi
postOncemenggunakanhttp.NewRequestWithContextdengan 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:
IncrementAttemptsOTP undangan staff dipindahkan sebelum operasibcrypt compare.UPDATE RETURNINGatomik 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:
handleVerifyStaffInvitationmemiliki rate limiting per IP dan mengembalikan pesan error generik. DoS via pemicuanErrTooManyAttemptspada seluruh undangan aktif tidak dimungkinkan. -
Error internal tidak bocor ke client mobile: Error internal dari
LoadActiveMerchantContextdicatat di server viaslog. Client mobile menerima pesan generik tanpa detail teknis atau skema database. -
OTP check mengembalikan 503 bukan 403 saat store tidak terinisialisasi:
HasRecentVerifiedOTPmengembalikanErrDatabaseNotConfiguredsaatdb==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:
CancelLoginChallengememeriksa statusverifiedsebelum 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.gomenggunakan pipeline atau Lua script untuk operasiINCR + EXPIREsecara atomik. Orphan key tanpa TTL tidak terjadi jikaEXPIREgagal setelahINCRberhasil. -
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_namedanmerchant_idtidak lagi terekspos di respons publik endpoint ini. -
Handler store yang tidak terinisialisasi mengembalikan error eksplisit:
GetRevenueByDay,GetTopMerchants, danlegaldocuments/store.gomengembalikanslog.Warndan 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.Cleandan menolak path yang mengandung../. Key dengan komponen traversal tidak dapat mengakses prefix tenant lain di object storage. -
Error registration tidak membocorkan skema database: Fungsi
writeManualRegistrationErrormencatat 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):
handleMeNotificationPrefsmencatat detail error dari firebase_service keslog.Errordi 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 viaslog.Warndi server. Attacker tidak dapat membedakan apakahrequestIDmasih valid untuk targeting brute-force.