Bài này là danh sách kiểm, không phải lý thuyết. Mỗi mục có một cách tìm trong dự án của bạn.
Một: tham chiếu đối tượng không an toàn
@GetMapping("/don/{id}")
Don xem(@PathVariable Long id) { return repo.findById(id).orElseThrow(); }
Endpoint có yêu cầu đăng nhập. Nhưng đơn của ai? Đổi id thành số khác là đọc đơn của người khác.
Đây là lỗ hổng tôi gặp nhiều nhất, và nó đứng đầu danh sách OWASP nhiều năm liền — vì nó không lộ ra trong bất kỳ phép thử nào trừ khi bạn thử bằng tài khoản của người khác.
Cách chữa đáng tin nhất là đưa quyền vào truy vấn:
Optional<Don> findByIdAndKhachHangId(Long id, Long khachId);
Không tìm thấy thì không có gì để rò rỉ, và không có đường nào quên kiểm.
grep -rn "findById(\|getReferenceById(" --include='*.java' src/main | grep -i controller
Hai: gán thuộc tính hàng loạt
@PostMapping("/ho-so")
void capNhat(@RequestBody NguoiDung nguoi) { repo.save(nguoi); }
Nhận thẳng entity từ JSON. Kẻ tấn công gửi {"ten":"x","vai":"ADMIN","daXacMinh":true} và tự nâng quyền.
Đây là hệ quả trực tiếp của việc dùng entity làm DTO — bài 15 và 30 đã nói ở góc độ kiến trúc; đây là góc độ bảo mật.
Chữa bằng DTO chỉ có đúng những trường được phép sửa. Đừng dùng @JsonIgnore làm rào chắn duy nhất — nó dễ bị mất khi ai đó tái cấu trúc.
grep -rn "@RequestBody" --include='*.java' src/main | grep -iE "entity|model|domain"
Ba: SSRF
@GetMapping("/xem-truoc")
String xemTruoc(@RequestParam String url) { return client.get().uri(url).retrieve().body(String.class); }
Máy chủ của bạn gọi URL do người dùng cung cấp. Kẻ tấn công đưa http://169.254.169.254/latest/meta-data/ và đọc được thông tin xác thực của máy ảo trên AWS.
Chữa: danh sách tên miền cho phép, chặn dải IP nội bộ, và phân giải DNS trước rồi kiểm IP — vì một tên miền công khai có thể trỏ về 127.0.0.1.
Bài 46 sê-ri Java cũng chỉ ra đường vào thứ hai: XXE. Một tệp XML với thực thể ngoài đọc được /etc/passwd với cấu hình DocumentBuilderFactory mặc định.
Bốn: Actuator lộ ra ngoài
management:
endpoints:
web:
exposure:
include: "*" # KHÔNG BAO GIỜ
/actuator/env in ra toàn bộ biến môi trường gồm mật khẩu CSDL và khoá API. /actuator/heapdump tải về nguyên bộ nhớ ứng dụng — trong đó có mọi thứ đang xử lý.
management:
endpoints:
web:
exposure:
include: health, info, metrics, prometheus
endpoint:
health:
show-details: when-authorized
server:
port: 9090 # cổng riêng, nginx không proxy ra
curl -s https://ung-dung-cua-ban.com/actuator | jq
Có kết quả từ Internet là bạn cần sửa ngay hôm nay.
Năm: thông báo lỗi lộ nội tình
Trả e.getMessage() của DataIntegrityViolationException ra ngoài là lộ nguyên câu SQL và tên ràng buộc.
Và CLAUDE.md của blog này ghi thêm một điều: đừng đoán nguyên nhân trong thông báo. Cùng một ngoại lệ đó có thể là chuỗi quá dài, trùng khoá, hoặc ký tự CSDL không lưu được — đoán sai làm người gỡ lỗi đi lạc hẳn hướng.
Cùng nhóm: tắt dấu vết ngăn xếp trong phản hồi sản xuất.
server:
error:
include-stacktrace: never
include-message: never
Sáu: chuyển hướng mở
return "redirect:" + request.getParameter("tiep");
?tiep=https://trang-lua-dao.com và ứng dụng của bạn thành bàn đạp cho lừa đảo — link nằm trên tên miền thật của bạn.
CLAUDE.md của blog này ghi cách chữa chính xác: chỉ lấy phần đường dẫn, và bắt buộc bắt đầu bằng đúng một dấu gạch chéo. //evil.com/x là URL theo giao thức tương đối, trình duyệt đi thẳng ra ngoài.
String p = URI.create(tiep).getPath();
if (p == null || !p.startsWith("/") || p.startsWith("//")) p = "/";
Bảy: đầu vào sinh nhiều bản ghi không có trần
{"name":"x","totalParts":5000000}
Request 44 byte này từng đủ làm blog này hết bộ nhớ. Đã đo: 50.000 phần mất 24 giây.
Hai bài học từ lần đó, cả hai đều nằm trong CLAUDE.md:
Đặt trần. ContentPlanService.MAX_PARTS.
Kiểm trước khi ghi, đừng dựa vào rollback. Rollback phụ thuộc ngữ cảnh giao dịch của người gọi; chạy trong một giao dịch lớn hơn là bản ghi dở dang vẫn nằm lại.
Cùng họ với nó: ?size=1000000 ở bài 22, và tệp tải lên không giới hạn ở bài 19.
Tám: phụ thuộc có lỗ hổng
Bài 99 sê-ri Java quét một pom.xml năm dòng và tìm ra 73 lỗ hổng đã biết, trong đó 6 nghiêm trọng — và không cái nào thuộc thư viện tôi tự khai. Tất cả đến bắc cầu.
docker run --rm -v $(pwd):/src ghcr.io/google/osv-scanner:latest scan --lockfile=/src/pom.xml
Một lệnh, ba phút, không cần khoá API. Rồi đưa nó vào CI với ngưỡng làm đỏ build — báo cáo không ai đọc thì không bảo vệ được gì.
Ba thứ nhỏ hơn nhưng vẫn hay gặp
Log ghi bí mật. DataSeeder của blog này từng in nguyên văn mật khẩu quản trị ở lần khởi động đầu tiên. Log container được gom về nơi khác và giữ rất lâu; ai đọc được log là đăng nhập được. Giờ có AdminPasswordNotLoggedTest canh chừng bằng cách bắt log thật.
Rò dữ liệu qua ThreadLocal. Bài 78 sê-ri Java đo được request thứ tư đọc ra tên người dùng của request thứ hai, vì luồng trong pool mang theo giá trị cũ. SecurityContextHolder và MDC đều chạy trên ThreadLocal.
Dùng Random cho token. Phải là SecureRandom — Random đoán được sau vài mẫu.
Bốn việc làm được trong một buổi chiều
Quét phụ thuộc và đưa vào CI. Kiểm Actuator từ ngoài Internet. Tắt dấu vết ngăn xếp trong phản hồi. Thử đổi id trong URL bằng tài khoản khác.
Bốn việc đó không đòi hỏi hiểu biết bảo mật sâu, và chúng xử lý phần lớn rủi ro thực tế.
Thử ba mươi giây
curl -s https://ung-dung-cua-ban.com/actuator/env | head -c 300
Có JSON trả về là bạn đang công khai biến môi trường — gồm cả mật khẩu CSDL. Không có gì cần bàn thêm, sửa ngay.
Ngày mai: kiểm thử bảo mật — và vì sao MockMvc bỏ lọt ba loại lỗi.