Password encoding, CORS và CSRF

4 câu hỏi~30 phútTừ khóa: BCrypt, Argon2, DelegatingPasswordEncoder, {bcrypt}, CORS, preflight, @CrossOrigin, CorsConfigurationSource, CSRF, SameSite, HttpOnly, XSS

Tiếp đi: password, CORS và CSRF.

Bài này có một câu hỏi phỏng vấn rất phổ biến: "Why do you disable CSRF in your Spring Boot REST API?" Nhiều người trả lời "Because I'm using JWT". Câu này chưa đủ chính xác. Trước đó mình gom luôn chuyện lưu password, vì cả ba đều là "cấu hình nền" mà shop-app nào cũng cần.

1. Bài toán

2. Vấn đề

3. Giải pháp

Password: dùng hàm hash chậm, có salt: BCrypt (mặc định), Argon2, scrypt, PBKDF2. Bọc trong DelegatingPasswordEncoder để đổi thuật toán mà không phải bắt mọi user đặt lại password.

{bcrypt}$2a$10$N9qo8uLOickgx2ZMRZoMye...
{argon2}$argon2id$v=19$m=16384,t=2,p=1$...
   ^ prefix = id thuật toán

BCrypt mỗi lần encode cho hash khác nhau vì salt ngẫu nhiên, nhưng matches() vẫn đúng. Không giải mã được, chỉ so khớp.

CORS (Cross-Origin Resource Sharing): cơ chế của browser cho phép JS ở origin A đọc response từ origin B, nếu B trả header cho phép. localhost:3000 và localhost:8080 khác origin vì khác port.

CSRF (Cross-Site Request Forgery): tấn công lợi dụng việc browser tự động gửi cookie. Spring Security bật chống CSRF mặc định: request thay đổi trạng thái phải kèm CSRF token mà site khác không đọc được.

4. Code từng bước

Bước 1: PasswordEncoder

@Bean
PasswordEncoder passwordEncoder() {
    // mặc định encode bằng bcrypt, matches() được cả {argon2}, {pbkdf2}, {noop}...
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

// Muốn Argon2 làm mặc định (cần thư viện bouncycastle):
@Bean
PasswordEncoder argon2First() {
    Map<String, PasswordEncoder> encoders = new HashMap<>();
    encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8());
    encoders.put("bcrypt", new BCryptPasswordEncoder(12));
    return new DelegatingPasswordEncoder("argon2", encoders);
}
// Đăng ký
user.setPassword(passwordEncoder.encode(request.password()));

// Nâng cấp hash dần dần khi user login (Spring gọi tự động nếu có bean này)
@Service
class UserPasswordService implements UserDetailsPasswordService {
    public UserDetails updatePassword(UserDetails user, String newEncodedPassword) {
        // lưu newEncodedPassword vào DB
        return User.withUserDetails(user).password(newEncodedPassword).build();
    }
}

Bước 2: CORS trong Spring Security

@Bean
CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedOrigins(List.of("http://localhost:3000"));
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
    config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
    config.setMaxAge(3600L);              // cache preflight 1 giờ
    // config.setAllowCredentials(true);  // chỉ khi gửi cookie; khi đó KHÔNG được dùng origin "*"

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/api/**", config);
    return source;
}

Bước 3: SecurityFilterChain cho REST API stateless (Bearer token)

@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
    http
        .securityMatcher("/api/**")
        .cors(Customizer.withDefaults())          // dùng bean CorsConfigurationSource
        .csrf(csrf -> csrf.disable())             // chỉ vì token gửi qua header Authorization
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
        .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()));
    return http.build();
}

Cho phần dùng cookie (admin portal, BFF, refresh cookie) thì giữ CSRF:

http.csrf(csrf -> csrf
    .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())  // SPA đọc cookie XSRF-TOKEN
    .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()));  // gửi lại qua header X-XSRF-TOKEN

Bước 4: test bằng curl

# giả lập preflight của browser
curl -i -X OPTIONS localhost:8080/api/orders \
  -H "Origin: http://localhost:3000" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: authorization,content-type"
# => 200, Access-Control-Allow-Origin: http://localhost:3000

curl -i -X OPTIONS localhost:8080/api/orders -H "Origin: http://evil.com" \
  -H "Access-Control-Request-Method: POST"
# => 403 Invalid CORS request

# admin portal có CSRF: POST không kèm token
curl -i -X POST localhost:8080/admin/transfer -b "JSESSIONID=..."
# => 403 Forbidden

5. Cách hoạt động

CORS preflight:

Browser (localhost:3000)                     API (localhost:8080)
  | OPTIONS /api/orders                          |
  | Origin: http://localhost:3000                |
  | Access-Control-Request-Method: POST          |
  | Access-Control-Request-Headers: authorization|
  |-------------------------------------------->| CorsFilter (trước authentication)
  |<--------------------------------------------| 200 + Access-Control-Allow-*
  | POST /api/orders + Authorization: Bearer ... |
  |-------------------------------------------->| authentication, authorization
  |<--------------------------------------------| 201 + Access-Control-Allow-Origin

Preflight không mang token. Vì vậy CORS phải xử lý trong Spring Security (http.cors()), nếu không request OPTIONS bị chặn 401 trước khi tới Spring MVC.

CSRF:

John đã login bank.com (cookie JSESSIONID=abc123)
   |
   | mở evil.com, có form tự submit:
   |   <form action="https://bank.com/transfer" method="POST">
   v
Browser gửi POST bank.com/transfer + Cookie JSESSIONID (tự động!)
   |
   v
Spring Security CsrfFilter: thiếu/sai X-CSRF-TOKEN --> 403
evil.com không đọc được token vì Same-Origin Policy

6. So sánh

Session cookieBearer token ở header
Browser tự gửi credentials?CóKhông, JS tự gắn
Nguy cơ CSRFCóThường không
Cần CSRF protectionCóCó thể disable
CORSCSRF
Bản chấtCơ chế browser nới lỏng Same-Origin PolicyMột kiểu tấn công
Bảo vệ cái gìQuyền JS đọc response khác originChống request giả mạo kèm cookie
Có phải authentication?Không, Postman/curl không bị CORSKhông
Thuật toánGhi chú
BCryptMặc định, cost factor (10–12), giới hạn 72 byte
Argon2idMemory-hard, chống GPU tốt nhất, OWASP khuyến nghị
PBKDF2Dùng khi cần chuẩn FIPS
MD5 / SHA-256 trực tiếpQuá nhanh, không dùng cho password

7. Khi nào nên dùng / không nên dùng

Vậy @CrossOrigin trên controller với cấu hình trong SecurityFilterChain khác nhau chỗ nào?

Câu hỏi hay. @CrossOrigin và WebMvcConfigurer.addCorsMappings là CORS của Spring MVC, chạy ở DispatcherServlet. Nhưng Spring Security filter chain chạy trước đó:

Request OPTIONS (không có token)
   |
   v
Security filter chain --- không có http.cors() ---> 401, không có header CORS
   |                                                  browser báo "CORS error"
   v
DispatcherServlet (@CrossOrigin chỉ có hiệu lực ở đây)
@CrossOriginCorsConfigurationSource + http.cors()
Chạy ởSpring MVCFilter, trước authentication
Phạm viTừng controller/methodTập trung theo URL pattern
Preflight khi có SecurityCó thể bị chặn 401Được xử lý đúng

Khi có Spring Security, cấu hình tập trung bằng bean CorsConfigurationSource và gọi http.cors(...). Nếu không có bean đó, http.cors() sẽ dùng cấu hình của Spring MVC (handler mapping), nên @CrossOrigin vẫn chạy được, nhưng tập trung một chỗ thì dễ audit hơn.

Nếu em lưu JWT trong cookie cho an toàn hơn localStorage, rồi disable CSRF vì dùng JWT, được không?

Không được, đây đúng là bẫy phỏng vấn. Yếu tố quyết định không phải token là JWT hay không, mà là browser có tự gửi credentials hay không. JWT trong cookie thì browser tự gửi, nên CSRF vẫn tồn tại.

Cách làm đúng khi dùng cookie:

Set-Cookie: access_token=...; HttpOnly; Secure; SameSite=Lax; Path=/api
Thuộc tínhÝ nghĩa
HttpOnlyJS không đọc được cookie (giảm rủi ro XSS đánh cắp)
SecureChỉ gửi qua HTTPS
SameSite=Strict/LaxHạn chế gửi cookie cho request cross-site
PathGiới hạn URL nhận cookie

Cộng thêm CSRF token (hoặc kiểm tra Origin). SameSite là lớp phụ tốt nhưng không thay CSRF token hoàn toàn: SameSite dựa trên site, còn subdomain cùng site vẫn gửi được.

Nhớ thêm: HttpOnly không chặn được XSS. Script độc vẫn gửi request thay user được. Phòng XSS là việc khác: escape output, dùng textContent thay innerHTML, Content-Security-Policy.

Phỏng vấn hỏi password, CORS, CSRF thì trả lời sao?

Q1. Vì sao REST API thường disable CSRF?

🇻🇳 Tiếng Việt:

CSRF lợi dụng credentials mà browser tự động gửi, như cookie. Nếu API chỉ xác thực bằng Bearer token mà JS tự gắn vào header Authorization, site khác không gắn được token đó nên CSRF không áp dụng. Nhưng nếu token nằm trong cookie thì vẫn phải bật CSRF.

🇬🇧 English:

CSRF exploits credentials that the browser attaches automatically, such as cookies. We can disable CSRF protection for a stateless API when authentication relies only on bearer tokens explicitly sent in the Authorization header. If the token is stored in a cookie, CSRF protection is still required.

Q2. CORS khác CSRF thế nào?

🇻🇳 Tiếng Việt:

CORS là cơ chế browser kiểm soát JS khác origin có được đọc response hay không, không phải authentication. CSRF là một kiểu tấn công gửi request ngoài ý muốn bằng cookie của user. CORS không chống được CSRF vì request vẫn được gửi đi, chỉ là JS không đọc được response.

🇬🇧 English:

CORS is a browser mechanism that controls whether cross-origin JavaScript can read a response; it is not authentication. CSRF is an attack that sends unwanted requests using the user's cookies. CORS does not prevent CSRF, because a simple request is still sent even if its response cannot be read.

Q3. Lưu password thế nào trong Spring Security?

🇻🇳 Tiếng Việt:

Dùng hàm hash chậm có salt như BCrypt hoặc Argon2, qua DelegatingPasswordEncoder. Hash lưu kèm prefix như {bcrypt}, nên đổi thuật toán mặc định mà password cũ vẫn verify được, và có thể nâng cấp hash khi user login lại bằng UserDetailsPasswordService.

🇬🇧 English:

I use a slow, salted password hashing function such as BCrypt or Argon2 through DelegatingPasswordEncoder. Each hash carries a prefix like {bcrypt}, so we can change the default algorithm while old hashes still verify, and upgrade them on the next login with UserDetailsPasswordService.

Cách nhớ ngắn gọn


Tự kiểm tra

Vì sao không dùng SHA-256 trực tiếp cho password?

Quá nhanh, GPU thử được hàng tỷ lần mỗi giây. Password cần hàm chậm, có salt, có work factor.

DB có hash {noop}secret và {bcrypt}$2a$.... DelegatingPasswordEncoder xử lý thế nào?

Đọc prefix để chọn encoder phù hợp cho matches(). Hash mới luôn dùng encoder mặc định.

Có @CrossOrigin nhưng browser vẫn báo lỗi CORS ở request có Authorization. Vì sao?

Preflight OPTIONS không có token bị Security chặn 401 trước khi tới MVC. Cần http.cors() trong filter chain.

allowedOrigins("*") + allowCredentials(true) có được không?

Không. Browser từ chối; phải liệt kê origin cụ thể (hoặc dùng allowedOriginPatterns).

JWT lưu trong cookie, dev disable CSRF vì "đã dùng JWT". Đúng không?

Sai. Browser tự gửi cookie nên vẫn bị CSRF. Cần CSRF token, SameSite, kiểm tra Origin.