Kenapa ini penting
Begitu API-mu online, ia akan "dikunjungi" bukan hanya oleh frontend milikmu. Dalam hitungan jam, bot pemindai otomatis mulai mencoba alamat-alamat umum seperti /.env, /wp-admin, dan /phpmyadmin, mencoba ribuan kombinasi password di /login, dan mengirim input aneh untuk mencari error yang membocorkan informasi. Ini bukan paranoia. Siapa pun yang pernah membaca log server publik akan melihatnya setiap hari.
Kabar baiknya, sebagian besar serangan itu bisa ditangkal dengan beberapa lapis pertahanan standar yang murah dipasang. Analoginya seperti keamanan toko: gembok dan teralis (header keamanan), daftar tamu yang boleh masuk (CORS), batas jumlah orang yang boleh mencoba kunci (rate limit), petugas yang memeriksa isi tas (validasi input), dan brankas untuk kunci cadangan (rahasia di environment variable). Tidak ada satu lapis yang sempurna. Yang membuat aman adalah tumpukan beberapa lapis sekaligus. Prinsip ini disebut defense in depth.
Konsep inti
helmet: header keamanan HTTP
helmet (8.3.0) adalah middleware yang memasang belasan header respons keamanan sekaligus. Beberapa yang penting:
| Header | Fungsi |
|---|---|
Content-Security-Policy |
Membatasi dari mana browser boleh memuat skrip, gambar, dan gaya. Lapis penting melawan XSS |
Strict-Transport-Security |
Memberi tahu browser untuk selalu memakai HTTPS ke domain ini |
X-Content-Type-Options: nosniff |
Browser tidak boleh "menebak" tipe file. File teks tidak akan dijalankan sebagai skrip |
X-Frame-Options: SAMEORIGIN |
Halaman tidak boleh di-embed di iframe situs lain (melawan clickjacking) |
Cross-Origin-Resource-Policy |
Mengatur apakah situs lain boleh memuat resource-mu (misalnya gambar) |
Helmet juga menghapus header X-Powered-By: Express, yang memberi tahu penyerang teknologi apa yang kamu pakai. Untuk API murni yang hanya mengirim JSON, sebagian header di atas tidak berpengaruh, tapi tidak ada ruginya, dan berguna begitu API-mu juga menyajikan file.
Satu penyesuaian di Proyek Akhir: default helmet memasang Cross-Origin-Resource-Policy: same-origin, yang membuat gambar produk dari api.tokoku.id diblokir saat ditampilkan oleh frontend di tokoku.id. Karena itu kita set crossOriginResourcePolicy: { policy: 'cross-origin' }.
CORS: siapa yang boleh memanggil dari browser
CORS (Cross-Origin Resource Sharing) adalah aturan browser. Secara bawaan, JavaScript di https://tokoku.id tidak boleh membaca respons dari https://api.tokoku.id karena origin-nya (kombinasi protokol + domain + port) berbeda. Server harus mengizinkannya secara eksplisit lewat header Access-Control-Allow-Origin.
Beberapa hal yang sering salah dipahami:
- CORS melindungi pengguna browser, bukan servermu.
curldan Postman tidak peduli CORS sama sekali. CORS bukan pengganti autentikasi. origin: '*'(semua origin) tidak boleh digabung dengancredentials: true. Browser menolak kombinasi itu, dan memang sebaiknya begitu. Kalau API memakai cookie, daftarkan origin frontend satu per satu.- Untuk request "tidak sederhana" (misalnya
POSTdenganContent-Type: application/json), browser lebih dulu mengirim request preflightOPTIONSuntuk bertanya izin. Paketcors(2.8.6) menangani ini otomatis.
Rate limiting: batasi percobaan
express-rate-limit (8.7.1) menghitung request per kunci (default: alamat IP) dalam jendela waktu tertentu. Lewat batas, server membalas 429 Too Many Requests. Ini menghambat brute force password, scraping massal, dan spam pendaftaran. Opsi yang dipakai di dokumentasi resminya:
windowMs: panjang jendela waktu dalam milidetik.limit: jumlah request maksimal per jendela.standardHeaders: 'draft-8': kirim headerRateLimitdanRateLimit-Policysesuai draf standar IETF terbaru.legacyHeaders: false: matikan header lamaX-RateLimit-*.keyGenerator: function untuk menentukan kunci penghitung, misalnya id user alih-alih IP.
Pasang limiter ketat di endpoint sensitif (login, register, lupa password) dan limiter longgar di seluruh API. Penyimpanan default ada di memori proses. Kalau kamu menjalankan beberapa instance server, pakai store bersama seperti Redis supaya hitungannya tidak terpecah.
Jika server berada di belakang reverse proxy (Nginx, load balancer platform), semua request akan terlihat datang dari IP proxy. Set app.set('trust proxy', 1) supaya Express membaca IP asli dari header X-Forwarded-For. Jangan set ke true tanpa pikir, karena penyerang bisa memalsukan header itu kalau tidak ada proxy di depanmu.
Validasi input dan batas ukuran body
Kamu sudah memakai Zod di bagian 1. Dari sudut pandang keamanan, validasi berarti:
- Allowlist, bukan blocklist: definisikan apa yang boleh (tipe, panjang, format), bukan mencoba memblokir semua yang jahat.
- Batasi panjang setiap string dan ukuran setiap array (
.max()). String 10 MB di fieldnameadalah serangan. - Buang field asing.
z.object()di Zod 4 secara default membuang key yang tidak didefinisikan, sehingga"role": "admin"di body tidak ikut tersimpan. - Batasi ukuran body:
express.json({ limit: '100kb' }). Default-nya juga 100kb, tapi menuliskannya membuat niatmu jelas. - Validasi juga
req.paramsdanreq.query, bukan hanya body. Di Express 5,req.queryadalah getter yang tidak bisa ditimpa (hasil uji:Cannot set property query of #<IncomingMessage> which has only a getter), jadi simpan hasil parse di variabel lokal.
Error handler yang tidak membocorkan rahasia
Express 5 otomatis meneruskan error dari handler async ke error handler, tanpa perlu try/catch atau next(err) manual. Pastikan error handler-mu tidak mengirim stack trace atau pesan error internal ke klien. Pesan seperti SQLITE_ERROR: no such column: pasword_hash memberi penyerang peta struktur database-mu. Catat detailnya di log server, kirim pesan umum ke klien.
Rahasia di environment variable
Kunci JWT, password database, dan API key tidak boleh ditulis di kode atau di-commit ke Git. Repositori publik dipindai bot setiap menit untuk mencari kunci yang bocor. Simpan rahasia di environment variable, dibaca dari file .env saat development (yang masuk .gitignore), dan dari pengaturan platform saat produksi. Commit file .env.example berisi nama variabel tanpa nilai asli supaya rekan tim tahu apa saja yang dibutuhkan. Kalau sebuah rahasia terlanjur ter-commit, menghapus commit-nya tidak cukup: ganti (rotasi) rahasia itu.
OWASP API Security Top 10 (edisi 2023)
OWASP (Open Worldwide Application Security Project) menerbitkan daftar risiko keamanan API yang paling sering terjadi. Edisi terbarunya adalah 2023. Ringkasnya, beserta tempat kita menanganinya:
| Kode | Risiko | Arti singkat | Penangkal di materi ini |
|---|---|---|---|
| API1 | Broken Object Level Authorization | Bisa akses data orang lain dengan mengganti id | Cek kepemilikan (Modul 4) |
| API2 | Broken Authentication | Login/token lemah | argon2, cookie httpOnly, rate limit login (Modul 3) |
| API3 | Broken Object Property Level Authorization | Bisa membaca/mengubah field yang tidak boleh (mass assignment, bocor passwordHash) |
Zod membuang field asing, publicUser() |
| API4 | Unrestricted Resource Consumption | Tidak ada batas ukuran/jumlah/frekuensi | limit body, limits multer, rate limit, max di pagination |
| API5 | Broken Function Level Authorization | User biasa bisa memanggil fungsi admin | requireRole('admin') |
| API6 | Unrestricted Access to Sensitive Business Flows | Alur bisnis disalahgunakan otomatis (borong stok promo, spam voucher) | Rate limit per user di checkout (Latihan 2) |
| API7 | Server Side Request Forgery | Server disuruh mengambil URL pilihan penyerang | Jangan fetch URL dari input tanpa allowlist |
| API8 | Security Misconfiguration | Header keamanan absen, CORS longgar, stack trace bocor | helmet, CORS ketat, error handler |
| API9 | Improper Inventory Management | Endpoint lama/tes lupa dimatikan | Catat semua endpoint, hapus yang tidak dipakai |
| API10 | Unsafe Consumption of APIs | Terlalu percaya data dari API pihak ketiga | Validasi juga respons API eksternal |
Contoh kode
Bagian keamanan dari src/app.js di Proyek Akhir:
// src/app.js (potongan keamanan)
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import { rateLimit } from 'express-rate-limit';
export function createApp({ db, config }) {
const app = express();
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 menit
limit: config.AUTH_RATE_LIMIT, // default 10 percobaan
standardHeaders: 'draft-8',
legacyHeaders: false,
message: { error: 'Terlalu banyak percobaan, coba lagi nanti' },
});
app.disable('x-powered-by');
if (config.isProd) app.set('trust proxy', 1); // di belakang Nginx: IP asli dari X-Forwarded-For
app.use(helmet({ crossOriginResourcePolicy: { policy: 'cross-origin' } })); // gambar boleh dipakai frontend di origin lain
app.use(cors({ origin: config.corsOrigins, credentials: true })); // daftar origin dari env, bukan '*'
app.use(express.json({ limit: '100kb' }));
app.use(cookieParser());
// ... routes; authLimiter dipasang di /register dan /login
// error handler: 4 parameter. Express 5 otomatis meneruskan error dari async handler ke sini.
app.use((err, req, res, next) => {
if (err.type === 'entity.parse.failed') return res.status(400).json({ error: 'JSON rusak' });
if (err.type === 'entity.too.large') return res.status(413).json({ error: 'Body terlalu besar' });
const status = err.status ?? 500;
if (status >= 500) req.log.error({ err }, 'error tak terduga'); // detail ke log (Modul 7)
// jangan kirim stack trace / pesan internal ke klien
res.status(status).json({ error: status >= 500 ? 'Terjadi kesalahan di server' : err.message });
});
return app;
}
Konfigurasi dibaca dan divalidasi sekali saat start. Kalau JWT_SECRET kependekan, server menolak jalan:
// src/config.js, baca & validasi environment variable SEKALI saat start
import { z } from 'zod';
const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().default(3000),
DATABASE_FILE: z.string().default('toko.db'),
JWT_SECRET: z.string().min(32, 'JWT_SECRET minimal 32 karakter'),
CORS_ORIGIN: z.string().default('http://localhost:5173'),
UPLOAD_DIR: z.string().default('uploads'),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']).default('info'),
AUTH_RATE_LIMIT: z.coerce.number().int().default(10),
});
export function loadConfig(env = process.env) {
const hasil = EnvSchema.safeParse(env);
if (!hasil.success) {
console.error('Konfigurasi tidak valid:', z.flattenError(hasil.error).fieldErrors);
process.exit(1); // gagal cepat: lebih baik mati saat start daripada error aneh di tengah jalan
}
const c = hasil.data;
return { ...c, isProd: c.NODE_ENV === 'production', corsOrigins: c.CORS_ORIGIN.split(',') };
}
Hasil uji terhadap server yang berjalan:
# header keamanan dari helmet
curl -s -D - -o /dev/null http://localhost:3000/api/products | grep -iE "^(x-|content-security|strict)"
# → Content-Security-Policy: default-src 'self';base-uri 'self';font-src 'self' https: data:;form-action 'self';
# frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self';script-src-attr 'none';
# style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests
# → Strict-Transport-Security: max-age=31536000; includeSubDomains
# → X-Content-Type-Options: nosniff
# → X-Frame-Options: SAMEORIGIN
# → X-XSS-Protection: 0
# (X-Powered-By tidak ada)
# CORS: origin terdaftar vs origin asing
curl -s -D - -o /dev/null -H "Origin: http://localhost:5173" http://localhost:3000/api/products | grep -i access-control
# → Access-Control-Allow-Origin: http://localhost:5173
# → Access-Control-Allow-Credentials: true
curl -s -D - -o /dev/null -H "Origin: https://jahat.com" http://localhost:3000/api/products | grep -i allow-origin
# → (kosong: tidak ada Access-Control-Allow-Origin, browser akan memblokir)
# preflight
curl -s -D - -o /dev/null -X OPTIONS -H "Origin: http://localhost:5173" \
-H "Access-Control-Request-Method: POST" -H "Access-Control-Request-Headers: content-type" \
http://localhost:3000/api/orders | grep -iE "^(HTTP|access)"
# → HTTP/1.1 204 No Content
# → Access-Control-Allow-Origin: http://localhost:5173
# → Access-Control-Allow-Credentials: true
# → Access-Control-Allow-Methods: GET,HEAD,PUT,PATCH,POST,DELETE
# → Access-Control-Allow-Headers: content-type
# rate limit login: 3 request auth sebelumnya + 9 percobaan password salah
for i in $(seq 1 9); do curl -s -o /dev/null -w "%{http_code} " -X POST http://localhost:3000/api/auth/login \
-H 'content-type: application/json' -d '{"email":"x@x.com","password":"salah"}'; done
# → 401 401 401 401 401 401 401 429 429
curl -s -D - -o /dev/null -X POST http://localhost:3000/api/auth/login -H 'content-type: application/json' \
-d '{"email":"admin@toko.id","password":"admin12345"}' | grep -i ratelimit # (pada request ke-3)
# → RateLimit: "10-in-15min"; r=7; t=900
# → RateLimit-Policy: "10-in-15min"; q=10; w=900; pk=:YmIwY2Q1MTM2YWU1:
# body 200 KB → ditolak sebelum menyentuh handler
curl -s -w " [%{http_code}]\n" -X POST http://localhost:3000/api/auth/login \
-H 'content-type: application/json' --data-binary @big.json
# → {"error":"Body terlalu besar"} [413]
# JSON rusak → pesan rapi, bukan stack trace
curl -s -X POST http://localhost:3000/api/auth/login -H 'content-type: application/json' -d '{"email":'
# → {"error":"JSON rusak"}
Header RateLimit dibaca begini: r=7 berarti sisa 7 request, t=900 berarti jendela di-reset dalam 900 detik.
Express 5 meneruskan error async ke error handler. Diuji dengan Supertest:
app.get('/boom', async () => { throw new Error('password db: s3cr3t'); });
app.use((err, req, res, next) => res.status(500).json({ error: 'Terjadi kesalahan di server' }));
console.log((await request(app).get('/boom')).body);
// → { error: 'Terjadi kesalahan di server' } (pesan rahasia tidak bocor)
Kesalahan umum
cors()tanpa opsi di produksi. Tanpa opsi, paket cors mengizinkan semua origin (*). Untuk API yang memakai cookie, daftarkan origin frontend secara eksplisit dari environment variable.- Menganggap CORS sebagai pengaman API. CORS hanya mengatur browser. Penyerang dengan
curltetap bisa memanggil API-mu. Autentikasi dan otorisasi tetap wajib. - Rate limit di belakang proxy tanpa
trust proxy. Semua request terlihat dari satu IP (milik proxy), sehingga satu penyerang bisa membuat semua pengguna terkena 429. Settrust proxysesuai jumlah proxy di depanmu. - Mengirim
err.messageatauerr.stackmentah ke klien. Error internal membocorkan nama tabel, path file, bahkan potongan query. Catat di log, kirim pesan umum. - Meng-commit
.env. Tambahkan.envke.gitignoresebelum commit pertama. Kalau sudah terlanjur, rotasi semua rahasia di dalamnya.
Latihan
- Mudah, Konfigurasikan CORS supaya hanya
https://tokoku.iddanhttps://admin.tokoku.idyang diizinkan (dibaca dari string dipisah koma), lalu buktikanhttps://jahat.comtidak mendapat headerAccess-Control-Allow-Origin. - Sedang, Buat limiter khusus checkout: maksimal 3 pesanan per jam per user (bukan per IP). Petunjuk:
keyGeneratordan pasang setelahrequireAuth. - Menantang, Buat middleware
requireJsonyang membalas 415 Unsupported Media Type kalau requestPOST/PUT/PATCHyang punya body tidak memakaiContent-Type: application/json. Kenapa ini berguna melawan CSRF?
Lihat solusi
1. Paket cors menerima array origin.
const allowed = 'https://tokoku.id,https://admin.tokoku.id'.split(',').map((s) => s.trim());
app.use(cors({ origin: allowed, credentials: true }));
// uji dengan Supertest
for (const o of ['https://tokoku.id', 'https://jahat.com']) {
const r = await request(app).get('/x').set('Origin', o);
console.log(o, r.headers['access-control-allow-origin'] ?? '(tidak ada)');
}
// → https://tokoku.id https://tokoku.id
// → https://jahat.com (tidak ada)
2. Kunci penghitung diambil dari req.user.id, sehingga limiter harus dipasang setelah requireAuth.
const checkoutLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
limit: 3,
keyGenerator: (req) => `user:${req.user.id}`,
standardHeaders: 'draft-8',
legacyHeaders: false,
message: { error: 'Terlalu banyak pesanan dalam satu jam' },
});
r.post('/', checkoutLimiter, validate(OrderSchema), (req, res) => { /* ... */ });
// (di ordersRouter, requireAuth sudah dipasang lewat r.use)
// user 7: 201 201 201 429 | user 8: 201 (user lain tidak ikut terkena)
3. req.is() memeriksa Content-Type request.
function requireJson(req, res, next) {
const butuhBody = ['POST', 'PUT', 'PATCH'].includes(req.method);
if (butuhBody && req.headers['content-length'] !== '0' && !req.is('application/json')) {
return res.status(415).json({ error: 'Content-Type harus application/json' });
}
next();
}
// pasang di route JSON (jangan di route upload, yang memakai multipart)
// POST dengan body form 'a=1' → 415 { error: 'Content-Type harus application/json' }
Form HTML biasa di situs jahat hanya bisa mengirim application/x-www-form-urlencoded, multipart/form-data, atau text/plain tanpa preflight. Untuk mengirim application/json lintas origin, browser wajib melakukan preflight CORS, yang akan ditolak karena origin-nya tidak terdaftar. Jadi mewajibkan JSON menambah satu lapis perlindungan CSRF di atas sameSite.
Ringkasan
- Keamanan adalah tumpukan lapis (defense in depth): header, CORS, rate limit, validasi, error handler, dan pengelolaan rahasia.
- helmet memasang header keamanan dan menghapus
X-Powered-By. SesuaikancrossOriginResourcePolicykalau gambar dipakai frontend di origin lain. - CORS ketat: daftar origin eksplisit, tidak pernah
*bersamacredentials: true. CORS melindungi pengguna browser, bukan pengganti autentikasi. - Rate limit ketat di login/register, longgar di seluruh API. Di belakang proxy, set
trust proxydengan benar. - Validasi body, params, dan query dengan batas panjang. Error handler mengirim pesan umum, detailnya ke log. Rahasia di environment variable,
.envtidak pernah di-commit. - OWASP API Security Top 10 edisi 2023 adalah daftar periksa yang baik, dimulai dari BOLA (API1).