본문 바로가기

프로젝트

SameSite 쿠키 정책으로 발생한 문제와 해결 방법에 관하여

지스트 청원 프로젝트를 진행하는 과정에서 쿠키가 의도하는 방식으로 동작하지 않았고, 이를 해결하는 과정에서 SameSite 쿠키의 문제임을 확인했고 이를 정리해보고자 한다.

 

문제 상황

- 프론트 서버: S3정적 웹호스팅을 활용하여 배포(http://petition-bucket.s3-website.ap-northeast-2.amazonaws.com)

- api 서버: ec2서버에 도메인 연결(http://dev-api.gist-petition.com)

 

우리 프로젝트는 왜 세션을 도입하게 되었는가? 글에서 확인할 수 있듯이, 우리 팀은 세션 방식을 활용하여 로그인 상태를 유지한다.

처음 api 요청 시 `Set-Cookie: JSESSIONID={세션 ID}; HttpOnly`가 응답에 담겨 오고, 이후에 요청에서는 `Cookie: JSESSIONID={세션 ID}` 값이 헤더에 담겨서 보내질 것으로 예상했다.

하지만 실제 위 환경에서 실행했을 때, 응답에서는 Set-Cookie가 담겨오지만, 이를 이후에 요청 쿠키에 담아서 보내지는 않고 있었다. 브라우저의 Warning 메시지를 봤을 때, 브라우저의 SameSite정책으로 발생한 문제였고 이에 대해 알아봤다.

SameSite 쿠키 정책

MDN 문서에서는 다음과 같이 SameSite를 정의한다.

The SameSite attribute of the Set-Cookie HTTP response header allows you to declare if your cookie should be restricted to a first-party or same-site context.

SameSite는 HTTP 응답의 Set-Cookie의 하나의 속성 중에 하나로, 무조건적으로 Cookie를 저장하는 것이 아니라 같은 Site여부에 따라 결정하도록 한다. 각각의 옵션을 우리 서비스에 대입해보며 어떤 의미인지를 분석해보자.

 

1. SameSite=Strict

서버 to 브라우저: "같은 Site에서 작성된 요청일 때에만 쿠키 정보를 담아서 보내줘!"

우리 프로젝트에서는 프론트 js 코드로 api 서버에 요청을 보내게 된다. 브라우저가 입장에서는 'petition-bucket.s3-website.ap-northeast-2.amazonaws.com'에서 작성된 코드가 'dev-api.gist-petition.com'으로 요청을 보낸다라고 인식하고 있고, 이는 같은 Site가 아니기 때문에 쿠키 정보를 담지 않고 보내게 된다.

 

2. SameSite=Lax (사전에서는 loose의 의미)

서버 to 브라우저: "같은 Site에서 작성된 요청일 때에만 쿠키 정보를 담아서 보내주는데, 몇 가지 예외상황인 경우는 같이 보내줘도 댐"

 

위의 Strict와 기본은 같지만, 이미지 요청을 보낼 때와 같은 예외 상황에서는 쿠키를 담아 보내준다. 만약 프론트의 코드가 api서버의 이미지를 요청한다면 이때에는 쿠키 정보를 같이 보내주도록 진행할 것이다.

 

3. SameSite=None

서버 to 브라우저: "우리 서버는 다른 Site에서 작성된 요청도 모두 쿠키를 담아서 보내줘도 상관없음!"

 

s3의 도메인 주소로 부터 받아온 코드로부터 우리 api서버로 요청을 보내도 항상 쿠키를 같이 보내주게 된다.

 

하지만, 우리 서버는 SameSite에 대한 설정을 진행하지 않았다!

 

20년 4월에 론칭된 크롬 80 버전부터는 SameSite의 기본값이 None에서 Lax로 변경되었다. 즉 프론트와 api의 서버가 다른 Site로 구성이 된 우리의 구조상 당연히 쿠키를 보내주지 않는 것이다. 그렇다면, 우리 서비스는 SameSite 설정을 None으로 바꿔 이를 해결하면 될까?

 

SameSite가 None인 경우 발생할 수 있는 문제점

은행 계정을 로그인하고 이 로그인 정보가 토큰으로 관리된다고 하자. 이 경우 악성 스크립트를 통해 특정 계좌로 돈을 입금하는 명령을 보내도록 진행할 수 있다. None인 경우에는 Site에 상관없이 쿠키를 전달하기 때문에 실제로 돈이 입금된다.(이러한 공격을 CSRF 공격이라 한다) 이러한 문제를 막기 위해 크롬에서는 Lax를 기본값으로 설정하여 이미지 요청에 경우만 쿠키를 같이 실어 보내주는 거라 생각된다.

 

우리 서비스 또한 악성 스크립트를 통해 로그인된 사용자가 청원 게시글을 작성할 수 있기 때문에, SameSite=None을 사용하게 되었을 때의 위험성이 존재한다.

해결 방법

우리 팀이 해결한 방법은 간단하다.

프론트 서버를 api서버와 Same-Site로 만들어 주는 것이다. Same-Site가 명확히 무엇인지에 대해 궁금한 부분은 이 링크를 확인하기 바란다. 결론을 이야기하자면, subdomain을 다르게 가져가더라도 브라우저는 Same-Site로 인식한다. 아래와 같은 도메인 연결로 이 문제를 해결했다. cloudfront는 https로 동작하는데, http, https의 스키마 차이로 다른 사이트로 인식한다는 문제도 발생해 둘 모두 https로 스키마 변경을 진행해주었다. (s3 정적 호스팅을 도메인에 연결하는데 정상적으로 되지 않았고, 도메인 연결했던 경험이 있는 cloudfront를 도입하게 되었다) 

 

- 프론트 서버: cloudfront 도메인 연결 (https://dev.gist-petition.com)

- api 서버: ec2서버에 도메인 연결 (https://dev-api.gist-petition.com)

 

 

한 가지 남은 문제점

위의 방식을 통해 프론트 서버와 api 서버의 통신 문제를 해결할 수 있었다. 하지만, 프론트 서버는 local환경에선 dev-api 서버의 api호출을 하는 필요성이 있으며, 이는 다시 `localhost:3000`, `dev-api.gist-petition.com` 서로 다른 site 간에 통신에서 api호출 시 쿠키를 보내지 않는 현상이 발생했다.

 

생각해본 해결 방안

- dev 환경: SameSite=None

- prod 환경: SameSite=Lax or Strict

이 방식으로 진행하는 것이 어떨까 싶다. dev서버의 경우 CSRF 공격을 받더라도 그 데이터의 의미가 크지 않기 때문에 가장 간단한 해결 방법이 되지 않을까 생각한다.

 

 

Spring Servlet Session를 사용할 떄 Same-Site 옵션 설정하기

우리 프로젝트에서는 Springboot Servlet에서 제공하는 Session을 사용한다. 해당 클래스에는 쿠키 클래스도 정의해 두고 있는데 다음과 같다. domain, path, httpOnly, secure옵션을 제공하고 있지만 Same-Site의 옵션을 제공하지는 않고 있다. 이 옵션들은 application.yml에 작성해 주는 것으로 설정할 수 있다.

public static class Cookie {
   private String name;
   private String domain;
   private String path;
   private String comment;
   private Boolean httpOnly;
   private Boolean secure;
}
//application.yml
server.servlet.session.cookie:
  http-only: true
  path: /
  secure: true

하지만, Same-Site 옵션은 Servlet Session에서는 제공해주지 않는다. 어떻게 Set-Cookie쿠키 header 값에 `SameSite=None` 옵션을 추가해줄 수 있을까?

 

해결 방법 => Servlet Filter에서 SameSite 옵션 추가하기
아래의 SameSiteFilter를 정의함으로써 이 문제를 해결할 수 있었다. Set-Cookie 헤더를 순회하며 직접 SameSite 옵션을 추가해주었다. `@Value`어노테이션을 이용하여 yml 파일에서 해당 설정을 진행할 수 있도록 확장성을 고려했다.

@Component
public class SameSiteFilter implements Filter {
    @Value("${server.servlet.session.cookie.sameSite}")
    private String sameSitePolicy;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        chain.doFilter(request, response);
        addSameSiteCookieAttribute((HttpServletResponse) response);
    }

    private void addSameSiteCookieAttribute(HttpServletResponse response) {
        Collection<String> headers = response.getHeaders(HttpHeaders.SET_COOKIE);
        boolean firstHeader = true;
        for (String header : headers) {
            if (firstHeader) {
                response.setHeader(HttpHeaders.SET_COOKIE, String.format("%s; %s", header, "SameSite=" + sameSitePolicy));
                firstHeader = false;
                continue;
            }
            response.addHeader(HttpHeaders.SET_COOKIE, String.format("%s; %s", header, "SameSite=" + sameSitePolicy));
        }
    }
}

Session에 대해 추가 학습을 진행해보니, Servlet Session과 별도로  Spring Session을 사용할 수 있는 것을 확인했다. Spring Session의 CookieSerializer 활용하면 이를 더 우아하게 풀어낼 수 있을 것이라 생각한다. 프로젝트에서는 Spring Session을 도입하지 않아 이번에는 진행하지 않았지만, 추후 학습을 진행하게 된다면 이 글에 반영하겠다.