본문 바로가기
빌드노트 검색

빌드노트

CORS 오류가 났는데 서버에는 POST가 들어왔다

CORS 오류가 나도 단순 POST는 서버에 도착하고 처리될 수 있다. 두 loopback 출처에서 응답 공유 헤더 유무와 서버 Origin 거절을 비교해, 요청 수신·실제 변경·응답 읽기를 구분하고 CSRF 방어를 점검할 기준을 설명한다.

브라우저에서 POST가 CORS 오류로 끝났는데 서버에는 요청과 처리 기록이 남을 수 있다. 사전 요청 없이 보내는 단순 요청은 서버에 도착한 뒤, 응답을 JavaScript에 넘기는 단계에서 막힐 수 있기 때문이다. 프런트의 실패 표시만으로 “서버가 아무 일도 하지 않았다”고 판단하면 안 된다.

이때는 실패한 요청의 메서드·본문 형식을 확인하고, 서버의 수신 기록과 실제 변경 결과를 함께 찾는다. 응답을 읽을 수 있게 할 문제인지, 허용하지 않은 요청의 처리를 서버에서 막을 문제인지가 여기서 갈린다.

같은 POST의 수신 기록과 변경 결과를 찾는다

문제가 난 페이지의 개발자 도구에서 Network와 Console을 연다. 이미 발생한 요청을 기준으로 다음을 대조한다.

  1. Network에서 실제 POST의 URL, 시각, 요청의 Origin과 Content-Type을 확인한다. OPTIONS와 POST는 구분한다. OPTIONS가 없다는 사실만으로 단순 요청이라고 단정하지는 않는다. 사전 요청의 허용 결과가 캐시돼 있을 수도 있으므로 메서드와 헤더 조건도 봐야 한다.
  2. 서버나 게이트웨이의 수신 로그에서 같은 URL·시각의 POST를 찾는다. 기존 요청 식별자가 있다면 함께 대조한다. 브라우저의 실패 행만으로 서버 수신 여부를 결정하지 않는다.
  3. 수신 이후의 처리 결과를 확인한다. 접근 로그의 POST 한 건은 요청 도착의 근거다. 저장·변경까지 끝났는지는 처리 로그나 저장 결과에서 별도로 확인해야 한다. 서버에 남은 200만으로 업무 처리가 완료됐다고 단정할 수도 없다.

catch로 넘어갔다는 이유만으로 POST를 곧바로 다시 보내면, 이미 실행된 작업을 반복할 수 있다. 아래 실험에서 실제로 측정한 것은 각 요청 한 번의 수신과 메모리 기록 추가다. 재시도나 중복 방지 기능을 검증한 실험은 아니다.

CORS 헤더를 빼도 서버 기록은 한 건 늘었다

원인을 확인하려고 두 loopback 출처를 새로 열었다. 페이지는 http://127.0.0.1:31852, API는 http://127.0.0.1:31853이었다. 포트가 달라 교차 출처다. Windows 11, Python 3.12.10, Playwright 1.59.0과 Chromium 147.0.7727.15에서 실행한 별도 실험이며 실제 서비스 장애 기록은 아니다.

브라우저의 fetch는 POST, mode: "cors", credentials: "omit"으로 실행했다. 본문은 new URLSearchParams({ case: "hidden", message: "synthetic" })처럼 합성 문자열만 넣었다. 서버가 받은 Content-Type은 application/x-www-form-urlencoded;charset=UTF-8이었다. 사용자 지정 요청 헤더와 스트림 본문은 넣지 않았고, Cookie와 Authorization도 없었다.

MDN의 단순 요청 설명 (새 탭에서 열림)은 이런 조건의 POST가 CORS 사전 요청 없이 전송될 수 있지만 응답 공유에는 서버의 허용이 필요하다고 설명한다. POST라는 메서드만 보고 모두 같은 동작을 한다고 생각하면 안 된다. JSON Content-Type이나 별도 요청 헤더를 추가한 호출은 이 실험과 조건이 다르다.

API의 두 경로는 모두 본문을 받아 메모리 목록에 합성 기록을 하나 추가했다. /hidden은 CORS 응답 헤더를 보내지 않았다. /shared는 같은 처리 뒤 Access-Control-Allow-Origin: http://127.0.0.1:31852와 Vary: Origin을 보냈다. 서버 기록과 fetch 결과를 묶으면 다음과 같다. 각 숫자는 해당 요청 한 번에 대한 값이다.

/hidden  POST 수신=1  기록 추가=1  서버 응답=200  fetch=rejected TypeError
/shared  POST 수신=1  기록 추가=1  서버 응답=200  fetch=resolved status=200

두 요청 모두 서버에 도착했고 기록도 추가됐다. 차이는 브라우저 코드가 응답을 받았는지였다. /shared에서는 본문 recorded shared를 읽었다. /hidden에서는 TypeError: Failed to fetch가 발생했고 응답 객체를 얻지 못했다. 브라우저의 네트워크 이벤트에는 HTTP 200 응답 헤더가 있었지만, Console에는 Allow-Origin 헤더가 없어 CORS 정책에 막혔다는 오류가 남았다. 서버의 OPTIONS 수신 횟수는 0이었다.

이 결과는 Fetch 표준의 HTTP fetch 절차 (새 탭에서 열림)와 맞는다. 네트워크 응답을 얻은 뒤 CORS 검사가 실패하면 호출자에게 네트워크 오류를 돌려주는 경로가 있다. CORS 검사 규칙 (새 탭에서 열림)은 Allow-Origin 헤더가 없으면 실패하도록 정의한다. 그 오류가 앞서 실행한 서버 처리를 되돌려 주지는 않는다.

다만 JavaScript의 TypeError만으로 CORS 원인을 확정할 수는 없다. 이 실험은 Console의 구체적인 CORS 메시지, 브라우저 네트워크 이벤트, 서버 수신·처리 기록을 함께 확인했다. 자신의 환경에서도 응답 공유 설정을 바꾸기 전에 같은 근거를 대조한다. MDN의 CORS 오류 설명 (새 탭에서 열림)

Origin 검사를 처리 앞에 두자 기록이 늘지 않았다

응답 헤더 누락과 서버의 요청 거절을 비교하기 위해 /guarded를 추가했다. 이 경로는 요청의 Origin이 API 출처인 http://127.0.0.1:31853과 정확히 같을 때만 기록을 추가했다. 다른 값은 기록 추가 전에 403으로 끝냈다. 이 실험의 허용 목록은 API 출처 하나다.

먼저 31852 페이지에서 같은 형식의 POST를 보냈다. 이어서 31853 페이지로 이동해 같은 /guarded에 POST를 보냈다. 두 경우 모두 쿠키와 인증은 제외했다.

31852 → /guarded  POST 수신=1  기록 추가=0  서버 응답=403  fetch=rejected TypeError
31853 → /guarded  POST 수신=1  기록 추가=1  서버 응답=200  fetch=resolved status=200

첫 줄의 요청도 전송됐다. 서버가 요청을 받은 뒤 Origin을 검사해 처리를 거절한 것이다. 이 403에는 CORS 허용 헤더도 없어서 브라우저 코드는 상태 403을 읽지 못하고 앞선 /hidden과 같은 TypeError를 받았다. 같은 프런트 오류라도 서버에서는 한쪽은 기록 추가, 다른 쪽은 처리 거절이었다.

두 번째 줄은 서버의 허용 조건을 만족하는 대조다. 동일 출처에서는 CORS 허용 헤더 없이도 200과 응답 본문을 읽었다. 네 번의 POST 전체에서 OPTIONS는 0건이었다.

자기 서버에서 거절 여부를 확인할 때도 Origin 검사 뒤에 저장·변경 코드가 실행되는지 봐야 한다. 처리부터 끝낸 뒤 허용 헤더만 생략하면 이 실험의 /hidden처럼 응답 읽기만 실패할 수 있다. 반대로 승인된 프런트가 응답을 읽어야 하는 경우에는 서버·프록시가 만드는 실제 응답의 CORS 허용 설정을 확인한다. 요청의 Origin을 검사 없이 그대로 허용값으로 되돌리는 방식은 피한다.

CORS 오류를 CSRF 방어 성공으로 세지 않는다

CSRF는 다른 페이지가 사용자의 브라우저를 통해 원치 않는 요청을 보내게 하는 문제다. 공격하는 페이지가 응답 내용을 읽을 필요가 없는 경우도 있다. 따라서 “CORS 오류가 났으니 상태 변경도 차단됐다”는 가정으로 서버의 요청 검증을 생략할 수 없다. 이번 비교는 응답 공유 실패와 서버 처리 거절이 다른 결과라는 점을 보여 준다.

쿠키로 인증하는 앱이라면 사용하는 프레임워크의 CSRF 보호를 먼저 확인하고, 상태 변경 요청에서 토큰을 서버가 검증하는지 점검한다. Origin·Referer 검증과 SameSite 쿠키 등은 앱의 인증 방식과 함께 판단한다. OWASP의 CSRF 방어 지침 (새 탭에서 열림)은 프레임워크 보호와 서버 검증을 설명한다.

Origin 검사도 이 글의 문자열 비교 하나를 그대로 옮기는 것으로 끝나지 않는다. 실제 서비스의 승인된 스킴·호스트·포트를 명시하고, Origin이 없거나 null인 요청을 어떻게 검증할지 정해야 한다. 이를 자동 허용으로 처리하지 않는다. Origin 허용이 사용자 인증과 권한 검사를 대신하지도 않는다. OWASP의 출처 검증 설명 (새 탭에서 열림)

실험은 인증·쿠키·계정 없이 메모리 목록만 변경했다. 두 주소는 포트가 다른 교차 출처지만 같은 사이트였고, 실제 쿠키가 전송되는 CSRF 공격이나 SameSite 정책은 검증하지 않았다. Origin 누락·null, 운영 프록시, 다른 브라우저 엔진과 JSON·사용자 지정 헤더의 preflight 동작도 실행 범위 밖이다.

← 전체 글 보기