토리맘의 한글라이즈 프로젝트 logo 토리맘의 한글라이즈 프로젝트

스프링 시큐리티 공식 레퍼런스를 한글로 번역한 문서입니다.

전체 목차는 여기에 있습니다.

목차:


스프링 시큐리티는 인증 (authentication), 인가 (authorization), 주요 취약점 공격 방어를 종합적으로 지원한다. 다른 라이브러리와의 통합도 지원하므로 사용성을 단순화해 준다.


5.1. Authentication

스프링 시큐리티는 종합적인 인증 (authentication) 처리를 지원한다. 인증은 특정 리소스에 접근하려고 하는 사용자가 누구인지를 확인할 때 사용한다. 보통 사용자가 이름과 비밀번호를 입력하는 것으로 사용자를 인증하곤 한다. 한 번 인증하고 나면 사용자를 식별하고 권한을 부여할 수 있다 (인가, authorization).

5.1.1. Authentication Support

스프링 스큐리티는 사용자 인증 처리를 기본으로 제공한다. 서블릿과 웹플럭스 스택에서 지원하는 인증에 관한 상세 정보는 각 섹션을 참고하라.

5.1.2. Password Storage

스프링 시큐리티의 PasswordEncoder 인터페이스는 비밀번호를 안전하게 저장할 수 있도록 단방향 변환을 수행해 준다. PasswordEncoder는 비밀번호를 단방향으로 변환한다. 즉, 양방향 변환을 목적으로 만들지 않았다 (i.e. 인증에 사용할 credential 정보 저장). 보통 PasswordEncoder를 사용해서 저장하는 비밀번호는 인증 시점에 사용자가 입력하는 비밀번호와 비교하는 용도로 쓴다.

Password Storage History

지난 몇 년 동안 표준 암호 저장 메커니즘도 진화했다. 처음에는 일반 텍스트로 암호를 저장했다. 이때는 비밀번호를 담고 있는 데이터에 접근하려면 credential이 필요했기 때문에 비밀번호가 안전하다고 생각했었다. 하지만 악의적인 SQL 인젝션 등의 공격으로 사용자 이름과 비밀번호 “데이터 덤프”를 읽어갈 수 있는 방법이 존재했다. 점점 더 많은 사용자의 credential이 공개 보안이 돼버리면서, 전문가들은 사용자 비밀번호를 보호하기 위한 다른 무언가가 필요하다는 것을 깨달았다.

이후엔 비밀번호를 저장하기 전에 SHA-256같은 단방향 해시를 적용하는 게 개발자들의 관행이었다. 사용자가 인증을 시도하면, 이 해시 처리한 비밀번호를 사용자가 입력한 비밀번호의 해시값과 비교한다. 즉, 시스템에선 비밀번호의 단방향 해시만 저장하면 됐다. 악의적으로 다른 사람의 비밀번호를 조회하더라도, 비밀번호의 단방향 해시값만 조회할 수 있을 뿐이었다. 해시는 단방향이고, 해시값만으로 비밀번호를 추측하기란 거의 불가능했기 때문에, 시스템의 암호를 알아내려고 해도 소용없었다. 이런 시스템에 대응하기 위해 악의적인 사용자들은 레인보우 테이블로 알려진 룩업 테이블을 만들기 시작했다. 매번 비밀번호를 추측해내는 대신, 비밀번호를 미리 계산해서 룩업 테이블에 저장한 것이다.

레인보우 테이블을 무력화하기 위한 방안으로 개발자들은 솔티드 패스워드 (salted password)를 사용했다. 단순히 비밀번호를 해시 함수 입력으로 사용하는 대신, 모든 사용자의 비밀번호로 랜덤 바이트 (솔트로 알려진)를 만들었다. 솔트와 사용자의 비밀번호로 해시 함수를 실행하면 유니크한 해시값을 생성한다. 솔트는 사용자의 비밀번호와 함께 일반 텍스트로 저장한다. 사용자가 인증을 시도하면, 해시처리한 비밀번호를 저장된 솔트와 사용자가 입력한 비밀번호의 해시값과 비교한다. 솔트는 유니크하기 때문에 솔트와 비밀번호 조합도 절대 동일할 수 없으며, 레인보우 테이블은 효력을 잃었다.

하지만 이제는 SHA-256같은 암호화 해시가 더 이상 안전하지 않다는 것을 안다. 최신 하드웨어를 사용하면 해시를 초당 수십억 건 계산할 수 있기 때문이다. 즉, 모든 비밀번호를 쉽게 해독할 수 있다.

현재는 적응형 단방향 함수 (adaptive one-way function)로 비밀번호를 저장하는 게 좋다. 적응형 단방향 함수는 많은 리소스를 (i.e. CPU, 메모리 등) 소모해서 비밀번호를 검증한다. 적응형 단방향 함수는 하드웨어 사양에 따라 “워크 팩터 (work factor)”를 지정할 수 있다. 시스템에서 비밀번호를 검증할 때 1초 정도 소요되도록 “워크 팩터”를 튜닝하는 것을 권장한다. 이 방식은 공격자가 쉽게 비밀번호를 해독하지 못하게 만들긴 하지만, 그만큼 시스템 자체에 부담이 되기도 한다. 스프링 시큐리티는 “워크 팩터”의 시작점을 제공하지만, 성능은 시스템마다 천차만별이기 때문에 각자의 시스템에 맞게 커스텀하는 게 좋다. 사용할 수 있는 적응형 단방향 함수의 예로는 bcrypt, PBKDF2, scrypt, argon2가 있다.

적응형 단방향 함수는 내부적으로 리소스를 많이 소모하기 때문에 매 요청마다 사용자 이름과 비밀번호를 검증하면 어플리케이션 성능을 크게 떨어트릴 수 있다. 보안을 적용해서 검증하는 것 자체가 리소스를 소모하는 일이기 때문에 스프링 시큐리티 (또는 다른 라이브러리라도)가 비밀번호 검증 속도를 끌어올릴 방법은 없다. 사용하는 쪽에서 직접 장기 credential을 (i.e. 사용자 이름과 비밀번호) 단기 credential (i.e. 세션, OAuth 토큰 등)로 바꾸는 게 좋다. 단기 credential은 동일한 보안 수준을 유지하면서도 빨리 검증할 수 있다.

DelegatingPasswordEncoder

스프링 시큐리티 5.0 버전 이전엔 일반 텍스트 비밀번호를 받는 NoOpPasswordEncoder가 디폴트 PasswordEncoder였다. Password History 섹션을 봤다면 현재 디폴트 PasswordEncoderBCryptPasswordEncoder나 이와 유사한 무언가라고 생각했을 것이다. 하지만 현실 세계에 있는 세 가지 문제를 간과했다:

대신 스프링 시큐리티는 DelegatingPasswordEncoder를 도입해서 다음과 같은 방법으로 이 문제를 해결한다:

DelegatingPasswordEncoder 인스턴스는 간단히 PasswordEncoderFactories로 만들 수 있다.

Example 18. Create Default DelegatingPasswordEncoder

PasswordEncoder passwordEncoder =
    PasswordEncoderFactories.createDelegatingPasswordEncoder();

아니면 커스텀 인스턴스를 만들 수도 있다. 예를 들어:

Example 19. Create Custom DelegatingPasswordEncoder

String idForEncode = "bcrypt";
Map encoders = new HashMap<>();
encoders.put(idForEncode, new BCryptPasswordEncoder());
encoders.put("noop", NoOpPasswordEncoder.getInstance());
encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());
encoders.put("sha256", new StandardPasswordEncoder());

PasswordEncoder passwordEncoder =
    new DelegatingPasswordEncoder(idForEncode, encoders);

Password Storage Format

일반적인 비밀번호 포맷은 다음과 같다:

Example 20. DelegatingPasswordEncoder Storage Format

{id}encodedPassword

여기서 id는 사용할 PasswordEncoder를 식별하는 데 사용하는 값이고, encodedPassword는 선택한 PasswordEncoder에서 사용할 인코딩된 비밀번호다. id는 반드시 비밀번호 앞에 있어야 하며, {로 시작하고 }로 끝나야 한다. id를 찾을 수 없다면 id는 null이 된다. 다음 예시처럼 다양한 id와 인코딩한 비밀번호를 조합할 수 있다. 원본 비밀번호는 모두 “password”다.

Example 21. DelegatingPasswordEncoder Encoded Passwords Example

{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG // (1)
{noop}password // (2)
{pbkdf2}5d923b44a6d129f3ddf3e3c8d29412723dcbde72445e8ef6bf3b508fbf17fa4ed4d6b99ca763d8dc // (3)
{scrypt}$e0801$8bWJaSu2IKSn9Z9kM+TPXfOc/9bdYSrN1oD9qfVThWEwdRTnO7re7Ei+fUZRJ68k9lTyuTeUp4of4g24hHnazw==$OAOec05+bXxvuu/1qZ6NUR+xQYvYv7BeL1QxwRpY5Pc= // (4)
{sha256}97cde38028ad898ebc02e690819fa220e88c62e0699403e94fff291cfffaf8410849f27605abcbc0 // (5)

(1) 첫 번째 비밀번호의 PasswordEncoder id는 “bcrypt”이고, encodedPassword는 $2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG”이다. 이는 BCryptPasswordEncoder로 위임한다.
(2) 두 번째 비밀번호는 PasswordEncoder id는 “noop”이고, encodedPassword는 “password”다. 이는 NoOpPasswordEncoder로 위임한다.
(3) 세 번째 비밀번호의 PasswordEncoder id는 “pbkdf2”이고, encodedPassword는 “5d923b44a6d129f3ddf3e3c8d29412723dcbde72445e8ef6bf3b508fbf17fa4ed4d6b99ca763d8dc”이다. 이는 Pbkdf2PasswordEncoder로 위임한다.
(4) 네 번째 비밀번호의 PasswordEncoder id는 “scrypt”이고, encodedPassword는 “$e0801$8bWJaSu2IKSn9Z9kM+TPXfOc/9bdYSrN1oD9qfVThWEwdRTnO7re7Ei+fUZRJ68k9lTyuTeUp4of
4g24hHnazw==$OAOec05+bXxvuu/1qZ6NUR+xQYvYv7BeL1QxwRpY5Pc=”이다. 이는 SCryptPasswordEncoder로 위임한다.

(5) 마지막 비밀번호의 PasswordEncoder id는 “sha256”이고, encodedPassword는 “97cde38028ad898ebc02e690819fa220e88c62e0699403e94fff291cfffaf8410849f27605abcbc0”이다. 이는 StandardPasswordEncoder로 위임한다.

저장 포맷이 해커에게 노출될까 염려되는 사람도 있을 것이다. 하지만 비밀번호 저장은 비밀로 할 알고리즘과는 관계가 없으므로 걱정하지 않아도 된다. 게다가 프리픽스가 없어도 포맷을 대부분 쉽게 알아낼 수 있다. 예를 들어 BCrypt 비밀번호는 보통 $2a$로 시작한다.

Password Encoding

생성자에 전달한 idForEncode가 비밀번호를 인코딩할 때 사용할 PasswordEncoder를 결정한다. 위에서 만든 DelegatingPasswordEncoder에선 password 인코딩 결과를 BCryptPasswordEncoder로 위임하며, 프리픽스는 {bcrypt}를 사용한다. 결과는 다음과 같을 것이다:

Example 22. DelegatingPasswordEncoder Encode Example

{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG

Password Matching

{id}를 기반으로 매칭되며, id는 생성자에 제공한 PasswordEncoder로 매핑된다. Password Storage Format에서 사용한 예제는 동작 방식을 확인할 수 있는 실전 예시다. 기본적으로 비밀번호와 id가 매핑되지 않으면 (null id 포함) matches(CharSequence, String)IllegalArgumentException을 던진다. 이는 DelegatingPasswordEncoder.setDefaultPasswordEncoderForMatches(PasswordEncoder)로 커스텀할 수 있다.

id를 사용하면 어떤 인코딩과도 매치시킬 수 있지만, 가장 최신에 나온 기법으로 비밀번호를 인코딩할 수 있다. 비밀번호 해시는 암호화와는 달리 간단히 일반 텍스트로 복구할 수 없게 설계했기 때문에, 인코딩 id가 중요한 역할을 한다. 물론, 일반 텍스트로 복구할 방법이 없기 때문에 비밀번호를 마이그레이션하기도 어렵다. 좀 더 쉽게 시작할 수 있도록 마이그레이션하기 쉬운 NoOpPasswordEncoder를 기본적으로 포함시켰다.

Getting Started Experience

데모나 샘플을 구성한다면 사용자 비밀번호를 해싱하는 데 걸리는 시간이 꽤나 번거롭게 느껴질 것이다. 쉽게 해결할 수 있는 메커니즘이 있긴 하지만, 프로덕션 레벨에선 사용하지 않는 게 좋다.

Example 23. withDefaultPasswordEncoder Example

User user = User.withDefaultPasswordEncoder()
  .username("user")
  .password("password")
  .roles("user")
  .build();
System.out.println(user.getPassword());
// {bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG

사용자를 여러 개 만든다면 빌더를 재사용해도 된다.

Example 24. withDefaultPasswordEncoder Reusing the Builder

UserBuilder users = User.withDefaultPasswordEncoder();
User user = users
  .username("user")
  .password("password")
  .roles("USER")
  .build();
User admin = users
  .username("admin")
  .password("password")
  .roles("USER","ADMIN")
  .build();

이렇게 하면 저장할 비밀번호를 해싱하긴 하지만 메모리나 컴파일된 소스 코드에 비밀번호가 노출된다. 따라서 프로덕션 환경에서 사용할 만큼 안전하다고 할 수 없다. 프로덕션 환경에선 비밀번호를 외부에서 해싱해야 한다.

Encode with Spring Boot CLI

스프링 부트 CLI를 사용하면 가장 쉬우면서도 적합한 방법으로 비밀번호를 인코딩할 수 있다.

예를 들어 아래처럼 사용하면 비밀번호 password를 인코딩해 주며 DelegatingPasswordEncoder에서도 사용할 수 있다:

Example 25. Spring Boot CLI encodepassword Example

spring encodepassword password
{bcrypt}$2a$10$X5wFBtLrL/kHcmrOGGTrGufsBX8CJ0WpQpF3pgeuxBB/H73BK1DW6

Troubleshooting

저장된 비밀번호 중 하나라도 Password Storage Format에서 설명했던 id가 없으면 아래와 같은 에러가 발생한다.

java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"
    at org.springframework.security.crypto.password.DelegatingPasswordEncoder$UnmappedIdPasswordEncoder.matches(DelegatingPasswordEncoder.java:233)
    at org.springframework.security.crypto.password.DelegatingPasswordEncoder.matches(DelegatingPasswordEncoder.java:196)

이 에러를 해결할 가장 쉬운 방법은 비밀번호를 인코딩한 PasswordEncoder를 직접 명시하는 것이다. 비밀번호가 어떻게 저장됐는지 파악한 다음 알맞는 PasswordEncoder를 지정하는 식으로 말이다.

스프링 시큐리티 4.2.x에서 마이그레이션한다면 NoOpPasswordEncoder 빈을 정의해서 이전 동작으로 되돌릴 수 있다.

아니면 모든 비밀번호에 알맞은 id를 프리픽스로 추가하면 계속해서 DelegatingPasswordEncoder를 사용할 수도 있다. 예를 들어 BCrypt를 사용한다면 다음과 같이 비밀번호를 마이그레이션한다:

// 원본
$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
// 마이그레이션 결과
{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG

매핑할 수 있는 전체 id는 PasswordEncoderFactories javadoc을 참고하라.

BCryptPasswordEncoder

BCryptPasswordEncoder 구현체는 널리 사용되고 있는 bcrypt 알고리즘으로 비밀번호를 해싱한다. bcrypt는 의도적으로 느리게 동작하기 때문에 비밀번호를 해독하기 어렵다. 다른 적응형 단방향 함수와 마찬가지로 시스템에서 비밀번호 하나를 검증하는 데 1초가량 걸리도록 조정해야 한다.

// Create an encoder with strength 16
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(16);
String result = encoder.encode("myPassword");
assertTrue(encoder.matches("myPassword", result));

Argon2PasswordEncoder

Argon2PasswordEncoder 구현체는 Argon2 알고리즘으로 비밀번호를 해싱한다. Argon2는 Password Hashing Competition에서 우승한 전력도 있다. 커스텀 하드웨어에서도 비밀번호를 해독하지 못하도록 Argon2는 의도적으로 메모리를 많이 사용하며 느리게 동작한다. 다른 적응형 단방향 함수와 마찬가지로 시스템에서 비밀번호 하나를 검증하는 데 1초가량 걸리도록 조정해야 한다. 최신 구현체는 BouncyCastle 라이브러리가 필요하다.

// Create an encoder with all the defaults
Argon2PasswordEncoder encoder = new Argon2PasswordEncoder();
String result = encoder.encode("myPassword");
assertTrue(encoder.matches("myPassword", result));

Pbkdf2PasswordEncoder

Pbkdf2PasswordEncoder 구현체는 PBKDF2 알고리즘으로 비밀번호를 해싱한다. PBKDF2는 의도적으로 느리게 동작하기 때문에 비밀번호를 해독하기 어렵다. 다른 적응형 단방향 함수와 마찬가지로 시스템에서 비밀번호 하나를 검증하는 데 1초가량 걸리도록 조정해야 한다. FIPS 인증이 필요하다면 이 알고리즘이 적합할 것이다.

// Create an encoder with all the defaults
Pbkdf2PasswordEncoder encoder = new Pbkdf2PasswordEncoder();
String result = encoder.encode("myPassword");
assertTrue(encoder.matches("myPassword", result));

SCryptPasswordEncoder

SCryptPasswordEncoder 구현체는 scrypt 알고리즘으로 비밀번호를 해싱한다. 커스텀 하드웨어에서도 비밀번호를 해독하지 못하도록 scrypt는 의도적으로 메모리를 많이 사용하며 느리게 동작한다. 다른 적응형 단방향 함수와 마찬가지로 시스템에서 비밀번호 하나를 검증하는 데 1초가량 걸리도록 조정해야 한다.

// Create an encoder with all the defaults
SCryptPasswordEncoder encoder = new SCryptPasswordEncoder();
String result = encoder.encode("myPassword");
assertTrue(encoder.matches("myPassword", result));

Other PasswordEncoders

이전 버전과의 호환성을 위해 남겨둔 다른 PasswordEncoder 구현체도 아주 많다. 이 구현체는 모두 더 이상 안전하지 않다는 것을 나타내기 위해 deprecated 되어있다. 하지만 기존에 사용하던 레거시 시스템을 마이그레이션하기는 어렵기 때문에 삭제할 계획은 없다.

Password Storage Configuration

스프링 시큐리티는 디폴트로 DelegatingPasswordEncoder를 사용한다. 하지만 PasswordEncoder를 스프링 빈으로 정의하면 변경할 수 있다.

스프링 시큐리티 4.2.x에서 마이그레이션한다면 NoOpPasswordEncoder 빈을 정의해서 이전 동작으로 되돌릴 수 있다.

NoOpPasswordEncoder로 돌아가면 안전하다고 할 수 없다. DelegatingPasswordEncoder로 마이그레이션해서 안전한 비밀번호 인코딩을 지원하는 것이 좋다.

Example 26. NoOpPasswordEncoder

java xml kotlin
@Bean
public static NoOpPasswordEncoder passwordEncoder() {
    return NoOpPasswordEncoder.getInstance();
}
<b:bean id="passwordEncoder"
        class="org.springframework.security.crypto.password.NoOpPasswordEncoder" factory-method="getInstance"/>
@Bean
fun passwordEncoder(): PasswordEncoder {
    return NoOpPasswordEncoder.getInstance();
}

XML 설정에선 NoOpPasswordEncoder 빈 이름을 passwordEncoder로 설정해야 한다.


5.2. Protection Against Exploits

스프링 시큐리티는 주요 취약점 공격으로부터 어플리케이션을 보호해준다. 가능한 모든 곳에서 디폴트로 활성화되어 있다. 아래에서는 스프링 시큐리티가 대응하는 다양한 취약점 공격 심화 내용을 살펴볼 것이다.

5.2.1. Cross Site Request Forgery (CSRF)

스프링은 사이트 간 요청 위조 (Cross Site Request Forgery, CSRF) 공격에 대한 방어를 종합적으로 지원한다. 이어지는 섹션에서 다음을 알아보겠다:

여기에선 CSRF 방어에 관한 일반적인 주제를 다룬다. 서블릿웹플럭스 기반 어플리케이션에서 필요한 CSRF 방어 관련 정보는 해당 섹션을 참고하라.

What is a CSRF Attack?

CSRF 공격을 이해하려면 구체적인 예시를 살펴보는 게 가장 좋다.

현재 로그인한 계정에서 다른 은행 계좌로 돈을 송금할 수 있는 은행 웹사이트를 만든다고 가정해 보자. 다음과 같은 송금 폼을 제공할 것이다:

Example 27. Transfer form

<form method="post"
    action="/transfer">
<input type="text"
    name="amount"/>
<input type="text"
    name="routingNumber"/>
<input type="text"
    name="account"/>
<input type="submit"
    value="Transfer"/>
</form>

이를 위한 HTTP 요청은 다음과 같을 것이다:

Example 28. Transfer HTTP request

POST /transfer HTTP/1.1
Host: bank.example.com
Cookie: JSESSIONID=randomid
Content-Type: application/x-www-form-urlencoded

amount=100.00&routingNumber=1234&account=9876

이제 은행 웹사이트에서 인증한 뒤, 로그아웃하지 않고 다른 악의적인 웹사이트에 방문했다고 해보자. 이 웹사이트의 HTML 페이지엔 다음과 같은 폼이 있다:

Example 29. Evil transfer form

<form method="post"
    action="https://bank.example.com/transfer">
<input type="hidden"
    name="amount"
    value="100.00"/>
<input type="hidden"
    name="routingNumber"
    value="evilsRoutingNumber"/>
<input type="hidden"
    name="account"
    value="evilsAccountNumber"/>
<input type="submit"
    value="Win Money!"/>
</form>

돈을 준다길래 제출 버튼을 누른다. 이 과정에서 의도치 않게 악의적인 사용자에게 100달러를 송금하고 말았다. 이 웹사이트에선 쿠키를 볼 순 없지만, 은행 사이트에서 사용했던 쿠키도 요청에 포함되기 때문에 실제로 가능한 일이다.

아직 끝나지 않았다. 이 전체 프로세스를 자바스크립트로 자동화했을 수도 있다. 버튼을 클릭할 필요도 없었다는 뜻이다. 게다가 정직한 사이트를 방문했다고 해도 XSS 공격의 표적이 되면 똑같이 쉽게 재현할 수 있는 일이다. 그렇다면 어떻게 사용자를 이런 공격으로부터 보호할 수 있을까?

Protecting Against CSRF Attacks

CSRF 공격이 먹히는 이유는 공격받는 웹사이트의 HTTP 요청과 공격하는 웹사이트의 요청이 완전히 동일하기 때문이다. 그렇기 때문에 악의적인 웹사이트의 요청은 거절하고 은행 웹사이트의 요청만 수락할 수 있는 뾰족한 방법이 없다. CSRF 공격을 방어하려면 악의적인 사이트에서는 제공할 수 없는 무언가를 요청에 사용해서 두 요청을 구분해야만 한다.

스프링은 CSRF 공격을 방어하기 위한 두 가지 메커니즘을 제공한다:

두 가지 모두 safe HTTP 메소드는 반드시 멱등성을 보장해야 한다.

Safe Methods Must be Idempotent

CSRF를 방어하는 두 가지 메커니즘 모두 잘 동작하려면 “safe” HTTP 메소드는 모두 멱등성을 보장해야 한다. 즉, HTTP 메소드 GET, HEAD, OPTIONS, TRACE 요청은 어플리케이션 상태를 변화시키면 안 된다.

Synchronizer Token Pattern

CSRF 공격을 방어하는 방법은 동기화 토큰 패턴이 지배적이며, 가장 포괄적이기도 하다. 이 패턴은 모든 HTTP 요청에 세션 쿠키와는 별도로 CSRF 토큰이라 불리는 안전한, 랜덤으로 생성한 값을 추가한다.

HTTP 요청을 제출하면 서버에서 의도한 CSRF 토큰을 찾아 실제 HTTP 요청에 있는 CSRF 토큰과 비교한다. 값이 일치하지 않으면 HTTP 요청을 거절한다.

여기서 핵심은 HTTP 요청에 브라우저가 자동으로 넣어주지 않는 CSRF 토큰이 있어야 한다는 것이다. 예를 들어 HTTP 파라미터나 HTTP 헤더에서 실제 CSRF 토큰을 받으면 CSRF 공격을 방어할 수 있다. 쿠키는 브라우저가 HTTP 요청에 자동으로 포함시키기 때문에, CSRF 토큰을 쿠키에서 받으면 효과가 없다.

어플리케이션 상태를 업데이트하는 HTTP 요청에서만 CSRF 토큰을 사용하도록 조건을 완화해도 좋다. 이를 위해선 반드시 safe 메소드는 멱등성을 보장해야 한다. 이렇게 하면 외부 사이트에서 링크를 통해 우리 웹사이트에 들어올 수 있으므로 사용성을 개선하는 효과도 있다. 게다가 HTTP GET 요청에 랜덤 토큰을 사용하면 토큰이 유출될 수도 있다.

동기화 토큰 패턴을 사용하면 우리 예제가 어떻게 바뀌는지 살펴보자. CSRF 토큰은 _csrf란 이름의 HTTP 파라미터로 받는다고 가정한다. 어플리케이션의 송금 폼은 다음과 같이 바뀐다:

Example 30. Synchronizer Token Form

<form method="post"
    action="/transfer">
<input type="hidden"
    name="_csrf"
    value="4bfd1575-3ad1-4d21-96c7-4ef2d9f86721"/>
<input type="text"
    name="amount"/>
<input type="text"
    name="routingNumber"/>
<input type="hidden"
    name="account"/>
<input type="submit"
    value="Transfer"/>
</form>

이제 폼에는 CSRF 토큰값을 가지고 있는 숨겨진 입력이 있다. 동일 출처 정책 (same origin policy )에 따라 외부 사이트는 응답을 볼 수 없기 때문에 CSRF를 읽어갈 수 없다.

이에 따른 돈을 송금하는 HTTP 요청은 다음과 같다:

Example 31. Synchronizer Token request

POST /transfer HTTP/1.1
Host: bank.example.com
Cookie: JSESSIONID=randomid
Content-Type: application/x-www-form-urlencoded

amount=100.00&routingNumber=1234&account=9876&_csrf=4bfd1575-3ad1-4d21-96c7-4ef2d9f86721

이제 HTTP 요청엔 안전한 랜덤 값이 _csrf 파라미터로 들어가 있다. 악의적인 웹사이트에선 _csrf 파라미터에 정확한 값을 넣을 수 없으며 (직접 제공해야 하기 때문에), 서버에서 가지고 있는 CSRF 토큰과 실제 요청에 있는 CSRF 토큰을 비교하면 돈을 송금하지 못한다.

SameSite Attribute

최근에 생겨난 다른 CSRF 공격 방어법은 쿠키에 SameSite 속성을 지정하는 것이다. 서버에서 쿠키에 SameSite 속성을 명시하는 것으로 외부 사이트가 보내는 요청엔 쿠키를 사용하지 않겠다고 지정할 수 있다.

스프링 시큐리티는 세션 쿠키 생성을 직접 제어하지 않으므로, SameSite 속성을 지원하지 않는다. 서블릿 기반 어플리케이션에선 스프링 세션SameSite 속성을 지원한다. 웹플럭스 기반 어플리케이션 전용 SameSite 속성은 스프링 프레임워크의 CookieWebSessionIdResolver가 지원한다.

예를 들어 SameSite 속성을 사용한 HTTP 응답 헤더는 다음과 같다:

Example 32. SameSite HTTP response

Set-Cookie: JSESSIONID=randomid; Domain=bank.example.com; Secure; HttpOnly; SameSite=Lax

SameSite 속성에 사용할 수 있는 값은 다음과 같다:

우리 예제SameSite 속성을 사용하면 어떻게 될지 살펴보자. 이제 세션 쿠키에 SameSite 속성을 명시해서 은행 어플리케이션을 CSRF 공격으로부터 보호할 것이다.

세션 쿠키에 SameSite 속성을 설정했다면, 브라우저는 은행 웹사이트에서 보내는 요청엔 JSESSIONID 쿠키를 계속해서 보낸다. 하지만 다른 웹사이트에서 보내는 송금 요청엔 더 이상 JSESSIONID 쿠키를 전송하지 않는다. 악의적인 웹사이트에서 보내는 송금 요청엔 더 이상 세션이 존재하지 않으므로, 이제 어플리케이션은 CSRF 공격으로부터 안전하다.

SameSite 속성으로 CSRF 공격을 방어할 땐 꼭 알아둬야 할 주의사항이 있다.

SameSite 속성을 Strict로 지정하는 게 더 안전하긴 하지만, 사용자를 혼란스럽게 만들 수도 있다. 사용자가 https://social.example.com에서 호스팅하는 SNS에 로그인한 상태라고 가정해 보자. 사용자는 https://email.example.org에서 이 SNS 사이트로 가는 링크가 적힌 이메일을 하나 받는다. 링크를 클릭하면 SNS 사이트에서 정당한 방식으로 사용자를 인증할 것이라 생각했을 것이다. 하지만 SameSite 속성이 Strict였다면 쿠키를 전송하지 않기 때문에 사용자를 인증할 수 없다.

gh-7537을 구현하면 보안을 강화하면서도 CSRF 공격 방어를 위한 SameSite 사용성을 개선할 수 있다.

또 하나 반드시 주의해야 할 점은, SameSite 속성으로 사용자를 보호하려면 반드시 브라우저도 SameSite 속성을 지원해야 한다는 것이다. 최신 브라우저는 대부분 SameSite 속성을 지원한다. 하지만 아직 사용 중인 옛날 브라우저 중에는 지원하지 않는 브라우저도 있다.

이와 같은 이유로 SameSite 속성은 보통 CSRF 공격을 방어하는 유일한 대안이라기보단 이중 방어 장치로 사용하길 권장한다.

When to use CSRF protection

CSRF는 어떨 때 방어해야 할까? 일반 사용자가 브라우저에서 처리할 수 있는 모든 요청에 CSRF 방어를 적용하길 권장하는 바이다. 만들고 있는 서비스를 브라우저가 아닌 다른 클라이언트에서만 사용한다면 CSRF 방어를 비활성화해도 좋다.

CSRF protection and JSON

흔히들 “자바스크립트에서 만드는 JSON 요청에도 방어처리가 필요한가요?”라고 묻곤 한다. 짧게 대답하자면, 상황에 따라 다르다. 단, JSON 요청을 이용하는 CSRF 취약점 공격도 있다는 점에 주의해야 한다. 예를 들어 악의적으로 다음 폼을 사용해서 JSON으로 CSRF 공격을 시도할 수 있다:

Example 33. CSRF with JSON form

<form action="https://bank.example.com/transfer" method="post" enctype="text/plain">
    <input name='{"amount":100,"routingNumber":"evilsRoutingNumber","account":"evilsAccountNumber", "ignore_me":"' value='test"}' type='hidden'>
    <input type="submit"
        value="Win Money!"/>
</form>

이 폼은 다음과 같은 JSON을 만든다:

Example 34. CSRF with JSON request

{ 
  "amount": 100,
  "routingNumber": "evilsRoutingNumber",
  "account": "evilsAccountNumber",
  "ignore_me": "=test"
}

어플리케이션에서 Content-Type을 검증하지 않았다면 바로 취약점 공격에 노출된다. 스프링 MVC 어플리케이션은 Content-Type을 검증하더라도, 설정에 따라 다음과 같이 .json으로 끝나는 URL에선 여전히 공격에 취약할 수도 있다.

Example 35. CSRF with JSON Spring MVC form

<form action="https://bank.example.com/transfer.json" method="post" enctype="text/plain">
    <input name='{"amount":100,"routingNumber":"evilsRoutingNumber","account":"evilsAccountNumber", "ignore_me":"' value='test"}' type='hidden'>
    <input type="submit"
        value="Win Money!"/>
</form>

CSRF and Stateless Browser Applications

어플리케이션에 상태가 없다면 어떨까? 꼭 안전하다고만은 할 수 없다. 사실 사용자가 웹 브라우저 요청으로 수행하는 작업이 없다고 해도 CSRF 공격에 노출돼 있는 것은 동일하다.

예를 들어 인증을 위한 모든 상태를 JSESSIONID 대신 커스텀 쿠키에 저장하는 어플리케이션을 생각해 보자. CSRF 공격을 받으면 전에 살펴본 예제에서 JSESSIONID 쿠키가 전송된 것과 동일하게 커스텀 쿠키도 요청에 포함될 것이다. 이런 어플리케이션은 CSRF 공격에 취약하다.

기본 인증을 사용하는 어플리케이션도 똑같이 CSRF 공격에 노출돼 있다. 이전 예제에서 JSESSIONID 쿠키가 전송된 것과 동일한 방식으로 브라우저가 모든 요청에 사용자 이름과 비밀번호를 추가하기 때문이다.

CSRF Considerations

CSRF 공격을 방어한다면 특별히 고려해야 할 몇 가지가 있다.

Logging In

로그인 요청 위조를 막으려면 로그인 HTTP 요청을 CSRF 공격으로부터 보호해야 한다. 악의적으로 다른 사람의 민감 정보를 볼 수 없게 하려면 반드시 로그인 요청 위조를 막아야 한다. 공격은 다음과 같이 이루어진다:

로그인 HTTP 요청을 CSRF로부터 보호하고 나면 생길 수 있는 문제는, 세션이 타임아웃되면 사용자가 요청을 거절당하는 불편함을 겪을 수 있다는 것이다. 로그인할 때 세션이 필요하다는 것을 모르는 사용자에게 세션 타임아웃은 뜻밖일 것이다. 자세한 정보는 CSRF and Session Timeouts를 참고하라.

Logging Out

로그아웃 요청 위조를 막으려면 로그아웃 HTTP 요청을 CSRF 공격으로부터 보호해야 한다. 악의적으로 다른 사람의 민감 정보를 볼 수 없게 하려면 반드시 로그아웃 요청 위조를 막아야 한다. 공격에 관한 자세한 내용은 이 블로그 게시글을 참고하라.

로그아웃 HTTP 요청을 CSRF로부터 보호하고 나면 생길 수 있는 문제는, 세션이 타임아웃되면 사용자가 요청을 거절당하는 불편함을 겪을 수 있다는 것이다. 로그아웃할 때 세션이 필요하다는 것을 모르는 사용자에게 세션 타임아웃은 뜻밖일 것이다. 자세한 정보는 CSRF and Session Timeouts를 참고하라.

CSRF and Session Timeouts

서버에서 비교할 때 쓸 CSRF 토큰값은 종종 세션에 저장하곤 한다. 이 말은 세션이 만료하는 즉시 서버에선 CSRF 토큰값을 조회할 수 없으므로 HTTP 요청을 거절한다는 뜻이다. 타임아웃을 해결할 수 있는 방법은 많으며 모두 각각의 장단점이 있다.

Multipart (file upload)

CSRF 공격으로부터 멀티파트 요청을 (파일 업로드) 보호하려고 할 땐 닭이 먼저인가, 달걀이 먼저인가하는 문제와 맞닥뜨리게 된다. CSRF 공격을 막으려면 반드시 HTTP 요청 body를 읽어 실제 CSRF 토큰을 확인해야 한다. 하지만 body를 읽는다는 것은 파일이 업로드된다는 뜻이므로, 외부 사이트에서도 파일을 업로드할 수 있다.

multipart/form-data에서 사용할 수 있는 CSRF 방어 옵션은 두 가지가 있다. 두 방법 모두 장단점이 있다.

스프링 시큐리티의 CSRF 방어와 멀티파트 파일 업로드를 통합하기 전에 먼저, CSRF 방어 없이 업로드가 잘 되는지 확인해 봐라. 스프링에서 멀티파트 폼을 사용하는 방법은 스프링 레퍼런스 1.1.11. Multipart Resolver 섹션과 MultipartFilter javadoc에서 찾아볼 수 있다.

Place CSRF Token in the Body

첫 번째 방법은 요청 body에 실제 CSRF 토큰을 추가하는 것이다. CSRF 토큰을 body에 넣으면 body를 읽고 나서 권한을 부여한다. 즉, 누구든지 서버에 임시 파일을 만들 수 있다. 하지만 결국엔 인가된 사용자가 제출한 파일만 처리된다. 임시 파일 업로드가 서버에 주는 영향은 거의 무시해도 될 수준이기 때문에, 일반적으로 권장하는 방법이다.

Include CSRF Token in URL

권한이 없는 사용자가 임시 파일을 업로드하게 만드는 게 불가능하다면, 폼의 action 속성에 쿼리 파라미터로 CSRF 토큰을 넣는 것도 방법이다. 쿼리 파라미터가 유출될 수 있다는 단점은 있다. 민감한 정보를 유출하지 않는, 좀 더 일반적인 관행은 body나 헤더에 두는 것이다. 자세한 정보는 RFC 2616 섹션 15.1.3 URI의 민감한 정보 인코딩하기에서 찾아볼 수 있다.

HiddenHttpMethodFilter

일부 어플리케이션에선 폼 파라미터로 HTTP 메소드를 재정의하기도 한다. 예를 들어 아래 폼은 post가 아닌 delete 메소드를 사용한다.

Example 36. CSRF Hidden HTTP Method Form

<form action="/process"
    method="post">
    <!-- ... -->
    <input type="hidden"
        name="_method"
        value="delete"/>
</form>

HTTP 메소드 재정의는 필터에서 일어난다. 이 필터는 스프링 시큐리티 필터보다 먼저 처리돼야 한다. 물론 재정의는 post에서만 일어나므로 실제로 문제를 일으키진 않을 것이다. 그래도 스프링 시큐리티 필터보다 앞에 두는 게 가장 좋은 관행이다.

5.2.2. Security HTTP Response Headers

이번에 다룰 주제는 보안 HTTP 응답 헤더에 관한 일반적인 내용이다. 서블릿웹플럭스 기반 어플리케이션에서 필요한 보안 헤더 정보는 해당 섹션을 참고하라.

웹 어플리케이션의 보안을 위해 사용할 수 있는 HTTP 응답 헤더는 다양하다. 이번 섹션은 스프링 시큐리티가 직접 지원하는 여러 가지 HTTP 응답 헤더를 설명할 것이다. 필요하다면 스프링 시큐리티에 커스텀 헤더를 설정할 수도 있다.

Default Security Headers

서블릿웹플럭스 기반 어플리케이션에서 디폴트 헤더를 커스텀하는 방법은 각 해당 섹션을 참고하라.

스프링 시큐리티는 기본적인 보안을 위한 HTTP 응답 헤더의 디폴트 셋을 제공한다.

아래 있는 헤더도 스프링 시큐리티의 디폴트 헤더에 해당한다:

Example 37. Default Security HTTP Response Headers

Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Content-Type-Options: nosniff
Strict-Transport-Security: max-age=31536000 ; includeSubDomains
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block

Strict-Transport-Security 헤더는 HTTP 요청에만 추가된다.

이 디폴트 헤더만으로 해결할 수 없는 요구사항이 있다면, 간단하게 디폴트 헤더를 제거하거나, 수정, 추가할 수 있다. 각 헤더에 대한 자세한 설명은 해당 섹션을 참고하라:

Cache Control

서블릿웹플럭스 기반 어플리케이션에서 디폴트 헤더를 커스텀하는 방법은 각 해당 섹션을 참고하라.

스프링 시큐리티는 사용자 컨텐츠를 보호하기 위해 기본적으로 캐시를 비활성화한다.

민감한 정보 조회 권한을 인가받은 사용자가 로그아웃했을 때, 다른 사용자가 악의적으로 뒤로 가기 버튼을 눌러 해당 정보를 보지 못하게 하기 위해서다. Cache-Control 헤더는 디폴트로 다음과 같이 전송한다:

Example 38. Default Cache Control HTTP Response Headers

Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0

스프링 시큐리티는 보안을 위해 기본적으로 이 헤더를 추가한다. 하지만 어플리케이션 자체에서 Cache-Control 헤더를 사용하면 스프링 시큐리티는 디폴트 헤더 값을 추가하지 않는다. 따라서 어플리케이션에서 원하는 CSS나 자바스크립트같은 스태틱 리소스를 캐시할 수 있다.

Content Type Options

서블릿웹플럭스 기반 어플리케이션에서 디폴트 헤더를 커스텀하는 방법은 각 해당 섹션을 참고하라.

인터넷 익스플로러를 포함해서, 브라우저는 컨텐츠 스니핑을 사용해 요청의 컨텐츠 타입을 추론해왔다. 브라우저가 컨텐츠 타입을 명시하지 않은 리소스의 컨텐츠 타입을 추측하기 때문에 사용자는 좀 더 나은 경험을 할 수 있다. 예를 들어 브라우저는 컨텐츠 타입을 명시하지 않은 자바스크립트 파일을 만나면, 컨텐츠 타입을 추론한 다음 실행한다.

컨텐츠 업로드를 허용한다면 추가로 해야 할 일이 많다 (i.e. 별도 도메인에서 문서를 노출하고, Content-Type 헤더를 설정했는지 확인하고, 유해 컨텐츠를 삭제하는 등). 하지만 이는 스프링 시큐리티 범위를 벗어난다. 컨텐츠 스니핑을 비활성화한다면 반드시 컨텐트 타입을 지정해야 정상 작동한다는 점도 주의해야 한다.

컨텐츠 스니핑의 문제점은 악의적으로 다국어를 (i.e. 여러 컨텐츠 타입에서 유효한 파일) 사용해 XSS 공격을 실행할 수 있다는 점이다. 예를 들어 사용자가 후기 글을 작성하고 조회할 수 있는 웹사이트가 있다고 해보자. 악의적으로 자바스크립트 파일로서도 유효한 후기 글을 생성해서 XSS 공격을 시도할 수 있다.

스프링 시큐리티는 디폴트로 HTTP 응답에 다음 헤더를 추가해서 컨텐츠 스니핑을 비활성화한다:

Example 39. nosniff HTTP Response Header

X-Content-Type-Options: nosniff

HTTP Strict Transport Security (HSTS)

서블릿웹플럭스 기반 어플리케이션에서 디폴트 헤더를 커스텀하는 방법은 각 해당 섹션을 참고하라.

은행 웹사이트 주소를 직접 치고 들어간다면, mybank.example.com, https://mybank.example.com 둘 중 무엇을 타이핑할 것인가? https 프로토콜을 생략하는 건 중간자 공격 (Man in the Middle attack)에 취약하다. 웹사이트에서 https://mybank.example.com으로 리다이렉트하더라도, 악의적으로 최초 HTTP 요청을 가로채서 응답을 조작할 수 있다 (i.e. https://mibank.example.com로 리다이렉트해서 credential 탈취).

https 프로토콜을 생략하고 접근하는 사용자가 많기 때문에 HTTP Strict Transport Security (HSTS)가 생겨났다. mybank.example.com이 HSTS 호스트에 추가되면 브라우저는 mybank.example.com 요청을 https://mybank.example.com으로 해석해야 한다는 것을 미리 알 수 있다. 따라서 중간자 공격을 받을 가능성이 현저히 떨어진다.

RFC6797에 따르면 HTTPS 응답에만 HSTS 헤더를 추가한다. 브라우저가 헤더를 인식하려면 먼저 브라우저가 신뢰할 수 있는 CA에서 서명한 SSL 인증서로 커넥션을 맺어야 한다 (단순히 SSL 인증서라고 다 되는 게 아니다).

사이트를 HSTS 호스트로 표시하는 한 가지 방법은 브라우저에 미리 호스트를 로드하는 것이다. 응답에 Strict-Transport-Security 헤더를 추가하는 방법도 있다. 예를 들어 스프링 시큐리티는 디폴트로 다음 헤더를 추가해서 브라우저에게 1년간 (1년은 대략 31536000초다) 이 도메인을 HSTS 호스트로 취급하라고 알린다:

Example 40. Strict Transport Security HTTP Response Header

Strict-Transport-Security: max-age=31536000 ; includeSubDomains ; preload

includeSubDomains는 선택 사항인데, 브라우저에게 하위 도메인도 (i.e. secure.mybank.example.com) HSTS 도메인으로 취급해야 한다고 알리는 지시문이다.

preload도 선택이며, 이는 브라우저에 이 도메인을 미리 HSTS 도메인으로 로드하도록 알린다. HSTS 프리로드에 관한 자세한 내용은 https://hstspreload.org를 참고하라.

HTTP Public Key Pinning (HPKP)

스프링 시큐리티는 하위 호환성을 위해 아직 서블릿 환경에서 HPKP를 지원하고 있지만, 위에서 봐왔던 이유로 더 이상 HPKP 사용을 권장하지 않는다.

HTTP Public Key Pinning (HPKP)은 특정 웹 서버에서 사용할 공개키를 웹 클라이언트에 지정하는 방식으로, 위조 인증서를 사용하는 중간자(MITM) 공격을 방어한다. HPKP를 제대로 사용한다면 인증서 손상을 막기 위한 레이어가 추가된다. 하지만 HPKP의 복잡성 때문에 많은 전문가가 더 이상 사용하지 않기를 권장하고 있으며, 크롬은 지원을 중단했다.

HPKP를 더 이상 권장하지 않는 이유는 HTTP 공개키 고정은 끝났나요?HPKP를 포기합니다를 참고하라.

X-Frame-Options

서블릿웹플럭스 기반 어플리케이션에서 디폴트 헤더를 커스텀하는 방법은 각 해당 섹션을 참고하라.

프레임에 웹사이트를 넣을 수 있게 허용하는 것은 보안 이슈가 있다. 예를 들어 CSS를 잘 활용하면 무언가를 클릭했을 때 의도치 않은 동작을 실행할 수 있다 (데모 영상). 예를 들어 은행 사이트에 로그인한 사용자가 버튼을 클릭하면 다른 사용자에게 권한을 내줄 수도 있다. 이런 류의 공격을 클릭재킹 (Clickjacking)이라고 한다.

컨텐츠 보안 정책 (Content Security Policy , CSP)도 클릭재킹을 방어하는 또 하나의 최신 메커니즘이다.

클릭재킹 공격을 막을 방법은 여러 가지가 있다. 예를 들어 구 버전 브라우저에선 프레임을 막는 코드를 작성하면 된다. 완전하진 않지만 구 버전 브라우저에서 할 수 있는 최선의 방법이다.

최신 브라우저는 X-Frame-Options 헤더로 클릭재킹을 방어한다. 스프링 시큐리티는 기본적으로 다음 헤더를 통해 아이프레임 내부에서 페이지를 렌더링할 수 없게 막는다:

X-Frame-Options: DENY

X-XSS-Protection

서블릿웹플럭스 기반 어플리케이션에서 디폴트 헤더를 커스텀하는 방법은 각 해당 섹션을 참고하라.

일부 브라우저는 기본적으로 reflected XSS 공격을 필터링한다. 이것만으로 완전하게 막을 순 없지만, 어쨌든 XSS 공격을 일차적으로 막아주는 것은 맞다.

필터링은 디폴트로 활성화되어 있기 때문에 이 헤더를 추가하는 것만으로 확인할 수 있으며, 브라우저가 XSS 공격을 감지했을 때 할 일을 지시할 수 있다. 예를 들어 브라우저가 가장 덜 극단적인 방법을 택해서, 컨텐츠를 수정하고 페이지를 전부 렌더링하게 만들 수 있다. 하지만 이 방식은 컨텐츠 수정 자체가 XSS 취약점이 되기도 한다. 컨텐츠를 수정하는 대신 컨텐츠를 막아버리는 게 가장 좋다. 스프링 시큐리티는 디폴트로 다음 헤더를 사용해서 컨텐츠를 허용을 막는다:

X-XSS-Protection: 1; mode=block

Content Security Policy (CSP)

서블릿웹플럭스 기반 어플리케이션에서 디폴트 헤더를 커스텀하는 방법은 각 해당 섹션을 참고하라.

컨텐츠 보안 정책(CSP)은 웹 어플리케이션에서 XSS(cross-site scripting)같은 컨텐츠 인젝션 공격 취약성을 개선할 수 있는 메커니즘이다. CSP는 웹 어플리케이션이 로드할 수 있는 리소스를 개발자가 직접 명시해서 궁극적으로 클라이언트에게 (user-agent) 알리는 정책이다.

컨텐츠 보안 정책은 모든 인젝션 공격 취약점을 해결해 주지 않는다. 그보단 CSP는 컨텐츠 인젝션 공격의 피해를 최소화해준다고 봐야 한다. 일차적인 방어는 웹 어플리케이션에서 입력을 검증하고 출력을 인코딩하는 것이다.

웹 어플리케이션에서 CSP를 사용하려면 다음 헤더 중 하나를 HTTP 응답에 추가하면 된다:

이 헤더는 클라이언트에게 보안 정책을 전달하는 메커니즘으로 사용된다. 보안 정책은 특정 리소스 표현을 제안하는 보안 정책 지시문의 집합이다.

예를 들어 웹 어플리케이션 응답에 다음 헤더를 추가하면, 신뢰할 수 있는 특정 리소스에서만 스크립트를 로드한다:

Example 41. Content Security Policy Example

Content-Security-Policy: script-src https://trustedscripts.example.com

script-src에 선언한 리소스 외의 다른 리소스에서 스크립트를 로드하려고 하면 user-agent에서 막힌다. 추가로 보안 정책에 report-uri를 선언하면 user-agent는 이런 시도가 있을 때마다 지정 URL에 보고한다.

예를 들어 다음 응답 헤더는 웹 어플리케이션이 선언한 보안 정책을 위반하면, user-agent에 report-uri에 명시한 URL로 보고할 것을 지시한다.

Example 42. Content Security Policy with report-uri

Content-Security-Policy: script-src https://trustedscripts.example.com; report-uri /csp-report-endpoint/

Violation reports는 표준 JSON 구조를 따르며, 웹 어플리케이션 자체 API로 수집할 수도 있고, 아니면 https://report-uri.io/같은 공개적으로 호스팅되는 CSP violation 리포팅 서비스를 이용해도 된다.

Content-Security-Policy-Report-Only 헤더를 사용하면 보안 정책을 바로 적용하는 대신, 개발자와 관리자가 보안 정책을 모니터링할 수 있다. 이 헤더는 보통 보안 정책이 실험/개발 단계일 때 사용한다. 정책에 효과가 있다고 판단되면, 이 헤더 대신 Content-Security-Policy 헤더 필드를 사용해서 정책을 시행하면 된다.

다음은 두 리소스 중 하나에서 리소스를 로드할 수 있다는 정책을 선언한 헤더다:

Example 43. Content Security Policy Report Only

Content-Security-Policy-Report-Only: script-src 'self' https://trustedscripts.example.com; report-uri /csp-report-endpoint/

이땐 evil.com의 스크립트를 로드하려고 하면 정책에 위배되므로, user-agent는 report-uri에 명시한 URL로 리포트를 보내지만, 리소스는 여전히 로드할 수 있다.

웹 어플리케이션에 컨텐츠 보안 정책을 적용하기가 쉽지 않을 수도 있다. 다음 사이트를 참고하면 효과적인 보안 정책을 만드는 데 조금이나마 보탬이 될 것이다.

Referrer Policy

서블릿웹플럭스 기반 어플리케이션에서 디폴트 헤더를 커스텀하는 방법은 각 해당 섹션을 참고하라.

Referrer 정책은 사용자가 마지막으로 방문한 페이지를 가지고 있는 referrer 필드를 관리할 수 있는 메커니즘이다.

스프링 시큐리티는 다양한 정책을 지원하는 Referrer-Policy 헤더를 사용한다:

Example 44. Referrer Policy Example

Referrer-Policy: same-origin

응답 헤더에 Referrer-Policy를 사용하면 브라우저가 사용자가 이전에 방문했던 곳을 도착지에 알린다.

Feature Policy

서블릿웹플럭스 기반 어플리케이션에서 디폴트 헤더를 커스텀하는 방법은 각 해당 섹션을 참고하라.

Feature 정책은 웹 개발자가 원하는 대로 특정 API와 브라우저의 웹 기능을 활성화, 비활성화하거나 동작을 수정할 수 있게 해 준다.

Example 45. Feature Policy Example

Feature-Policy: geolocation 'self'

Feature 정책을 사용하면, 개발자가 브라우저의 “정책” 집합에 관여해서 사이트 전체에서 사용할 특정 기능을 강제할 수 있다. 예를 들어 사이트에서 접근할 수 있는 API를 제한하거나, 특정 기능에서의 브라우저 동작 방식을 바꿀 수 있다.

Clear Site Data

서블릿웹플럭스 기반 어플리케이션에서 디폴트 헤더를 커스텀하는 방법은 각 해당 섹션을 참고하라.

Clear Site Data는 HTTP 응답에 아래 헤더를 추가해서 쿠키, 로컬 스토리지 등의 브라우저 단 데이터를 삭제할 수 있는 메커니즘이다:

Clear-Site-Data: "cache", "cookies", "storage", "executionContexts"

데이터 삭제는 로그아웃할 때 실행하기 알맞은 작업이다.

Custom Headers

서블릿 기반 어플리케이션에서 디폴트 헤더를 커스텀하는 방법은 해당 섹션을 참고하라.

스프링 시큐리티는 좀 더 일반적인 보안 헤더를 어플리케이션에 편리하게 추가할 수 있는 메커니즘을 제공한다. 하지만 커스텀 헤더를 추가할 수 있는 훅도 함께 제공한다.

5.2.3. HTTP

스태틱 리소스를 포함한 모든 HTTP 통신은 TLS로 보호해야 한다.

스프링 시큐리티는 프레임워크이기 때문에 HTTP 커넥션을 직접 다루지 않으며, HTTPS를 직접적으로 지원하지 않는다. 하지만 HTTPS 사용을 도와줄 만한 몇 가지 기능을 제공한다.

Redirect to HTTPS

스프링 시큐리티는 클라이언트에서 HTTP를 사용했을 때 서블릿, 웹플럭스 환경 모두 HTTPS로 리다이렉트하게 설정할 수 있다.

Strict Transport Security

스프링 시큐리티는 Strict Transport Security를 지원하며 기본적으로 활성화되어 있다.

Proxy Server Configuration

프록시 서버를 사용한다면 어플리케이션 설정에 문제가 없는지도 잘 확인해야 한다. 많은 어플리케이션이 로드 밸런서를 사용하고 있으며, 예를 들어 https://example.com/에 들어온 요청을 https://192.168.1:8080에 있는 어플리케이션 서버로 전달한다. 설정을 잘못하면 어플리케이션 서버에선 로드 밸런서의 존재를 알지 못하고, 클라이언트가 https://192.168.1:8080로 요청한 것으로 인지할 수 있다.

RFC 7239를 사용해서 로드 밸런서를 사용 중임을 명시하면 이를 방지할 수 있다. 어플리케이션 서버에도 X-Forwarded 헤더류 알 수 있게 설정해줘야 이를 인지할 수 있다. 예를 들어 톰캣은 RemoteIpValve를, 제티는 ForwardedRequestCustomizer를 사용한다. 스프링을 사용한다면 ForwardedHeaderFilter를 활용해도 된다.

스프링 부트 사용자는 어플리케이션 설정에 server.use-forward-headers 프로퍼티를 추가하면 된다. 자세한 내용은 스프링 부트 문서를 참고하라.


전체 목차는 여기에 있습니다.

<< >>