GitHubGitHub
← 홈으로 돌아가기
projects·

로컬에선 되는데요 - CORS인 줄 알았던 WAF 이야기

로컬에서는 멀쩡하던 관리자 Excel 업로드가 운영 환경에서만 막혔다. CORS 설정을 아무리 고쳐도 뚫리지 않았던 요청이 사실 어디서 잘리고 있었는지 추적한 기록

로컬에선 되는데요 - CORS인 줄 알았던 WAF 이야기

1. 문제 상황: 로컬에선 되는데 운영에선 안 된다

관리자 페이지에 학교 데이터를 엑셀로 올리는 기능을 만들었다. 검증(validate)과 반영(apply)을 나눠서 만들었고, 로컬과 Postman에서 다 통과했다. 커밋하고 배포했다.

운영 페이지에서 같은 파일을 올렸더니 아무것도 안 됐다.

Access to fetch at 'https://api.<도메인>/<관리자 엑셀 업로드 경로>'
from origin 'https://<운영 프론트 도메인>' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

POST https://api.<도메인>/<관리자 엑셀 업로드 경로> net::ERR_FAILED 403 (Forbidden)

CORS. 운영 도메인을 허용 목록에 안 넣었나 보다 생각했다. 흔한 실수니까.

2. 첫 번째 가설: CORS 설정이 빠졌다

main.ts를 열어 허용 origin을 확인하고 운영 도메인을 추가했다. preflight에 필요한 method와 header도 명시했다.

중간에 다른 것도 의심했다. FormData를 보낼 때 Content-Type을 직접 지정하면 boundary가 깨져서 요청이 실패하는 경우가 있다. 프론트 코드를 확인했는데 Content-Type을 넣고 있지 않았다. 이 가설은 탈락.

ECS 환경변수까지 손보고 재배포한 뒤 커밋했다.

fix(cors): 운영 프론트 도메인 허용 설정 추가

다시 시도했다. 에러 메시지가 한 글자도 안 바뀌었다.

3. preflight를 직접 찍어보다

같은 가설로 세 번째 시도를 하는 건 아닌 것 같았다. 브라우저 밖에서 preflight만 따로 보내봤다.

curl.exe -i -X OPTIONS "https://api.<도메인>/<관리자 엑셀 업로드 경로>" \
  -H "Origin: https://<운영 프론트 도메인>" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: authorization,content-type"
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://<운영 프론트 도메인>
Access-Control-Allow-Headers: Content-Type,Authorization
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS
Via: 1.1 ...cloudfront.net (CloudFront)

204다. CORS는 정상이었다.

여기서 방향이 완전히 바뀌었다. 브라우저는 계속 CORS를 탓하고 있었는데, 정작 preflight는 통과하고 있었다.

혹시 권한 문제인가 싶어 /auth/me로 관리자 계정도 확인했다. ADMIN 맞았다. ECS 로그도 열어봤다.

Allowed CORS origins: [
  'http://localhost:3000',
  'https://<운영 프론트 도메인>',
  'https://www.<운영 프론트 도메인>'
]

운영 컨테이너에도 제대로 들어가 있었다. 설정은 전부 맞는데 요청은 계속 막힌다.

4. 그럼 이 403은 누가 주는 건가

OPTIONS가 아니라 실제 POST를 직접 쏴봤다. 엑셀 파일을 붙여서.

curl.exe -i -X POST "https://api.<도메인>/<관리자 엑셀 업로드 경로>" \
  -H "Origin: https://<운영 프론트 도메인>" \
  -H "Authorization: Bearer <token>" \
  -F "file=@test.xlsx"
HTTP/1.1 403 Forbidden
Server: CloudFront
Content-Type: text/html
X-Cache: Error from cloudfront

<HTML><HEAD>
<TITLE>ERROR: The request could not be satisfied</TITLE>
</HEAD><BODY>
<H1>403 ERROR</H1>

이게 결정적이었다.

Server: CloudFront 이고 X-Cache: Error from cloudfront 다. 응답 본문도 내 NestJS가 만들 수 있는 형태가 아니라 CloudFront가 뱉는 HTML 에러 페이지였다.

요청이 백엔드에 도착하지도 못하고 있었다. OPTIONS는 통과하는데 실제 POST만 앞에서 잘린다. 브라우저는 그 403 응답에 CORS 헤더가 없으니 "CORS 정책에 막혔다"고 말할 수밖에 없었던 것이다.

로컬에서 되고 운영에서만 안 되는 이유도 이걸로 설명됐다. 로컬 요청은 내 서버로 바로 가지만, 운영 요청은 앞을 여러 겹 통과한다.

브라우저 → CloudFront ── ALB → ECS(NestJS)
            AWS WAF

CloudFront Behavior 설정부터 확인했다. 허용 HTTP 메서드는 GET, HEAD, OPTIONS, PUT, POST, PATCH, DELETE 로 전부 열려 있었고, 캐시 정책은 CachingDisabled, 원본 요청 정책은 AllViewerExceptHostHeader 였다. 문제 없었다.

CloudFront 설정이 정상인데 CloudFront가 403을 준다면 남는 건 그 앞에 붙어 있는 WAF였다.

WAF 콘솔의 Sampled requests를 열어봤는데 해당 요청이 잡히지 않았다. 표시 지연이었는지 필터 문제였는지는 지금도 확실하지 않다. 다만 정황은 충분했다.

  • OPTIONS(본문 없음)는 통과
  • POST(multipart 바이너리 본문)만 차단
  • 차단 주체는 CloudFront 계층

엑셀 업로드는 multipart/form-data에 바이너리가 실린다. 관리형 규칙 입장에서는 일반적인 API 요청과 다르게 보이고, 본문 크기나 패턴을 검사하는 룰에 걸릴 수 있다. 정확히 어떤 룰이었는지는 끝내 특정하지 못했다.

5. 규칙을 만들려는데 두 번 막혔다

WAF에 이 경로만 허용하는 규칙을 추가하기로 했다. 그런데 바로 되지 않았다.

첫 번째 실수. 조건을 국가(Korea, Republic of - KR)로 잡았다. 만들다 보니 이상했다. 한국에서 오는 모든 요청이 관리형 규칙을 우회하게 된다. 문제를 해결하려다 방어를 통째로 여는 꼴이었다. 지웠다.

필요한 건 국가가 아니라 경로 + 메서드 + origin 기준이었다.

두 번째 막힘. 조건을 제대로 잡고 저장하려는데 안 됐다. CloudFront flat-rate plan의 Free 플랜은 WAF 규칙이 5개까지였고, 이미 다 쓰고 있었다. 사용자 지정 규칙을 추가하려면 Pro 업그레이드가 필요했다.

월 $15. 잠깐 고민했지만 대안이 마땅치 않았다. WAF 보호를 통째로 끄는 선택지도 있었는데, 관리자 업로드 하나 때문에 서비스 전체 방어를 내리는 건 아니라고 봤다. 업그레이드했다.

6. 해결: 조건을 좁힌 Allow 룰과 우선순위

규칙 이름은 AllowAdminExcelUpload. 세 조건을 모두(AND) 만족할 때만 통과시킨다.

Action: Allow

1. URI path starts with  <관리자 엑셀 업로드 경로>
2. HTTP method exactly matches  POST
3. header "origin" exactly matches  https://<운영 프론트 도메인>

여기서 짚고 갈 건, 이게 WAF를 느슨하게 만든 게 아니라는 점이다. 이 엔드포인트는 여전히 JWT 인증과 관리자 권한 검사를 통과해야 한다. WAF 예외는 "이 경로의 이 형태 요청만 관리형 규칙 검사에서 먼저 통과시킨다"는 의미고, 애플리케이션 레벨 방어는 그대로다.

그리고 이 규칙을 AWS Managed Rules보다 위에 놓았다. WAF 규칙은 우선순위 순으로 평가되고 먼저 매칭된 지점에서 판정이 끝나기 때문에, 아래에 두면 관리형 규칙이 먼저 차단해버린다. 규칙을 만들어놓고도 안 먹히는 경우는 대개 이 순서 문제다.

반영까지 1~3분 기다린 뒤 운영 페이지에서 다시 올렸다. 됐다. 검증도 반영도 정상이었다.

7. 고찰: 에러 메시지는 원인이 아니라 증상이다

이 문제에 한 시간 넘게 붙어 있었는데, 그중 절반은 CORS 설정을 다시 들여다보던 시간이었다.

브라우저가 CORS라고 했으니 CORS를 고쳐야 한다고 생각했다. 그런데 CORS 에러는 "서버가 허용 헤더를 안 줬다"는 결과만 말해줄 뿐, 그 응답을 누가 만들었는지는 말해주지 않는다. 이 경우엔 아예 백엔드까지 가지도 못한 요청이었는데, 브라우저 화면만 보고 있으면 그걸 알 수가 없다.

방향이 바뀐 건 curl을 켰을 때였다. OPTIONS가 204로 통과하는 걸 본 순간 "설정이 부족한가"라는 질문이 "그럼 이 403은 누가 주는가"로 바뀌었다. 그때부터는 응답 헤더 두 줄(Server: CloudFront, X-Cache: Error from cloudfront)로 범위가 좁혀졌다.

정리하면 이렇다. 브라우저는 요청 경로의 마지막 결과만 보여준다. 중간에 몇 개의 계층을 지나왔는지, 어디서 잘렸는지는 알려주지 않는다. 그걸 보려면 계층별로 직접 찔러봐야 한다.

로컬과 운영의 차이는 대부분 애플리케이션 코드가 아니라 그 앞에 늘어난 구간에서 온다. 로컬에는 CDN도 WAF도 로드밸런서도 없다. "로컬에선 되는데요"라는 말이 나오는 순간, 코드보다 그 사이 구간을 먼저 의심하는 게 맞다.

마지막으로 하나 더. 디버깅하느라 관리자 토큰을 여기저기 붙여 넣었는데, 끝나고 로그아웃 후 재발급받았다. 급할수록 흘리기 쉽고, 흘린 건 대개 나중에 기억나지 않는다. 디버깅 흔적을 정리하는 것까지가 이 작업의 일부였다.

로컬에선 되는데요 - CORS인 줄 알았던 WAF 이야기 | OnlyMinkk Blog