Tôi từng nghĩ việc tự viết lớp xác thực (authentication layer) là một niềm tự hào. Nếu bạn hiểu về JWT và có thể băm (hash) mật khẩu, thì việc đó khó đến mức nào chứ? Tôi đã thiết lập một Node backend, cấp phát token và coi như xong. Mã nguồn hoạt động tốt. Các bài kiểm tra đều vượt qua. Sau đó, tôi bắt đầu đọc về cách các cuộc tấn công thực tế diễn ra, và mọi thứ sụp đổ. Mỗi chương về injection, enumeration và side-channel leaks đều khiến tôi quay lại gặp biên tập viên của mình với một cảm giác nặng nề. Hệ thống auth của tôi không chỉ có lỗ hổng; nó có bốn cánh cửa mở toang mà chính tôi đã lắp đặt. Đây là những gì tôi đã tìm thấy, và chính xác những gì tôi đã thay đổi.
SQL Injection thông qua việc nối chuỗi (String Concatenation)
Lỗi đầu tiên là chiêu trò cũ rích nhất. Tôi đã lấy dữ liệu đầu vào của người dùng và đưa trực tiếp vào các chuỗi SQL. Trong route đăng nhập, tôi lấy email từ request body và nối nó vào một truy vấn như SELECT * FROM users WHERE email = '${email}'. Tôi cảm thấy nó vô hại vì tôi kiểm soát frontend. Đó là một giả định nguy hiểm. Kẻ tấn công không cần frontend của bạn. Chỉ cần một POST body được tạo dựng tinh vi có thể biến việc kiểm tra đăng nhập đó thành một vụ rò rỉ dữ liệu hoặc xóa sạch cơ sở dữ liệu. Gửi một payload như ' OR '1'='1 hoặc tệ hơn, một stacked query để drop các bảng, và nếu chuỗi đó được thực thi thô, dữ liệu của bạn sẽ biến mất. Tôi đã không cho cơ sở dữ liệu bất kỳ cách nào để phân biệt giữa mã nguồn của tôi và dữ liệu của kẻ tấn công.
Cách khắc phục không phải là kiểm tra đầu vào nhiều hơn hay tự tay escape các chuỗi. Cách khắc phục thực sự là parameterized queries (truy vấn có tham số). Tôi đã chuyển sang dùng node-postgres và bắt đầu sử dụng các placeholder như $1. Truy vấn trở thành một template: SELECT * FROM users WHERE email = $1. Driver sẽ gửi SQL và các giá trị qua các kênh riêng biệt. Cơ sở dữ liệu sẽ xử lý đầu vào thuần túy là dữ liệu, bất kể nó chứa các ký tự nào. Thay đổi duy nhất này sẽ đóng toàn bộ lớp tấn công injection. Nó dễ đọc hơn, dễ bảo trì hơn và loại bỏ gánh nặng phải trở thành một "phù thủy regex" mỗi khi bạn viết một mệnh đề WHERE.
Liệt kê Email (Email Enumeration) thông qua thông báo lỗi
Sai lầm thứ hai của tôi trông giống như một trải nghiệm người dùng (UX) tốt. Khi người dùng nhập sai email, tôi trả về User not found. Khi họ nhập đúng email nhưng sai mật khẩu, tôi trả về Incorrect password. Nó có vẻ hữu ích. Nhưng nó cũng là một công cụ trinh sát cho kẻ tấn công. Các script enumeration có thể tấn công dồn dập vào endpoint đăng nhập của bạn với hàng ngàn địa chỉ email. Nếu body phản hồi hoặc mã trạng thái thay đổi tùy thuộc vào việc tài khoản có tồn tại hay không, script đó có thể xây dựng một danh sách người dùng đã được xác minh. Danh sách đó trở thành nền tảng cho credential stuffing, phishing có mục tiêu và các nỗ lực brute-force tiếp theo.
Tôi phải chấp nhận rằng đôi khi sự thân thiện với người dùng cần phải nhường chỗ cho bảo mật. Tôi đã thay đổi mọi đường dẫn đăng nhập thất bại để trả về cùng một chuỗi chính xác: Invalid credentials. Không gợi ý. Không có logic rẽ nhánh trong phản hồi lỗi. Cho dù email bị thiếu, mật khẩu sai hay tài khoản bị khóa, văn bản vẫn giữ nguyên như cũ. Điều này cũng áp dụng cho các luồng đăng ký và đặt lại mật khẩu; đừng tiết lộ liệu một địa chỉ đã có trong hệ thống của bạn hay chưa. Một thông báo chung giúp loại bỏ lỗ hổng rò rỉ thông tin mà kẻ tấn công thường dựa vào.
Tấn công thời gian (Timing Attacks) trong so sánh mật khẩu
Lỗi thứ ba là một lỗi vô hình. Tôi đã
