Lompat ke konten
·30 menit membaca

Best Practice Keamanan API: Checklist yang Saya Jalankan di Setiap Proyek

Autentikasi, otorisasi, validasi input, rate limiting, CORS, manajemen secret, dan OWASP API Top 10. Apa yang saya periksa sebelum setiap deployment produksi.

Bagikan:X / TwitterLinkedIn

Saya pernah mengirimkan API yang terbuka lebar. Bukan dengan sengaja, bukan karena malas — saya hanya tidak tahu apa yang tidak saya ketahui. Sebuah endpoint yang mengembalikan setiap field dalam objek user, termasuk password yang sudah di-hash. Sebuah rate limiter yang hanya memeriksa alamat IP, yang berarti siapa pun di balik proxy bisa membombardir API tersebut. Sebuah implementasi JWT di mana saya lupa memverifikasi klaim iss, sehingga token dari layanan yang sama sekali berbeda bisa berfungsi dengan baik.

Setiap kesalahan itu berhasil sampai ke produksi. Setiap kesalahan itu tertangkap — beberapa oleh saya, beberapa oleh pengguna, satu oleh peneliti keamanan yang cukup baik hati untuk mengirim email kepada saya daripada mempostingnya di Twitter.

Tulisan ini adalah checklist yang saya buat dari kesalahan-kesalahan itu. Saya menjalankannya sebelum setiap deployment produksi. Bukan karena saya paranoid, tetapi karena saya sudah belajar bahwa bug keamanan adalah yang paling menyakitkan. Tombol yang rusak membuat pengguna kesal. Alur autentikasi yang rusak membocorkan data mereka.

Autentikasi vs Otorisasi#

Dua kata ini sering digunakan secara bergantian dalam rapat, dalam dokumentasi, bahkan dalam komentar kode. Keduanya bukan hal yang sama.

Autentikasi menjawab: "Siapa Anda?" Ini adalah langkah login. Username dan password, alur OAuth, magic link — apa pun yang membuktikan identitas Anda.

Otorisasi menjawab: "Apa yang boleh Anda lakukan?" Ini adalah langkah perizinan. Bisakah pengguna ini menghapus resource ini? Bisakah mereka mengakses endpoint admin ini? Bisakah mereka membaca data pengguna lain?

Bug keamanan paling umum yang saya temui di API produksi bukanlah alur login yang rusak. Melainkan pemeriksaan otorisasi yang hilang. Pengguna sudah terautentikasi — mereka memiliki token yang valid — tetapi API tidak pernah memeriksa apakah mereka diizinkan untuk melakukan aksi yang diminta.

JWT: Anatomi dan Kesalahan yang Penting#

JWT ada di mana-mana. Mereka juga disalahpahami di mana-mana. JWT memiliki tiga bagian, dipisahkan oleh titik:

header.payload.signature

Header menunjukkan algoritma mana yang digunakan. Payload berisi klaim (user ID, role, kedaluwarsa). Signature membuktikan tidak ada yang merusak dua bagian pertama.

Berikut verifikasi JWT yang benar di Node.js:

typescript
import jwt from "jsonwebtoken";
import { timingSafeEqual } from "crypto";
 
interface TokenPayload {
  sub: string;
  role: "user" | "admin";
  iss: string;
  aud: string;
  exp: number;
  iat: number;
  jti: string;
}
 
function verifyToken(token: string): TokenPayload {
  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET!, {
      algorithms: ["HS256"], // Jangan pernah izinkan "none"
      issuer: "api.yourapp.com",
      audience: "yourapp.com",
      clockTolerance: 30, // 30 detik toleransi untuk perbedaan jam
    }) as TokenPayload;
 
    return payload;
  } catch (error) {
    if (error instanceof jwt.TokenExpiredError) {
      throw new ApiError(401, "Token expired");
    }
    if (error instanceof jwt.JsonWebTokenError) {
      throw new ApiError(401, "Invalid token");
    }
    throw new ApiError(401, "Authentication failed");
  }
}

Beberapa hal yang perlu diperhatikan:

  1. algorithms: ["HS256"] — Ini sangat penting. Jika Anda tidak menentukan algoritma, penyerang bisa mengirim token dengan "alg": "none" di header dan melewati verifikasi sepenuhnya. Ini adalah serangan alg: none, dan telah mempengaruhi sistem produksi nyata.

  2. issuer dan audience — Tanpa ini, token yang dibuat untuk Layanan A bisa digunakan di Layanan B. Jika Anda menjalankan beberapa layanan yang berbagi secret yang sama (yang seharusnya tidak Anda lakukan, tapi orang melakukannya), inilah bagaimana penyalahgunaan token lintas-layanan terjadi.

  3. Penanganan error yang spesifik — Jangan mengembalikan "invalid token" untuk setiap kegagalan. Membedakan antara token kedaluwarsa dan token tidak valid membantu klien mengetahui apakah harus me-refresh atau re-autentikasi.

Rotasi Refresh Token#

Access token harus berumur pendek — 15 menit adalah standar. Tetapi Anda tidak ingin pengguna memasukkan ulang password mereka setiap 15 menit. Di situlah refresh token berperan.

Pola yang benar-benar berfungsi di produksi:

typescript
import { randomBytes } from "crypto";
import { redis } from "./redis";
 
interface RefreshTokenData {
  userId: string;
  family: string; // Keluarga token untuk deteksi rotasi
  createdAt: number;
}
 
async function rotateRefreshToken(
  oldRefreshToken: string
): Promise<{ accessToken: string; refreshToken: string }> {
  const tokenData = await redis.get(`refresh:${oldRefreshToken}`);
 
  if (!tokenData) {
    // Token tidak ditemukan — entah sudah kedaluwarsa atau sudah digunakan.
    // Jika sudah digunakan, ini adalah potensi serangan replay.
    // Batalkan seluruh keluarga token.
    const parsed = decodeRefreshToken(oldRefreshToken);
    if (parsed?.family) {
      await invalidateTokenFamily(parsed.family);
    }
    throw new ApiError(401, "Invalid refresh token");
  }
 
  const data: RefreshTokenData = JSON.parse(tokenData);
 
  // Hapus token lama segera — sekali pakai saja
  await redis.del(`refresh:${oldRefreshToken}`);
 
  // Buat token baru
  const newRefreshToken = randomBytes(64).toString("hex");
  const newAccessToken = generateAccessToken(data.userId);
 
  // Simpan refresh token baru dengan keluarga yang sama
  await redis.setex(
    `refresh:${newRefreshToken}`,
    60 * 60 * 24 * 30, // 30 hari
    JSON.stringify({
      userId: data.userId,
      family: data.family,
      createdAt: Date.now(),
    })
  );
 
  return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}
 
async function invalidateTokenFamily(family: string): Promise<void> {
  // Pindai semua token dalam keluarga ini dan hapus.
  // Ini adalah opsi nuklir — jika seseorang me-replay refresh token,
  // kita matikan setiap token dalam keluarga, memaksa re-autentikasi.
  const keys = await redis.keys(`refresh:*`);
  for (const key of keys) {
    const data = await redis.get(key);
    if (data) {
      const parsed = JSON.parse(data) as RefreshTokenData;
      if (parsed.family === family) {
        await redis.del(key);
      }
    }
  }
}

Konsep keluarga token inilah yang membuatnya aman. Setiap refresh token termasuk dalam sebuah keluarga (dibuat saat login). Ketika Anda merotasi, token baru mewarisi keluarga tersebut. Jika penyerang me-replay refresh token lama, Anda mendeteksi penggunaan ulang dan mematikan seluruh keluarga. Pengguna yang sah akan ter-logout, tetapi penyerang tidak bisa masuk.

Debat ini sudah berlangsung bertahun-tahun, dan jawabannya sudah jelas: httpOnly cookie untuk refresh token, selalu.

localStorage bisa diakses oleh JavaScript apa pun yang berjalan di halaman Anda. Jika Anda memiliki satu kerentanan XSS — dan pada skala besar, suatu saat Anda akan mengalaminya — penyerang bisa membaca token dan mengeksfiltrasinya. Selesai.

httpOnly cookie tidak bisa diakses oleh JavaScript. Titik. Kerentanan XSS masih bisa membuat request atas nama pengguna (karena cookie dikirim secara otomatis), tetapi penyerang tidak bisa mencuri token itu sendiri. Itu perbedaan yang bermakna.

typescript
// Mengatur cookie refresh token yang aman
function setRefreshTokenCookie(res: Response, token: string): void {
  res.cookie("refresh_token", token, {
    httpOnly: true,     // Tidak bisa diakses via JavaScript
    secure: true,       // Hanya HTTPS
    sameSite: "strict", // Tidak ada request lintas-situs
    maxAge: 30 * 24 * 60 * 60 * 1000, // 30 hari
    path: "/api/auth",  // Hanya dikirim ke endpoint autentikasi
  });
}

path: "/api/auth" adalah detail yang kebanyakan orang lewatkan. Secara default, cookie dikirim ke setiap endpoint di domain Anda. Refresh token Anda tidak perlu dikirim ke /api/users atau /api/products. Batasi path-nya, kurangi permukaan serangan.

Untuk access token, saya menyimpannya di memori (variabel JavaScript). Bukan localStorage, bukan sessionStorage, bukan cookie. Di memori. Mereka berumur pendek (15 menit), dan ketika halaman di-refresh, klien secara diam-diam mengakses endpoint refresh untuk mendapatkan yang baru. Ya, ini berarti request tambahan saat halaman dimuat. Itu sepadan.

Validasi Input: Jangan Pernah Percaya Klien#

Klien bukan teman Anda. Klien adalah orang asing yang masuk ke rumah Anda dan berkata "Saya diizinkan berada di sini." Anda tetap memeriksa identitasnya.

Setiap data yang datang dari luar server Anda — body request, parameter query, parameter URL, header — adalah input yang tidak dipercaya. Tidak masalah bahwa form React Anda memiliki validasi. Seseorang akan melewatinya dengan curl.

Zod untuk Validasi Type-Safe#

Zod adalah hal terbaik yang terjadi pada validasi input Node.js. Ia memberikan Anda validasi runtime dengan tipe TypeScript secara gratis:

typescript
import { z } from "zod";
 
const CreateUserSchema = z.object({
  email: z
    .string()
    .email("Format email tidak valid")
    .max(254, "Email terlalu panjang")
    .transform((e) => e.toLowerCase().trim()),
 
  password: z
    .string()
    .min(12, "Password harus minimal 12 karakter")
    .max(128, "Password terlalu panjang") // Mencegah DoS bcrypt
    .regex(
      /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
      "Password harus mengandung huruf besar, huruf kecil, dan angka"
    ),
 
  name: z
    .string()
    .min(1, "Nama wajib diisi")
    .max(100, "Nama terlalu panjang")
    .regex(/^[\p{L}\p{M}\s'-]+$/u, "Nama mengandung karakter yang tidak valid"),
 
  role: z.enum(["user", "editor"]).default("user"),
  // Catatan: "admin" sengaja tidak menjadi opsi di sini.
  // Penetapan role admin melalui endpoint terpisah yang memiliki privilege.
});
 
type CreateUserInput = z.infer<typeof CreateUserSchema>;
 
// Penggunaan di handler Express
app.post("/api/users", async (req, res) => {
  const result = CreateUserSchema.safeParse(req.body);
 
  if (!result.success) {
    return res.status(400).json({
      error: "Validasi gagal",
      details: result.error.issues.map((issue) => ({
        field: issue.path.join("."),
        message: issue.message,
      })),
    });
  }
 
  // result.data sudah memiliki tipe lengkap sebagai CreateUserInput
  const user = await createUser(result.data);
  return res.status(201).json({ id: user.id, email: user.email });
});

Beberapa detail yang relevan dengan keamanan:

  • max(128) pada password — bcrypt memiliki batas input 72 byte, dan beberapa implementasi langsung memotongnya secara diam-diam. Tetapi yang lebih penting, jika Anda mengizinkan password 10MB, bcrypt akan menghabiskan waktu yang signifikan untuk meng-hash-nya. Itu adalah vektor DoS.
  • max(254) pada email — RFC 5321 membatasi alamat email hingga 254 karakter. Apa pun yang lebih panjang bukan email yang valid.
  • Enum untuk role, tanpa admin — Mass assignment adalah salah satu kerentanan API tertua. Jika Anda menerima role dari body request tanpa memvalidasinya, seseorang akan mengirim "role": "admin" dan berharap yang terbaik.

SQL Injection Belum Terselesaikan#

"Gunakan saja ORM" tidak melindungi Anda jika Anda menulis query mentah untuk performa. Dan semua orang akhirnya menulis query mentah untuk performa.

typescript
// RENTAN — penggabungan string
const query = `SELECT * FROM users WHERE email = '${email}'`;
 
// AMAN — query terparameterisasi
const query = `SELECT * FROM users WHERE email = $1`;
const result = await pool.query(query, [email]);

Dengan Prisma, Anda sebagian besar aman — tetapi $queryRaw masih bisa menggigit:

typescript
// RENTAN — template literal dalam $queryRaw
const users = await prisma.$queryRaw`
  SELECT * FROM users WHERE name LIKE '%${searchTerm}%'
`;
 
// AMAN — menggunakan Prisma.sql untuk parameterisasi
import { Prisma } from "@prisma/client";
 
const users = await prisma.$queryRaw(
  Prisma.sql`SELECT * FROM users WHERE name LIKE ${`%${searchTerm}%`}`
);

NoSQL Injection#

MongoDB tidak menggunakan SQL, tetapi tidak kebal terhadap injection. Jika Anda melewatkan input pengguna yang belum disanitasi sebagai objek query, masalah terjadi:

typescript
// RENTAN — jika req.body.username adalah { "$gt": "" }
// ini mengembalikan pengguna pertama dalam koleksi
const user = await db.collection("users").findOne({
  username: req.body.username,
});
 
// AMAN — konversi secara eksplisit ke string
const user = await db.collection("users").findOne({
  username: String(req.body.username),
});
 
// LEBIH BAIK — validasi dengan Zod terlebih dahulu
const LoginSchema = z.object({
  username: z.string().min(1).max(50),
  password: z.string().min(1).max(128),
});

Perbaikannya sederhana: validasi tipe input sebelum mencapai driver basis data Anda. Jika username seharusnya berupa string, pastikan itu adalah string.

Path Traversal#

Jika API Anda menyajikan file atau membaca dari path yang menyertakan input pengguna, path traversal akan merusak minggu Anda:

typescript
import path from "path";
import { access, constants } from "fs/promises";
 
const ALLOWED_DIR = "/app/uploads";
 
async function resolveUserFilePath(userInput: string): Promise<string> {
  // Normalisasi dan resolve ke path absolut
  const resolved = path.resolve(ALLOWED_DIR, userInput);
 
  // Kritis: verifikasi path yang di-resolve masih dalam direktori yang diizinkan
  if (!resolved.startsWith(ALLOWED_DIR + path.sep)) {
    throw new ApiError(403, "Akses ditolak");
  }
 
  // Verifikasi file benar-benar ada
  await access(resolved, constants.R_OK);
 
  return resolved;
}
 
// Tanpa pemeriksaan ini:
// GET /api/files?name=../../../etc/passwd
// di-resolve ke /etc/passwd

Pola path.resolve + startsWith adalah pendekatan yang benar. Jangan coba menghapus ../ secara manual — ada terlalu banyak trik encoding (..%2F, ..%252F, ....//) yang akan melewati regex Anda.

Rate Limiting#

Tanpa rate limiting, API Anda adalah prasmanan sepuasnya untuk bot. Serangan brute force, credential stuffing, kehabisan resource — rate limiting adalah pertahanan pertama terhadap semuanya.

Token Bucket vs Sliding Window#

Token bucket: Anda memiliki ember yang menampung N token. Setiap request memerlukan satu token. Token terisi ulang pada kecepatan tetap. Jika ember kosong, request ditolak. Ini memungkinkan lonjakan — jika ember penuh, Anda bisa membuat N request secara instan.

Sliding window: Hitung request dalam jendela waktu yang bergerak. Lebih dapat diprediksi, lebih sulit untuk ditembus secara beruntun.

Saya menggunakan sliding window untuk sebagian besar hal karena perilakunya lebih mudah dipahami dan dijelaskan kepada tim:

typescript
import { Redis } from "ioredis";
 
interface RateLimitResult {
  allowed: boolean;
  remaining: number;
  resetAt: number;
}
 
async function slidingWindowRateLimit(
  redis: Redis,
  key: string,
  limit: number,
  windowMs: number
): Promise<RateLimitResult> {
  const now = Date.now();
  const windowStart = now - windowMs;
 
  const multi = redis.multi();
 
  // Hapus entri di luar jendela waktu
  multi.zremrangebyscore(key, 0, windowStart);
 
  // Hitung entri dalam jendela waktu
  multi.zcard(key);
 
  // Tambahkan request saat ini (kita akan menghapusnya jika melebihi batas)
  multi.zadd(key, now.toString(), `${now}:${Math.random()}`);
 
  // Atur kedaluwarsa pada key
  multi.pexpire(key, windowMs);
 
  const results = await multi.exec();
 
  if (!results) {
    throw new Error("Transaksi Redis gagal");
  }
 
  const count = results[1][1] as number;
 
  if (count >= limit) {
    // Melebihi batas — hapus entri yang baru kita tambahkan
    await redis.zremrangebyscore(key, now, now);
 
    return {
      allowed: false,
      remaining: 0,
      resetAt: windowStart + windowMs,
    };
  }
 
  return {
    allowed: true,
    remaining: limit - count - 1,
    resetAt: now + windowMs,
  };
}

Rate Limit Berlapis#

Satu rate limit global tidak cukup. Endpoint yang berbeda memiliki profil risiko yang berbeda:

typescript
interface RateLimitConfig {
  window: number;
  max: number;
}
 
const RATE_LIMITS: Record<string, RateLimitConfig> = {
  // Endpoint autentikasi — batas ketat, target brute force
  "POST:/api/auth/login": { window: 15 * 60 * 1000, max: 5 },
  "POST:/api/auth/register": { window: 60 * 60 * 1000, max: 3 },
  "POST:/api/auth/reset-password": { window: 60 * 60 * 1000, max: 3 },
 
  // Pembacaan data — lebih longgar
  "GET:/api/users": { window: 60 * 1000, max: 100 },
  "GET:/api/products": { window: 60 * 1000, max: 200 },
 
  // Penulisan data — moderat
  "POST:/api/posts": { window: 60 * 1000, max: 10 },
  "PUT:/api/posts": { window: 60 * 1000, max: 30 },
 
  // Fallback global
  "*": { window: 60 * 1000, max: 60 },
};
 
function getRateLimitKey(req: Request, config: RateLimitConfig): string {
  const identifier = req.user?.id ?? getClientIp(req);
  const endpoint = `${req.method}:${req.path}`;
  return `ratelimit:${identifier}:${endpoint}`;
}

Perhatikan: pengguna yang terautentikasi dibatasi berdasarkan user ID, bukan IP. Ini penting karena banyak pengguna yang sah berbagi IP (jaringan perusahaan, VPN, operator seluler). Jika Anda hanya membatasi berdasarkan IP, Anda akan memblokir seluruh kantor.

Header Rate Limit#

Selalu beritahu klien apa yang terjadi:

typescript
function setRateLimitHeaders(
  res: Response,
  result: RateLimitResult,
  limit: number
): void {
  res.set({
    "X-RateLimit-Limit": limit.toString(),
    "X-RateLimit-Remaining": result.remaining.toString(),
    "X-RateLimit-Reset": Math.ceil(result.resetAt / 1000).toString(),
    "Retry-After": result.allowed
      ? undefined
      : Math.ceil((result.resetAt - Date.now()) / 1000).toString(),
  });
 
  if (!result.allowed) {
    res.status(429).json({
      error: "Terlalu banyak request",
      retryAfter: Math.ceil((result.resetAt - Date.now()) / 1000),
    });
  }
}

Konfigurasi CORS#

CORS mungkin adalah mekanisme keamanan yang paling disalahpahami dalam pengembangan web. Setengah dari jawaban Stack Overflow tentang CORS adalah "cukup atur Access-Control-Allow-Origin: * dan itu berfungsi." Secara teknis itu benar. Itu juga cara Anda membuka API Anda untuk setiap situs jahat di internet.

Apa yang CORS Sebenarnya Lakukan (dan Tidak Lakukan)#

CORS adalah mekanisme browser. Ia memberitahu browser apakah JavaScript dari Origin A diizinkan untuk membaca respons dari Origin B. Itu saja.

Apa yang CORS tidak lakukan:

  • Tidak melindungi API Anda dari curl, Postman, atau request server-ke-server
  • Tidak mengautentikasi request
  • Tidak mengenkripsi apa pun
  • Tidak mencegah CSRF dengan sendirinya (meskipun membantu ketika dikombinasikan dengan mekanisme lain)

Apa yang CORS lakukan:

  • Mencegah situs-jahat.com dari membuat fetch request ke api-anda.com dan membaca respons di browser pengguna
  • Mencegah JavaScript penyerang dari mengeksfiltrasi data melalui sesi terautentikasi korban

Jebakan Wildcard#

typescript
// BERBAHAYA — mengizinkan situs web mana pun untuk membaca respons API Anda
app.use(cors({ origin: "*" }));
 
// JUGA BERBAHAYA — ini adalah pendekatan "dinamis" umum yang sebenarnya hanya * dengan langkah tambahan
app.use(
  cors({
    origin: (origin, callback) => {
      callback(null, true); // Mengizinkan semuanya
    },
  })
);

Masalah dengan * adalah ia membuat respons API Anda bisa dibaca oleh JavaScript mana pun di halaman mana pun. Jika API Anda mengembalikan data pengguna dan pengguna terautentikasi via cookie, situs web mana pun yang dikunjungi pengguna bisa membaca data tersebut.

Lebih buruk lagi: Access-Control-Allow-Origin: * tidak bisa dikombinasikan dengan credentials: true. Jadi jika Anda membutuhkan cookie (untuk autentikasi), Anda benar-benar tidak bisa menggunakan wildcard. Tetapi saya pernah melihat orang mencoba mengakalinya dengan memantulkan header Origin kembali — yang setara dengan * dengan credentials, yang terburuk dari keduanya.

Konfigurasi yang Benar#

typescript
import cors from "cors";
 
const ALLOWED_ORIGINS = new Set([
  "https://yourapp.com",
  "https://www.yourapp.com",
  "https://admin.yourapp.com",
]);
 
if (process.env.NODE_ENV === "development") {
  ALLOWED_ORIGINS.add("http://localhost:3000");
  ALLOWED_ORIGINS.add("http://localhost:5173");
}
 
app.use(
  cors({
    origin: (origin, callback) => {
      // Izinkan request tanpa origin (aplikasi mobile, curl, server-ke-server)
      if (!origin) {
        return callback(null, true);
      }
 
      if (ALLOWED_ORIGINS.has(origin)) {
        return callback(null, origin);
      }
 
      callback(new Error(`Origin ${origin} tidak diizinkan oleh CORS`));
    },
    credentials: true, // Izinkan cookie
    methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
    allowedHeaders: ["Content-Type", "Authorization"],
    exposedHeaders: ["X-RateLimit-Limit", "X-RateLimit-Remaining"],
    maxAge: 86400, // Cache preflight selama 24 jam
  })
);

Keputusan-keputusan kunci:

  • Set origin yang eksplisit, bukan regex. Regex rumit — yourapp.com bisa cocok dengan evilyourapp.com jika regex Anda tidak di-anchor dengan benar.
  • credentials: true karena kita menggunakan httpOnly cookie untuk refresh token.
  • maxAge: 86400 — Request preflight (OPTIONS) menambah latensi. Memberitahu browser untuk meng-cache hasil CORS selama 24 jam mengurangi round trip yang tidak perlu.
  • exposedHeaders — Secara default, browser hanya mengekspos segelintir header respons "sederhana" ke JavaScript. Jika Anda ingin klien membaca header rate limit Anda, Anda harus secara eksplisit mengeksposnya.

Request Preflight#

Ketika sebuah request bukan "sederhana" (menggunakan header non-standar, metode non-standar, atau tipe konten non-standar), browser mengirim request OPTIONS terlebih dahulu untuk meminta izin. Ini adalah preflight.

Jika konfigurasi CORS Anda tidak menangani OPTIONS, request preflight akan gagal, dan request yang sebenarnya tidak akan pernah dikirim. Kebanyakan library CORS menangani ini secara otomatis, tetapi jika Anda menggunakan framework yang tidak, Anda perlu menanganinya:

typescript
// Penanganan preflight manual (kebanyakan framework melakukan ini untuk Anda)
app.options("*", (req, res) => {
  res.set({
    "Access-Control-Allow-Origin": getAllowedOrigin(req.headers.origin),
    "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH",
    "Access-Control-Allow-Headers": "Content-Type, Authorization",
    "Access-Control-Max-Age": "86400",
  });
  res.status(204).end();
});

Header Keamanan#

Header keamanan adalah peningkatan keamanan termurah yang bisa Anda lakukan. Mereka adalah header respons yang memberitahu browser untuk mengaktifkan fitur keamanan. Sebagian besar dari mereka hanya satu baris konfigurasi, dan mereka melindungi terhadap seluruh kelas serangan.

Header yang Penting#

typescript
import helmet from "helmet";
 
// Satu baris. Ini adalah kemenangan keamanan tercepat di aplikasi Express mana pun.
app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'"],
        styleSrc: ["'self'", "'unsafe-inline'"], // Diperlukan untuk banyak solusi CSS-in-JS
        imgSrc: ["'self'", "data:", "https:"],
        connectSrc: ["'self'", "https://api.yourapp.com"],
        fontSrc: ["'self'"],
        objectSrc: ["'none'"],
        mediaSrc: ["'self'"],
        frameSrc: ["'none'"],
        upgradeInsecureRequests: [],
      },
    },
    hsts: {
      maxAge: 31536000, // 1 tahun
      includeSubDomains: true,
      preload: true,
    },
    referrerPolicy: { policy: "strict-origin-when-cross-origin" },
  })
);

Apa yang dilakukan setiap header:

Content-Security-Policy (CSP) — Header keamanan paling kuat. Ia memberitahu browser sumber mana yang diizinkan untuk script, style, gambar, font, dll. Jika penyerang menyisipkan tag <script> yang memuat dari evil.com, CSP memblokirnya. Ini adalah pertahanan tunggal paling efektif terhadap XSS.

Strict-Transport-Security (HSTS) — Memberitahu browser untuk selalu menggunakan HTTPS, bahkan jika pengguna mengetik http://. Direktif preload memungkinkan Anda mengirimkan domain Anda ke daftar HSTS bawaan browser, sehingga bahkan request pertama dipaksa menggunakan HTTPS.

X-Frame-Options — Mencegah situs Anda disematkan dalam iframe. Ini menghentikan serangan clickjacking di mana penyerang melapisi halaman Anda dengan elemen yang tidak terlihat. Helmet mengatur ini ke SAMEORIGIN secara default. Pengganti modernnya adalah frame-ancestors di CSP.

X-Content-Type-Options: nosniff — Mencegah browser menebak (sniffing) tipe MIME dari sebuah respons. Tanpa ini, jika Anda menyajikan file dengan Content-Type yang salah, browser mungkin mengeksekusinya sebagai JavaScript.

Referrer-Policy — Mengontrol berapa banyak informasi URL yang dikirim dalam header Referer. strict-origin-when-cross-origin mengirim URL lengkap untuk request same-origin tetapi hanya origin untuk request cross-origin. Ini mencegah bocornya parameter URL sensitif ke pihak ketiga.

Menguji Header Anda#

Setelah deployment, periksa skor Anda di securityheaders.com. Targetkan rating A+. Hanya butuh sekitar lima menit konfigurasi untuk mencapainya.

Anda juga bisa memverifikasi header secara programatis:

typescript
import { describe, it, expect } from "vitest";
 
describe("Header keamanan", () => {
  it("harus menyertakan semua header keamanan yang diperlukan", async () => {
    const response = await fetch("https://api.yourapp.com/health");
 
    expect(response.headers.get("strict-transport-security")).toBeTruthy();
    expect(response.headers.get("x-content-type-options")).toBe("nosniff");
    expect(response.headers.get("x-frame-options")).toBe("SAMEORIGIN");
    expect(response.headers.get("content-security-policy")).toBeTruthy();
    expect(response.headers.get("referrer-policy")).toBeTruthy();
    expect(response.headers.get("x-powered-by")).toBeNull(); // Helmet menghapus ini
  });
});

Pemeriksaan x-powered-by itu halus tapi penting. Express menetapkan X-Powered-By: Express secara default, memberitahu penyerang framework apa yang Anda gunakan. Helmet menghapusnya.

Manajemen Secret#

Yang satu ini seharusnya sudah jelas, tetapi saya masih melihatnya di pull request: API key, password basis data, dan secret JWT yang di-hardcode dalam file sumber. Atau di-commit dalam file .env yang tidak ada di .gitignore. Begitu masuk ke riwayat git, selamanya ada di sana, bahkan jika Anda menghapus file di commit berikutnya.

Aturan-Aturannya#

  1. Jangan pernah commit secret ke git. Tidak di kode, tidak di .env, tidak di file config, tidak di file Docker Compose, tidak di komentar "hanya untuk testing".

  2. Gunakan .env.example sebagai template. Ia mendokumentasikan variabel lingkungan apa yang diperlukan, tanpa mengandung nilai sebenarnya:

bash
# .env.example — commit ini
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
JWT_SECRET=your-secret-here
REDIS_URL=redis://localhost:6379
SMTP_API_KEY=your-smtp-key
 
# .env — JANGAN PERNAH commit ini
# Tercantum di .gitignore
  1. Validasi variabel lingkungan saat startup. Jangan tunggu sampai request menyentuh endpoint yang membutuhkan URL basis data. Gagal dengan cepat:
typescript
import { z } from "zod";
 
const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32, "JWT secret harus minimal 32 karakter"),
  REDIS_URL: z.string().url(),
  NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
  PORT: z.coerce.number().default(3000),
  CORS_ORIGINS: z.string().transform((s) => s.split(",")),
});
 
export type Env = z.infer<typeof envSchema>;
 
function validateEnv(): Env {
  const result = envSchema.safeParse(process.env);
 
  if (!result.success) {
    console.error("Variabel lingkungan tidak valid:");
    console.error(result.error.format());
    process.exit(1); // Jangan mulai dengan konfigurasi yang buruk
  }
 
  return result.data;
}
 
export const env = validateEnv();
  1. Gunakan secret manager di produksi. Variabel lingkungan berfungsi untuk setup sederhana, tetapi mereka memiliki keterbatasan: terlihat di daftar proses, bertahan di memori, dan bisa bocor melalui log error.

Untuk sistem produksi, gunakan secret manager yang tepat:

  • AWS Secrets Manager atau SSM Parameter Store
  • HashiCorp Vault
  • Google Secret Manager
  • Azure Key Vault
  • Doppler (jika Anda ingin sesuatu yang berfungsi di semua cloud)

Polanya sama terlepas dari mana yang Anda gunakan: aplikasi mengambil secret saat startup dari secret manager, bukan dari variabel lingkungan.

  1. Rotasi secret secara teratur. Jika Anda sudah menggunakan JWT secret yang sama selama dua tahun, saatnya merotasi. Implementasikan rotasi kunci: dukung beberapa kunci penandatanganan yang valid secara bersamaan, tandatangani token baru dengan kunci baru, verifikasi dengan kunci lama dan baru, dan pensiunkan kunci lama setelah semua token yang ada kedaluwarsa.
typescript
interface SigningKey {
  id: string;
  secret: string;
  createdAt: Date;
  active: boolean; // Hanya kunci aktif yang menandatangani token baru
}
 
async function verifyWithRotation(token: string): Promise<TokenPayload> {
  const keys = await getSigningKeys(); // Mengembalikan semua kunci yang valid
 
  for (const key of keys) {
    try {
      return jwt.verify(token, key.secret, {
        algorithms: ["HS256"],
      }) as TokenPayload;
    } catch {
      continue; // Coba kunci berikutnya
    }
  }
 
  throw new ApiError(401, "Token tidak valid");
}
 
function signToken(payload: Omit<TokenPayload, "iat" | "exp">): string {
  const activeKey = getActiveSigningKey();
  return jwt.sign(payload, activeKey.secret, {
    algorithm: "HS256",
    expiresIn: "15m",
    keyid: activeKey.id, // Sertakan ID kunci di header
  });
}

OWASP API Security Top 10#

OWASP API Security Top 10 adalah daftar standar industri untuk kerentanan API. Diperbarui secara berkala, dan setiap item dalam daftar adalah sesuatu yang pernah saya lihat di codebase nyata. Mari kita bahas satu per satu.

API1: Broken Object Level Authorization (BOLA)#

Kerentanan API paling umum. Pengguna sudah terautentikasi, tetapi API tidak memeriksa apakah mereka memiliki akses ke objek spesifik yang mereka minta.

typescript
// RENTAN — setiap pengguna terautentikasi bisa mengakses data pengguna mana pun
app.get("/api/users/:id", authenticate, async (req, res) => {
  const user = await db.users.findById(req.params.id);
  return res.json(user);
});
 
// DIPERBAIKI — verifikasi pengguna mengakses datanya sendiri (atau adalah admin)
app.get("/api/users/:id", authenticate, async (req, res) => {
  if (req.user.id !== req.params.id && req.user.role !== "admin") {
    return res.status(403).json({ error: "Akses ditolak" });
  }
  const user = await db.users.findById(req.params.id);
  return res.json(user);
});

Versi yang rentan ada di mana-mana. Ia melewati setiap pemeriksaan autentikasi — pengguna memiliki token yang valid — tetapi tidak memverifikasi apakah mereka berwenang untuk mengakses resource spesifik ini. Ubah ID di URL, dan Anda mendapatkan data orang lain.

API2: Broken Authentication#

Mekanisme login yang lemah, MFA yang hilang, token yang tidak pernah kedaluwarsa, password disimpan dalam plaintext. Ini mencakup lapisan autentikasi itu sendiri.

Perbaikannya adalah semua yang kita bahas di bagian autentikasi: persyaratan password yang kuat, bcrypt dengan rounds yang cukup, access token berumur pendek, rotasi refresh token, penguncian akun setelah percobaan gagal.

typescript
const MAX_LOGIN_ATTEMPTS = 5;
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 menit
 
async function handleLogin(email: string, password: string): Promise<AuthResult> {
  const lockoutKey = `lockout:${email}`;
  const attempts = await redis.get(lockoutKey);
 
  if (attempts && parseInt(attempts) >= MAX_LOGIN_ATTEMPTS) {
    const ttl = await redis.pttl(lockoutKey);
    throw new ApiError(
      429,
      `Akun terkunci. Coba lagi dalam ${Math.ceil(ttl / 60000)} menit.`
    );
  }
 
  const user = await db.users.findByEmail(email);
 
  if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
    // Tambah percobaan gagal
    await redis.multi()
      .incr(lockoutKey)
      .pexpire(lockoutKey, LOCKOUT_DURATION)
      .exec();
 
    // Pesan error sama untuk kedua kasus — jangan ungkapkan apakah email ada
    throw new ApiError(401, "Email atau password tidak valid");
  }
 
  // Reset percobaan gagal pada login berhasil
  await redis.del(lockoutKey);
 
  return generateTokens(user);
}

Komentar tentang "pesan error sama" itu penting. Jika API Anda mengembalikan "pengguna tidak ditemukan" untuk email yang tidak valid dan "password salah" untuk email valid dengan password yang salah, Anda memberitahu penyerang email mana yang ada di sistem Anda.

API3: Broken Object Property Level Authorization#

Mengembalikan lebih banyak data dari yang diperlukan, atau mengizinkan pengguna memodifikasi properti yang seharusnya tidak mereka modifikasi.

typescript
// RENTAN — mengembalikan seluruh objek pengguna, termasuk field internal
app.get("/api/users/:id", authenticate, authorize, async (req, res) => {
  const user = await db.users.findById(req.params.id);
  return res.json(user);
  // Respons menyertakan: passwordHash, internalNotes, billingId, ...
});
 
// DIPERBAIKI — allowlist eksplisit untuk field yang dikembalikan
app.get("/api/users/:id", authenticate, authorize, async (req, res) => {
  const user = await db.users.findById(req.params.id);
  return res.json({
    id: user.id,
    name: user.name,
    email: user.email,
    avatar: user.avatar,
    createdAt: user.createdAt,
  });
});

Jangan pernah mengembalikan seluruh objek basis data. Selalu pilih field yang ingin Anda ekspos. Ini berlaku untuk write juga — jangan spread seluruh body request ke query update Anda:

typescript
// RENTAN — mass assignment
app.put("/api/users/:id", authenticate, async (req, res) => {
  await db.users.update(req.params.id, req.body);
  // Penyerang mengirim: { "role": "admin", "verified": true }
});
 
// DIPERBAIKI — pilih field yang diizinkan
const UpdateUserSchema = z.object({
  name: z.string().min(1).max(100).optional(),
  avatar: z.string().url().optional(),
});
 
app.put("/api/users/:id", authenticate, async (req, res) => {
  const data = UpdateUserSchema.parse(req.body);
  await db.users.update(req.params.id, data);
});

API4: Unrestricted Resource Consumption#

API Anda adalah resource. CPU, memori, bandwidth, koneksi basis data — semuanya terbatas. Tanpa batasan, satu klien bisa menghabiskan semuanya.

Ini melampaui rate limiting. Termasuk:

typescript
// Batasi ukuran body request
app.use(express.json({ limit: "1mb" }));
 
// Batasi kompleksitas query
const MAX_PAGE_SIZE = 100;
const DEFAULT_PAGE_SIZE = 20;
 
const PaginationSchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce
    .number()
    .int()
    .positive()
    .max(MAX_PAGE_SIZE)
    .default(DEFAULT_PAGE_SIZE),
});
 
// Batasi ukuran upload file
const upload = multer({
  limits: {
    fileSize: 5 * 1024 * 1024, // 5MB
    files: 1,
  },
  fileFilter: (req, file, cb) => {
    const allowed = ["image/jpeg", "image/png", "image/webp"];
    if (allowed.includes(file.mimetype)) {
      cb(null, true);
    } else {
      cb(new Error("Tipe file tidak valid"));
    }
  },
});
 
// Timeout untuk request yang berjalan lama
app.use((req, res, next) => {
  res.setTimeout(30000, () => {
    res.status(408).json({ error: "Request timeout" });
  });
  next();
});

API5: Broken Function Level Authorization#

Berbeda dari BOLA. Ini tentang mengakses fungsi (endpoint) yang seharusnya tidak Anda akses, bukan objek. Contoh klasik: pengguna biasa menemukan endpoint admin.

typescript
// Middleware yang memeriksa akses berbasis role
function requireRole(...allowedRoles: string[]) {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!req.user) {
      return res.status(401).json({ error: "Tidak terautentikasi" });
    }
 
    if (!allowedRoles.includes(req.user.role)) {
      // Catat percobaan — ini mungkin serangan
      logger.warn("Percobaan akses tidak sah", {
        userId: req.user.id,
        role: req.user.role,
        requiredRoles: allowedRoles,
        endpoint: `${req.method} ${req.path}`,
        ip: req.ip,
      });
 
      return res.status(403).json({ error: "Izin tidak mencukupi" });
    }
 
    next();
  };
}
 
// Terapkan ke route
app.delete("/api/users/:id", authenticate, requireRole("admin"), deleteUser);
app.get("/api/admin/stats", authenticate, requireRole("admin"), getStats);
app.post("/api/posts", authenticate, requireRole("admin", "editor"), createPost);

Jangan mengandalkan penyembunyian endpoint. "Keamanan melalui ketidakjelasan" bukan keamanan. Bahkan jika URL panel admin tidak ditautkan di mana pun, seseorang akan menemukan /api/admin/users dengan fuzzing.

API6: Unrestricted Access to Sensitive Business Flows#

Penyalahgunaan otomatis terhadap fungsionalitas bisnis yang sah. Misalnya: bot membeli item stok terbatas, pembuatan akun otomatis untuk spam, scraping harga produk.

Mitigasinya bersifat konteks-spesifik: CAPTCHA, device fingerprinting, analisis perilaku, autentikasi step-up untuk operasi sensitif. Tidak ada satu cuplikan kode yang cocok untuk semua.

API7: Server Side Request Forgery (SSRF)#

Jika API Anda mengambil URL yang diberikan pengguna (webhook, URL foto profil, preview tautan), penyerang bisa membuat server Anda me-request resource internal:

typescript
import { URL } from "url";
import dns from "dns/promises";
import { isPrivateIP } from "./network-utils";
 
async function safeFetch(userProvidedUrl: string): Promise<Response> {
  let parsed: URL;
 
  try {
    parsed = new URL(userProvidedUrl);
  } catch {
    throw new ApiError(400, "URL tidak valid");
  }
 
  // Hanya izinkan HTTP(S)
  if (!["http:", "https:"].includes(parsed.protocol)) {
    throw new ApiError(400, "Hanya URL HTTP(S) yang diizinkan");
  }
 
  // Resolve hostname dan periksa apakah itu IP privat
  const addresses = await dns.resolve4(parsed.hostname);
 
  for (const addr of addresses) {
    if (isPrivateIP(addr)) {
      throw new ApiError(400, "Alamat internal tidak diizinkan");
    }
  }
 
  // Sekarang fetch dengan timeout dan batas ukuran
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 5000);
 
  try {
    const response = await fetch(userProvidedUrl, {
      signal: controller.signal,
      redirect: "error", // Jangan ikuti redirect (bisa redirect ke IP internal)
    });
 
    return response;
  } finally {
    clearTimeout(timeout);
  }
}

Detail kunci: resolve DNS terlebih dahulu dan periksa IP sebelum membuat request. Blokir redirect — penyerang bisa meng-host URL yang redirect ke http://169.254.169.254/ (endpoint metadata AWS) untuk melewati pemeriksaan level URL Anda.

API8: Security Misconfiguration#

Credential default yang tidak diubah, metode HTTP yang tidak perlu diaktifkan, pesan error yang verbose di produksi, directory listing diaktifkan, CORS salah konfigurasi. Ini adalah kategori "Anda lupa mengunci pintu".

typescript
// Jangan bocorkan stack trace di produksi
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  logger.error("Error tidak tertangani", {
    error: err.message,
    stack: err.stack,
    path: req.path,
    method: req.method,
  });
 
  if (process.env.NODE_ENV === "production") {
    // Pesan error generik — jangan ungkapkan internal
    res.status(500).json({
      error: "Internal server error",
      requestId: req.id, // Sertakan request ID untuk debugging
    });
  } else {
    // Di development, tampilkan error lengkap
    res.status(500).json({
      error: err.message,
      stack: err.stack,
    });
  }
});
 
// Nonaktifkan metode HTTP yang tidak perlu
app.use((req, res, next) => {
  const allowed = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"];
  if (!allowed.includes(req.method)) {
    return res.status(405).json({ error: "Metode tidak diizinkan" });
  }
  next();
});

API9: Improper Inventory Management#

Anda men-deploy v2 API tetapi lupa menonaktifkan v1. Atau ada endpoint /debug/ yang berguna selama pengembangan dan masih berjalan di produksi. Atau server staging yang bisa diakses publik dengan data produksi.

Ini bukan perbaikan kode — ini disiplin operasi. Kelola daftar semua endpoint API, semua versi yang di-deploy, dan semua environment. Gunakan pemindaian otomatis untuk menemukan layanan yang terekspos. Matikan yang tidak Anda butuhkan.

API10: Unsafe Consumption of APIs#

API Anda mengkonsumsi API pihak ketiga. Apakah Anda memvalidasi responnya? Apa yang terjadi jika payload webhook dari Stripe sebenarnya dari penyerang?

typescript
import crypto from "crypto";
 
// Verifikasi signature webhook Stripe
function verifyStripeWebhook(
  payload: string,
  signature: string,
  secret: string
): boolean {
  const timestamp = signature.split(",").find((s) => s.startsWith("t="))?.slice(2);
  const expectedSig = signature.split(",").find((s) => s.startsWith("v1="))?.slice(3);
 
  if (!timestamp || !expectedSig) return false;
 
  // Tolak timestamp lama (cegah serangan replay)
  const age = Math.abs(Date.now() / 1000 - parseInt(timestamp));
  if (age > 300) return false; // Toleransi 5 menit
 
  const signedPayload = `${timestamp}.${payload}`;
  const computedSig = crypto
    .createHmac("sha256", secret)
    .update(signedPayload)
    .digest("hex");
 
  return crypto.timingSafeEqual(
    Buffer.from(computedSig),
    Buffer.from(expectedSig)
  );
}

Selalu verifikasi signature pada webhook. Selalu validasi struktur respons API pihak ketiga. Selalu tetapkan timeout pada request keluar. Jangan pernah percaya data hanya karena datang dari "mitra terpercaya."

Audit Logging#

Ketika sesuatu berjalan salah — dan pasti akan — audit log adalah cara Anda mencari tahu apa yang terjadi. Tetapi logging adalah pedang bermata dua. Log terlalu sedikit dan Anda buta. Log terlalu banyak dan Anda menciptakan kewajiban privasi.

Apa yang Harus Dicatat#

typescript
interface AuditLogEntry {
  timestamp: string;
  action: string;           // "user.login", "post.delete", "admin.role_change"
  actor: {
    id: string;
    ip: string;
    userAgent: string;
  };
  target: {
    type: string;           // "user", "post", "setting"
    id: string;
  };
  result: "success" | "failure";
  metadata: Record<string, unknown>; // Konteks tambahan
  requestId: string;        // Untuk korelasi dengan log aplikasi
}
 
async function auditLog(entry: AuditLogEntry): Promise<void> {
  // Tulis ke data store terpisah yang hanya-tambah
  // Ini SEHARUSNYA BUKAN basis data yang sama yang digunakan aplikasi Anda
  await auditDb.collection("audit_logs").insertOne({
    ...entry,
    timestamp: new Date().toISOString(),
  });
 
  // Untuk aksi kritis, tulis juga ke log eksternal yang immutable
  if (isCriticalAction(entry.action)) {
    await externalLogger.send(entry);
  }
}

Catat event-event ini:

  • Autentikasi: login, logout, percobaan gagal, refresh token
  • Otorisasi: event akses ditolak (ini sering menjadi indikator serangan)
  • Modifikasi data: create, update, delete — siapa mengubah apa, kapan
  • Aksi admin: perubahan role, manajemen pengguna, perubahan konfigurasi
  • Event keamanan: pemicu rate limit, pelanggaran CORS, request yang malformed

Apa yang TIDAK Boleh Dicatat#

Jangan pernah mencatat:

  • Password (bahkan yang sudah di-hash — hash adalah credential)
  • Nomor kartu kredit lengkap (catat hanya 4 digit terakhir)
  • Nomor Jaminan Sosial atau ID pemerintah
  • API key atau token (catat prefix paling banyak: sk_live_...abc)
  • Informasi kesehatan pribadi
  • Body request lengkap yang mungkin mengandung PII
typescript
function sanitizeForLogging(data: Record<string, unknown>): Record<string, unknown> {
  const sensitiveKeys = new Set([
    "password",
    "passwordHash",
    "token",
    "secret",
    "apiKey",
    "creditCard",
    "ssn",
    "authorization",
  ]);
 
  const sanitized: Record<string, unknown> = {};
 
  for (const [key, value] of Object.entries(data)) {
    if (sensitiveKeys.has(key.toLowerCase())) {
      sanitized[key] = "[DIHAPUS]";
    } else if (typeof value === "object" && value !== null) {
      sanitized[key] = sanitizeForLogging(value as Record<string, unknown>);
    } else {
      sanitized[key] = value;
    }
  }
 
  return sanitized;
}

Log Anti-Rusak#

Jika penyerang mendapatkan akses ke sistem Anda, salah satu hal pertama yang akan mereka lakukan adalah memodifikasi log untuk menutupi jejak mereka. Logging anti-rusak membuat ini terdeteksi:

typescript
import crypto from "crypto";
 
let previousHash = "GENESIS"; // Hash awal dalam rantai
 
function createTamperEvidentEntry(entry: AuditLogEntry): AuditLogEntry & { hash: string } {
  const content = JSON.stringify(entry) + previousHash;
  const hash = crypto.createHash("sha256").update(content).digest("hex");
 
  previousHash = hash;
 
  return { ...entry, hash };
}
 
// Untuk memverifikasi integritas rantai:
function verifyLogChain(entries: Array<AuditLogEntry & { hash: string }>): boolean {
  let expectedPreviousHash = "GENESIS";
 
  for (const entry of entries) {
    const { hash, ...rest } = entry;
    const content = JSON.stringify(rest) + expectedPreviousHash;
    const computedHash = crypto.createHash("sha256").update(content).digest("hex");
 
    if (computedHash !== hash) {
      return false; // Rantai rusak — log telah dirusak
    }
 
    expectedPreviousHash = hash;
  }
 
  return true;
}

Ini adalah konsep yang sama dengan blockchain — hash setiap entri log bergantung pada entri sebelumnya. Jika seseorang memodifikasi atau menghapus entri, rantai terputus.

Keamanan Dependensi#

Kode Anda mungkin aman. Tetapi bagaimana dengan 847 paket npm di node_modules Anda? Masalah rantai pasokan itu nyata, dan semakin buruk dari tahun ke tahun.

npm audit Adalah Persyaratan Minimum#

bash
# Jalankan ini di CI, gagalkan build pada kerentanan high/critical
npm audit --audit-level=high
 
# Perbaiki yang bisa diperbaiki secara otomatis
npm audit fix
 
# Lihat apa yang sebenarnya Anda tarik
npm ls --all

Tetapi npm audit memiliki keterbatasan. Ia hanya memeriksa basis data advisory npm, dan peringkat keparahannya tidak selalu akurat. Tambahkan tool tambahan:

Pemindaian Dependensi Otomatis#

yaml
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"
    open-pull-requests-limit: 10
    reviewers:
      - "your-team"
    labels:
      - "dependencies"
    # Kelompokkan update minor dan patch untuk mengurangi noise PR
    groups:
      production-dependencies:
        patterns:
          - "*"
        update-types:
          - "minor"
          - "patch"

Lockfile Adalah Alat Keamanan#

Selalu commit package-lock.json Anda (atau pnpm-lock.yaml, atau yarn.lock). Lockfile menyematkan versi pasti dari setiap dependensi, termasuk yang transitif. Tanpanya, npm install mungkin menarik versi yang berbeda dari yang Anda uji — dan versi berbeda itu mungkin sudah dikompromikan.

bash
# Di CI, gunakan ci daripada install — ia menghormati lockfile secara ketat
npm ci

npm ci gagal jika lockfile tidak cocok dengan package.json, alih-alih memperbarui secara diam-diam. Ini menangkap kasus di mana seseorang memodifikasi package.json tetapi lupa memperbarui lockfile.

Evaluasi Sebelum Menginstal#

Sebelum menambahkan dependensi, tanyakan:

  1. Apakah saya benar-benar membutuhkan ini? Bisakah saya menulis ini dalam 20 baris daripada menambahkan paket?
  2. Berapa banyak download-nya? Jumlah download rendah belum tentu buruk, tetapi berarti lebih sedikit mata yang meninjau kode.
  3. Kapan terakhir diperbarui? Paket yang tidak diperbarui selama 3 tahun mungkin memiliki kerentanan yang belum dipatch.
  4. Berapa banyak dependensi yang ditariknya? is-odd bergantung pada is-number yang bergantung pada kind-of. Itu tiga paket untuk melakukan sesuatu yang satu baris kode bisa lakukan.
  5. Siapa yang memeliharanya? Satu maintainer adalah satu titik kompromi.
typescript
// Anda tidak butuh paket untuk ini:
const isEven = (n: number): boolean => n % 2 === 0;
 
// Atau ini:
const leftPad = (str: string, len: number, char = " "): string =>
  str.padStart(len, char);
 
// Atau ini:
const isNil = (value: unknown): value is null | undefined =>
  value === null || value === undefined;

Checklist Pra-Deploy#

Ini adalah checklist sebenarnya yang saya gunakan sebelum setiap deployment produksi. Ini tidak lengkap — keamanan tidak pernah "selesai" — tetapi ia menangkap kesalahan yang paling penting.

#PemeriksaanKriteria LulusPrioritas
1AutentikasiJWT diverifikasi dengan algoritma, issuer, dan audience eksplisit. Tidak ada alg: none.Kritis
2Kedaluwarsa tokenAccess token kedaluwarsa dalam 15 menit atau kurang. Refresh token dirotasi saat digunakan.Kritis
3Penyimpanan tokenRefresh token di httpOnly secure cookie. Tidak ada token di localStorage.Kritis
4Otorisasi di setiap endpointSetiap endpoint akses data memeriksa izin level objek. BOLA diuji.Kritis
5Validasi inputSemua input pengguna divalidasi dengan Zod atau setara. Tidak ada req.body mentah dalam query.Kritis
6SQL/NoSQL injectionSemua query basis data menggunakan query terparameterisasi atau metode ORM. Tidak ada penggabungan string.Kritis
7Rate limitingEndpoint autentikasi: 5/15menit. API umum: 60/menit. Header rate limit dikembalikan.Tinggi
8CORSAllowlist origin eksplisit. Tidak ada wildcard dengan credentials. Preflight di-cache.Tinggi
9Header keamananCSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy semuanya ada.Tinggi
10Penanganan errorError produksi mengembalikan pesan generik. Tidak ada stack trace, tidak ada error SQL yang terekspos.Tinggi
11SecretTidak ada secret di kode atau riwayat git. .env di .gitignore. Divalidasi saat startup.Kritis
12Dependensinpm audit bersih (tidak ada high/critical). Lockfile di-commit. npm ci di CI.Tinggi
13Hanya HTTPSHSTS diaktifkan dengan preload. HTTP redirect ke HTTPS. Flag secure cookie disetel.Kritis
14LoggingEvent autentikasi, akses ditolak, dan mutasi data dicatat. Tidak ada PII di log.Sedang
15Batas ukuran requestBody parser dibatasi (default 1MB). Upload file dibatasi. Paginasi query ditegakkan.Sedang
16Perlindungan SSRFURL yang diberikan pengguna divalidasi. IP privat diblokir. Redirect dinonaktifkan atau divalidasi.Sedang
17Penguncian akunPercobaan login gagal memicu penguncian setelah 5 kali. Penguncian dicatat.Tinggi
18Verifikasi webhookSemua webhook masuk diverifikasi dengan signature. Perlindungan replay via timestamp.Tinggi
19Endpoint adminKontrol akses berbasis role pada semua route admin. Percobaan dicatat.Kritis
20Mass assignmentEndpoint update menggunakan schema Zod dengan field yang di-allowlist. Tidak ada spread body mentah.Tinggi

Saya menyimpan ini sebagai template issue GitHub. Sebelum menandai rilis, seseorang di tim harus memeriksa setiap baris dan menandatangani. Tidak glamor, tetapi berhasil.

Perubahan Pola Pikir#

Keamanan bukan fitur yang Anda tambahkan di akhir. Bukan sprint yang Anda lakukan setahun sekali. Ini adalah cara berpikir tentang setiap baris kode yang Anda tulis.

Ketika Anda menulis endpoint, pikirkan: "Bagaimana jika seseorang mengirim data yang tidak saya harapkan?" Ketika Anda menambahkan parameter, pikirkan: "Bagaimana jika seseorang mengubah ini ke ID orang lain?" Ketika Anda menambahkan dependensi, pikirkan: "Apa yang terjadi jika paket ini dikompromikan Selasa depan?"

Anda tidak akan menangkap semuanya. Tidak ada yang bisa. Tetapi menjalankan checklist ini — secara metodis, sebelum setiap deployment — menangkap hal-hal yang paling penting. Kemenangan mudah. Lubang yang jelas. Kesalahan yang mengubah hari buruk menjadi kebocoran data.

Bangun kebiasaannya. Jalankan checklist-nya. Kirim dengan percaya diri.

Artikel Terkait