SNUXI 개발일지 - 2 [CORS와 보안]
프론트 분들과 협업하다가 만난 CORS에 대해 정리를 해보고자 한다.
SOP (Same-Origin Policy)
같은 origin, 즉 같은 출처에서의 요청만 접근할 수 있도록 하는 원칙이다.
여기서 origin이란, Protocol + domain(IP) + Port Number의 조합이다. 예를 들어, https://google.com:1234/… 에서 https가 프로토콜, google.com이 도메인, 1234가 포트번호 이런 식이다.
이러한 원칙이 존재하는 이유는, 악의적인 요청을 차단하기 위함이다.
예를 들어, 어떤 사용자가 민감한 개인정보가 포함된 사이트에 접속한 후에 악의적인 사이트에 실수로 접속했다고 해보자. 그렇다면 다음과 같은 일이 발생할 수 있다.
(사용자) ……… (개인정보.com) ……… (나쁜.com)
(1) ———> 접속(로그인 등)
(2) <——— 사용자 브라우저가 쿠키 저장
(3) ——————————————–> 실수로 나쁜.com 접속
(4) <———————————- 이 기회를 놓치지 않고 개인정보 가져오기 요청
(5) ———-> 쿠키를 포함해서 요청, 악의적인 요청임에도 정상 응답을 반환, 개인정보 유출
SOP를 사용한다면, (5)번 과정에서 브라우저가 나쁜.com이 응답을 읽지 못하도록 차단할 수 있다.
구체적으로, (4)번에서 나쁜.com에 의해 HTTP 요청 스크립트가 실행된 후, 사용자 브라우저는 개인정보.com으로 요청을 보낸다. 이때 쿠키가 자동 포함되므로 개인정보.com은 사용자가 맞다고 판단, 응답을 사용자 브라우저로 정상 반환한다.
이때 SOP가 한건 하게된다. ‘같은 출처’인 경우만 허용한다는 것인데, 좀 더 자세히는 ‘현재 주소창에 있는 host’와 ‘데이터를 요청하기 위해 기입한 host’의 origin이 같은지를 검사한다.
현재 주소창에 있는 host = 나쁜.com이며, 데이터 요청 시 기입한 host = 개인정보.com이므로, 둘의 도메인부터 다르기 때문에 origin이 다르다고 판단, 브라우저는 개인정보.com으로부터 받은 응답을 나쁜.com에게 주지 않고 에러만 던진다.
개념적으로 생각해보면, 개인정보에 접근하려면 당연히 개인정보를 취급하는 개인정보.com 주소창에서 http 요청을 수행해야 할 것이므로 origin 비교 기준은 타당하다.
그런데 만약 개발을 하고 있는 입장에서, 프론트 origin도 있고 백엔드 origin도 있는데 그 둘이 다르면 API를 사용하지 못하게 된다. 따라서, 이를 완화하기 위해 등장한 것이 CORS이다.
CORS(Cross-Origin Resource Sharing)
SOP에서 예외를 허용하도록(다른 출처에서의 요청의 접근을 허용) 하는 메커니즘이다. 서버가 허용하고 싶은 특정 출처를 브라우저에게 알려주면 된다. 만약 서버가 허용하지 않은 출처로부터 요청했다면 브라우저에서 CORS 에러를 띄우게 된다.
앞선 예시에서, 프론트엔드와 백엔드간의 origin이 달라도 CORS가 있다면 문제없다. 아래 상황을 보자.
(프론트 = localhost:3000) …….. (사용자 브라우저) …… (백엔드 = localhost:8080)
(1) 브라우저에게 백엔드 가서 데이터 가져오라고 시킴 ——————->
(2) <——————– 서버가 브라우저에 응답 반환(헤더)
(3) <—————— 헤더에서 localhost:3000 허용 -> 통과
CORS가 없다면 (3) 단계에서 막혔을 것이다. 그렇다면 이를 허용하는 헤더의 존재는 무엇일까? 이는 요청의 종류가 단순한 요청인지, 복잡한 요청인지에 따라 달라진다.
1) Simple Request(단순 요청)
아래 3가지 조건들을 모두 만족하는 요청을 단순 요청이라 한다.
(1) Method는 GET, HEAD, POST 중 하나
(2) 헤더가 Accept, Accept-Language, Content-Language, Content-Type 등 CORS-safelisted request headers = 브라우저가 허용한 헤더
(3) Content-Type가 application/x-www-form-urlencoded, multipart/form-data, text/plain
해당 요청의 경우, 브라우저가 서버에 요청을 하면 응답 헤더로 Access-Control-Allow-Origin을 내려주어, 해당 헤더에 적혀있는 도메인에서 오는 요청을 허용하도록 한다.
2) 복잡한 요청 -> Preflight Request
단순 요청이 아닌 요청의 경우, 본 요청 전송 이전 Prefilght Request를 먼저 전송한다.
이때의 http method는 OPTIONS이며, 이를 보냄으로써 먼저 허락을 받는다.
서버는 해당 요청에 대한 응답으로
- Access-Control-Allow-Origin : 어떤 도메인을 허용할지
- Access-Control-Allow-Methods : 어떤 method를 허용할지
- Access-Control-Allow-Headers : 클라이언트가 이후 본 요청시 여기에 명시된 사용자 정의 헤더를 사용할 수 있도록
- Access-Control-Max-Age : 브라우저가 이 ‘허락’을 얼마나 유지할지
를 전송한 후 본 요청을 전송한다.
그렇다면 왜 이렇게 요청의 종류를 구분하는 것일까?
요청 종류 구분
만약 요청 종류를 구분하지 않고, 그냥 요청이 들어오면 처리 후 응답을 반환한다고 해보자. 돌아온 응답에서의 헤더를 비교할 뿐 실제 악의적인 요청이 서버로 전송되었을 때 이를 막지 못한다. CORS는 응답을 받은 후 헤더를 검사하여 요청한 클라이언트에게 보여줄지 말지를 결정하기 때문이다.
즉 악의적인 요청으로 인해 서버의 상태가 변경될 수 있으며, 클라이언트에게 서버로부터의 응답을 내려줄지 말지 판단하는 단계에서 이를 막으면 너무 늦는다.
Simple Request를 막지 않는 이유는, 기존 웹 환경과의 호환성을 위해서이다. 과거 HTML 폼으로 전송 가능하던 요청들에 한해서 허용한다. 이제와서 모든 요청에 대해 preflight request를 전송하게 하면 기존 웹사이트들이 망가질 수 있다.
Simple Request라고 해서 무조건 안전하지는 않다. CORS는 공격자에게 응답을 못 읽게하는 메커니즘이므로, 요청 차단은 CORS의 몫이 아니다. 원래 세대의 웹이 그렇게 동작했기 때문에 simple request로 분류하여 허용하는 것이고, 이를 악용해 발생할 수 있는 CSRF 공격을 방어하기 위해 별도의 토큰 등 방어수단을 사용하여야 한다.
이러한 보안 이슈들은 서버나 프론트 쪽 코드의 문제가 아니라, 브라우저에서 차단하기 때문에 발생하는 것이다. 그렇다면 프론트/백엔드 둘 중에 누가 해결해야 할까?
브라우저에게 허용하고 싶은 특정 도메인을 알려주는 것은 서버쪽이기에, 백엔드 쪽에서 처리하는 것이 맞다(프론트 쪽에서 프록시 서버 등으로 우회할 수 있다고는 하지만, 우리 서비스에서는 프론트/백 둘다 협업하는 과정이기 때문에 백엔드 쪽에서 해결하는 것이 본질적으로 옳다).
SOP는 브라우저 정책이고, 다른 origin에서의 접근을 허용하기 위해서는 CORS를 서버가 명시해주어야 한다.
웹소켓 연결을 위한 handshake 과정에서 전달되는 origin을 기준으로 서버가 연결을 허용/비허용할 수 있으므로, WebSocket에서 WebSocketConfig.kt에서 registry.setAllowedOriginPatterns()를 이용하여, 신뢰하는 프론트 도메인을 추가한 것이다.
따라서 이를 해결하는 것은 서버의 책임이며, 명시적으로 신뢰하는 프론트 도메인을 추가해야한다.
세션 방식을 선택한 이유와 데이터 흐름
인증 방식에는 JWT 방식과 세션 방식이 있는데, 앞서 이에 대한 내용을 간략히 정리했기 때문에 내용적인 설명은 생략하고 왜 세션 방식을 선택했는지만 정리해보자.
JWT의 장점은 서버에 부하를 주지 않는다는 장점이 있는데, 현재 우리 서비스의 경우 인증된 학교 재학생, 그것도 통학을 하는 등 일부 사용자들만 사용할 것이므로 이용자수는 많아봤자 3자리수대이며 pod 1대로도 충분히 감당가능한 수준이므로 서버의 자원을 좀 사용하더라도 구현이 훨씬 단순한 세션이 낫다고 판단했다.
또한 WebSocket의 경우, 한번의 handshake 이후 지속적으로 연결되기 때문에, 이러한 상황에서 서버에 상태를 저장하지않는 JWT를 사용할 경우 JWT 토큰이 만료되면 연결중인 websocket에서 이를 탐지한 후 refresh하는 로직이 필요하다. 사용자수가 많지 않은(상대적으로) 서버에서 운영 복잡성이 증가한다는 것이다.
반면 세션 방식을 사용할 경우 서버가 세션을 들고있기 때문에 인증 부분에서 상당히 편하다. 따라서 세션 방식을 선택했다.
데이터 흐름 :
사용자가 로그인을 하면 -> Spring Security에 의해 우리 서버의 CustomOAuth2User(Authentication 객체, 사용자의 인증 정보 담음)가 생성됨 -> 이 객체를 담는 Security Context를 만들어 HTTP 세션에 저장
하는 방식이며, 요청이 언제 어디서 들어오던 현재 서비스 사용자의 보안 정보를 쉽게 가져올 수 있다. 세션이 만료되는 경우에도 Security Context를 지우기만 하면 되므로 매우 간단하다.
반면 JWT의 경우 토큰 만료를 서버에서 수행할 수 없으므로(기본 만료는 가능, but 로그아웃/강제 퇴장 등 즉시 만료가 필요해지는 경우에는) redis 블랙리스트 구현 등의 복잡성이 훨씬 증가한다.
이번에는 SOP, CORS의 대표적인 보안 이슈들을 정리해보고, 구현 상 마주쳤던 이슈들과 보안 인증 방식에 있어서 세션/JWT 중 어떤 것을 선택하였는지 기술적 의사결정들을 정리해보았다.
서버 개발자는 단순히 websocket, crud 와 같은 기능들을 붙여넣는 것을 넘어 네트워크 전체의 큰 그림을 이해해야 하고, 보안 관련 정책들을 고려하여 그에 맞춘 효율적인 설계를 해야 한다는 것을 다시한번 체감했다. 앞으로도 파이팅