kodingup.camp

Beranda/Node.js Backend: Database, Auth, Keamanan & Deploy

Keamanan API

Modul 6 dari 108 menit bacaAPI Security

Isi modul

    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:

    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:

    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:

    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

    1. 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.
    2. Menganggap CORS sebagai pengaman API. CORS hanya mengatur browser. Penyerang dengan curl tetap bisa memanggil API-mu. Autentikasi dan otorisasi tetap wajib.
    3. 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. Set trust proxy sesuai jumlah proxy di depanmu.
    4. Mengirim err.message atau err.stack mentah ke klien. Error internal membocorkan nama tabel, path file, bahkan potongan query. Catat di log, kirim pesan umum.
    5. Meng-commit .env. Tambahkan .env ke .gitignore sebelum commit pertama. Kalau sudah terlanjur, rotasi semua rahasia di dalamnya.

    Latihan

    1. Mudah, Konfigurasikan CORS supaya hanya https://tokoku.id dan https://admin.tokoku.id yang diizinkan (dibaca dari string dipisah koma), lalu buktikan https://jahat.com tidak mendapat header Access-Control-Allow-Origin.
    2. Sedang, Buat limiter khusus checkout: maksimal 3 pesanan per jam per user (bukan per IP). Petunjuk: keyGenerator dan pasang setelah requireAuth.
    3. Menantang, Buat middleware requireJson yang membalas 415 Unsupported Media Type kalau request POST/PUT/PATCH yang punya body tidak memakai Content-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

    Video pendamping

    Kalau penjelasan di atas belum masuk, tonton ini. Video dari kanal YouTube pihak lain.

    API Security Explained: Rate Limiting, CORS, SQL Injection, CSRF, XSS & MoreHayk Simonyan · Bahasa InggrisBuka di YouTube

    Kuis pemahaman

    Pilih satu jawaban per soal. Penjelasan muncul setelah kamu menjawab.