본문으로 건너뛰기

43. CORS 설정 오류와 실전 익스플로잇

CORS는 서버가 브라우저의 교차 출처 읽기를 허용할 범위를 선언하는 정책이다. 서버 간 요청을 막는 방화벽이 아니며, Access-Control-Allow-Origin이 있다고 해서 요청 자체가 인증된 것은 아니다. 이 차이를 놓치면 “헤더가 보인다”와 “민감한 응답을 읽을 수 있다”를 혼동하게 된다. 아래 검사는 승인된 실습 환경이나 자기 소유 API에서만 수행한다.

취약점은 헤더 조합에서 생긴다

가장 위험한 패턴은 요청의 Origin을 검증 없이 반사하면서 인증 정보까지 허용하는 구성이다.

Origin: https://lab-client.example

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://lab-client.example
Access-Control-Allow-Credentials: true
Vary: Origin

반사 자체가 항상 취약한 것은 아니다. 허용 목록에 정확히 일치한 출처만 반사하고, 민감한 응답에만 자격 증명을 허용한다면 의도된 구성일 수 있다. 문제는 https://legit.example.attacker.test 같은 문자열을 접두사 비교로 통과시키거나, 포트·스킴·대소문자·후행 점을 잘못 정규화하는 경우다. 출처는 URL 파서로 scheme, hostname, port를 분리해 비교하고, 허용 목록은 완전 일치로 유지한다.

from urllib.parse import urlsplit

ALLOWED = {("https", "app.example", 443)}

def allowed(origin: str) -> bool:
try:
p = urlsplit(origin)
port = p.port or (443 if p.scheme == "https" else 80)
return (p.scheme, p.hostname, port) in ALLOWED
except ValueError:
return False

null 출처는 sandbox iframe, 일부 로컬 문서와 특수한 브라우저 상황에서 나타난다. 특별히 필요한 제품 요구가 없다면 허용하지 않는다. *Access-Control-Allow-Credentials: true는 함께 사용할 수 없으므로, 별표를 썼다고 쿠키가 자동으로 노출된다고 설명해서도 안 된다.

프리플라이트와 캐시를 함께 본다

비단순 요청은 OPTIONS 프리플라이트를 거친다. 서버가 실제 요청과 프리플라이트에서 서로 다른 허용 출처·메서드·헤더를 반환하면 브라우저에서만 실패하는 설정 오류가 생긴다. 반대로 프리플라이트를 통과했다고 권한 검사가 대체되는 것은 아니다. CORS는 CSRF 방어가 아니므로 상태 변경 요청에는 별도 CSRF 방어와 인증 검사를 둔다.

동적으로 출처를 선택하는 서버는 Vary: Origin을 함께 반환해야 공유 캐시가 한 출처의 허용 응답을 다른 출처에 재사용하지 않는다. 테스트에서는 정상 출처, 유사 출처, null, 다른 포트와 스킴을 각각 보내고 응답 헤더를 비교한다. 브라우저 콘솔만 보지 말고 실제 응답 본문이 민감한지, 쿠키 정책(SameSite, Secure)과 서버 권한 검사가 어떤지 확인한다.

방어 기준

허용 출처를 코드 곳곳에 흩어놓지 말고 중앙 목록으로 관리한다. 공개 데이터만 제공하는 엔드포인트는 자격 증명 없는 CORS를 고려하고, 계정·결제·개인정보 API는 필요한 출처와 메서드만 좁혀 명시한다. Access-Control-Allow-HeadersAccess-Control-Max-Age도 최소 범위로 설정한다. 최종 판단 기준은 “헤더가 예쁘게 보이는가”가 아니라, 승인되지 않은 출처의 브라우저가 인증된 민감 응답을 읽을 수 있는가이다.