CORS를 우회하면 생기는 일 — Zoom이 보안 취약점을 스스로 만든 방법 (fosterelli.co)
목차(4)
한줄 요약
CORS를 모르면 우회하게 되고, 우회하면 반드시 구멍이 생긴다.
어떤 상황에서 필요한가?
CORS(Cross-Origin Resource Sharing) 오류는 외부 API 연동 작업에서 웹 개발자가 가장 자주 마주치는 브라우저 에러 중 하나다. 프론트엔드가 다른 도메인이나 포트의 서버에 요청을 보낼 때, 브라우저가 이를 차단하면서 콘솔에 빨간 에러가 뜨는 상황이다.
문제는 이 에러를 "일단 없애는 것"에만 집중하는 개발자가 많다는 점이다. Access-Control-Allow-Origin: *을 서버에 박아두거나, 프록시로 우회하거나, 아예 브라우저 보안 정책을 끄는 방식으로 해결한 뒤 배포하는 경우가 생각보다 흔하다.
Zoom의 사례는 이 문제가 얼마나 심각해질 수 있는지를 잘 보여준다. Zoom은 사용자 PC의 localhost:19421에 웹서버를 띄워두고, 웹에서 Zoom 미팅 링크를 클릭하면 이 로컬 서버를 통해 네이티브 앱을 실행하는 구조를 만들었다. 그런데 이 로컬 서버에 AJAX 요청을 보내면 브라우저가 CORS 정책으로 차단한다는 걸 해결하지 못했고, 결국 이미지 파일의 너비와 높이 값으로 응답 상태를 인코딩하는 방식을 택했다. 이미지 로딩은 CORS 정책 밖이었기 때문이다.
결과는 치명적이었다. CORS 헤더로 출처를 필터링하지 않았기 때문에, zoom.us뿐만 아니라 인터넷의 모든 사이트가 이 로컬 서버를 통해 Zoom을 조작할 수 있게 됐다.
핵심 구현 방법
CORS의 본질을 한 문장으로 정리하면 이렇다. "어떤 출처의 JavaScript가 이 서버에 접근할 수 있는지를 서버가 브라우저에게 알려주는 메커니즘."
브라우저는 cross-origin 요청을 보낼 때 서버 응답 헤더에서 Access-Control-Allow-Origin 값을 확인한다. 이 값이 요청을 보낸 출처와 일치하지 않으면 브라우저가 응답을 차단한다. 서버가 응답을 보내지 않는 게 아니라, 브라우저가 JavaScript에 응답을 노출하지 않는 것이다.
Zoom의 경우 올바른 구현은 간단했다. 로컬 서버의 응답에 아래 헤더만 추가하면 됐다.
Access-Control-Allow-Origin: https://zoom.us
이렇게 하면 zoom.us 도메인에서 실행되는 JavaScript만 이 로컬 서버와 통신할 수 있다. 다른 사이트에서 시도하면 브라우저가 차단한다. 추가로 zoom.us 쪽에 Content-Security-Policy 헤더를 설정해 iframe 내 렌더링을 막았다면 공격 표면은 더욱 줄어들었을 것이다.
외주 개발이나 실무 프로젝트에서 CORS 설정을 다룰 때 기억해야 할 원칙은 세 가지다.
1. Allow-Origin에 *를 쓰는 건 인증 없는 공개 API에서만 허용한다.
로그인이 필요하거나 민감한 작업을 수행하는 엔드포인트에 와일드카드를 쓰면 CSRF와 결합해 심각한 취약점이 된다.
2. CORS는 서버가 아니라 브라우저가 집행하는 정책이다. CORS 헤더를 서버에서 제대로 설정하지 않으면 브라우저가 차단하지만, curl이나 서버 간 통신은 CORS의 영향을 받지 않는다. 즉, CORS는 브라우저 사용자 보호 메커니즘이지 서버 접근 제어 수단이 아니다.
3. preflight 요청(OPTIONS)을 이해하고 처리해야 한다.
Content-Type: application/json 같은 커스텀 헤더나 PUT/DELETE 메서드를 쓰면 브라우저는 실제 요청 전에 OPTIONS 요청을 먼저 보낸다. 이 preflight에 올바르게 응답하지 않으면 본 요청이 나가지 않는다.
실전에서 주의할 점
CORS 설정 관련 Stack Overflow 답변에는 Access-Control-Allow-Origin: *를 아무런 조건 없이 설정하라고 안내하는 예시가 적지 않다. 특히 Express.js 관련 가이드 중 일부는 애플리케이션 전체에 와일드카드를 적용하는 설정을 그대로 복사하도록 유도하는데, 이를 그대로 프로덕션에 배포하면 취약점이 된다.
실무 기준에서 CORS 설정은 다음 순서로 접근하는 게 안전하다.
- 허용할 출처 목록을 환경변수로 관리하고, 요청의
Origin헤더와 비교해 동적으로 응답한다. - 인증이 포함된 요청(
credentials: include)에는 와일드카드를 쓸 수 없다. 반드시 특정 출처를 명시해야 한다. - 로컬 개발 환경에서는 localhost 출처를 허용하되, 이 설정이 프로덕션 빌드에 포함되지 않도록 분리한다.
CORS를 "없애야 할 장애물"로 보는 관점 자체가 문제다. CORS는 공격자가 악의적인 사이트에서 사용자의 인증 정보를 활용해 다른 서버에 요청을 보내는 것을 막아주는 브라우저의 방어선이다. 이걸 우회하면 그 방어선이 사라진다.
자주 묻는 질문
Q.localhost 개발 환경에서 CORS 오류가 나는데, 브라우저는 localhost를 예외로 처리하지 않나요?
흔한 오해다. Chrome을 포함한 현대 브라우저는 localhost에 대해서도 CORS 정책을 정상적으로 적용한다. 예외 처리가 있다는 주장은 사실이 아니다. Create React App처럼 프론트엔드와 백엔드가 다른 포트를 쓸 때 CORS 오류가 나는 건 이 때문이며, 서버 쪽에서 `Access-Control-Allow-Origin: http://localhost:3000` 식으로 개발 출처를 명시해줘야 한다.
Q.CORS 설정을 서버에서 했는데도 여전히 브라우저에서 막히는 이유가 뭔가요?
가장 흔한 원인은 preflight(OPTIONS) 요청 처리 누락이다. 커스텀 헤더나 특정 HTTP 메서드를 쓰면 브라우저는 본 요청 전에 OPTIONS 요청을 먼저 보낸다. 서버가 이 OPTIONS 요청에 적절한 CORS 헤더로 응답하지 않으면 본 요청 자체가 전송되지 않는다. 또 다른 원인은 응답 헤더는 맞게 설정했지만 서버가 에러 응답(4xx, 5xx)을 반환하는 경우다. 브라우저는 에러 응답의 CORS 헤더도 그대로 확인하기 때문에, 헤더가 없으면 에러 응답도 차단된다.
Q.CORS를 서버가 아닌 프론트엔드에서 해결하는 방법은 없나요?
프론트엔드 단독으로 CORS 정책을 변경하는 건 불가능하다. CORS는 서버의 응답 헤더를 기반으로 브라우저가 판단하기 때문이다. 다만 개발 환경에서는 webpack dev server나 Vite의 프록시 기능을 활용해 same-origin 요청처럼 우회할 수 있다. 프로덕션에서는 동일한 방식으로 Nginx나 API 게이트웨이를 프록시로 두는 아키텍처를 쓰거나, 서버에서 CORS 헤더를 올바르게 설정하는 것 외에 근본적인 해결책은 없다.
관련 아티클
관련 사례
이 글의 키워드와 맞닿은 실제 개발 사례를 함께 보세요.