Spring Security는 사용자의 악의적 공격, 보안침투에 대응하기 위한 여러가지 대응방안을 제공한다.
가장 보편적으로 알려진 공격방식 중, CORS/CSRF/SameSite의 3가지 형태가 있는데, 이를 중심으로 Spring Security는 어떻게 대응하고 방안대책을 제공하는지 분석해보고자 한다.
더불어, 어떻게보면 가장 기본적으로 알아야하는 보안저해행위에 대해, 이번 기회를 통해 명확하게 이해하고 이에 대한 대응방안까지 파악할 수 있는 기회로 활용해보도록 한다.
Cross Origin Resource Sharing, 교차 출처 리소스 공유라고도 하며 말 그대로 교차 출처, A사이트와 B사이트라는 출처가 다른 곳에서 리소스(자원)을 사용(확보)하고자 할 때, 이 교차 사이트 간의 공유 과정 자체를 읽컫는다.
중요한 것은, 개인정보와 같은 민감한 사항들을 읽는 시도를 할 때 Same-Origin Policy(동일출처정책, 동일 도메인에 대해서만 자원읽기를 허용)을 위반하여 이를 차단하는데, 잘못된 정책 설정 등으로 인해 이를 공격자가 악용하여 민감정보를 탈취하는 행위가 문제가 되는 것이다.
CORS정책은 기본적으로 해당 도메인 및 사이트에서 CORS정책 및 CORS요청에 대한 응답헤더를 구성하는 것에서 시작하는데, 이를 잘못 구성하면 리소스 공유 제한이 풀려서 CORS의 공격에 무방비해진다.
이에 대한 흐름을 살펴보면,
<script>
fetch("https://bank.com/user/profile", { credentials: "include" })
.then(r => r.text())
.then(data => sendToAttacker(data));
</script>
이때 다른 사이트의 사용자가 bank.com에 user/profile 요청을 하여 개인정보를 확보하고자 할 때, bank.com 서버가 same origin policy를 허용으로 하였을 경우, 그대로 민감정보를 sendToAttack(data)하게 된다.
이에 따라 공격자는 사용자의 민감정보를 그대로 탈취할 수 있게 된다.
특히, 서버 측에서 Access-Control-Allow-Origin: *로 모든 도메인에서 발생하는 자원공유 요청을 허용하면서 Access-Control-Allow-Credentials: true 내부적으로 사용자 인증 정보까지 포함하는 정책으로 설정하였다면, 사용자 민감 정보는 그대로 공격자에게 넘어갈 수 있는 것이다.
이러한 공격에 대응하기 위하여,
SOP정책을 기본적으로 사용하되(동일 도메인에서는 자원접근을 허용) 특징적인 CORS정책을 도입함으로써, 다른 사이트의 자원공유에 대해 “서버가 명시적으로 허락한 것만 허용”하는 방식으로 구현한 것이다.
이때, 브라우저가 보호막(중간매개자) 역할을 하며, 브라우저는 서버가 허가한 origin, http method 등의 특정 값 혹은 환경에 대해서만 자원 공유를 허용한다.
CORS의 핵심 방어 포인트는 크게 3가지이다.
[Origin : host]
브라우저가 Origin 헤더를 요청마다 자동으로 붙이며, 타 서버 측에서는 이 Origin을 보고 허용/거부 판단 가능하다.
[예비요청의 http method 1차 판단]
프리플라이트(OPTIONS) 요청을 분석하여, 민감한 요청(POST, PUT 등)을 하기 전에 브라우저가 안전성 검사를 진행하며 서버 측에서 허용한 http method가 아닐 경우 그대로 SOP 정책위반으로 자원공유를 차단한다.
[브라우저의 다리 역할]
서버 응답이 200이어도 브라우저가 CORS 정책 위반이라고 판단하면 JS에게 응답을 넘기지 않는다.
참고로 CORS는 보통 javascript단에서 이루어진다.
이러한 설계 및 SOP 정책이 구성되어, 서버가 오용하지 않는 한 공격자는 민감한 응답을 읽지 못하므로 CORS 공격에 대응할 수 있게 되는 것이다.
위에서 기술하였듯이, CORS에서 브라우저는 정책위반여부를 판단하는 중요한 역할을 한다.
보통 CORS라 하면, 브라우저는 다른 사이트에서의 요청헤더와 리소스 공유 요청을 받은 사이트의 응답헤더 값을 비교하여 사이트 자원 공유가 가능한지 판단하는 과정으로 알고 있을텐데, 쉽게 이해하기 위해서는 브라우저가 다른 사이트에게, 요청 사이트를 대신하여 데이터 공유가 가능한지 대신하여 물어보는 과정으로 생각하면 편하다.
즉, 한 사이트에서 다른 사이트로 자원 공유를 요청할때 SOP정책과 CORS정책 모두 적용이 되는 것이고, 판단 기준은 클라이언트의 요청 헤더와 서버의 요청 헤더를 모두 받아서 이를 비교하여 판단하게 된다.
그리고, CORS위반에 대한 구체적인 판단 기준은 Protocol, Host, Port이다.
이 3가지 중 하나라도 정책위반으로 판단되면 자원공유를 허락하지 않는다.
보통 사이트의 프론트엔드에서 다른 사이트로 javascript 기반의 fetch 요청 및 XMLHttpRequest을 사용한 요청이 들어올 경우(이를 브라우저가 http기반의 요청으로 변경), SOP/CORS 정책위반을 요청 및 응답헤더를 종합하여 판단하게 된다.

이 정책을 위반하게 된다면, 위와 같이 그 유명한 CORS 오류메시지가 뜨면서 자원확보에 실패하게 된다.
정확하게는, CORS를 진행하기 위한 사이트의 (javascript) 요청를 (HTTP요청으로 변환하여) 브라우저가 받고, 이 요청을 대신 브라우저가 다른 사이트로 보내어 응답요청을 대신 받아 이 요청헤더와 응답헤더를 비교하여 CORS정책위반 여부를 판단하는 과정은 크게 두가지로 나눌 수 있다.
[JS 코드]
|
v
[브라우저]
- Origin 헤더 붙임
- 요청 종류 판단(Simple / Preflight)
|
v
[서버]
- 요청 수신
- Origin 검사 → CORS 허용 헤더 응답
|
v
[브라우저]
- 서버 응답 수신
- 브라우저가 CORS 정책 비교
- 허용: JS 접근 가능
- 불허: JS 접근 차단
|
v
[JS 코드]
- 응답 사용 또는 에러 처리
쉽게 말하면, 노크를 하지 않고 바로 문을 여느냐와, 노크를 하고 "들어오세요"라는 대답을 듣고 문을 여느냐의 차이이다.
예비요청(Prefilght)없이 바로 서버에 자원공유요청을 하며, 브라우저는 이 요청과 서버의 응답헤더를 비교하여 CORS 정책여부를 검사한다.
간단한 과정인 만큼 제약사항이 존재하는데,
- GET/POST/HEAD 중 한가지의 메소드를 선택해야 한다.
- 헤더는 Accept/Accept-Language/Content-Language/Content-Type/DPR/Downlink/Save-Data/Viewport-Width Width에 한해서만 가능하며 Customized Header는 허용하지 않는다.
- Content-type은 application/x-www-form-urlencoded/multipart/form-data, text/plain만 가능하며, application/json은 허용하지 않는다.

보통 Customer Header가 없으면 Simpel Request 전송으로 간주한다.
예비요청(preflight)을 먼저 한 후에, 여기서 승낙이 되었을때 자원공유가 이루어지는 CORS를 말한다.
예비요청의 메소드에는 특별한 형태인 OPTIONS가 사용되며, 자원공유대상의 서버가 지원하는 http method를 파악하며 어떠한 헤더 타입을 지원하는지 본요청 전에 파악하고자 보내는 예비과정이라 생각하면 된다.

예비요청을 판단하는 기준은 간단하다.
등, 단순한 Origin 정보 뿐만 아니라, 사이트 측에서 "나는 이러한 메소드로, 이러한 헤더 등으로 자원을 요청할 것이다"라고 알려주는 정보가 포함되어 있다.
이에 대해 자원공유대상 측에서는 Access-Control-Allow-Origin 정보 뿐만 아니라, 여러 응답정보를 포함하여 보낸다.
브라우저는 이 요청을 종합하여, 해당 요청에 대해 CORS정책 위반 여부를 판단하고 위반 시 요청을 막거나 예외응답을 보내도록 한다.
먼저, 요청이 동일 출처인지 판단하기 위해서는 스킴/호스트/포트가 동일한지 확인한다.
즉, https://velog.io/ 에서 스킴(https), 호스트(velog.io), 포트가 동일한지 확인한다.
예를 들어 http://velog.io의 경우 스킴(http)이 다르고, https://velog.com의 경우 호스트(velog.com)이 다르기에 동일 출처가 아닌 것으로 판단한다.
CORS를 판단하는 기준은 서버의 응답헤더를 추출하여, 해당 서버의 CORS정책이 어떠한지 브라우저가 분석한다.
정책세부사항은 Access-Control-Allow-{} 에 따라 결정된다.
[출처]
말 그대로 도메인 출처에 따라 허용한다.
Access-Control-Allow-Origin: https://velog.io
Access-Control-Allow-Origin: *
아스타의 경우 모든 도메인에 대해 허용하기에, 보안상 적합하지 않은 정책이다.
[Http Methods]
리소스 요청 메소드의 형태에 따라 허용한다.
Access-Control-Allow-Methods: GET
Access-Control-Allow-Methods: OPTIONS
preFlight request에 대한 응답으로, 실제 요청 중에 어떠한 형태를 허용하는지 기재한다.
[Headers]
실제 요청의 헤더 필드이름 중 허용가능한 형태이다.
Access-Control-Allow-Headers: Origin
Access-Control-Allow-Headers: Accept
기본값은 Origin, 이 외 Accept, X-Requested-With, Content-Type, Access-Contorl-Request-Method, Access-Contorl-Request-Headers, Custom Header 등이 있다.
[Credentials]
실제 요청에서 사용자 인증정보의 포함여부가 필요한지에 대해 기재한다.
credentials:include일 경우 포함여부는 반드시 true여야 한다.
그러나, 이경우 말 그대로 요청정보에 사용자 인증정보가 포함되어 있을 수 있기에 공격에 노출될 수 있다.
[Age]
예비요청의 결과를 특정시간 동안 캐싱(유지)할 수 있도록 허용한다.
Access-Control-Allow-Age
해당 시간동안은 예비요청을 진행하지 않고 바로 Simple Request로 진행한다.
CORS의 사전요청(pre flight Request)에는 기본적으로 인증쿠키(JSession)이 포함되어있지 않으므로,
사전 요청에는 쿠키가 없기 때문에, 해당 요청에 대해 Spring Security를 먼저 거치게 되면 authentication이 없으므로 인증/인가 예외가 무조건 발생할 수 밖에 없다.
Spring Security는 이에 대한 처리로 corsFilter를 설정하는데, corsFilter를 거치고, 이후에 Spring MVC를 거치도록 설정을 해주어야만 정상적인 보안처리가 가능해진다.
Spring Security는 corsFilter처리를 위해 corsConfigurationSource()를 제공함으로써 Spring Security에 통합, Spring MVC를 거치기전에 corsFilter를 적용하여 cors정책을 먼저 취하고, 그 이후에 사용자 인증처리(Spring Security Filter)를 적용할 수 있다.
@Bean
public CorsConfiguration corsConfigurationSource(){
CorsConfiguration configuration = new CorsConfiguration();
configuration.....
}
빈설정을 통해 corsFilter 설정이 가능하다.
Spring Security filter chain에 적용하는 방식 자체는 간단하다.
http
.authorizeRequests(auth -> auth
.requestMatchers("/login").permitAll()
.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
위와 같이 cors api를 통해 corsConfiguration을 지정할 수 있다.
이때 주입받는 corsConfiguration을 아래와 같이 빈 객체로 지정해주어 설정해주도록 한다.
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
//출처
configuration.addAllowedOrigin("http://localhost:8080");
configuration.addAllowedMethod(HttpMethod.GET);
configuration.addAllowedHeader("*");
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L); //1H
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration); //모든 경로에 대해 cors 적용
return source;
}
이 경우, 정상적인 Cors 환경설정 및 정책적용이 가능하며, 이렇게 생성한 cors 환경설정은 자원공유를 요청받는 서버를 대상으로 적용된다.
Cross Site Request Forgery, 사이트간 요청 위조이자 허위요청의 유도이다.
CSRF공격의 본질은 "사용자가 의도하지 않았지만, 브라우저가 대신 인증된 요청을 보냄으로써 발생하는 공격"으로, 쉽게 말하면 사용자 측에서는 항상 하는 행위를 하지만 공격자가 여기에 악의적인 요청을 심어놓고 사용자가 원하지 않은 보안적으로 취약한 행위를 발생하게끔 유도하는 것이다.
CSRF의 핵심 포인트는 2가지이다.
브라우저는 쿠키를 자동으로 첨부한다.
(세션 쿠키, 인증 쿠키, remember-me 등)
공격자는 '요청을 보낼 권한'이 없지만, 사용자에게 '요청을 보내도록 유도'한다.
(즉, 사용자의 인증 상태를 악용한다)
결국, 공격자는 브라우저가 자동으로 보내는 Jsession 등의 쿠키나 기본 인증 세션을 활용하여, 사용자가 의도하지 않은 보안적으로 취약한 요청을 유도하도록 하고 공격자 측 서버로 민감한 정보를 전송하게끔 유도한다.
악의적인 웹사이트를 사용자가 방문하거나, 이메일 등 악의적인 링크를 심어놓은 요청을 사용자가 실행할때 CSRF가 발생할 수 있다.
핵심은 두가지이다.
첫째, 사용자가 공격자의 의도를 수행하는데, 이때 사용자의 인증정보(인증세션 및 쿠키)가 저장된 채로 요청을 수행한다.
둘째, 이 요청은 공격자의 의도된 요청이지만, 사용자의 정보가 담긴 요청(단, 사용자가 의도하지 않은 공격자의 악성코드 로직)이기에 사이트 측은 그대로 해당 요청을 허용하며 공격자의 의도된 악성로직이 실행된다.
이 악성로직을 실행함으로써, 공격자 측에서는 이득의 행위를 취할 수 있게 되는 것이다.
말 그대로, 인증되지 않은 사용자의 악의적인 행위를 인증된 사용자의 요청처럼 위조하여 불법적인 이득을 취하는 행위가 바로 CSRF이다.
예를 들어, bank.com에 로그인한 사용자가 특정행위를 한다고 가정하자.
1) 피해자는 bank.com 에 로그인하여 세션 쿠키가 브라우저에 저장되며,
브라우저는 이제 bank.com 요청마다 자동으로 쿠키를 보낸다(즉 인증을 받은 상태로 인지하게 된다).
2) 공격자는 악성 사이트에 CSRF 공격 로직을 심어 놓은 이미지 등의 링크를 첨부한다.
<img src="https://bank.com/transfer?to=attacker&amount=10000">
이 링크를 클릭한 시점에서 이미, 즉 피해자가 악성 사이트 혹은 링크를 방문하는 것 자체만으로도 공격자의 행위를 취하게 된다.
다시 말해 단순히 웹페이지에 접근하는 것만으로도 공격이 실행된다.
3) 이미지를 클릭하면 이미지 파일을 받기 위한 해당 로직을 실행하는데, 이때 웹 사이트로부터 해당 자원을 얻어오면서 공격자가 심은 악성로직을 의도치않게 실행하게 된다.
로그인하여 인증처리를 수행한 사용자가 진행하므로, 사이트 입장에서는 아무런 의심없이 해당 요청을 수행하게 되며, 공격자 측으로 사용자의 민감정보를 넘기는 등의 행위를 진행한다.
참고로, 이러한 쿠키를 자동으로 첨부하는 행위를 막기 위해 도입한 것이 바로 Same-Site이다.
[사용자] --- 로그인 ---> [bank.com 서버]
<--- 세션 쿠키 저장 --- [브라우저]
[공격자]
|
| 악성 링크/요청 포함된 페이지 제작
v
[악성 사이트]
[사용자] --- 방문 ---> [악성 사이트]
|
v (1) 악성 요청 자동 실행
<브라우저가 bank.com으로 요청 보냄>
|
| GET /transfer?...
| Cookie: SESSION=abcd1234
v
[bank.com 서버]
|
| (2) 인증된 요청으로 간주하고 처리
|
v
[공격자에게 이득 발생]
CSRF의 본질은 공격자의 의도된 로직을 실행하게 하여, 공격자에게 유리한 이득이 발생한다는 점이다.
그렇기에 반드시 csrf 기능을 활성화 해주는 것이 권장되며, Spring Security는 이러한 csrf관련 대응방안을 제공하여 준다.
참고로 별도 설정하지 않아도 기본적으로 csrf기능은 활성화된다.
Spring Security에서 제공하는 csrf 기능은 두가지이다.
- csrf 토큰을 생성하고 이를 쿠키가 아닌 세션에 저장하여, 서버사이드의 인증을 요구하도록 한다.
- GET과 같은 단순 데이터 읽기가 아닌, PUT/POST/DELETE/INSERT와 같은 변경 요청에 대해서만 csrf 토큰의 유효성을 검증한다.
이와 같은 핵심적인 사항으로 Spring Security의 csrf 기능을 활성화해줄 수 있다.
@Bean
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http.csrf(Customizer.withDefaults());
return http.build();
}
기능 활성화 시, 클라이언트 요청 시마다 csrf토큰을 생성하여 세션(서버)에 저장하며, 폼요청(데이터 수정에 대한 PUT/POST 등의 요청)이 발생할때마다 csrf토큰(세션)은 반드시 포함되어야 한다.
브라우저에 자동으로 포함되는 쿠키가 아닌, HTTP 매개변수나 헤더 등 csrf의 토큰을 서버사이드에서 검증하도록 구현하는 것이 핵심이다.
브라우저의 쿠키를 자동으로 포함하여 토큰을 검증하는 것은 csrf의 행위에 그대로 부합하는 위험한 것이기에, 모순적이다. Spring Security는 csrf의 토큰을 쿠키화하는 것을 지원하긴 하지만 csrf에 그대로 무방비하게 노출되는 행위이므로 해당 옵션을 선택해서는 안되겠다.
물론 필요 시 csrf기능을 비활성화할 수 있다.
csrf.disabled()
csrf자체가 쿠키를 이용한 공격이 많기 때문에, 쿠키를 사용하지 않는 사이트의 경우 csrf 기능을 굳이 사용하지 않아도 되겠다.
또한,
csrf.ignoringRequestMatchers("/api");
특정 요청에 대해서는 csrf 검증 과정을 제외할 수 있기도 한다.
만약 컨트롤러에 다음과 같이
//csrf
@PostMapping("/csrf")
public String csrf() {
return "csrf is applied";
}
postMapping 동작을 정의하였고,
http
.authorizeRequests(auth -> auth
.requestMatchers("/login", "/csrf").permitAll()
.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
;
return http.build();
와 같이 해당 요청(/csrf)을 접근 허용하도록 구성하였다.

이에 post 요청을 보내면, permitAll()하였기에 접근이 이루어질 것으로 예상했던 것과 달리 csrf 토큰 유효성을 검증하지 못하였기에 로그인이 필요하며, 로그인 폼과 별도로 hidden input인 _csrf 토큰이 발생하였음을 볼 수 있다.
정확하게 말하면 접근은 통과했는데, 활성화된 csrfFilter를 통과하지 못하였다.
[CSRF Filter] ← POST에서는 CSRF 토큰 필요
|
v
[Authentication/Authorization Filter] ← permitAll()은 여기만 통과
csrf 필터를 통과하기 위해서는,
이 서로 일치해야 하며, 또한 헤더에
{
"X-CSRF-TOKEN" : "..."
}
까지 포함되어 있는 상태에서 POST요청을 해야 통과한다.
정상적으로 csrf 필터링 과정 통과 시,

이와 같이 POST요청에 대해 정상적으로 요청할 수가 있다.
이후 로그인을 시도하였더라도, POST 요청에 대해서는 반드시 csrf 토큰 검증을 무조건 시도하게 된다. 인증과 csrf검증은 별도이므로 이에 유의하면서 적용하도록 한다.
csrf에 대한 대응방안은 브라우저 측에서 쿠키에 세션ID를 자동으로 첨부하는 과정의 취약점을 보완하는데서 출발한 것이다.
즉, csrf의 공격대응방안은 세션에 csrf토큰을 저장하는 것이다.
Spring Security의 기본 CSRF 방식은 세션 기반 토큰 (Synchronizer Token Pattern)이다.
서버는 세션 생성 시 CSRF 토큰을 같이 생성하며, 위에서 쿠키처럼 보여지는 것은 자동으로 첨부되는 것이 아니라, 서버가 HTML을 렌더링할 때 input type="hidden" name="_csrf"로 토큰을 포함하는 것일 뿐이다.
브라우저는 자동으로 보내지 않되, 반드시 form data로 명시적으로 포함해야 하긴 해야한다. 그래야 서버는 “세션에 있는 토큰”과 “요청 본문/헤더의 토큰”이 일치하는지 검사하여, 해당 요청이 csrf로부터 안전한 요청인지를 확인할 수 있는 것이다.
일치하면 통과한다.
위 과정을 살펴보면 csrf토큰은 세션에 저장되어 csrf검증이 필요할때마다 해당 저장소에서 꺼내어 쓴다는 것을 알 수 있다.
이처럼 csrf토큰 역시 어딘가에 저장해두고, 요청헤더(X-CSRF-TOKEN) 및 hidden input(http parameters)와 비교하여 검증을 진행한다.
이를 위해 CsrfTokenRepository라는 인터페이스를 제공하며, 해당 인터페이스에 대한 구현체를 통해 csrf토큰을 어디에 저장할지 선택해 줄 수 있다(기본값은 세션 저장).
Session에 토큰을 저장하기 위해서는 HttpSessionCsrfTokenRepository 구현체를 사용하면 된다.
이 경우, 일전 살펴보았던 요청헤더(X-CSRF-TOKEN) 및 http 매개변수(hidden input)에 csrf 토큰값을 저장하고, 이후 세션에 저장된 토큰값과 일치여부를 판단한다.
Spring Security는 쿠키에 토큰을 저장하도록 지원하기도 하지만, 원칙적으로는 쿠키를 사용해서는 안된다(csrf공격에 그대로 노출).
이 경우, CookieCsrfTokenRepository라는 구현체를 활용하여, 쿠키이름을 XSRF-TOKEN으로 지정해주고 HTTP 요청헤더에 X-XSRF-TOKEN 지정, http 매개변수에 _csrf를 지정해줌으로써 csrf의 검증을 진행하게 된다.
원칙적으로는 해서는 안될 쿠키이지만, Javascript 기반 애플리케이션 지원을 위해 제공을 하고 있으며, 해당 환경에서 쿠키를 읽기 위해 HttpOnly를 명시적으로 false할 수 있다.
다만, 쿠키기반의 csrf 동작은 그 자체로 모순적이고 보안에 매우 취약하기에 사용을 해서는 안된다.
내부적으로 실질적인 Csrf 기반의 토큰생성 및 검증을 진행하는 핸들러가 바로 CsrfTokenRequestHandler이다.
해당 핸들러를 통해, 토큰을 생성하기도 하며 HTTP헤더 및 매개변수로부터 csrf토큰을 추출하여, 특정 저장소의 값과 비교하여 csrf토큰의 유효성을 검증한다.
기본적인 핸들러 구현체로 XorCsrfTokenRequestAttributeHandler 및 CsrfTokenRequestAttributeHandler를 제공하며, 사용자 정의 구현체를 정의 및 주입해줄 수도 있다.
csrf토큰을 생성 및 저장할때,
이때 생성 시 클라이언트 요청마다 UUID(난수)를 만들고, 이를 인코딩하여 ServletRequest속성 및 요청헤더/http매개변수 등에 저장된다(속성저장 후 각각 적절한 곳에 최종 전달).
세션에는 csrf토큰 원본을 저장하고, 요청객체 및 헤더 등에는 인코딩된 값이 저장되어, 최종적으로 유효성 검증 시에는 속성값으로 부터 추출한 값을 디코딩하여 원본값과 비교하는 과정을 거치게 된다.
이러한 과정을 거쳐서 Csrf토큰의 유효성 검증이 이루어진다.
일전에 Spring Security는 인증정보, 정확하게는 authentication 객체정보가 들어있는 SecurityContext를 매 인증시마다 추출하지 않고 필요할때만 꺼내어 쓸 수 있도록 Supplier에 임시저장하여, 성능적 이점을 확보하였다.
csrf 토큰검증도 마찬가지로, 위에서 알 수 있듯이 생성한 토큰을 요청객체에 저장하여 요청헤더 및 http 매개변수에 저장할 뿐만 아니라 html 렌더링에 hidden input으로 로딩하는 등 상당히 많은 공수가 드는 것을 확인할 수 있었다.
따라서, csrf 검증도 마찬가지로 필요한 경우에만, 필요할때까지 토큰로딩을 지연하는 지연로딩 전략을 사용하여 Spring Security는 성능적으로 이점을 확보하도록 하였다.
따라서, 세션에 저장되어있는 csrf토큰은 매 요청마다 세션으로부터 토큰값을 추출하지 않고도 보안처리를 진행할 수 있으며, POST와 같은 안전하지 않은 메서드를 처리하는 경우에 대해서만 혹은 이에 준하는 특정 경우에 대해서만 csrf 토큰을 활용하여 보안적 처리를 진행할 수 있다.
참고로 Supplier는 메모리 기반 저장소가 아니다.
저장소를 캡쳐해두는 Wrapper 인터페이스의 역할을 하는 것이고, getter를 통해 해당 참조 저장소를 필요한 시점에 추출해오기 때문에 지연로딩이 가능한 것이다.
| 개념 | 역할 | 지연 로딩 가능 근거 |
|---|---|---|
Supplier<T> | get() 시점까지 값을 가져오지 않음. 래퍼 역할 | 저장소의 참조(request, session 등)를 캡처하고 있음 |
| static | 전혀 관련 없음 | ❌ |
| 메모리 기반 저장소 | 빠른 액세스, 효율성 ↑ | 지연 가능 이유는 아니지만 효율을 높임 |
| 실제 저장소 | HttpSession, HttpServletRequest, ThreadLocal 등 | Supplier가 지연 조회하는 곳 |
CSRF공격에 대응하기 위해 csrf 토큰패턴을 사용할 경우, csrf 토큰을 http 매개변수에 포함하거나 응답헤더에 전달해야 하며, 클라이언트 측에서는 hidden input으로 csrf 토큰을 렌더링해주어야 한다.
이때, javscript 애플리케이션의 경우 fetch나 XMLHttpRequest를 통해 서버와 http통신 자체는 가능하지만, HttpOnly 설정으로 된 쿠키는 클라이언트 측 javascript에서 해당 쿠키에 접근할 수 없다.
이 경우, 브라우저가 서버로 요청을 보낼때 자동적으로 쿠키헤더를 설정해서, 즉 csrf토큰을 쿠키에 넣고 이를 자동으로 첨부하는 방식으로 진행하게 되어, csrf 공격에 노출될 수 있다.
javascript 측에서는 보통 document.cookie를 통해 csrf토큰을 읽기 시도를 하며, httpOnly를 false로 해야지만(httpOnlyFalse(true)) 클라이언트 측에서 해당 토큰을 읽고 hidden input으로 렌더링하거나 응답헤더에 추가해줄 수 있다.
이때 비로소 요청헤더에 csrf 토큰을 넣거나 hidden input추가가 가능해지므로, 안전한 통신이 가능해진다.
javscript 측에서 csrf토큰을 읽고 적절한 장소에 전달해주는 것을 가능하도록 구성해주는 것이 중요하다.
이경우 사용자가 정의한 CsrfTokenRequestHandler를 만들어서, 서버측에서 클라이언트 응답헤더 및 http변수로 전달된 csrf토큰을 파싱하고 이를 검증할 수 있도록 구현해주어야 한다.
javscript가 multi page로 구성되어, 각 페이지에서 로드되는 애플리케이션이라면 html 메타태크 내에 csrf 토큰을 포함시킬 수 있다.
<meta name="_csrf" th:content="${_csrf.token}"/>
<meta name="_csrf" th:content="${_csrf.headerName}"/>
이후 ajax 등의 요청을 통해
const csrfToken = $('meta[name="_csrf"]').attr('content')
csrfToken 값을 메타데이터로부터 추출하고, 이를 응답헤더에 넣는 방식으로 csrf토큰을 요청에 추가해줄 수 있다.
By Thymeleaf
thymeleaf를 이용한다고 가정하면, Spring security와 연동하여 간단하게 csrf토큰을 hidden input에 넣고 이를 요청 시 전달해줄 수 있다.
@PostMapping("/formCsrf")
public CsrfToken formCsrf(CsrfToken token) {
return token;
}
이를 post mapping으로 구성하여 form에서 데이터를 제출할때, 해당 formCsrf를 실행하도록 해주면 된다.
th:action="${/formCsrf}"
이때 thymeleaf에서는 해당 post mapping을 실행하기 위한 action을 지정해준다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeRequests(auth -> auth
.requestMatchers("/login", "/formx", "/formCsrf").permitAll() //기본상태 = csrf 활성화, 로그인 요청을 시도하도록 유도.
.anyRequest().authenticated())
.csrf(Customizer.withDefaults())
.formLogin(Customizer.withDefaults())
;
return http.build();
}
spring security 측에서는 해당 postmapping에 대한 권한을 허용해놓아야 하겠다.
By csrf cookies
권장하는 방법은 아니지만 cookie를 이용하는 방안도 존재하는데, cookie에 csrf token을 저장하여 이를 html 랜더링 시 추출하는 방법이다.
@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
SpaCsrfTokenRequestHandler csrfTokenRequestHandler = new SpaCsrfTokenRequestHandler();
http
.authorizeRequests(auth -> auth
.requestMatchers("/login", "/formx", "/formCsrf").permitAll() //기본상태 = csrf 활성화, 로그인 요청을 시도하도록 유도.
.anyRequest().authenticated())
.csrf(csrf ->
csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(csrfTokenRequestHandler)
)
.addFilterBefore(new CsrfCookieFilter(), BasicAuthenticationFilter.class)
.formLogin(Customizer.withDefaults())
;
return http.build();
}
먼저 Spring Security에서 csrfTokenRepository에 CookieCsrfTokenRepository.withHttpOnlyFalse()를 구성해주면, java script application에서 cookie를 통해 csrf token을 읽을 수 있다.
여기에 우리가 지정한 csrfTokenHandler(헤더로부터 원본토큰을 추출하거나, 인코딩한 토큰일 경우 XorCsrfTokenReqeustAttributeHandler에 디코딩하도록 위임)를 지나치도록 구성해주고(SpaCsrfTokenRequestHandler), 이전에 csrfToken을 확보하는 csrfFilter를 적용할 수 있도록 addFilter를 해준다.
public class SpaCsrfTokenRequestHandler extends CsrfTokenRequestAttributeHandler {
//XOR - 토큰의 처리
//CsrfTokenRequestAttributeHandler - 토큰의 디코딩
private final CsrfTokenRequestAttributeHandler csrfTokenRequestAttributeHandler = new XorCsrfTokenRequestAttributeHandler();
//csrfToken 처리 = handler에게 그대로 위임
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, Supplier<CsrfToken> deferredCsrfToken){
csrfTokenRequestAttributeHandler.handle(request, response, deferredCsrfToken);
}
@Override
public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken){
if(StringUtils.hasText(request.getHeader(csrfToken.getHeaderName()))){
//헤더가 있으면 인코딩된 csrf 토큰이므로 디코딩 필요
return super.resolveCsrfTokenValue(request, csrfToken);
}
return csrfTokenRequestAttributeHandler.resolveCsrfTokenValue(request, csrfToken);
}
}
csrf 토큰값을 그대로 전달해줄 것인지(XorCsrfTokenRequestAttributeHandler.handle), 인코딩된 토큰일 경우(=헤더존재) 토큰을 디코딩하여 전달해줄 것인지(supre.resolve)에 대한 handler를 구성한다.
public class CsrfCookieFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
CsrfToken csrfToken = (CsrfToken) request.getAttribute("_csrf");
if(csrfToken != null){
//csrfToken을 html에 렌더링
csrfToken.getToken();
/*
* 여기서 추출한 csrfToken은 csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) 설정에 의해
* html에서 이를 렌더링하여 보여줄 수 있다.
* */
}
filterChain.doFilter(request, response);
}
}
더불어 customized filter를 구성하여(해당 필터를 가장 먼저 거치게 된다), csrfToken에서 토큰값을 추출하고 이를 csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())과 연동하여 html 렌더링 시 csrf 토큰값과 같이 렌더링 할 수 있도록 한다(이후 요청 시 핸들러를 적용하여 토큰을 추출해오는 방식).
이러한 방식으로 csrf token을 사용할 수 있고, 이는 React/thymeleaf와 같이 특별한 경우이며 보통의 백엔드에서는 기본적인 csrf 기능 활성화를 통해 진행해도 무방하다.
브라우저가 쿠키를 어떠한 상황에서 넣을지를 제어하는 보안 옵션으로, csrf 공격을 억제할 수 있는 강화된 방법이라 할 수 있다.
same site의 작동원리는 간단하게 말하면, same site에서 설정한 정책에 따라 쿠키의 첨부여부를 결정하며 이에 따른 요청차단 여부를 최종적으로 결정하는 것이다.
[공격자 사이트 attacker.com]
|
| (1) 사용자의 브라우저에서 공격용 POST 요청 자동 제출
v
[브라우저 SameSite 정책 검사]
|
| (2) "이건 cross-site 요청이다"
|
| (3) SameSite=Lax/Strict → 쿠키 전송 금지
v
[피해자 사이트 victim.com]
|
| (4) 쿠키 없는 요청 → 인증 정보 없음
|
v
(5) 서버는 인증 실패 처리 → CSRF 공격 무효화
기존의 simple request와 preflight 방법도 쿠키를 무조건적으로 첨부하기에 csrf 공격 노출에 위험이 존재한다면, same site의 경우 설정한 정책에 따라 브라우저가 쿠키를 첨부할지 안할지 결정할 수 있는 옵션이다.
따라서, 공격자 입장에서는 http 요청에 쿠키가 포함되지 않기에 공격자의 의도된 행동을 인증되지 않은 사용자의 행위라 간주하여 이를 차단한다.
CORS와 same site는 애초에 다른 layer이다.
1) Simple Request
안전한 헤더만 포함한 요청하며, 브라우저는 바로 서버로 요청을 보내어 서버의 CORS 설정 응답에 따라 성공/실패한다.
2) Preflight Request
위험한 요청(커스텀 헤더, PUT/DELETE 등)에 대해 먼저 OPTIONS 요청을 보내서 서버가 허용하는지 확인하며, 허용되면 실제 요청을 전송한다.
여기서 쿠키 전송 여부를 SameSite가 결정하는 것이다.
CORS = "요청을 보낼 수 있냐?"
SameSite = "보낼 때 쿠키는 넣을 거냐?"
Spring Security에서는 same site에 대한 기능을 직접적으로 제공해주지는 않지만, 대신 Spring Session을 통한 제어가 필요한 영역이다.
더불어 독자적인 보안관리방안이 아닌, 기존의 simpe request나 PreFlight의 강화/보완 정책으로 생각하여 활용하는 것을 권장한다.
기본값은 lax이며, 엄격도에 따라 strict/lax/none으로 나눈다.
| 속성 | 의미 |
|---|---|
| Strict | 다른 사이트에서 온 요청(링크 클릭 포함)에는 절대 쿠키를 보내지 않음 |
| Lax | GET/HEAD/OPTIONS 같은 “안전한 요청”에는 쿠키 전송 허용하지만, 외부 사이트의 POST 요청에는 쿠키 미전송 |
| None | 제약 없음. 모든 크로스 사이트 요청에서도 쿠키 전송됨. (단 Secure=true 필수) |
None의 경우 보안에 가장 취약하므로, HTTPS에 의한 통신 및 Secure 쿠키를 설정해주어야 한다.
일전 기술하였듯이, Spring Security의 제어가 아닌 Spring Session을 통한 제어가 필요하다.
먼저 build.gradle에
implementation group: 'org.springframework.session', name:'spring-session-core', version:'3.2.1'
이와 같이 spring session에 대한 의존성을 추가해준다.
@Configuration
@EnableSpringHttpSession
public class HttpSessionConfig{
@Bean
public CookieSerializer cookieSerializer(){
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
serializer.setUseSecureCookie(true);
serializer.setuseHttpOnlyCookie(true);
serializer.setSameSite("lax")
}
}
이후 위와 같이 HttpSessionConfig 정보를 설정해준다면, 이제부터는 WAS에 의해 생성된 JSession이 아닌 Session 쿠키로 생성이 되며, 이는 SPring Session 관할로 관리가 된다.
마지막으로 해당 쿠키정보를 저장할 sessionRepository 빈객체를 생성한다.
@Bean
public SessionRepository<MapSession> sessionRepository() {
return new MapSessionRepository(new ConcurrentHashMap<>());
}
이처럼 Spring Security는 CSRF의 공격에 대비하여 적절한 대응방안(SOP/CORS/same site(*by spring session))을 제공하고, 성능적 이점을 확보하기 위해 지연로딩 전략을 취하는 등 사용자에게 적절한 보안전략을 취할 수 있도록 도움을 준다.
Spring Security가 제공하는 이러한 기능을 적극적으로 활용하여 기본적인 보안문제에 대응할 수 있어야 하며, 더불어 실무테스트를 진행할때도 이러한 기능이 왜 있고, 왜 적용해야 하는지 생각하면서 누락을 최소화해야 할 것이다.
나아가 다양한 상황을 가정하여, 사용자 정의 보안객체를 지정해줄 수 도 있으므로 상황에 맞는 적절한 선택 및 적용이 필요할 것으로 보인다.
Simple Request/Prefilght Request - https://pakstech.com/blog/cors-simply-explained/