Post

CloudFront의 403 응답 해결기

CloudFront의 403 응답 해결기

이번 글에서는 매일메일 서브 도메인을 추가하며 겪었던 지독한 403 문제 해결기를 다룰려고 합니다. 구글에 아무리 검색하고, AI에 조언을 구해도 원인을 파악하기 쉽지 않았던터라, 혹시 같은 문제를 겪고 계신다면 한 번쯤 의심해보면 좋을 것 같습니다.



배경

최근, 매일메일 서비스를 봄봄에서 시작하면서 새로운 서브 도메인이 생겼습니다. 매일메일이 기존에 존재하던 서비스였고, 아무래도 저희 봄봄이 뉴스레터를 대신 수신해서 보여주는 서비스인 만큼, 성격이 다르다고 판단했기 때문에 분리를 결정한 것입니다. 매일메일 자체도 봄봄 안에서 관리하기 보다 분리하는 것이 확장하기 좋다고 생각한 점도 있습니다.

그리하여 CloudFront에 매일메일 배포(distribution)를 생성했습니다.



화면이 안보여요

서비스에 접속해도 빈 화면, 그런데 200 OK를 곁들인..

CloudFront에 배포를 생성하고 s3를 연결한 뒤 배포 자동화까지 모조리 마쳤습니다. 이제 남은건 서비스가 성공적으로 배포되었는지 확인하는 것 뿐이었습니다.

그러나 저를 맞이한건 매일메일 로고가 아닌, 빈 화면이었습니다.

error-blank-screen

현실 (???)


expected-screen

기대한 화면 (정상 버전)

분명히 로컬에서는 잘 실행되었는데.. 배포도 성공했고 제대로 세팅했는데..

혹시 CloudFront에서 설정이 잘못되었나 싶어서 거듭 확인해봐도 문제를 찾지 못했습니다.

그래서 일단 네트워크 탭을 자세히 확인해서 응답이 어떻게 오고 있는지 짚어보았습니다.


blank-screen-network

하지만 응답 결과도 너무나 정상이었습니다. 모든 리소스가 200 코드이고, HTML과 JS 리소스도 누락 없이 잘 오고 있었습니다.

HTML, JS 번들 모두 잘 받았으면 화면이 그려져야 하는거 아닌가? 어딘가에 문제가 있을 것이라는 생각으로 리소스들의 정보를 하나씩 확인했습니다.


JS 번들이 왜 text/html 형식일까?

1
2
3
4
5
6
7
curl -I https://maeilmail.bombom.news/assets/index-DpLAHEUH.js

HTTP/2 200
content-type: text/html
content-length: 323
server: AmazonS3
x-cache: Error from cloudfront

드디어 첫번째 이상한 점을 발견했습니다. 바로 JS 번들의 형식인데요, JS 번들의 형식인 application/javascript 대신에 text/html로 오고 있었습니다.

curl 은 CLI에서 HTTP 요청을 보낼 수 있는 명령어 입니다. 이 명령어를 사용하여 https://maeilmail.bombom.news/assets/index-DpLAHEUH.js로 HTTP 요청을 보냈지만

기대했던 JS 파일 대신, 위의 블록과 같이 text/html이 응답으로 온 것을 확인할 수 있었습니다.

네트워크 탭에서 실제 콘텐츠를 확인해보니, 아니나 다를까 내용물도 index.html과 동일했습니다.


그럼 이건 aws 문제다!

js 번들이 정상적인 파일이 아니므로 CloudFront가 리소스를 잘못 주고 있는 상황으로 해석하였습니다.

이미 aws 콘솔을 몇 번이고 확인을 하였지만 이건 CloudFront나 s3 문제일 확률이 높다고 생각하여 다시 점검에 나섰습니다.


1. CloudFront가 s3에서 잘못된 경로로 파일을 찾고 있는가?

origin-edit-screen


js-directory-correct

제대로된 경로에 잘 있는 js 파일들..


> 아니요



2. /assets/* 요청이 다른 behavior로 빠지고 있는가?

cloudfront-behavior

Default만 설정되어 있는 CloudFront 배포 behavior

모든 요청 경로를 Default(*)로 보내고 있어서 JS 요청만 다른 origin으로 빠지는 구조도 아니었습니다.


> 아니요



3. Default behavior가 잘못된 origin을 바라보고 있는가?

cloudfront-default-origin

/build-origin이 설정된 origin


cloudfront-default-behavior

/build-origin이 설정된 origin을 선택하고 있는 Default behavior

behavior가 다른 origin(원본)으로 설정되어 있는 경우에도 잘못된 경로로 파일을 찾을 수 있습니다.

하지만 매일메일 배포에는 하나의 origin만 생성되어 있고, behavior가 선택할 수 있는 origin도 이 1개 뿐이었습니다.

의도한대로 /build-origin 경로에서 파일을 찾도록 설정해둔 origin으로 지정되어 있었습니다.


> 아니요



3개의 체크리스트를 모두 통과하여 CloudFront 설정에는 문제가 없다는 결론을 내렸습니다.

응답 데이터를 제외하고 모든 게 정상이니, 어디서부터 문제를 진단해야 할지 감이 오지 않았습니다.

그래서 유일하게 의도와 어긋났던 JS 응답 데이터부터 다시 들여다보기로 했습니다.


이번엔 JS 응답에 직접 영향을 주는 CloudFront - s3 배포 설정 대신, 응답 자체에 집중해보기로 했습니다.

이 배포 설정을 제외하고 JS 응답에 영향을 줄 수 있는 요소가 무엇이 있을까?

처음에 매일메일의 CloudFront 배포를 생성하면서 어떤 것을 설정했는지도 되짚었고 마침내 JS 요청이 text/html 응답으로 둔갑한 이유를 알아냈습니다.



custom-error-responses

text/html의 정체는 바로 이 커스텀 오류 페이지였던 것입니다.

JS 스크립트가 실행되기 전에는 라우팅이 불가능한 CSR 페이지의 구조적 문제 때문에, CloudFront에 오류 페이지를 fallback으로 추가하는 건 이제 기본 설정으로 굳어졌습니다.

메인 URL에 라우트를 붙인 경로로 CSR 페이지에 접근하면 CloudFront는 S3에서 해당 경로의 index.html을 찾으려 합니다.

하지만 당연히 S3에는 이 파일이 없습니다. CSR은 매 페이지마다 index.html 파일을 두지 않고, js를 실행해 라우팅하기 때문입니다.

즉, 하나의 index.html 파일이 js 코드에 의해 여러 라우트 페이지로 변환되는 것이 CSR의 기본 원리입니다.

결국 라우트와 매칭되는 index.html 파일을 찾지 못한 CloudFront는 404 또는 403 에러를 응답으로 내려줍니다.

이 문제의 우회책이 바로 커스텀 오류 페이지입니다. CloudFront가 페이지를 찾지 못하더라도 오류 응답 대신에 index.html을 클라이언트로 내려줍니다. 이후 index.html의 JS 스크립트가 실행되면 사용자가 접속하려 했던 경로로 라우팅됩니다.


그런데 이번 문제에서는 이 해결책이 도리어 디버깅을 어렵게 만들었습니다.

JS 번들 요청은 분명히 실패하고 있었는데, CloudFront의 커스텀 오류 응답 설정이 실패 응답을 /index.html로 바꿔서 내려주고 있었습니다. 덕분에 네트워크 탭에서는 모든 요청이 200 OK처럼 보였고, 실제로 어디서 403이 발생하는지 바로 알아차리기 어려웠습니다.

정상이라고 믿었던 CSR fallback 설정이, 이번에는 오류를 조용히 숨기고 있었던 셈입니다.



어째서 403인가요

빈 화면에서 벗어나다

오류 페이지 설정을 잠시 꺼두니, 비로소 진짜 화면이 드러났습니다.

403-error

이전의 빈 화면과 다르게 어떤 오류인지 안내하는 문구들이 보였습니다.

해석해보니, 요청 자체가 CloudFront의 권한 부족으로 차단된 듯 했습니다. 403은 대표적인 권한 오류 응답이니 예상했던 내용이었습니다.

중요한건 권한 문제가 발생한 이유였습니다. 이번엔 S3의 버킷 정책을 해부할 차례였습니다.


매일메일 버킷 정책은 CloudFront의 작업을 환영합니다

S3의 버킷 정책은 외부에서 S3에 접근할 수 있는 권한을 제어하는 기준입니다. S3의 권한 탭에서 JSON 형식으로 설정할 수 있습니다.

버킷 정책에는 누가, 어떤 작업을, 어떤 리소스에 수행할 수 있는지가 명시됩니다.

버킷 정책을 설정하여 S3로 오는 모든 접근을 허용하거나 CloudFront만 S3의 객체를 읽게 할 수도 있습니다.

물론 정책을 잘못 설정하면 CloudFront의 작업도 거부할 수 있습니다. 그렇기 때문에 403 오류의 원인으로 가장 먼저 의심되었습니다.


1
2
3
4
5
6
7
8
9
"Principal": {
  "Service": "cloudfront.amazonaws.com"
},
"Action": "s3:GetObject",
"Condition": { 
  "StringEquals": { 
    "AWS:SourceArn": "arn:aws:cloudfront::(비공개)" 
  } 
}

매일메일 S3의 버킷 정책 일부 발췌


그러나 매일메일 S3의 버킷 정책은 CloudFront의 작업을 차단하지 않았습니다.

접근 주체 Principal, 허용하는 작업 유형 Action, 허용하는 조건 Condition, 위 정책에는 표시하지 않았지만 타겟 객체를 의미하는 Resource까지.

‘매일메일 CloudFront가 매일메일 리소스를 읽을 수 있도록’ 권한이 올바르게 설정되어 있었습니다.

도무지 원인을 알 수 없어, 아예 모든 외부 접근을 허용하도록 정책을 public으로 열기까지 해보았지만 여전히 403 오류가 발생했습니다.

즉, 버킷 정책은 이 문제의 원인이 아니었던 것입니다.



닫힌 방화벽 열림

사실상 버킷 정책이 무용지물인 수준으로 모든 접근을 허용해보았지만 403 문제를 해결할 수 없었습니다.

버킷 정책 문제라는 가설이 빗나가면서 새로운 시도가 필요했습니다. AI와 의논도 해보고 열심히 서치한 끝에, AWS 문서에서 새로운 정보를 얻었습니다.

aws-waf-403

바로, CloudFront는 요청이 차단되었을 때 Origin 서버에서 반환되는 403 오류 코드와 AWS WAF(방화벽)에서 반환되는 403 오류 코드를 구분할 수 없다는 점입니다.

즉, 지금 매일메일 화면에 보이는 403은 Origin 서버에서 오는 것이 아니라 AWS WAF가 반환하는 것일 수 있다는 점입니다.

버킷 정책에서 문제를 발견하지 못했기 떄문에 상당히 신빙성 있게 읽혔습니다.

저는 즉시 CloudFront 보안 탭에 들어가서 방화벽 상태를 확인했습니다.


maeil-mail-waf

아무런 rule도 세팅되어 있지 않는 WAF

놀랍게도 방화벽에는 아무 것도 없었습니다. Web ACL 자체는 CloudFront에 연결되어 있었지만, ACL에 실제 요청을 검사하거나 차단할 Rule이 추가되어 있지 않았습니다.

Web ACL은 WAF에서 여러 보안 규칙(Rule)을 묶어 AWS 리소스에 연결할 수 있는 일종의 보안 정책 Group입니다. ACL에 설정된 Rule에 매칭되는 요청을 차단하거나 허용하는 방식으로 방화벽이 동작합니다.

그러나 매일메일 CloudFront에는 Rule이 세팅되어 있지 않았습니다. WAF가 붙어 있어도 사실상 보안 필터로서의 역할은 하지 못하고 있었습니다.

Rule이 없으면 방화벽도 모든 요청을 허용해야 하는 거 아닌가? 왜 요청이 차단되는걸까? 하는 의문이 들기 시작했습니다.


WAF Rule 세팅은 다른 CloudFront와 매일메일 CloudFront의 유일한 차이였기 때문에 이 점을 좀 더 파고들었습니다.

운영 서버, 개발 서버 등 다른 배포에는 기본적인 Rule이 적용되어 있었지만 매일메일에만 유일하게 아무런 Rule도 추가되어 있지 않았습니다.

이 부분이 이상하게 느껴졌습니다. 403 에러의 원인이 방화벽이라면, 당연히 Rule이 요청을 차단한 결과이지 않을까 생각했습니다.

그런데 막상 상황은 반대였습니다. Rule이 적용된 배포는 정상적으로 동작했고, Rule이 없는 배포에서만 403 에러가 발생했습니다. 제가 예상했던 흐름과는 완전히 달랐습니다.

이 동작에 의문을 갖고 조사한 끝에, WAF에 Default action이라는 설정이 있다는 걸 알게 되었습니다.



Rule이 없다고 통과가 아니다

AWS 문서에 따르면 ACL의 모든 Rule을 통과한 규칙에는 Default action으로 선택한 작업이 적용됩니다.

요청 → 규칙1 → 규칙2 → … → 매칭 규칙 없음 → Default action

문서에서는 Default action을 ‘terminating action(최종 작업)’이라고 표현합니다. ACL의 Default action이 웹 요청의 최종 처리 방식을 결정하기 때문입니다.

Default action의 옵션은 Allow(허용)와 Block(차단) 두 가지 입니다.

AWS WAF는 ACL 안의 모든 Rule 평가를 거쳤지만, 허용할지 차단할지 결정되지 않은 요청에 Default action을 적용합니다.

눈치채셨을 수도 있겠지만, 매일메일의 ACL은 이 Default action이 차단으로 설정되어 있었고 403 오류의 원인이 되었습니다.


이 문제를 조사하기 전까지는 이 WAF의 내부 구성과 동작에 대해 잘 알지 못했습니다.

Rule이 설정되어 있지 않으면 모든 요청을 허용하는게 기본 동작이고, Rule이 설정되어 있으면 Rule을 통과하는 요청만 허용할 것이라고 추론했습니다.

하지만 실제로는 각 Rule도 조건을 충족하면 허용할지 차단할지 action으로 설정할 수 있었고, Rule에 매칭되지 않는 요청들도 허용할지 차단할지 선택할 수 있는 철저한 흐름으로 이루어져 있었습니다.



문제 해결하기

진상을 파악하니 해결은 간단했습니다.

403 권한 차단 오류가 발생하는 ACL의 Default action을 Allow(허용)으로 변경하면 끝입니다.

기본적인 서비스 접근은 허용하되, Rule을 설정하여 악성 공격만 방어하는 방식입니다.

제가 직접 문제를 해결한 과정은 아래와 같습니다.


1. 우선 AWS에서 **WAF & Shield(WAF 및 쉴드) 서비스에 접속합니다.**

search-waf

2. 문제가 된 ACL을 찾아 이름을 클릭합니다.

이 때, ACL이 보이지 않는다면, 리전 범위를 변경합니다. 저의 경우, CloudFront에 사용하는 ACL이기 때문에 CloudFront(글로벌) 또는 CloudFront(글로벌) 및 리전별을 선택했을 때 ACL 목록이 표시됐습니다.

CloudFront를 기준으로 디버깅 중이라 어떤 ACL인지 헷갈린다면, ARN을 확인하여 타겟 CloudFront의 ARN과 일치하는 ACL을 선택합니다.

aws-waf

3. 우측에 열리는 사이드 패널에서 세부 정보 관리를 클릭합니다.

detail-info-select

4. 보호 팩(웹 ACL) 동작의 기본 작업에서 구성을 클릭하여 모달을 열고, 허용으로 변경합니다.

set-default-action



정리

문제의 기술적인 원인

이번 문제의 기술적인 핵심은 아래와 같습니다.

  • CloudFront의 커스텀 오류페이지는 오류 응답을 지정한 파일로 교체하여 200 OK로 반환한다.
    • 따라서 실제로 403이 발생하고 있어도 네트워크 탭에서는 정상 응답처럼 보인다.
  • CloudFront는 Origin 서버에서 반환되는 403 오류 코드와 AWS WAF(방화벽)에서 반환되는 403 오류 코드를 구분할 수 없다.
    • 따라서 Origin의 권한 정책에 문제가 없다면 WAF에 의한 요청 차단일 가능성도 있다.
  • WAF Web ACL은 모든 요청을 Rule 목록에 순서대로 매칭시킨다. 매칭되지 않는 요청에는 Default action을 적용한다. Default action의 설정에 따라 요청이 허용 또는 차단된다.
    • Rule이 하나도 없으면 모든 요청이 규칙에 매칭되지 않으므로 전부 Default action에 의해 허용/차단이 결정된다.
    • 따라서 Default action이 차단 상태라면 요청이 거부된다.


배운 점

이번 문제를 다루면서 각 설정이 의도대로 동작하더라도, 그 조합이 항상 의도한 결과를 만들지는 않는 점을 새삼 느꼈습니다.

CloudFront의 커스텀 오류 페이지와 WAF의 Default action은 각각 설계된 대로 동작했습니다. 아이러니하게도, 두 설정이 모두 정상이었던 탓에 오히려 원인을 단번에 잡기 어려웠습니다.

조용한 에러가 까다로운 이유가 여기에 있다고 생각합니다. 오류를 숨기거나 우회하는 동작을 인지하지 못하면, 눈앞의 단서를 그냥 지나치게 됩니다.

실제로 이번 문제는 S3 버킷 정책, OAC, 퍼블릭 접근 등 제가 직접 세팅해온 Origin 설정과는 무관했기 때문에 방향 자체를 잡기가 쉽지 않았습니다.

WAF가 요청을 차단하고 있다는 가설을 세운 뒤에도 단서를 찾기까지 시간이 걸렸습니다. 특히 CloudFront 보안 간소화 뷰에서는 Default action 설정을 보여주지 않기 때문에 WAF 규칙 목록이 깨끗하다는 점 외에는 이상한 점을 바로 찾지 못했습니다.

저의 작업이 의도와 무관하게 다른 동작에 영향을 줄 수 있다는 점을 염두에 두고, 현상 자체에 집중해 가장 유사한 동작을 재현하는 기술을 찾아가는 방식이 필요하다는 걸 이번에 배웠습니다.



참고 자료

AWS HTTP 403 status code (Permission Denied)

Setting the protection pack (web ACL) default action in AWS WAF

This post is licensed under CC BY 4.0 by the author.