ํ”„๋ก ํŠธ์—”๋“œ์™€ ๋ฐฑ์—”๋“œ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์ด ๋ถ„๋ฆฌ๋˜์–ด์žˆ์–ด๋„ Cross Site Request Forgery (CSRF)๋ฅผ ๋น„ํ™œ์„ฑํ™”ํ•˜์ง€ ๋ง์ž.

CSRF ์ž์ฒด์— ๋Œ€ํ•ด์„œ ์ž˜ ๋ชจ๋ฅด๋Š” ๊ฐœ๋ฐœ์ž๋ผ๋ฉด Cross-Site Request Forgery Prevention Cheat Sheet๋ฅผ ์ฐธ๊ณ ํ•ด๋ณด๋„๋ก ํ•˜์ž. ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๋ณด์•ˆ ๊ฐ€์ด๋“œ์—์„œ ๋กœ๊ทธ์ธ๊ณผ ๋กœ๊ทธ์•„์›ƒ ํ–‰์œ„์— ๋Œ€ํ•ด์„œ๋Š” CSRF ํ† ํฐ์„ ์‚ฌ์šฉํ•œ ๊ฒ€์ฆ์„ ์š”๊ตฌํ•˜๋Š” ํŽธ์ด๋‹ค. ์Šคํ”„๋ง ์‹œํ๋ฆฌํ‹ฐ์—์„œ๋Š” CSRF ๊ณต๊ฒฉ์— ๋ฐฉ์–ดํ•˜๋Š” ๋งค์ปค๋‹ˆ์ฆ˜์„ ์ œ๊ณตํ•˜์—ฌ ์‰ฝ๊ฒŒ CSRF ํ† ํฐ์„ ์ ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค.

  • HttpSessionCsrfTokenRepository
  • CookieCsrfTokenRepository
  • XorCsrfTokenRequestAttributeHandler
  • XorCsrfChannelInterceptor
  • CsrfFilter
  • CsrfLogoutHandler

์Šคํ”„๋ง ์‹œํ๋ฆฌํ‹ฐ์˜ SecurityFilterChain์„ ๊ตฌ์„ฑํ•˜๋Š” ๊ณผ์ •์˜ CsrfConfigurer๋ฅผ ์‚ดํŽด๋ณด๋ฉด CsrfFilter๋ฅผ ํ•„ํ„ฐ์— ๋“ฑ๋กํ•˜๋Š”๋ฐ CsrfTokenRepository์™€ CsrfTokenRequestHandler๊ฐ€ ์‚ฌ์šฉ๋˜๋„๋ก ์ „๋‹ฌ๋œ๋‹ค. ๊ณต์‹ ๋ฌธ์„œ๋ฅผ ์ฐธ๊ณ ํ•ด๋ณด๋ฉด ๊ธฐ๋ณธ์ ์œผ๋กœ๋Š” ์Šคํ”„๋ง ์‹œํ๋ฆฌํ‹ฐ 6 ๋ถ€ํ„ฐ HttpSessionCsrfTokenRepository์™€ XorCsrfTokenRequestAttributeHandler๊ฐ€ ์‚ฌ์šฉ๋˜๋„๋ก ๋˜์–ด์žˆ์œผ๋ฉฐ HTTP๊ฐ€ ์•„๋‹Œ ์›น์†Œ์ผ“์„ ์œ„ํ•œ ๋ณด์•ˆ ์„ค์ • ์‹œ(@EnableWebSocketSecurity)์—๋Š” XorCsrfChannelInterceptor์ด ์ ์šฉ๋˜์–ด ๋™์ž‘ํ•œ๋‹ค.

@Configuration
public SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
       http.csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()));
    }
}

CSRF ํ† ํฐ API ์—”๋“œํฌ์ธํŠธ

๋ฐฑ์—”๋“œ์™€ ํ”„๋ก ํŠธ์—”๋“œ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์ด ๋ถ„๋ฆฌ๋˜์–ด์žˆ๋‹ค๊ณ  ํ•ด์„œ HttpOnly ์†์„ฑ์ด ์ง€์ •๋˜์ง€ ์•Š์€ CSRF ์ฟ ํ‚ค๋ฅผ ์ „๋‹ฌํ•˜๊ธฐ ์œ„ํ•ด CookieCsrfTokenRepository๋ฅผ ์‚ฌ์šฉํ•  ํ•„์š”๋Š” ์—†๋‹ค. ํ”„๋ก ํŠธ์—”๋“œ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์„ ์œ„ํ•œ CSRF ํ† ํฐ์ด ํ•„์š”ํ•˜๋‹ค๋ฉด ์•„๋ž˜์™€ ๊ฐ™์€ CSRF ํ† ํฐ์„ ์‘๋‹ตํ•ด์ฃผ๋Š” API๋ฅผ ๋งŒ๋“ค์–ด์„œ ์ œ๊ณตํ•˜์ž. ๊ธฐ๋ณธ์ ์œผ๋กœ GET ์š”์ฒญ์€ ์•ˆ์ „ํ•œ ๋ฉ”์†Œ๋“œ๋กœ ๊ฐ„์ฃผํ•˜์—ฌ ์Šคํ”„๋ง ์‹œํ๋ฆฌํ‹ฐ๋Š” CSRF ํ† ํฐ์— ๋Œ€ํ•œ ๊ฒ€์ฆ์„ ์ฒ˜๋ฆฌํ•˜์ง€ ์•Š๋Š”๋‹ค.

@RestController
public class CsrfController {
    @GetMapping("/csrf")
    public CsrfToken csrf(CsrfToken csrfToken) {
        return csrfToken;
    }
}

์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๋ณด์•ˆ ๊ฐ€์ด๋“œ์—์„œ๋Š” ๋กœ๊ทธ์ธ๊ณผ ๋กœ๊ทธ์•„์›ƒ ์š”์ฒญ์— ๋Œ€ํ•ด CSRF ๊ณต๊ฒฉ์— ๋Œ€ํ•œ ๋ฐฉ์–ด๋ฅผ ์š”๊ตฌํ•œ๋‹ค. ๊ฐ„ํ˜น ๋ฐฑ์—”๋“œ์™€ ํ”„๋ก ํŠธ์—”๋“œ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์ด ๋ถ„๋ฆฌ๋˜์–ด JWT์™€ ๊ฐ™์€ ํ† ํฐ ๊ธฐ๋ฐ˜ ์ธ์ฆ์„ ์ˆ˜ํ–‰ํ•œ๋‹ค๋ฉด ๋น„ํ™œ์„ฑํ™”ํ•˜๊ฑฐ๋‚˜ ์กฐ์น˜ํ•  ํ•„์š”๊ฐ€ ์—†๋‹ค๋Š” ๊ฒƒ์„ ๊ธฐ๋กํ•œ ๋ธ”๋กœ๊ทธ๊ฐ€ ๋ณด์ด๋Š”๋ฐ ์ด๊ฒƒ์€ ์ž˜๋ชป๋œ ์ •๋ณด์ด๋‹ค. ๋ธŒ๋ผ์šฐ์ €์—์„œ ํ† ํฐ์„ ์ „๋‹ฌํ•  ๋ฐฉ๋ฒ•์€ ์ฟ ํ‚ค๋‚˜ ๋ณ„๋„์˜ ํ—ค๋” ๋ฟ์ด๋ฉฐ ์ฟ ํ‚ค๋„ ์‚ฌ์‹ค ์ƒ ํ—ค๋” ์ค‘ ํ•˜๋‚˜์ผ ๋ฟ์ด๋‹ค.

private static final class DefaultRequiresCsrfMatcher implements RequestMatcher {
    private final HashSet<String> allowedMethods = new HashSet<>(Arrays.asList("GET", "HEAD", "TRACE", "OPTIONS"));

    @Override
    public boolean matches(HttpServletRequest request) {
        return !this.allowedMethods.contains(request.getMethod());
    }
}

์Šคํ”„๋ง ์‹œํ๋ฆฌํ‹ฐ์—์„œ๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ GET, HEAD, TRACE, OPTIONS์— ๋Œ€ํ•ด์„œ๋Š” ์•ˆ์ „ํ•œ ๋ฉ”์†Œ๋“œ๋กœ ํŒ๋‹จํ•˜์—ฌ CSRF ๊ฒ€์ฆ์„ ๋ฌด์‹œํ•œ๋‹ค. ๋งŒ์•ฝ, GET์„ ์•ˆ์ „ํ•˜์ง€ ์•Š๋Š” ํ–‰์œ„๋กœ ์‚ฌ์šฉํ•œ๋‹ค๋ฉด CSRF ๊ฒ€์ฆ์„ ๋ณ„๋„๋กœ ์ˆ˜ํ–‰ํ•ด์•ผํ•œ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, ์‰ฝ๊ฒŒ ๋กœ๊ทธ์•„์›ƒ ํ•˜๊ธฐ ์œ„ํ•ด์„œ POST ์š”์ฒญ์ด ์•„๋‹Œ GET ์š”์ฒญ์œผ๋กœ ๊ตฌํ˜„ํ–ˆ๋‹ค๋ฉด CSRF ํ•„ํ„ฐ์—์„œ ๋ฌด์‹œ๋˜๊ณ  ๋„˜์–ด๊ฐ€๋ฏ€๋กœ requireCsrfProtectionMatcher ๋ฅผ ์ˆ˜์ •ํ•˜์ž.

CSRF ํ† ํฐ์„ ์ „๋‹ฌํ•˜๋Š” ๋ฐฉ๋ฒ•

CSRF ํ† ํฐ์€ ์ผ๋ฐ˜์ ์œผ๋กœ HTML ํผ ์ „์†ก ์‹œ _csrf ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ์ „๋‹ฌํ•˜๋Š”๋ฐ X-CSRF-TOKEN ๋˜๋Š” X-XSRF-TOKEN ํ—ค๋”๋กœ๋„ ์ „๋‹ฌํ•  ์ˆ˜ ์žˆ๋„๋ก ์Šคํ”„๋ง ์‹œํ๋ฆฌํ‹ฐ์—์„œ ์ง€์›ํ•œ๋‹ค. ๊ธฐ๋ณธ์ ์œผ๋กœ๋Š” HttpSessionCsrfTokenRepository ๊ฐ€ ์‚ฌ์šฉ๋˜๋Š”๋ฐ X-CSRF-TOKEN ์ด๋ผ๋Š” ํ—ค๋”๋ฅผ CookieCsrfTokenRepository๋Š” X-XSRF-TOKEN ํ—ค๋”๋ฅผ ๋งค์นญํ•œ๋‹ค. ๊ทธ๋Ÿฌ๋‹ˆ๊นŒ, ๊ธฐ๋ณธ์ ์œผ๋กœ๋Š” X-CSRF-TOKEN ํ—ค๋”๋กœ ์ „๋‹ฌํ•ด์•ผํ•˜์ง€๋งŒ ์ฟ ํ‚ค ๊ธฐ๋ฐ˜์˜ CookieCsrfTokenRepository๋ฅผ ์ ์šฉํ–ˆ๋‹ค๋ฉด X-XSRF-TOKEN ํ—ค๋”๋กœ ์š”์ฒญ ์‹œ ์ „๋‹ฌํ•ด์•ผ CSRF ํ† ํฐ์„ ์ œ๋Œ€๋กœ ๊ฒ€์ฆํ•  ์ˆ˜ ์žˆ๋‹ค.

@FunctionalInterface
public interface CsrfTokenRequestHandler extends CsrfTokenRequestResolver {
    void handle(HttpServletRequest request, HttpServletResponse response, Supplier<CsrfToken> csrfToken);

    @Override
    default String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
        Assert.notNull(request, "request cannot be null");
        Assert.notNull(csrfToken, "csrfToken cannot be null");
        String actualToken = request.getHeader(csrfToken.getHeaderName());
        if (actualToken == null) {
            actualToken = request.getParameter(csrfToken.getParameterName());
        }
        return actualToken;
    }
}

์•ž์„œ CSRF ํ† ํฐ์„ ์‘๋‹ตํ•˜๋Š” API ์—์„œ๋Š” ํ† ํฐ ๋ฟ๋งŒ ์•„๋‹ˆ๋ผ ์š”์ฒญ ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ์ „๋‹ฌํ•  ์ด๋ฆ„๊ณผ ํ—ค๋”๋ฅผ ํ•จ๊ป˜ ์ œ๊ณตํ•ด์ค€๋‹ค. ๋”ฐ๋ผ์„œ, Axios์™€ ๊ฐ™์€ HTTP ์š”์ฒญ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ํ†ตํ•ด XHR ์š”์ฒญ์„ ์ˆ˜ํ–‰ํ•œ๋‹ค๋ฉด ์•„๋ž˜์™€ ๊ฐ™์ด ์ „๋‹ฌํ•  ์ˆ˜ ์žˆ๋‹ค.

axios.get('/csrf').then(res => {
    const csrf = res.data
    axios.defaults.headers.post[csrf.headerName] = csrf.token
    axios.defaults.headers.put[csrf.headerName] = csrf.token
    axios.defaults.headers.delete[csrf.headerName] = csrf.token
})