Audit keamanan pada aplikasi Express berukuran menengah biasanya menemukan hal yang sama, dan hampir tidak pernah berupa kerentanan eksotis. Yang ditemukan: satu route admin yang lupa dipasangi middleware autentikasi, satu kueri yang masih menyambung string, header keamanan yang tidak pernah dipasang, dan sesi yang kedaluwarsanya salah satuan.
Checklist di bawah mengambil lima kategori OWASP Top 10 yang paling relevan untuk situs marketing plus panel admin, dengan cara memeriksanya — bukan definisinya.
Broken access control: audit setiap route admin terhadap requireAuth
Ini kategori nomor satu OWASP, dan pada aplikasi Express bentuknya hampir selalu sederhana: satu route yang ditambahkan tergesa-gesa tanpa middleware.
Cara memeriksanya harus otomatis, karena pemeriksaan manual akan terlewat pada route ke-23:
# Setiap route admin harus punya requireAuth di baris yang sama.
grep -nE "router\.(get|post|put|delete)\(" routes/admin.js \
| grep -v requireAuth
Untuk routes/admin.js yang benar, keluarannya hanya satu baris: router.get('/', ...) — halaman login, satu-satunya yang memang publik. Apa pun selain itu adalah temuan.
Lakukan hal yang sama untuk semua service router, karena endpoint penulisan justru yang lebih berbahaya:
grep -rnE "router\.(post|put|delete)\(" routes/services/ | grep -v requireAuth
Dua kesalahan lain yang masuk kategori ini dan sering lolos:
IDOR — objek orang lain lewat id di URL. POST /services/blog/delete/42 yang hanya memeriksa "apakah login" tanpa memeriksa "apakah berhak atas artikel 42". Pada panel satu pengguna ini tidak jadi masalah, tapi begitu ada peran kedua (misalnya penulis yang hanya boleh mengedit tulisannya sendiri), setiap endpoint harus memeriksa kepemilikan, bukan hanya sesi.
Mutasi lewat GET. GET /logout atau GET /admin/blog/delete/42 bisa dipicu oleh <img src> di halaman lain atau oleh prefetcher browser. Semua yang mengubah keadaan harus POST — dan logout termasuk.
Verifikasi akhir yang paling meyakinkan: jalankan satu skrip yang memanggil semua path admin tanpa cookie dan pastikan tidak ada yang mengembalikan 200.
for p in /admin/blog /admin/works /admin/analytics /admin/automation /admin/uploader /admin/contact; do
printf '%s %s\n' "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:3000$p")" "$p"
done
# Semua harus 302 (redirect ke login). Satu baris 200 adalah kebocoran.
Injection: membuktikan placeholder mysql2 dan menemukan query rawan
SQL injection tidak dicegah oleh kehati-hatian; ia dicegah oleh placeholder. Dan yang perlu kamu lakukan adalah membuktikan tidak ada kueri yang menyambung string.
Cari pola berbahayanya:
# Template literal di dalam query() — hampir selalu penyambungan nilai.
grep -rnE "query\(\s*\`" routes/ utils/ scripts/ | grep -vE "\?\s*\)|LIVE_CONDITION"
# Penyambungan dengan + di dalam query()
grep -rnE "query\([^)]*['\"][^)]*\+" routes/ utils/ scripts/
Yang aman dan akan ikut tertangkap grep di atas: fragmen SQL konstan yang tidak memuat input pengguna sama sekali, misalnya kondisi visibilitas artikel yang ditulis sebagai konstanta di satu modul. Yang tidak aman: apa pun yang menyisipkan req.body, req.query, atau req.params.
// BERBAHAYA — pernah ada di endpoint kontak, dan satu tanda kutip merusak seluruh kueri.
connection.query(`INSERT INTO contact (name, email) VALUES ('${name}', '${email}')`);
// BENAR
connection.query('INSERT INTO contact (name, email) VALUES (?, ?)', [name, email]);
Dua tempat yang tidak bisa memakai placeholder dan karena itu wajib memakai daftar putih:
// Nama kolom untuk ORDER BY tidak bisa jadi placeholder. Jangan pernah dari req langsung.
const SORTS = { recent: 'updated_at DESC', title: 'title ASC', popular: 'views DESC' };
const orderBy = SORTS[req.query.sort] || SORTS.recent; // daftar putih, bukan sanitasi
Untuk LIMIT, mysql2 menerima placeholder — pakai itu, dan paksa jadi angka:
const perPage = Math.min(100, Math.max(1, parseInt(req.query.per, 10) || 20));
await db.query('SELECT ... LIMIT ? OFFSET ?', [perPage, offset]);
Injection kategori kedua yang relevan di aplikasi Handlebars: XSS lewat konten. {{ }} sudah meng-escape, tapi {{{ }}} tidak. Audit setiap pemakaiannya:
grep -rn "{{{" views/ | grep -v "blog.content\|section.html"
Setiap {{{ }}} harus punya jawaban untuk "siapa yang boleh mengisi nilai ini". Kalau jawabannya "editor yang login", itu risiko yang diterima. Kalau jawabannya "pengunjung", itu bug yang harus diperbaiki hari ini.
Security misconfiguration: header helmet dan menonaktifkan x-powered-by
Periksa dulu apa yang sekarang terkirim:
curl -sI https://situsmu.com | grep -iE "x-powered-by|strict-transport|x-content-type|x-frame|referrer|content-security"
Kalau yang muncul hanya X-Powered-By: Express, kamu punya pekerjaan lima menit dengan hasil nyata.
app.disable('x-powered-by'); // tidak perlu memberi tahu versi framework-mu
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
// HSTS hanya kalau HTTPS sudah benar-benar jalan di semua subdomain.
if (req.secure) {
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
next();
});
CSP layak dipasang, tapi pasang dalam mode laporan dulu — CSP yang salah mematikan seluruh JavaScript situs, dan tidak ada yang akan menyadarinya sampai ada yang mengeluh tombol tidak berfungsi:
res.setHeader('Content-Security-Policy-Report-Only',
"default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; script-src 'self'");
Jalankan mode laporan satu minggu, baca pelanggarannya di konsol browser, sesuaikan, baru ganti ke Content-Security-Policy. Kalau situsmu memakai tag analitik dari pihak ketiga, bagian script-src akan jadi bagian tersulit — dan itu informasi yang berguna tentang seberapa banyak kode pihak ketiga yang kamu jalankan.
Empat kesalahan konfigurasi lain yang layak diperiksa sekaligus:
- Stack trace di produksi. Pastikan
NODE_ENV=productionbenar-benar diset di pm2 — halaman error Express menampilkan jalur berkas server kalau tidak. - Direktori yang tidak sengaja tersaji. Coba
curl -I https://situsmu.com/.envdan/.git/config. Keduanya harus 404. - Rahasia di repo.
git log -p -S "SMTP_PASS" | head— kalau pernah ter-commit, rotasi kredensialnya, karena riwayat git tidak lupa. - Kredensial default. Akun admin dengan kata sandi yang pernah dikirim lewat WhatsApp saat pengembangan.
Identifikasi dan autentikasi gagal: kebijakan sesi serta rate limit
Empat hal yang harus benar, dan satu di antaranya punya jebakan satuan yang halus.
1. Kata sandi di-hash dengan bcrypt, cost minimal 10. Bukan MD5, bukan SHA-256. Dan perbandingannya memakai bcrypt.compare, bukan ===.
2. Cookie sesi dikonfigurasi lengkap. maxAge dalam milidetik — nilai 36000 berarti 36 detik, bukan 10 jam, dan gejalanya sangat menyesatkan: admin merasa sudah login, tetapi setiap form yang dikirim beberapa menit kemudian diam-diam mengarahkan ulang ke halaman login dan tidak menyimpan apa pun.
app.use(session({
secret: process.env.SESSION_SECRET, // dari .env, tidak di-commit
name: 'sid', // bukan 'connect.sid' yang mengumumkan Express
resave: false,
saveUninitialized: false,
rolling: true,
cookie: {
maxAge: 8 * 60 * 60 * 1000, // 8 jam, dalam MILIDETIK
httpOnly: true, // JS tidak bisa membaca id sesi
sameSite: 'lax', // menahan POST lintas situs
secure: process.env.NODE_ENV === 'production',
},
}));
3. Regenerasi sesi setelah login. Tanpa req.session.regenerate(), id sesi yang dipegang sebelum login tetap berlaku sesudahnya — itu session fixation.
4. Rate limit pada login. Tanpa ini, bcrypt hanya memperlambat penyerang, tidak menghentikannya. Batasi per IP dan per email yang dicoba, dan pakai pesan galat yang sama untuk "email tidak ada" dan "kata sandi salah" supaya tidak membocorkan akun mana yang terdaftar.
Satu catatan tentang penyimpanan sesi: MemoryStore bawaan express-session kehilangan semua sesi saat restart dan tidak bisa dipakai lintas proses. Untuk panel satu admin itu bisa diterima sebagai keputusan sadar — yang tidak bisa diterima adalah tidak mengetahuinya saat pm2 cluster dinyalakan dan admin tiba-tiba logout secara acak.
Logging dan monitoring: apa yang wajib dicatat saat insiden
Kategori terakhir OWASP adalah yang paling sering kosong sama sekali, dan baru terasa saat kamu perlu menjawab "sejak kapan ini terjadi".
Catat enam peristiwa ini, dengan waktu dan sumber:
1. Login berhasil : siapa, kapan, IP (hash), user-agent (hash)
2. Login gagal : email yang dicoba (hash), IP (hash), alasan
3. Penolakan rate limit: endpoint, kunci (hash)
4. Akses ditolak : route admin yang dicoba tanpa sesi
5. Perubahan konten : siapa mengubah apa, kapan (tabel revisi sudah memberi ini)
6. Kegagalan sistem : galat database, kegagalan pengiriman email
Yang tidak boleh dicatat: kata sandi (termasuk yang salah), isi token sesi, nomor kartu, dan data pribadi utuh. Log biasanya berumur lebih panjang dan lebih mudah diakses daripada database, jadi hash apa pun yang mengidentifikasi orang.
Praktik minimum yang realistis untuk satu VPS: format log satu baris yang bisa di-grep, rotasi supaya disk tidak penuh, dan satu ritual mingguan lima menit.
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 10M
pm2 set pm2-logrotate:retain 14
# Ritual mingguan:
grep -c '\[auth\] gagal' ~/.pm2/logs/web-out.log # lonjakan = serangan
grep '\[limit\]' ~/.pm2/logs/web-out.log | wc -l
grep -iE 'ER_|ECONNREFUSED|uncaught' ~/.pm2/logs/web-error.log | tail -20
Satu hal terakhir yang termasuk kategori ini dan spesifik untuk Node: throw di dalam callback mysql akan mematikan proses. Tidak ada try/catch yang menangkapnya dan Express tidak melihatnya. Pada endpoint publik seperti form kontak, itu berarti siapa pun bisa mematikan situsmu dengan satu kiriman yang memicu galat database. Catat galatnya, jawab request-nya, jangan pernah throw.
Langkah berikutnya
Jalankan lima perintah pemeriksaan di atas hari ini — grep route tanpa requireAuth, grep kueri tersambung, curl -I untuk header, curl /.env, dan grep '{{{' di views. Kelimanya memakan sepuluh menit dan biasanya menghasilkan dua sampai tiga temuan nyata.
Perbaiki dalam urutan ini: akses kontrol dulu (paling berdampak), lalu injection, lalu header, lalu sesi, lalu logging. Dan tulis hasilnya di satu berkas dengan tanggal, supaya audit berikutnya tiga bulan lagi punya titik awal.
