CSRF 공격 방어 메커니즘
사이트 간 요청 위조(CSRF)는 인증된 사용자의 세션을 악용해 무단 작업을 수행하려는 공격 기법이다. 예를 들어 사용자가 로그인 상태일 때 악성 사이트에서 의도하지 않은 송금이나 설정 변경 요청이 자동으로 전송될 수 있다. 이를 막기 위해선 요청의 진위를 검증하는 추가적인 보안 계층이 필요하다.
CSRF 원리와 대응 방안
공격은 다음과 같은 흐름으로 진행된다:
- 사용자가 A사이트에 로그인하고 쿠키 기반 세션이 유지된다.
- 사용자가 악성 B사이트를 방문하면, 해당 페이지 내 숨겨진 폼 또는 스크립트가 A사이트로 요청을 전송한다.
- 브라우저는 자동으로 A사이트의 쿠키를 포함시켜 요청을 보내며, 서버는 이를 정상 요청으로 간주할 수 있다.
- 결과적으로 사용자 몰래 계정 정보 수정, 결제 등 민감 작업이 실행된다.
이러한 위험을 차단하기 위한 주요 방법은 다음과 같다:
- 토큰 기반 검증: 서버가 고유한 비밀 토큰을 생성하고 클라이언트가 후속 요청 시 이 값을 함께 제출하도록 한다. 서버는 일치 여부를 확인한다.
- 쿠키-헤더 일치 검사: 동일한 값의 토큰을 쿠키와 요청 본문(또는 헤더)에 모두 저장하고, 두 값이 동일한지 비교한다. 크로스 도메인에서는 쿠키 읽기가 불가능하므로 공격자는 이를 우회하기 어렵다.
- 커스텀 HTTP 헤더: AJAX 요청 시
X-Requested-With와 같은 임의 헤더를 포함하면, 브라우저의 동일 출처 정책에 의해 타 도메인에서 요청을 재현하기 어려워진다. - Referer 헤더 검증: 요청의 출처 도메인이 신뢰 가능한지 확인하지만, 프라이버시 설정으로 인해 누락되거나 변조될 수 있어 단독 사용은 권장되지 않는다.
Spring Boot 환경에서의 CSRF 보호 구현
Spring Security는 기본적으로 CSRF 방어를 활성화하며, 개발자는 이를 쉽게 확장하거나 커스터마이징할 수 있다.
기본 설정 예시
먼저 보안 설정 클래스에서 CSRF 보호를 명시적으로 구성한다.
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/public/**").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.csrf().disable(); // 특정 상황에서만 비활성화 (주의!)
}
}
실제 운영 환경에서는 대부분 활성화 상태로 유지해야 하며, 일부 API 엔드포인트에서만 예외 처리를 고려한다.
템플릿 엔진과의 통합
Thymeleaf를 사용할 경우, 아래와 같이 폼 내에 토큰이 자동 삽입된다.
<form th:action="@{/submit}" method="post">
<input type="text" name="data" />
<button type="submit">전송</button>
<!-- Thymeleaf 자동 생성: <input type="hidden" name="_csrf" value="..." /> -->
</form>
AJAX 요청 처리
JavaScript 기반 요청에서는 메타 태그에서 토큰을 추출하여 헤더에 포함시킨다.
<meta name="_csrf" content="${_csrf.token}" />
<meta name="_csrf_header" content="${_csrf.headerName}" />
// JS 코드
const token = document.querySelector('meta[name="_csrf"]').getAttribute('content');
const header = document.querySelector('meta[name="_csrf_header"]').getAttribute('content');
fetch('/api/data', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
[header]: token
},
body: JSON.stringify({ value: 'test' })
});
CORS 정책 관리
다른 도메인에서의 자원 접근을 허용하기 위해 CORS(Cross-Origin Resource Sharing)를 적절히 설정해야 한다. 잘못된 설정은 보안 취약점을 유발할 수 있으므로 세심한 주의가 필요하다.
전역 설정
애플리케이션 전체에 적용할 CORS 규칙을 정의할 수 있다.
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.cors(c -> {
CorsConfigurationSource source = request -> {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://trusted-site.com"));
config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
config.setAllowCredentials(true);
return config;
};
c.configurationSource(source);
})
.authorizeRequests().anyRequest().authenticated();
}
}
컨트롤러 수준 설정
특정 엔드포인트에 대해 더 세밀한 제어가 필요한 경우, 애너테이션을 사용한다.
@RestController
@RequestMapping("/api")
public class ApiController {
@CrossOrigin(
origins = "https://trusted-site.com",
methods = {RequestMethod.GET},
maxAge = 3600
)
@GetMapping("/data")
public ResponseEntity<String> getData() {
return ResponseEntity.ok("Success");
}
}
내용 보안 정책(CSP) 적용
CSP는 XSS 공격을 방지하기 위한 강력한 도구로, 어떤 출처의 스크립트나 리소스가 로드될 수 있는지를 선언적으로 제어한다.
기본 CSP 설정
Spring Security를 통해 응답 헤더에 CSP 정책을 추가할 수 있다.
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.headers()
.contentSecurityPolicy("default-src 'self'; script-src 'self' https://cdn.example.com; img-src 'self' data:;")
.and()
// 기타 설정...
}
이 정책은 기본적으로 모든 리소스를 동일 출처에서만 허용하며, 스크립트는 지정된 외부 CDN에서만 로드 가능하게 한다.
CSP 위반 보고 기능
정책 위반이 발생했을 때 이를 수집하기 위해 보고 URI를 지정할 수 있다.
.contentSecurityPolicy("default-src 'self'; report-uri /csp-violation-report;")
수신 컨트롤러는 다음과 같이 구현한다.
@PostMapping("/csp-violation-report")
public ResponseEntity<Void> handleCspReport(@RequestBody String payload) {
// 위반 로그 기록 또는 분석 시스템 전달
log.warn("CSP 위반 감지: {}", payload);
return ResponseEntity.accepted().build();
}
동적 보안 전략
정적 설정만으로는 부족한 경우, 요청 컨텍스트에 따라 보안 정책을 동적으로 조정할 수 있다.
사용자 역할 기반 CSP
관리자와 일반 사용자에게 서로 다른 스크립트 로딩 권한을 부여하는 필터 예시:
@Component
public class DynamicCspFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
String role = extractUserRole(request); // 실제 로직 구현 필요
String policy = role.equals("ADMIN") ?
"script-src 'self' 'unsafe-inline'" :
"script-src 'self'";
response.setHeader("Content-Security-Policy", policy);
chain.doFilter(req, res);
}
}
분산 환경에서의 CSRF 토큰 관리
여러 인스턴스를 운영할 경우, Redis와 같은 외부 저장소에 토큰을 공유할 수 있다.
public class RedisCsrfTokenRepository implements CsrfTokenRepository {
private final StringRedisTemplate redisTemplate;
public RedisCsrfTokenRepository(StringRedisTemplate template) {
this.redisTemplate = template;
}
@Override
public CsrfToken generateToken(HttpServletRequest request) {
return new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", UUID.randomUUID().toString());
}
@Override
public void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response) {
String key = "csrf:" + request.getSession().getId();
if (token == null) {
redisTemplate.delete(key);
} else {
redisTemplate.opsForValue().set(key, token.getToken(), Duration.ofMinutes(30));
}
}
@Override
public CsrfToken loadToken(HttpServletRequest request) {
String key = "csrf:" + request.getSession().getId();
String token = redisTemplate.opsForValue().get(key);
return token != null ? new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", token) : null;
}
}
이러한 전략들은 각각의 보안 요구사항에 맞게 조합되어 사용되며, 과도한 보안은 UX를 해칠 수 있고, 부족한 보안은 시스템을 노출시키므로 균형 잡힌 설계가 중요하다.