Đi đến nội dung
·37 phút đọc

Bảo Mật API: Danh Sách Kiểm Tra Tôi Chạy Cho Mọi Dự Án

Authentication, authorization, validation đầu vào, rate limiting, CORS, quản lý secrets và OWASP API Top 10. Những gì tôi kiểm tra trước mỗi triển khai production.

Chia sẻ:X / TwitterLinkedIn

Tôi đã triển khai các API hoàn toàn mở toang. Không phải do ác ý, không phải do lười biếng — tôi chỉ đơn giản là không biết những gì mình không biết. Một endpoint trả về mọi trường trong đối tượng user, bao gồm cả mật khẩu đã hash. Một rate limiter chỉ kiểm tra địa chỉ IP, nghĩa là bất kỳ ai đứng sau proxy đều có thể tấn công API thoải mái. Một triển khai JWT mà tôi quên xác minh claim iss, nên token từ một service hoàn toàn khác vẫn hoạt động bình thường.

Mỗi sai lầm đó đều đã lên production. Mỗi sai lầm đều bị phát hiện — một số bởi tôi, một số bởi người dùng, một lần bởi một nhà nghiên cứu bảo mật đủ tử tế để gửi email cho tôi thay vì đăng lên Twitter.

Bài viết này là danh sách kiểm tra mà tôi xây dựng từ những sai lầm đó. Tôi chạy qua nó trước mỗi lần triển khai production. Không phải vì tôi hoang tưởng, mà vì tôi đã học được rằng lỗi bảo mật là những lỗi gây đau đớn nhất. Một nút bị hỏng làm phiền người dùng. Một luồng xác thực bị hỏng làm rò rỉ dữ liệu của họ.

Authentication vs Authorization#

Hai từ này được sử dụng thay thế cho nhau trong các cuộc họp, trong tài liệu, thậm chí trong code comments. Chúng không phải là cùng một thứ.

Authentication trả lời: "Bạn là ai?" Đó là bước đăng nhập. Tên người dùng và mật khẩu, luồng OAuth, magic link — bất cứ thứ gì chứng minh danh tính của bạn.

Authorization trả lời: "Bạn được phép làm gì?" Đó là bước phân quyền. Người dùng này có thể xóa tài nguyên này không? Họ có thể truy cập endpoint admin này không? Họ có thể đọc dữ liệu của người dùng khác không?

Lỗi bảo mật phổ biến nhất mà tôi thấy trong các API production không phải là luồng đăng nhập bị hỏng. Đó là thiếu kiểm tra authorization. Người dùng đã được xác thực — họ có token hợp lệ — nhưng API không bao giờ kiểm tra xem họ có được phép thực hiện hành động mà họ yêu cầu hay không.

JWT: Cấu Trúc và Những Sai Lầm Quan Trọng#

JWT có mặt ở khắp nơi. Chúng cũng bị hiểu sai ở khắp nơi. Một JWT có ba phần, ngăn cách bởi dấu chấm:

header.payload.signature

Header cho biết thuật toán nào đã được sử dụng. Payload chứa các claims (user ID, roles, thời hạn). Signature chứng minh không ai đã can thiệp vào hai phần đầu.

Đây là cách xác minh JWT đúng cách trong 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"], // Không bao giờ cho phép "none"
      issuer: "api.yourapp.com",
      audience: "yourapp.com",
      clockTolerance: 30, // 30 giây dung sai cho lệch đồng hồ
    }) 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");
  }
}

Một vài điều cần lưu ý:

  1. algorithms: ["HS256"] — Điều này cực kỳ quan trọng. Nếu bạn không chỉ định thuật toán, kẻ tấn công có thể gửi token với "alg": "none" trong header và bỏ qua xác minh hoàn toàn. Đây là tấn công alg: none, và nó đã ảnh hưởng đến các hệ thống production thực tế.

  2. issueraudience — Nếu không có những thứ này, token được tạo cho Service A sẽ hoạt động trên Service B. Nếu bạn chạy nhiều service dùng chung cùng một secret (điều bạn không nên làm, nhưng mọi người vẫn làm), đây là cách lạm dụng token xuyên service xảy ra.

  3. Xử lý lỗi cụ thể — Đừng trả về "invalid token" cho mọi lỗi. Phân biệt giữa hết hạn và không hợp lệ giúp client biết nên refresh hay xác thực lại.

Xoay Vòng Refresh Token#

Access token nên có thời gian sống ngắn — 15 phút là tiêu chuẩn. Nhưng bạn không muốn người dùng nhập lại mật khẩu mỗi 15 phút. Đó là lúc refresh token xuất hiện.

Pattern thực sự hoạt động trong production:

typescript
import { randomBytes } from "crypto";
import { redis } from "./redis";
 
interface RefreshTokenData {
  userId: string;
  family: string; // Token family để phát hiện xoay vòng
  createdAt: number;
}
 
async function rotateRefreshToken(
  oldRefreshToken: string
): Promise<{ accessToken: string; refreshToken: string }> {
  const tokenData = await redis.get(`refresh:${oldRefreshToken}`);
 
  if (!tokenData) {
    // Token không tìm thấy — hoặc đã hết hạn hoặc đã được sử dụng.
    // Nếu đã được sử dụng, đây có thể là tấn công replay.
    // Vô hiệu hóa toàn bộ token family.
    const parsed = decodeRefreshToken(oldRefreshToken);
    if (parsed?.family) {
      await invalidateTokenFamily(parsed.family);
    }
    throw new ApiError(401, "Invalid refresh token");
  }
 
  const data: RefreshTokenData = JSON.parse(tokenData);
 
  // Xóa token cũ ngay lập tức — chỉ sử dụng một lần
  await redis.del(`refresh:${oldRefreshToken}`);
 
  // Tạo token mới
  const newRefreshToken = randomBytes(64).toString("hex");
  const newAccessToken = generateAccessToken(data.userId);
 
  // Lưu refresh token mới với cùng family
  await redis.setex(
    `refresh:${newRefreshToken}`,
    60 * 60 * 24 * 30, // 30 ngày
    JSON.stringify({
      userId: data.userId,
      family: data.family,
      createdAt: Date.now(),
    })
  );
 
  return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}
 
async function invalidateTokenFamily(family: string): Promise<void> {
  // Quét tất cả token trong family này và xóa chúng.
  // Đây là tùy chọn hạt nhân — nếu ai đó replay refresh token,
  // chúng ta tiêu diệt mọi token trong family, buộc xác thực lại.
  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);
      }
    }
  }
}

Khái niệm token family là thứ làm cho điều này an toàn. Mỗi refresh token thuộc về một family (được tạo khi đăng nhập). Khi bạn xoay vòng, token mới kế thừa family. Nếu kẻ tấn công replay refresh token cũ, bạn phát hiện việc tái sử dụng và tiêu diệt toàn bộ family. Người dùng hợp pháp bị đăng xuất, nhưng kẻ tấn công không vào được.

Cuộc tranh luận này đã diễn ra nhiều năm, và câu trả lời rất rõ ràng: httpOnly cookie cho refresh token, luôn luôn.

localStorage có thể truy cập được bởi bất kỳ JavaScript nào chạy trên trang của bạn. Nếu bạn có một lỗ hổng XSS duy nhất — và ở quy mô lớn, bạn sẽ có — kẻ tấn công có thể đọc token và đánh cắp nó. Game over.

httpOnly cookie không thể truy cập được bởi JavaScript. Chấm hết. Một lỗ hổng XSS vẫn có thể thực hiện request thay mặt người dùng (vì cookie được gửi tự động), nhưng kẻ tấn công không thể đánh cắp bản thân token. Đó là một sự khác biệt có ý nghĩa.

typescript
// Thiết lập refresh token cookie an toàn
function setRefreshTokenCookie(res: Response, token: string): void {
  res.cookie("refresh_token", token, {
    httpOnly: true,     // Không thể truy cập qua JavaScript
    secure: true,       // Chỉ HTTPS
    sameSite: "strict", // Không có request cross-site
    maxAge: 30 * 24 * 60 * 60 * 1000, // 30 ngày
    path: "/api/auth",  // Chỉ gửi đến các endpoint auth
  });
}

path: "/api/auth" là một chi tiết mà hầu hết mọi người bỏ lỡ. Mặc định, cookie được gửi đến mọi endpoint trên domain của bạn. Refresh token của bạn không cần đi đến /api/users hay /api/products. Giới hạn path, giảm bề mặt tấn công.

Đối với access token, tôi giữ chúng trong bộ nhớ (một biến JavaScript). Không phải localStorage, không phải sessionStorage, không phải cookie. Trong bộ nhớ. Chúng có thời gian sống ngắn (15 phút), và khi trang refresh, client âm thầm gọi endpoint refresh để lấy token mới. Đúng, điều này có nghĩa là thêm một request khi tải trang. Nó xứng đáng.

Validation Đầu Vào: Đừng Bao Giờ Tin Client#

Client không phải là bạn của bạn. Client là một người lạ bước vào nhà bạn và nói "Tôi được phép ở đây." Bạn vẫn kiểm tra giấy tờ của họ.

Mọi dữ liệu đến từ bên ngoài server của bạn — request body, query parameters, URL params, headers — đều là đầu vào không đáng tin cậy. Không quan trọng form React của bạn có validation. Ai đó sẽ bypass nó bằng curl.

Zod cho Validation Type-Safe#

Zod là điều tuyệt vời nhất xảy ra với validation đầu vào Node.js. Nó cho bạn validation runtime với TypeScript types miễn phí:

typescript
import { z } from "zod";
 
const CreateUserSchema = z.object({
  email: z
    .string()
    .email("Invalid email format")
    .max(254, "Email too long")
    .transform((e) => e.toLowerCase().trim()),
 
  password: z
    .string()
    .min(12, "Password must be at least 12 characters")
    .max(128, "Password too long") // Ngăn DoS bcrypt
    .regex(
      /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
      "Password must contain uppercase, lowercase, and a number"
    ),
 
  name: z
    .string()
    .min(1, "Name is required")
    .max(100, "Name too long")
    .regex(/^[\p{L}\p{M}\s'-]+$/u, "Name contains invalid characters"),
 
  role: z.enum(["user", "editor"]).default("user"),
  // Lưu ý: "admin" cố tình không phải là tùy chọn ở đây.
  // Gán role admin đi qua một endpoint riêng biệt, đặc quyền.
});
 
type CreateUserInput = z.infer<typeof CreateUserSchema>;
 
// Sử dụng trong Express handler
app.post("/api/users", async (req, res) => {
  const result = CreateUserSchema.safeParse(req.body);
 
  if (!result.success) {
    return res.status(400).json({
      error: "Validation failed",
      details: result.error.issues.map((issue) => ({
        field: issue.path.join("."),
        message: issue.message,
      })),
    });
  }
 
  // result.data được typed đầy đủ là CreateUserInput
  const user = await createUser(result.data);
  return res.status(201).json({ id: user.id, email: user.email });
});

Một vài chi tiết liên quan đến bảo mật:

  • max(128) cho password — bcrypt có giới hạn đầu vào 72 byte, và một số implementation cắt ngắn âm thầm. Nhưng quan trọng hơn, nếu bạn cho phép password 10MB, bcrypt sẽ mất thời gian đáng kể để hash nó. Đó là vector DoS.
  • max(254) cho email — RFC 5321 giới hạn địa chỉ email ở 254 ký tự. Bất cứ thứ gì dài hơn không phải email hợp lệ.
  • Enum cho role, không có admin — Mass assignment là một trong những lỗ hổng API cổ xưa nhất. Nếu bạn chấp nhận role từ request body mà không validate, ai đó sẽ gửi "role": "admin" và hy vọng điều tốt nhất.

SQL Injection Chưa Được Giải Quyết#

"Chỉ cần dùng ORM" không bảo vệ bạn nếu bạn viết raw query cho hiệu năng. Và cuối cùng ai cũng viết raw query cho hiệu năng.

typescript
// DỄ BỊ TẤN CÔNG — nối chuỗi
const query = `SELECT * FROM users WHERE email = '${email}'`;
 
// AN TOÀN — parameterized query
const query = `SELECT * FROM users WHERE email = $1`;
const result = await pool.query(query, [email]);

Với Prisma, bạn hầu như an toàn — nhưng $queryRaw vẫn có thể cắn bạn:

typescript
// DỄ BỊ TẤN CÔNG — template literal trong $queryRaw
const users = await prisma.$queryRaw`
  SELECT * FROM users WHERE name LIKE '%${searchTerm}%'
`;
 
// AN TOÀN — sử dụng Prisma.sql cho parameterization
import { Prisma } from "@prisma/client";
 
const users = await prisma.$queryRaw(
  Prisma.sql`SELECT * FROM users WHERE name LIKE ${`%${searchTerm}%`}`
);

NoSQL Injection#

MongoDB không sử dụng SQL, nhưng nó không miễn nhiễm với injection. Nếu bạn truyền đầu vào người dùng chưa được sanitize như một query object, mọi thứ sẽ sai:

typescript
// DỄ BỊ TẤN CÔNG — nếu req.body.username là { "$gt": "" }
// điều này trả về user đầu tiên trong collection
const user = await db.collection("users").findOne({
  username: req.body.username,
});
 
// AN TOÀN — ép kiểu rõ ràng thành string
const user = await db.collection("users").findOne({
  username: String(req.body.username),
});
 
// TỐT HƠN — validate với Zod trước
const LoginSchema = z.object({
  username: z.string().min(1).max(50),
  password: z.string().min(1).max(128),
});

Cách sửa rất đơn giản: validate kiểu đầu vào trước khi chúng đến database driver. Nếu username phải là string, hãy assert rằng nó là string.

Path Traversal#

Nếu API của bạn phục vụ file hoặc đọc từ một đường dẫn bao gồm đầu vào người dùng, path traversal sẽ phá hủy tuần của bạn:

typescript
import path from "path";
import { access, constants } from "fs/promises";
 
const ALLOWED_DIR = "/app/uploads";
 
async function resolveUserFilePath(userInput: string): Promise<string> {
  // Normalize và resolve thành đường dẫn tuyệt đối
  const resolved = path.resolve(ALLOWED_DIR, userInput);
 
  // Quan trọng: xác minh đường dẫn resolved vẫn nằm trong thư mục cho phép
  if (!resolved.startsWith(ALLOWED_DIR + path.sep)) {
    throw new ApiError(403, "Access denied");
  }
 
  // Xác minh file thực sự tồn tại
  await access(resolved, constants.R_OK);
 
  return resolved;
}
 
// Không có kiểm tra này:
// GET /api/files?name=../../../etc/passwd
// sẽ resolve thành /etc/passwd

Pattern path.resolve + startsWith là cách tiếp cận đúng. Đừng cố gắng loại bỏ ../ thủ công — có quá nhiều thủ thuật encoding (..%2F, ..%252F, ....//) sẽ bypass regex của bạn.

Rate Limiting#

Không có rate limiting, API của bạn là buffet thoải mái ăn cho bot. Tấn công brute force, credential stuffing, cạn kiệt tài nguyên — rate limiting là tuyến phòng thủ đầu tiên chống lại tất cả.

Token Bucket vs Sliding Window#

Token bucket: Bạn có một thùng chứa N token. Mỗi request tốn một token. Token được nạp lại với tốc độ cố định. Nếu thùng trống, request bị từ chối. Điều này cho phép burst — nếu thùng đầy, bạn có thể thực hiện N request ngay lập tức.

Sliding window: Đếm request trong một cửa sổ thời gian di động. Dễ dự đoán hơn, khó burst qua hơn.

Tôi sử dụng sliding window cho hầu hết mọi thứ vì hành vi dễ suy luận và giải thích cho team hơn:

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();
 
  // Xóa các entry ngoài cửa sổ
  multi.zremrangebyscore(key, 0, windowStart);
 
  // Đếm entry trong cửa sổ
  multi.zcard(key);
 
  // Thêm request hiện tại (sẽ xóa nếu vượt giới hạn)
  multi.zadd(key, now.toString(), `${now}:${Math.random()}`);
 
  // Đặt thời hạn cho key
  multi.pexpire(key, windowMs);
 
  const results = await multi.exec();
 
  if (!results) {
    throw new Error("Redis transaction failed");
  }
 
  const count = results[1][1] as number;
 
  if (count >= limit) {
    // Vượt giới hạn — xóa entry vừa thêm
    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 Phân Lớp#

Một rate limit toàn cục là không đủ. Các endpoint khác nhau có các profile rủi ro khác nhau:

typescript
interface RateLimitConfig {
  window: number;
  max: number;
}
 
const RATE_LIMITS: Record<string, RateLimitConfig> = {
  // Endpoint auth — giới hạn chặt, mục tiêu 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 },
 
  // Đọc dữ liệu — rộng rãi hơn
  "GET:/api/users": { window: 60 * 1000, max: 100 },
  "GET:/api/products": { window: 60 * 1000, max: 200 },
 
  // Ghi dữ liệu — vừa phải
  "POST:/api/posts": { window: 60 * 1000, max: 10 },
  "PUT:/api/posts": { window: 60 * 1000, max: 30 },
 
  // Fallback toàn cục
  "*": { 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}`;
}

Lưu ý: người dùng đã xác thực được rate limit theo user ID, không phải IP. Điều này quan trọng vì nhiều người dùng hợp pháp chia sẻ IP (mạng doanh nghiệp, VPN, nhà mạng di động). Nếu bạn chỉ giới hạn theo IP, bạn sẽ chặn cả văn phòng.

Header Rate Limit#

Luôn cho client biết chuyện gì đang xảy ra:

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: "Too many requests",
      retryAfter: Math.ceil((result.resetAt - Date.now()) / 1000),
    });
  }
}

Cấu Hình CORS#

CORS có lẽ là cơ chế bảo mật bị hiểu sai nhiều nhất trong phát triển web. Một nửa câu trả lời Stack Overflow về CORS là "chỉ cần đặt Access-Control-Allow-Origin: * là xong." Điều đó đúng về mặt kỹ thuật. Nó cũng là cách bạn mở API cho mọi trang web độc hại trên internet.

CORS Thực Sự Làm Gì (và Không Làm Gì)#

CORS là một cơ chế trình duyệt. Nó cho trình duyệt biết JavaScript từ Origin A có được phép đọc response từ Origin B hay không. Chỉ vậy thôi.

Những gì CORS không làm:

  • Nó không bảo vệ API của bạn khỏi curl, Postman, hoặc các request server-to-server
  • Nó không xác thực request
  • Nó không mã hóa bất cứ thứ gì
  • Nó không ngăn CSRF tự nó (mặc dù nó giúp khi kết hợp với các cơ chế khác)

Những gì CORS làm:

  • Nó ngăn malicious-website.com thực hiện fetch request đến your-api.com và đọc response trong trình duyệt của người dùng
  • Nó ngăn JavaScript của kẻ tấn công đánh cắp dữ liệu thông qua session đã xác thực của nạn nhân

Bẫy Wildcard#

typescript
// NGUY HIỂM — cho phép bất kỳ website nào đọc response API của bạn
app.use(cors({ origin: "*" }));
 
// CŨNG NGUY HIỂM — đây là cách tiếp cận "động" phổ biến chỉ là * với thêm bước
app.use(
  cors({
    origin: (origin, callback) => {
      callback(null, true); // Cho phép mọi thứ
    },
  })
);

Vấn đề với * là nó làm cho response API của bạn có thể đọc được bởi bất kỳ JavaScript nào trên bất kỳ trang nào. Nếu API của bạn trả về dữ liệu người dùng và người dùng được xác thực qua cookie, bất kỳ website nào người dùng truy cập đều có thể đọc dữ liệu đó.

Tệ hơn nữa: Access-Control-Allow-Origin: * không thể kết hợp với credentials: true. Vì vậy nếu bạn cần cookie (cho auth), bạn thực sự không thể dùng wildcard. Nhưng tôi đã thấy người ta cố gắng lách bằng cách phản chiếu Origin header — tương đương với * có credentials, tệ nhất của cả hai thế giới.

Cấu Hình Đúng#

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) => {
      // Cho phép request không có origin (ứng dụng mobile, curl, server-to-server)
      if (!origin) {
        return callback(null, true);
      }
 
      if (ALLOWED_ORIGINS.has(origin)) {
        return callback(null, origin);
      }
 
      callback(new Error(`Origin ${origin} not allowed by CORS`));
    },
    credentials: true, // Cho phép cookie
    methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
    allowedHeaders: ["Content-Type", "Authorization"],
    exposedHeaders: ["X-RateLimit-Limit", "X-RateLimit-Remaining"],
    maxAge: 86400, // Cache preflight 24 giờ
  })
);

Các quyết định chính:

  • Tập origin rõ ràng, không phải regex. Regex rất phức tạp — yourapp.com có thể match evilyourapp.com nếu regex của bạn không được neo đúng cách.
  • credentials: true vì chúng ta sử dụng httpOnly cookie cho refresh token.
  • maxAge: 86400 — Preflight request (OPTIONS) thêm độ trễ. Nói cho trình duyệt cache kết quả CORS trong 24 giờ giảm các round trip không cần thiết.
  • exposedHeaders — Mặc định, trình duyệt chỉ expose một số ít response header "đơn giản" cho JavaScript. Nếu bạn muốn client đọc header rate limit của bạn, bạn phải expose chúng rõ ràng.

Preflight Request#

Khi một request không "đơn giản" (nó sử dụng header không chuẩn, method không chuẩn, hoặc content type không chuẩn), trình duyệt gửi một request OPTIONS trước để xin phép. Đây là preflight.

Nếu cấu hình CORS của bạn không xử lý OPTIONS, preflight request sẽ thất bại, và request thực sự sẽ không bao giờ được gửi. Hầu hết các thư viện CORS xử lý điều này tự động, nhưng nếu bạn đang sử dụng framework không có, bạn cần xử lý nó:

typescript
// Xử lý preflight thủ công (hầu hết framework làm điều này cho bạn)
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 Bảo Mật#

Header bảo mật là cải tiến bảo mật rẻ nhất bạn có thể thực hiện. Chúng là response header nói cho trình duyệt bật các tính năng bảo mật. Hầu hết chỉ cần một dòng cấu hình, và chúng bảo vệ chống lại toàn bộ lớp tấn công.

Các Header Quan Trọng#

typescript
import helmet from "helmet";
 
// Một dòng. Đây là chiến thắng bảo mật nhanh nhất trong bất kỳ ứng dụng Express nào.
app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'"],
        styleSrc: ["'self'", "'unsafe-inline'"], // Cần cho nhiều giải pháp 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 năm
      includeSubDomains: true,
      preload: true,
    },
    referrerPolicy: { policy: "strict-origin-when-cross-origin" },
  })
);

Mỗi header làm gì:

Content-Security-Policy (CSP) — Header bảo mật mạnh mẽ nhất. Nó nói cho trình duyệt chính xác nguồn nào được phép cho script, style, hình ảnh, font, v.v. Nếu kẻ tấn công inject một thẻ <script> tải từ evil.com, CSP chặn nó. Đây là phòng thủ hiệu quả nhất chống XSS.

Strict-Transport-Security (HSTS) — Nói cho trình duyệt luôn sử dụng HTTPS, ngay cả khi người dùng gõ http://. Directive preload cho phép bạn submit domain vào danh sách HSTS tích hợp của trình duyệt, nên ngay cả request đầu tiên cũng buộc sử dụng HTTPS.

X-Frame-Options — Ngăn trang web của bạn bị nhúng trong iframe. Điều này chặn tấn công clickjacking nơi kẻ tấn công phủ lên trang của bạn với các phần tử vô hình. Helmet đặt mặc định là SAMEORIGIN. Thay thế hiện đại là frame-ancestors trong CSP.

X-Content-Type-Options: nosniff — Ngăn trình duyệt đoán (sniffing) kiểu MIME của response. Không có điều này, nếu bạn phục vụ file với Content-Type sai, trình duyệt có thể thực thi nó như JavaScript.

Referrer-Policy — Kiểm soát bao nhiêu thông tin URL được gửi trong header Referer. strict-origin-when-cross-origin gửi URL đầy đủ cho request cùng origin nhưng chỉ origin cho request cross-origin. Điều này ngăn rò rỉ tham số URL nhạy cảm cho bên thứ ba.

Kiểm Tra Header Của Bạn#

Sau khi triển khai, kiểm tra điểm của bạn tại securityheaders.com. Nhắm đến xếp hạng A+. Chỉ mất khoảng năm phút cấu hình để đạt được.

Bạn cũng có thể xác minh header theo chương trình:

typescript
import { describe, it, expect } from "vitest";
 
describe("Security headers", () => {
  it("should include all required security headers", 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 xóa cái này
  });
});

Kiểm tra x-powered-by tinh tế nhưng quan trọng. Express đặt X-Powered-By: Express mặc định, nói cho kẻ tấn công chính xác framework bạn đang sử dụng. Helmet xóa nó.

Quản Lý Secrets#

Điều này lẽ ra phải hiển nhiên, nhưng tôi vẫn thấy nó trong pull request: API key, mật khẩu database, và JWT secret được hardcode trong file mã nguồn. Hoặc commit trong file .env không nằm trong .gitignore. Một khi nó ở trong git history, nó ở đó mãi mãi, ngay cả khi bạn xóa file ở commit tiếp theo.

Các Quy Tắc#

  1. Không bao giờ commit secret vào git. Không trong code, không trong .env, không trong file config, không trong file Docker Compose, không trong comment "chỉ để test".

  2. Sử dụng .env.example làm mẫu. Nó ghi lại những biến môi trường nào cần thiết, mà không chứa giá trị thực:

bash
# .env.example — commit cái này
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 — KHÔNG BAO GIỜ commit cái này
# Được liệt kê trong .gitignore
  1. Validate biến môi trường khi khởi động. Đừng đợi đến khi request đến endpoint cần database URL. Fail nhanh:
typescript
import { z } from "zod";
 
const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32, "JWT secret must be at least 32 characters"),
  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("Invalid environment variables:");
    console.error(result.error.format());
    process.exit(1); // Không khởi động với config sai
  }
 
  return result.data;
}
 
export const env = validateEnv();
  1. Sử dụng secret manager trong production. Biến môi trường hoạt động cho setup đơn giản, nhưng chúng có hạn chế: chúng hiển thị trong process listing, chúng tồn tại trong bộ nhớ, và chúng có thể rò rỉ qua error log.

Cho hệ thống production, sử dụng secret manager chuyên dụng:

  • AWS Secrets Manager hoặc SSM Parameter Store
  • HashiCorp Vault
  • Google Secret Manager
  • Azure Key Vault
  • Doppler (nếu bạn muốn thứ hoạt động trên mọi cloud)

Pattern giống nhau bất kể bạn dùng cái nào: ứng dụng lấy secret khi khởi động từ secret manager, không phải từ biến môi trường.

  1. Xoay vòng secret thường xuyên. Nếu bạn đã sử dụng cùng JWT secret trong hai năm, đã đến lúc xoay vòng. Triển khai xoay vòng key: hỗ trợ nhiều signing key hợp lệ đồng thời, ký token mới bằng key mới, xác minh với cả key cũ và mới, và loại bỏ key cũ sau khi tất cả token hiện tại hết hạn.
typescript
interface SigningKey {
  id: string;
  secret: string;
  createdAt: Date;
  active: boolean; // Chỉ key active ký token mới
}
 
async function verifyWithRotation(token: string): Promise<TokenPayload> {
  const keys = await getSigningKeys(); // Trả về tất cả key hợp lệ
 
  for (const key of keys) {
    try {
      return jwt.verify(token, key.secret, {
        algorithms: ["HS256"],
      }) as TokenPayload;
    } catch {
      continue; // Thử key tiếp theo
    }
  }
 
  throw new ApiError(401, "Invalid token");
}
 
function signToken(payload: Omit<TokenPayload, "iat" | "exp">): string {
  const activeKey = getActiveSigningKey();
  return jwt.sign(payload, activeKey.secret, {
    algorithm: "HS256",
    expiresIn: "15m",
    keyid: activeKey.id, // Bao gồm key ID trong header
  });
}

OWASP API Security Top 10#

OWASP API Security Top 10 là danh sách tiêu chuẩn ngành về lỗ hổng API. Nó được cập nhật định kỳ, và mỗi mục trong danh sách là thứ tôi đã thấy trong các codebase thực. Hãy để tôi đi qua từng cái.

API1: Broken Object Level Authorization (BOLA)#

Lỗ hổng API phổ biến nhất. Người dùng đã được xác thực, nhưng API không kiểm tra xem họ có quyền truy cập vào đối tượng cụ thể mà họ đang yêu cầu hay không.

typescript
// DỄ BỊ TẤN CÔNG — bất kỳ người dùng đã xác thực nào cũng truy cập được dữ liệu của bất kỳ user nào
app.get("/api/users/:id", authenticate, async (req, res) => {
  const user = await db.users.findById(req.params.id);
  return res.json(user);
});
 
// ĐÃ SỬA — xác minh người dùng đang truy cập dữ liệu của chính họ (hoặc là 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: "Access denied" });
  }
  const user = await db.users.findById(req.params.id);
  return res.json(user);
});

Phiên bản dễ bị tấn công có mặt ở khắp nơi. Nó vượt qua mọi kiểm tra auth — người dùng có token hợp lệ — nhưng nó không xác minh họ có được phép truy cập tài nguyên cụ thể này hay không. Thay đổi ID trong URL, và bạn lấy được dữ liệu của người khác.

API2: Broken Authentication#

Cơ chế đăng nhập yếu, thiếu MFA, token không bao giờ hết hạn, mật khẩu lưu dưới dạng plaintext. Điều này bao phủ lớp xác thực chính nó.

Cách sửa là mọi thứ chúng ta đã thảo luận trong phần xác thực: yêu cầu mật khẩu mạnh, bcrypt với đủ vòng lặp, access token ngắn hạn, xoay vòng refresh token, khóa tài khoản sau các lần thử thất bại.

typescript
const MAX_LOGIN_ATTEMPTS = 5;
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 phút
 
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,
      `Account locked. Try again in ${Math.ceil(ttl / 60000)} minutes.`
    );
  }
 
  const user = await db.users.findByEmail(email);
 
  if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
    // Tăng số lần thử thất bại
    await redis.multi()
      .incr(lockoutKey)
      .pexpire(lockoutKey, LOCKOUT_DURATION)
      .exec();
 
    // Cùng thông báo lỗi cho cả hai trường hợp — không tiết lộ email có tồn tại hay không
    throw new ApiError(401, "Invalid email or password");
  }
 
  // Reset số lần thử thất bại khi đăng nhập thành công
  await redis.del(lockoutKey);
 
  return generateTokens(user);
}

Comment về "cùng thông báo lỗi" rất quan trọng. Nếu API của bạn trả về "user not found" cho email không hợp lệ và "wrong password" cho email hợp lệ với sai mật khẩu, bạn đang nói cho kẻ tấn công biết email nào tồn tại trong hệ thống của bạn.

API3: Broken Object Property Level Authorization#

Trả về nhiều dữ liệu hơn cần thiết, hoặc cho phép người dùng sửa đổi thuộc tính mà họ không nên.

typescript
// DỄ BỊ TẤN CÔNG — trả về toàn bộ đối tượng user, bao gồm các trường nội bộ
app.get("/api/users/:id", authenticate, authorize, async (req, res) => {
  const user = await db.users.findById(req.params.id);
  return res.json(user);
  // Response bao gồm: passwordHash, internalNotes, billingId, ...
});
 
// ĐÃ SỬA — danh sách cho phép rõ ràng các trường trả về
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,
  });
});

Không bao giờ trả về toàn bộ đối tượng database. Luôn chọn các trường bạn muốn expose. Điều này cũng áp dụng cho write — đừng spread toàn bộ request body vào update query:

typescript
// DỄ BỊ TẤN CÔNG — mass assignment
app.put("/api/users/:id", authenticate, async (req, res) => {
  await db.users.update(req.params.id, req.body);
  // Kẻ tấn công gửi: { "role": "admin", "verified": true }
});
 
// ĐÃ SỬA — chọn các trường được phép
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: Tiêu Thụ Tài Nguyên Không Giới Hạn#

API của bạn là một tài nguyên. CPU, bộ nhớ, băng thông, kết nối database — tất cả đều hữu hạn. Không có giới hạn, một client duy nhất có thể cạn kiệt tất cả.

Điều này vượt ra ngoài rate limiting. Nó bao gồm:

typescript
// Giới hạn kích thước request body
app.use(express.json({ limit: "1mb" }));
 
// Giới hạn độ phức tạp 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),
});
 
// Giới hạn kích thước 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("Invalid file type"));
    }
  },
});
 
// Timeout cho request chạy lâu
app.use((req, res, next) => {
  res.setTimeout(30000, () => {
    res.status(408).json({ error: "Request timeout" });
  });
  next();
});

API5: Broken Function Level Authorization#

Khác với BOLA. Đây là về việc truy cập các function (endpoint) mà bạn không nên có quyền truy cập, không phải đối tượng. Ví dụ kinh điển: người dùng thường phát hiện endpoint admin.

typescript
// Middleware kiểm tra quyền truy cập dựa trên role
function requireRole(...allowedRoles: string[]) {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!req.user) {
      return res.status(401).json({ error: "Not authenticated" });
    }
 
    if (!allowedRoles.includes(req.user.role)) {
      // Log lần thử — đây có thể là tấn công
      logger.warn("Unauthorized access attempt", {
        userId: req.user.id,
        role: req.user.role,
        requiredRoles: allowedRoles,
        endpoint: `${req.method} ${req.path}`,
        ip: req.ip,
      });
 
      return res.status(403).json({ error: "Insufficient permissions" });
    }
 
    next();
  };
}
 
// Áp dụng vào các 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);

Đừng dựa vào việc ẩn endpoint. "Bảo mật thông qua che giấu" không phải là bảo mật. Ngay cả khi URL admin panel không được liên kết ở đâu, ai đó sẽ tìm thấy /api/admin/users bằng fuzzing.

API6: Truy Cập Không Giới Hạn đến Luồng Nghiệp Vụ Nhạy Cảm#

Lạm dụng tự động các chức năng nghiệp vụ hợp pháp. Hãy nghĩ đến: bot mua hàng giới hạn số lượng, tạo tài khoản tự động để spam, scraping giá sản phẩm.

Các biện pháp giảm thiểu phụ thuộc vào ngữ cảnh: CAPTCHA, device fingerprinting, phân tích hành vi, step-up authentication cho các thao tác nhạy cảm. Không có đoạn code nào phù hợp cho mọi trường hợp.

API7: Server Side Request Forgery (SSRF)#

Nếu API của bạn fetch URL do người dùng cung cấp (webhook, URL ảnh đại diện, link preview), kẻ tấn công có thể khiến server của bạn request tài nguyên nội bộ:

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, "Invalid URL");
  }
 
  // Chỉ cho phép HTTP(S)
  if (!["http:", "https:"].includes(parsed.protocol)) {
    throw new ApiError(400, "Only HTTP(S) URLs are allowed");
  }
 
  // Resolve hostname và kiểm tra xem có phải IP private không
  const addresses = await dns.resolve4(parsed.hostname);
 
  for (const addr of addresses) {
    if (isPrivateIP(addr)) {
      throw new ApiError(400, "Internal addresses are not allowed");
    }
  }
 
  // Bây giờ fetch với timeout và giới hạn kích thước
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 5000);
 
  try {
    const response = await fetch(userProvidedUrl, {
      signal: controller.signal,
      redirect: "error", // Không theo redirect (chúng có thể redirect đến IP nội bộ)
    });
 
    return response;
  } finally {
    clearTimeout(timeout);
  }
}

Chi tiết quan trọng: resolve DNS trước và kiểm tra IP trước khi thực hiện request. Chặn redirect — kẻ tấn công có thể host URL redirect đến http://169.254.169.254/ (AWS metadata endpoint) để bypass kiểm tra URL-level của bạn.

API8: Cấu Hình Bảo Mật Sai#

Credential mặc định không thay đổi, các HTTP method không cần thiết được bật, thông báo lỗi chi tiết trong production, directory listing được bật, CORS bị cấu hình sai. Đây là loại "bạn quên khóa cửa".

typescript
// Không để lộ stack trace trong production
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  logger.error("Unhandled error", {
    error: err.message,
    stack: err.stack,
    path: req.path,
    method: req.method,
  });
 
  if (process.env.NODE_ENV === "production") {
    // Thông báo lỗi chung — không tiết lộ nội bộ
    res.status(500).json({
      error: "Internal server error",
      requestId: req.id, // Bao gồm request ID để debug
    });
  } else {
    // Trong development, hiển thị lỗi đầy đủ
    res.status(500).json({
      error: err.message,
      stack: err.stack,
    });
  }
});
 
// Tắt các HTTP method không cần thiết
app.use((req, res, next) => {
  const allowed = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"];
  if (!allowed.includes(req.method)) {
    return res.status(405).json({ error: "Method not allowed" });
  }
  next();
});

API9: Quản Lý Inventory Không Đúng#

Bạn triển khai v2 của API nhưng quên tắt v1. Hoặc có endpoint /debug/ hữu ích trong development và vẫn chạy trong production. Hoặc server staging có thể truy cập công khai với dữ liệu production.

Đây không phải là sửa code — đây là kỷ luật vận hành. Duy trì danh sách tất cả API endpoint, tất cả phiên bản đã triển khai, và tất cả môi trường. Sử dụng quét tự động để tìm service bị expose. Tắt những gì bạn không cần.

API10: Tiêu Thụ API Không An Toàn#

API của bạn tiêu thụ API bên thứ ba. Bạn có validate response của chúng không? Chuyện gì xảy ra nếu payload webhook từ Stripe thực ra là từ kẻ tấn công?

typescript
import crypto from "crypto";
 
// Xác minh chữ ký 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;
 
  // Từ chối timestamp cũ (ngăn tấn công replay)
  const age = Math.abs(Date.now() / 1000 - parseInt(timestamp));
  if (age > 300) return false; // Dung sai 5 phút
 
  const signedPayload = `${timestamp}.${payload}`;
  const computedSig = crypto
    .createHmac("sha256", secret)
    .update(signedPayload)
    .digest("hex");
 
  return crypto.timingSafeEqual(
    Buffer.from(computedSig),
    Buffer.from(expectedSig)
  );
}

Luôn xác minh chữ ký trên webhook. Luôn validate cấu trúc response API bên thứ ba. Luôn đặt timeout cho outgoing request. Không bao giờ tin dữ liệu chỉ vì nó đến từ "đối tác đáng tin cậy."

Audit Logging#

Khi điều gì đó sai — và nó sẽ sai — audit log là cách bạn tìm ra chuyện gì đã xảy ra. Nhưng logging là con dao hai lưỡi. Log quá ít và bạn mù. Log quá nhiều và bạn tạo ra trách nhiệm pháp lý về quyền riêng tư.

Nên Log Gì#

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>; // Ngữ cảnh bổ sung
  requestId: string;        // Để liên kết với application log
}
 
async function auditLog(entry: AuditLogEntry): Promise<void> {
  // Ghi vào data store riêng biệt, chỉ cho phép thêm
  // Đây KHÔNG nên là cùng database ứng dụng của bạn sử dụng
  await auditDb.collection("audit_logs").insertOne({
    ...entry,
    timestamp: new Date().toISOString(),
  });
 
  // Cho các hành động quan trọng, cũng ghi vào log bên ngoài bất biến
  if (isCriticalAction(entry.action)) {
    await externalLogger.send(entry);
  }
}

Log các sự kiện này:

  • Xác thực: đăng nhập, đăng xuất, lần thử thất bại, refresh token
  • Authorization: sự kiện bị từ chối truy cập (đây thường là chỉ báo tấn công)
  • Sửa đổi dữ liệu: tạo, cập nhật, xóa — ai đã thay đổi gì, khi nào
  • Hành động admin: thay đổi role, quản lý user, thay đổi cấu hình
  • Sự kiện bảo mật: kích hoạt rate limit, vi phạm CORS, request không hợp lệ

Những Gì KHÔNG Nên Log#

Không bao giờ log:

  • Mật khẩu (ngay cả đã hash — hash là credential)
  • Số thẻ tín dụng đầy đủ (chỉ log 4 số cuối)
  • Số An sinh Xã hội hoặc giấy tờ chính phủ
  • API key hoặc token (log tối đa prefix: sk_live_...abc)
  • Thông tin sức khỏe cá nhân
  • Request body đầy đủ có thể chứa 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] = "[REDACTED]";
    } else if (typeof value === "object" && value !== null) {
      sanitized[key] = sanitizeForLogging(value as Record<string, unknown>);
    } else {
      sanitized[key] = value;
    }
  }
 
  return sanitized;
}

Log Chống Giả Mạo#

Nếu kẻ tấn công truy cập được vào hệ thống của bạn, một trong những việc đầu tiên họ làm là sửa đổi log để che dấu vết. Log chống giả mạo làm cho điều này có thể phát hiện được:

typescript
import crypto from "crypto";
 
let previousHash = "GENESIS"; // Hash ban đầu trong chuỗi
 
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 };
}
 
// Để xác minh tính toàn vẹn chuỗi:
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; // Chuỗi bị đứt — log đã bị giả mạo
    }
 
    expectedPreviousHash = hash;
  }
 
  return true;
}

Đây là cùng khái niệm với blockchain — hash của mỗi mục log phụ thuộc vào mục trước đó. Nếu ai đó sửa đổi hoặc xóa một mục, chuỗi bị đứt.

Bảo Mật Dependency#

Code của bạn có thể an toàn. Nhưng còn 847 package npm trong node_modules của bạn thì sao? Vấn đề supply chain là thực tế, và nó đã trở nên tệ hơn qua các năm.

npm audit Là Mức Tối Thiểu#

bash
# Chạy trong CI, fail build khi có lỗ hổng high/critical
npm audit --audit-level=high
 
# Sửa những gì có thể tự động sửa
npm audit fix
 
# Xem bạn thực sự đang kéo vào gì
npm ls --all

Nhưng npm audit có hạn chế. Nó chỉ kiểm tra cơ sở dữ liệu advisory npm, và xếp hạng mức độ nghiêm trọng không phải lúc nào cũng chính xác. Thêm công cụ bổ sung:

Quét Dependency Tự Động#

yaml
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"
    open-pull-requests-limit: 10
    reviewers:
      - "your-team"
    labels:
      - "dependencies"
    # Nhóm cập nhật minor và patch để giảm nhiễu PR
    groups:
      production-dependencies:
        patterns:
          - "*"
        update-types:
          - "minor"
          - "patch"

Lockfile Là Công Cụ Bảo Mật#

Luôn commit package-lock.json (hoặc pnpm-lock.yaml, hoặc yarn.lock) của bạn. Lockfile ghim phiên bản chính xác của mọi dependency, bao gồm cả transitive. Không có nó, npm install có thể kéo phiên bản khác với những gì bạn đã test — và phiên bản khác đó có thể bị compromise.

bash
# Trong CI, sử dụng ci thay vì install — nó tuân thủ lockfile nghiêm ngặt
npm ci

npm ci thất bại nếu lockfile không khớp package.json, thay vì âm thầm cập nhật nó. Điều này bắt các trường hợp ai đó sửa đổi package.json nhưng quên cập nhật lockfile.

Đánh Giá Trước Khi Cài Đặt#

Trước khi thêm dependency, hãy hỏi:

  1. Tôi thực sự cần cái này không? Tôi có thể viết trong 20 dòng thay vì thêm package không?
  2. Nó có bao nhiêu lượt tải? Số lượt tải thấp không nhất thiết là xấu, nhưng nghĩa là ít mắt hơn xem xét code.
  3. Lần cập nhật cuối khi nào? Package không được cập nhật trong 3 năm có thể có lỗ hổng chưa được vá.
  4. Nó kéo vào bao nhiêu dependency? is-odd phụ thuộc vào is-number phụ thuộc vào kind-of. Đó là ba package để làm thứ một dòng code có thể làm.
  5. Ai duy trì nó? Một maintainer duy nhất là một điểm compromise duy nhất.
typescript
// Bạn không cần package cho điều này:
const isEven = (n: number): boolean => n % 2 === 0;
 
// Hoặc điều này:
const leftPad = (str: string, len: number, char = " "): string =>
  str.padStart(len, char);
 
// Hoặc điều này:
const isNil = (value: unknown): value is null | undefined =>
  value === null || value === undefined;

Danh Sách Kiểm Tra Trước Triển Khai#

Đây là danh sách kiểm tra thực tế tôi sử dụng trước mỗi triển khai production. Nó không toàn diện — bảo mật không bao giờ "xong" — nhưng nó bắt những sai lầm quan trọng nhất.

#Kiểm TraTiêu Chí ĐạtƯu Tiên
1AuthenticationJWT được xác minh với thuật toán, issuer và audience rõ ràng. Không có alg: none.Quan trọng
2Hết hạn tokenAccess token hết hạn trong 15 phút hoặc ít hơn. Refresh token xoay vòng khi sử dụng.Quan trọng
3Lưu trữ tokenRefresh token trong httpOnly secure cookie. Không có token trong localStorage.Quan trọng
4Authorization trên mọi endpointMỗi endpoint truy cập dữ liệu đều kiểm tra quyền cấp đối tượng. BOLA đã test.Quan trọng
5Validation đầu vàoTất cả đầu vào người dùng được validate với Zod hoặc tương đương. Không có raw req.body trong query.Quan trọng
6SQL/NoSQL injectionTất cả database query sử dụng parameterized query hoặc phương thức ORM. Không nối chuỗi.Quan trọng
7Rate limitingEndpoint auth: 5/15 phút. API chung: 60/phút. Header rate limit được trả về.Cao
8CORSDanh sách origin cho phép rõ ràng. Không wildcard với credentials. Preflight được cache.Cao
9Header bảo mậtCSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy đều có mặt.Cao
10Xử lý lỗiLỗi production trả về thông báo chung. Không stack trace, không SQL error bị expose.Cao
11SecretsKhông có secret trong code hoặc git history. .env trong .gitignore. Validate khi khởi động.Quan trọng
12Dependenciesnpm audit sạch (không high/critical). Lockfile được commit. npm ci trong CI.Cao
13Chỉ HTTPSHSTS được bật với preload. HTTP redirect sang HTTPS. Flag secure cookie được đặt.Quan trọng
14LoggingSự kiện auth, truy cập bị từ chối, và mutation dữ liệu được log. Không PII trong log.Trung bình
15Giới hạn kích thước requestBody parser giới hạn (mặc định 1MB). Upload file bị giới hạn. Pagination query được enforce.Trung bình
16Bảo vệ SSRFURL do người dùng cung cấp được validate. IP private bị chặn. Redirect bị tắt hoặc validate.Trung bình
17Khóa tài khoảnSố lần đăng nhập thất bại kích hoạt khóa sau 5 lần thử. Khóa được log.Cao
18Xác minh webhookTất cả webhook đến được xác minh bằng chữ ký. Bảo vệ replay qua timestamp.Cao
19Endpoint adminKiểm soát truy cập dựa trên role trên tất cả route admin. Các lần thử được log.Quan trọng
20Mass assignmentEndpoint update sử dụng Zod schema với trường được cho phép. Không spread raw body.Cao

Tôi giữ cái này như template GitHub issue. Trước khi tag release, ai đó trong team phải kiểm tra mọi hàng và ký xác nhận. Nó không hào nhoáng, nhưng nó hoạt động.

Thay Đổi Tư Duy#

Bảo mật không phải là tính năng bạn thêm vào cuối cùng. Nó không phải là sprint bạn làm một lần một năm. Nó là cách suy nghĩ về mọi dòng code bạn viết.

Khi bạn viết một endpoint, hãy nghĩ: "Nếu ai đó gửi dữ liệu tôi không mong đợi thì sao?" Khi bạn thêm parameter, hãy nghĩ: "Nếu ai đó thay đổi cái này thành ID của người khác thì sao?" Khi bạn thêm dependency, hãy nghĩ: "Chuyện gì xảy ra nếu package này bị compromise vào thứ Ba tuần sau?"

Bạn sẽ không bắt được mọi thứ. Không ai làm được. Nhưng chạy qua danh sách kiểm tra này — một cách có phương pháp, trước mỗi lần triển khai — bắt được những thứ quan trọng nhất. Những chiến thắng dễ dàng. Những lỗ hổng rõ ràng. Những sai lầm biến một ngày tồi tệ thành vi phạm dữ liệu.

Xây dựng thói quen. Chạy danh sách kiểm tra. Triển khai với tự tin.

Bài viết liên quan