Test bảo mật xanh không có nghĩa ứng dụng an toàn. Bài này đo chính xác khoảng cách đó.

Cùng một request, hai kết quả

  MockMvc  GET  /admin/xem (vai USER)  -> 403
  MockMvc  POST /admin/xoa (vai USER)  -> 403

  máy chủ thật  GET  /admin/xem        -> 403
  máy chủ thật  POST /admin/xoa        -> 405

Test xanh, sản xuất trả 405 Method Not Allowed cho một request đáng lẽ phải là 403.

Nguyên nhân nằm ở một chi tiết:

.exceptionHandling(e -> e.accessDeniedPage("/error/403"))

@GetMapping("/error/403")        // <- chỉ GET
String batGet(){ return "403-page"; }

Spring Security forward sang trang lỗi và giữ nguyên method của request gốc. POST bị từ chối sẽ forward thành một POST tới /error/403, mà chỗ đó chỉ khai @GetMapping — nên Spring MVC trả 405.

Cách chữa là một chữ:

@RequestMapping("/error/403")    // không phải @GetMapping
MockMvc không tái hiện được vì nó không thực hiện forward của container servlet theo cùng cách. Nó dừng ở mã trạng thái mà Security đặt ra, chưa đi qua bước dispatch thứ hai — đúng bước đã đo ở bài 17 và bài 42.

Ba loại lỗi MockMvc không bắt được

Forward tới trang lỗi, như trên.

Lỗi multipart. Bài 19 đã nói: MockMultipartFile không đi qua bộ phân tích multipart thật, nên MaxUploadSizeExceededException không bao giờ phát sinh trong test. Chạy thật thì 413 với thân rỗng.

Script nội tuyến bị CSP chặn. Bài 45: MockMvc không thực thi JavaScript, nên mọi <script> viết thẳng trong template vẫn "chạy" trong test và chết trên trình duyệt.

Mạch chung: MockMvc mô phỏng tầng MVC, không mô phỏng container servlet và không mô phỏng trình duyệt. Ba loại lỗi trên đều nằm ở hai tầng nó không có.

Cách bù: một nhúm test dùng @SpringBootTest(webEnvironment = RANDOM_PORT) với TestRestTemplate hoặc WebTestClient — chúng đi qua Tomcat thật.

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class ThatSuChayTest {
    @Autowired TestRestTemplate rest;

    @Test void postBiTuChoiTra403KhongPhai405() {
        var r = rest.withBasicAuth("an", "m").postForEntity("/admin/xoa", null, String.class);
        assertThat(r.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
    }
}

Không cần nhiều — vài chục test loại này đủ phủ những chỗ MockMvc mù.

spring-security-test

<dependency>
  <groupId>org.springframework.security</groupId>
  <artifactId>spring-security-test</artifactId>
  <scope>test</scope>
</dependency>
@Test @WithMockUser(roles = "USER")
void nguoiThuongKhongVaoDuocAdmin() throws Exception {
    mvc.perform(get("/admin/xem")).andExpect(status().isForbidden());
}

@Test @WithAnonymousUser
void khachPhaiDangNhap() throws Exception {
    mvc.perform(get("/rieng")).andExpect(status().isUnauthorized());
}

Chú ý @WithMockUser(roles = "USER") tạo quyền ROLE_USER — nó tự thêm tiền tố, đúng như hasRole ở bài 41. Muốn quyền không có tiền tố thì dùng authorities = "...".

Với @AuthenticationPrincipal là kiểu riêng của bạn, @WithMockUser không đủ — dùng @WithUserDetails("an") để nạp qua UserDetailsService thật, hoặc viết một annotation riêng.

Cho request cần CSRF (bài 42):

mvc.perform(post("/web/ghi").with(csrf()))

đừng dùng .with(csrf()) ở mọi chỗ theo phản xạ — hãy có ít nhất một test khẳng định request thiếu token bị từ chối. Nếu không, bạn có thể tắt CSRF mà cả bộ test vẫn xanh.

Bốn test bảo mật đáng viết

Mỗi endpoint được bảo vệ có một test cho vai sai. Nghe hiển nhiên, nhưng thực tế phần lớn dự án chỉ test đường đi đúng.

Test tham chiếu đối tượng. Đây là loại có giá trị cao nhất, vì nó bắt lỗ hổng số một ở bài 46:

@Test @WithMockUser("nguoi-b")
void khongDocDuocDonCuaNguoiKhac() throws Exception {
    Long idCuaA = taoDonCho("nguoi-a");
    mvc.perform(get("/don/" + idCuaA)).andExpect(status().isNotFound());
}

Test gán thuộc tính hàng loạt:

@Test @WithMockUser("an")
void khongTuNangQuyenDuoc() throws Exception {
    mvc.perform(post("/ho-so").contentType(JSON)
            .content("{\"ten\":\"x\",\"vai\":\"ADMIN\"}"));
    assertThat(repo.findByTen("an").getVai()).isEqualTo(Vai.USER);
}

Test rằng chỗ nhạy cảm không lộ ra:

@Test void actuatorKhongLoEnv() throws Exception {
    mvc.perform(get("/actuator/env")).andExpect(status().isNotFound());
}

Blog này có một test tương tự cho log — AdminPasswordNotLoggedTest bắt log thật và khẳng định mật khẩu quản trị không xuất hiện trong đó.

Quét tự động trong CI

Ba thứ chạy được không cần chuyên gia:

Quét phụ thuộc. Bài 46 và bài 99 sê-ri Java: một lệnh Docker, và nhớ đặt ngưỡng làm đỏ build — báo cáo không ai đọc thì không bảo vệ được gì.

Phân tích tĩnh. SpotBugs với find-sec-bugs bắt được SQL injection nối chuỗi, Random dùng cho token, đường dẫn không kiểm.

Quét động. OWASP ZAP ở chế độ baseline chạy được trong CI và tìm header thiếu, cookie không có cờ, trang lộ thông tin.

Không cái nào thay được đọc mã, nhưng cả ba đều rẻ và chạy mỗi lần build.

Ba điều về test bảo mật

Test cả đường đi sai. Test bảo mật chỉ kiểm "người đúng vào được" là không kiểm gì cả.

Đừng tắt bảo mật trong test. Thấy @AutoConfigureMockMvc(addFilters = false) nghĩa là toàn bộ chuỗi lọc bị bỏ, và mọi test sau đó không nói gì về bảo mật. Nó có chỗ dùng — test riêng logic controller — nhưng đừng để nó thành mặc định.

Test cấu hình sản xuất, không phải cấu hình test. Profile test thường tắt HTTPS, nới CSP, mở Actuator. Có ít nhất một test chạy với cấu hình thật.

Thử ba mươi giây

Lấy một test bảo mật của bạn và đảo ngược khẳng định — đổi isForbidden() thành isOk().

Test vẫn xanh nghĩa là nó chưa bao giờ kiểm điều bạn nghĩ. Tôi đã gặp chuyện này với một test dùng .andExpect(status().is4xxClientError()) — nó xanh cả khi mã trả về là 405 thay vì 403.

Ngày mai: chốt chặng bảo mật — mười bài, và danh sách kiểm rút ra từ chúng.