안녕하세요
오늘은 얼마전 프로젝트 진행중에 마주했던 403 에러를 TroubleShooting한 경험을 공유해드리겠습니다.
결론부터 말씀드리면 앱에서 발생한 오류는 간단한 이유였지만 덕분에 CORS 에러를 더 자세히 공부하게 되었습니다.
해당 에러를 쉽게 파악하지 못했던 이유와 헷갈렸던 부분, 고민했던 부분들을 적어보겠습니다.
주의사항을 하나 말씀드리자면 아래 403 에러에 관한 부분은 swagger에서 요청을 보냈을 때 발생한 오류입니다.
다른 사이트에서 요청을 보내면 403 에러가 아닌 CORS 에러로 표시될 수도 있습니다.
프로젝트 회의를 하기 한시간 전, 팀원으로 부터 연락을 받았습니다.
로그인을 하려고 하는데 안된다는 연락이었습니다.
로그인 버튼을 클릭했을 때 어떤 응답이 오냐고 여쭤봤더니 아무 반응이 없다는군요...
이전에는 문제없는 것을 확인했기에 이상하다 생각하면서 서버의 swagger로 접속해 로그인을 시도해보았습니다.
그런데 두둥!
403 에러 응답이 오는 것을 발견할 수 있었습니다.
잘되던 기능에서 오류가 나는 것을 발견하고 당황한 저는 바로 문제를 해결에 착수했습니다.
403 에러는 해당 요청에 권한이 없다는 의미입니다.
보통 관리자가 아닌데 관리자만 접근할 수 있는 기능을 요청할 때와 같이 권한이 없어 거부했을 때 많이 발생하는 에러죠.
권한이라... 권한을 사용한 부분이 어디지 고민하다가 Spring Server에서 Spring Security를 활용하여 url 패턴별로 권한을 부여한 것이 생각났습니다.
바로 Spring Security 설정을 확인해보는데 로그인 관련된 url은 모두 접근이 가능하도록 잘 설정되있는 것을 보았습니다.
이 밖에 로그인 기능에서 권한을 설정해주는 부분이 없는데... 하면서 혹시 다른 기능은 잘되나 시도해 보았는데, 되는 요청도 있고 403 응답이 오는 요청도 있었습니다.
그러다가 되는 기능과 안되는 기능의 규칙을 발견할 수 있었습니다.
바로 GET 요청은 잘 되는데, POST를 비롯한 나머지 요청이 모두 403 에러가 return되었습니다.
해당 증상에 대해 구글링과 레퍼런스를 찾아보았더니 Spring Security에서 csrf를 disable 하도록 설정해주면 된다는 의견을 다수 발견할 수 있었습니다.
여기서 CSRF 무엇일까요?
오늘 포스팅에서 중심 주제는 아니기에 간단하게만 알아보자면
CSRF Cross Site Request Forgery의 약자로 사이트 간 위조 요청 공격을 의미합니다.
기존에 로그인을 하고 세션 데이터가 있을 때 공격자가 이 세션 데이터로 원래 사용자가 의도하지 않은 요청을 수행하도록 하는 공격이죠.
자세한 내용은 csrf에 대해 잘 정리해놓은 블로그 링크로 대체하겠습니다. https://devscb.tistory.com/123 를 참고해주세요.
그렇다면 CSRF를 disable 하도록 설정하면 공격에 노출될 수 있는 것 아닐까요?
결론부터 말하자면 프로젝트 상황에 따라 다르지만 현재 제 프로젝트에서는 disable 설정을 해도 괜찮다고 생각했습니다.
CSRF 공격이 발생할 수 있는 경우를 보면, 쿠키와 세션 데이터를 활용하는 경우인데 저는 현재 프로젝트에 JWT 토큰을 적용하고 요청 header에 JWT 토큰을 담아 보내도록 하고 있기 때문입니다.
물론 JWT 토큰을 쓴다고 하여 항상 안전한 것은 아닙니다.
JWT 토큰을 사용하는 경우라도 토큰을 쿠키에 담아 보내게 되면 CSRF 공격에 대비해야 한다고 합니다.
저도 CSRF 설정을 disable 하지 않았기에 403 에러가 발생했을까요?
다시 Spring Security 설정으로 달려가서 확인해보니 이전에 CSRF disable 설정을 잘 적용해놓았더군요.
다시 미궁에 빠지는 순간입니다...
원인을 찾기 위해 엄청난 시간을 쏟았습니다.
꼬박 이틀 정도를 구글링하고 책을 찾아보고 프로젝트를 분석해 보았습니다.
구글링하면서 발견한 403 에러에 관한 내용은 거의 CSRF에 관한 글만 있었습니다.
저의 상황과 비슷한 내용은 발견하지 못해 어디서 문제가 발생하는지 부터 찾아보자 생각했습니다.
앞선 포스팅을 보신 분이라면 아시겠지만 현재 프로젝트 구조를 보면 클라인트에서 요청을 보내면 ALB를 통해 Nginx를 거쳐 Spring Server로 도달하게 되어있습니다.
즉, 로그를 볼 수 있는 부분이 4곳 이라는 말이죠.
브라우저 검사 창의 네트워크와 ALB, Nginx, Spring Server의 로그를 모두 확인해 보았습니다.
네트워크 응답도 403, ALB의 로그에서도 403, Nginx의 로그에서도 403을 확인할 수 있었습니다.
Spring Server에서도 요청이 도달하는 것을 확인할 수 있었습니다만 요청받은 로직을 수행하지 않고 응답이 반환되는 것 또한 발견할 수 있었습니다.
로그 분석을 통해 정확한 원인은 알 수 없었지만 클라이언트에서 보낸 요청이 Spring Server까지는 잘 도달하고 Spring Server에서 403 오류를 응답한다는 것을 알아냈습니다.
이것이 첫 번째 힌트가 되었습니다.
고민을 거듭하다가 오류가 나기 전과 지금, 추가로 진행한 작업이 어떤 것이 있나 고민해봤습니다.
바로 이전에 CloudWatch를 붙이고, 그 전에는 HTTPS 설정을 리팩토링하고...
CloudWatch는 코드 레벨에서 수정한 부분이 없고 요청 부분과는 관련없으니 관련이 없을 것 같았고 HTTPS 관련 작업이 영향을 끼쳤을 수도 있다 생각했습니다.
Swagger에서 같은 오류 화면만 마주하다가 문득 에러 조금위에 적혀있는 curl 요청문이 눈에 띄었습니다.
curl 요청이 무엇이냐 하면, 아래와 같습니다.
curl(client url) 명령어는 프로토콜들을 이용해 URL 로 데이터를 전송하여 서버에 데이터를 보내거나 가져올때 사용하기 위한 명령줄 도구 및 라이브러리이다.
쉽게말해 예를들어 자바스크립트 환경에서 REST API(http)를 테스트하고싶다면 보통 ajax, fetch 를 이용해 요청을 보내는 것과 같이, SHELL(커맨드라인 환경)에서 REST API(http) 테스트 하고 싶으면 curl 명령어를 이용하면 된다 라고 이해하면 된다.
이 요청문을 보고 어차피 안될 것 같은데 한 번 터미널에서 해보자는 생각이 들었습니다.
터미널을 켜고 요청을 날렸는데... 오잉?
정상적으로 응답이 오는 것을 보고 잘 못 본건가??? 하는 생각이 들었습니다.
몇 번을 해봐도 정상적으로 응답이 오는 것을 보고 또 한번 멘탈이 붕괴해버렸습니다.
정신을 차리고 평소 Swagger 말고도 요청을 보내는데 많이 쓰던 postman에서 요청을 보내봤습니다.
그런데 postman에서도 정상적으로 응답이 오는 것을 확인할 수 있었습니다.
이것이 두 번째 힌트였습니다.
여러 시도를 해보면서 두 가지 힌트를 얻었습니다.
정리해보자면 다음과 같습니다.
1. 요청은 Spring Server까지 잘 도달하고 Spring Server에서 403 에러가 return 된다.
2. 브라우저를 제외한 curl, postman 요청은 잘 작동한다.
얻은 힌트들을 가만히 보고 있자니, 두 번째 힌트를 언젠가 마주한 것 같은 기억이 떠올랐습니다.
언제였더라... 생각해보니 이전에 관리자 페이지 웹으로 만들고 연결할 때 마주쳤다는 것이 생각났습니다.
당시에도 postman으로 요청을 보낼 때는 잘 작동했는데, 관리자 페이지 웹에서 요청을 보내면 에러가 발생했고 웹 브라우저에서 CORS 에러라고 알려주었기에 CORS 에러라는 것을 인지하고 해결할 수 있었습니다. (CORS 에러가 무엇인지는 조금 이따가 설명하도록 하겠습니다.)
지금은 브라우저에 CORS 에러라고 뜨지 않고 403 응답이 발생하고 있기에 CORS 에러를 크게 의심하지 않았습니다.
하지만 첫 번째 힌트에를 통해 Spring Server에서쪽 문제로 범위를 좁혔기에 시도해 볼만하다 생각했습니다.
큰 기대는 없이 CORS 설정에 https://도메인 을 추가해주고 다시 배포를 해봤는데...
!!!!!!
정상적으로 응답이 오는 것이었습니다!
몇 일을 고민한 것이 코드 한줄에 해결되다니...ㅎㅎㅎ
매우 허탈했지만 해결했다는 안도감이 들었습니다.
하지만 동시에 이게 왜 되지? 이게 왜 CORS 에러지? 하는 의문들이 남았습니다.
일단은 문제를 해결했다고 생각해 팀원들에게 다시 로그인을 시도해보라고 요청했습니다만...
여전히 아무 응답이 없다는 답변이 돌아왔습니다.
서버쪽에서 발생하는 오류는 없었기에 일단 앱 개발자분께 확인을 부탁드리고 기다려보았습니다.
여전히 아무 응답이 없는 앱...
뭐가 문제일까 고민에 빠졌는데, 앱 개발자분께 연락이 왔습니다.
"디버그 모드로 확인해봤는데 301 응답이 돌아옵니다!"
순간 아차 싶었습니다.
이전에 HTTPS 설정을 리팩토링하면서 HTTP 요청이 들어오면 ALB에서 HTTPS 요청으로 redirect 하도록 설정했습니다.
내부적으로 좀 더 살펴보면, HTTP 요청을 받은 ALB는 브라우저로 301 응답과 함께 redirect 할 URL을 return 합니다.
브라우저는 301 응답을 받으면 함께 온 URL로 재요청 하도록 설계되어있죠.
하지만 이것은 브라우저의 기능입니다.
앱에서는 개발자가 이 응답을 처리해주어야 하는데 제가 이 부분을 작업해놓고 앱에서도 잘 redirect 하겠지 하고 넘어갔던 것 이었습니다.
하찮은 실수 였기에 부끄러웠고ㅜㅠ 앱 개발자분께 해당 내용을 공유하여 바로 해결할 수 있었습니다.
결론적으로 이전에 했던 작업이 영향을 끼친게 맞았네요...ㅎㅎ
문제는 해결했지만 도중에 얻은 의문들을 해결하기 위해 고민하고 찾아보기 시작했습니다.
제가 CORS 에러라고 생각하지 못한 가장 큰 이유였습니다.
위에 말씀드렸던 것처럼 이전에 이미 CORS 에러를 겪은적이 있습니다.
그때는 브라우저 검사의 네트워크 창에서 CORS 에러라고 친절하게 알려주었기에, CORS 에러는 친절하게 알려주는구나 생각했습니다.
그래서 이 부분을 좀 더 자세히 찾아보았습니다.
일단 CORS가 무엇인지 알아보면,
Cross-Origin Resource Sharing의 약자로 그대로 해석하면 교차 출처 리소스 공유라고 해석할 수 있습니다.
뜻을 살펴보면 서로 교차하는 출처, 즉 출처가 다른 리소스 공유를 허락한다는 뜻의 정책입니다.
여기서 출처란 무엇인지 알아보겠습니다.
보통 브라우저에서 요청을 보내는 주소를 살펴보면 https://도메인 과 같은 주소를 입력합니다.
위 주소에서는 포트 번호를 생략했는데, https://도메인:443 과 같이 포트 번호를 붙일 수도 있죠.
이 주소를 뜯어보면 https 라는 프로토콜과 "도메인" 이라는 호스트네임, 443이라는 포트로 구분할 수 있는데요, 이 세개를 합쳐서 출처라고 합니다.
그래서 출처가 다르다는 것은, 두 출처의 세 부분중에 서로 다른 부분이 있다는 것을 의미하는 것이죠.
이 정책이 생기게 된 이유와 자세한 내용은 아래의 잘 설명해놓은 블로그를 참고해주세요.
https://evan-moon.github.io/2020/05/21/about-cors/
여기서 주목해야할 부분은, 브라우저와 출처의 구성입니다.
CORS 정책을 위반했는지 판단라는 로직은 원래 브라우저에 구성되어 있습니다.
위 블로그에서 설명했듯이 브라우저에서 서버로 요청을 보낼 때 header에 origin을 담아 보냅니다.
서버에서는 CORS 설정이 없다면 헤더에 Access-Control-Allow-Origin 제외하고 response를 보내게 되고, 브라우저에서는 origin과 비교할 Access-Control-Allow-Origin이 없으니 CORS 에러를 표시하게 되는 것 입니다.
하지만 저는 이전에 CORS 정책 위반 오류를 마주하면서 Spring Server에 특정 출처를 허용하도록 CORS 설정을 해놓았기에 위와 조금 다르게 동작한 것 같습니다.
지금부터 서술하는 내용은 확실한 내용은 아니라 혹시 이유를 아시면 댓글 부탁드리겠습니다.
찾아본 바로는 서버에 CORS 설정이 있다면 서버 측에서 허용한 origin과 요청 헤더의 origin과 비교하는 로직을 거친다고 합니다.
요청 헤더 origin이 서버의 허용 origin에 포함되지 않으면 접근이 불가능하다 판단하여 403 에러를 응답한다는 것이죠.
제가 요청을 보낸 사이트가 Spring Server와 함께 떠 있는, swagger라는 특수성도 있기에 다른 사이트에서 요청을 보낸 것과는 조금 다르게 동작하는 것도 있는 것 같습니다.
또 한가지, CORS 설정을 해주지 않았는데 Swagger 요청은 어떻게 CORS 에러를 뱉지 않는지 궁금했습니다.
해당 부분도 열심히 찾아봤지만 만족할만한 내용을 발견하지 못했습니다.
이 부분도 아시는 분이 계시다면 댓글 부탁드립니다...
결론을 알고보면 앱이 작동하지 않은 것은 참 허무한 이유였습니다.
그리고 swagger로 요청을 보냈기에 CORS 에러라는 표시 대신 403 이라는 에러 표시가 나온 것 같기도 해서, 조건이 하나만 맞지 않았어도 더 빠르게 해결할 수 있었을 것 같습니다.
하지만 이런 기회를 통해 로그도 뜯어보고 알고있던 내용도 정리하고, 궁금한 부분을 계속 찾아보고 고민하는 시간을 가질 수 있었습니다.
이런 시간을 가지며 알아가고 배워가는 재미가 정말 큰 것 같습니다.
안녕하세요 저도 같은 에러로 고생했는데요.
저의 경우 Get은 잘작동하였는데 Post, Put은 잘 동작하지 않았네요.
이유는 정확히 잘 모르겠는데 왜 swagger에서 Cors설정이 필요한지는 잘 모르곘네요.