포스트

CORS와 CSRF - 브라우저가 막는 것과 서버가 막아야 하는 것

이름이 비슷해서 자주 섞인다. “CORS를 열었더니 CSRF가 생기나요” 같은 질문이 나오는 것이 그 증거다. 둘은 반대 방향의 문제다. CORS는 브라우저가 거는 제약을 푸는 이야기이고, CSRF는 브라우저가 알아서 보내는 것 때문에 생기는 공격이다.

출발점은 동일 출처 정책

브라우저에는 동일 출처 정책(Same-Origin Policy) 이 있다. 출처는 스킴 + 호스트 + 포트의 조합이고, 다른 출처의 응답을 스크립트가 읽는 것을 막는다.

여기서 중요한 구분이 있다. 동일 출처 정책이 막는 것은 읽기이지 보내기가 아니다. <img src>, <form action>, <script src>는 예전부터 교차 출처로 요청을 보낼 수 있었다. 응답을 스크립트가 못 읽을 뿐이다.

이 한 문장에서 두 문제가 갈린다.

  • CORS: 다른 출처의 응답을 읽고 싶은데 막힌다 → 서버가 허용을 표시하면 풀린다.
  • CSRF: 요청이 보내지는 것 자체가 문제다 → 응답을 못 읽어도 서버 상태가 바뀐다.

CORS: 서버가 허용을 표시하는 방식

브라우저가 교차 출처 요청을 보낼 때 Origin 헤더를 붙인다. 서버가 Access-Control-Allow-Origin으로 허용을 표시하면 브라우저가 응답을 스크립트에 넘긴다. 표시가 없으면 응답은 이미 도착했지만 브라우저가 스크립트에게 주지 않는다.

여기서 오해가 자주 생긴다. CORS는 서버를 보호하는 장치가 아니다. 요청은 서버에 도달했고 처리됐다. CORS가 보호하는 것은 사용자의 브라우저에 있는 데이터다. 서버 보호는 인증과 인가의 일이다.

프리플라이트는 단순 요청이 아닐 때(커스텀 헤더, PUT/DELETE, JSON Content-Type 등) 브라우저가 먼저 보내는 OPTIONS 요청이다. 서버가 허용 메서드와 헤더를 답하면 본 요청이 나간다. 프리플라이트는 캐시된다(Access-Control-Max-Age).

자격 증명(쿠키)을 포함하려면 양쪽이 필요하다. 클라이언트는 credentials: 'include', 서버는 Access-Control-Allow-Credentials: true. 그리고 이때 Access-Control-Allow-Origin: *는 허용되지 않는다. 출처를 명시해야 한다. 와일드카드와 자격 증명을 함께 쓰지 못하게 한 것이 표준의 안전장치다.

흔한 안티패턴은 Origin 헤더를 그대로 반사하는 것이다. 사실상 모든 출처를 허용하면서 자격 증명까지 켜면 제약이 사라진다.

CSRF: 브라우저가 알아서 보내는 것

쿠키는 요청이 어디서 시작됐든 대상 도메인으로 자동 전송된다. 사용자가 은행에 로그인한 채 공격자 사이트를 열면, 그 사이트의 폼이 은행으로 요청을 보내고 쿠키가 함께 간다. 응답을 읽을 수는 없지만 이체는 일어난다.

방어는 셋이 쓰인다.

SameSite 쿠키. SameSite=Lax가 현대 브라우저의 기본값이 되면서 CSRF의 상당 부분이 막혔다. Lax는 교차 사이트의 POST에 쿠키를 보내지 않고, 최상위 내비게이션의 GET에만 보낸다. Strict는 더 엄격하지만 외부 링크로 들어올 때 로그인이 풀린 것처럼 보이는 UX 문제가 있다. None은 교차 사이트 전송을 허용하며 Secure가 필수다.

CSRF 토큰. 서버가 발급한 예측 불가능한 값을 폼이나 헤더에 넣게 한다. 공격자는 그 값을 읽을 수 없다(동일 출처 정책 덕분이다). 스프링 시큐리티의 기본 방식이고, 동기화 토큰 패턴이라 부른다.

Origin/Referer 검증. 서버가 요청의 출처 헤더를 확인한다. 간단하지만 프록시 환경에서 헤더가 지워질 수 있다.

언제 무엇이 필요한가

인증 방식CSRF 대책이유
세션 쿠키필요브라우저가 자동으로 보낸다
HttpOnly 쿠키에 담은 JWT필요마찬가지로 자동 전송된다
Authorization 헤더에 담은 토큰대개 불필요브라우저가 자동으로 붙이지 않는다

마지막 줄이 “JWT를 쓰면 CSRF가 없다”는 말의 근거인데, 토큰을 쿠키에 담으면 그 근거가 사라진다. 그리고 localStorage에 담으면 CSRF는 피하지만 XSS에 노출된다. 어느 쪽이든 하나를 고르고 그에 맞는 대책을 세우는 것이지, 공짜인 선택지는 없다.

SameSite=Lax가 기본이 된 지금도 CSRF 토큰을 쓰는 이유는 방어를 한 겹으로 두지 않기 위해서다. 구형 브라우저, 서브도메인 간 요청, SameSite=None이 필요한 통합이 남아 있다.

이 설명이 깨지는 곳

  • CORS 오류 메시지가 원인을 가리지 않는다. 서버가 500을 냈을 때도 브라우저는 CORS 오류로 보고한다. 응답에 헤더가 안 붙었기 때문이다. 네트워크 탭에서 실제 상태 코드를 봐야 한다.
  • 서브도메인은 다른 출처지만 쿠키는 공유될 수 있다. Domain 속성으로 넓힌 쿠키는 서브도메인 전체에 간다. 그 안의 취약한 서비스 하나가 통로가 된다.
  • XSS가 있으면 둘 다 무의미하다. 스크립트가 같은 출처에서 실행되므로 토큰을 읽어 붙일 수 있다. CSRF 대책은 XSS 대책 위에 서 있다.
  • API 게이트웨이나 프록시가 헤더를 바꾼다. CORS 헤더를 앱과 프록시가 각각 붙이면 중복 헤더로 오히려 실패한다.

무엇을 재면 확인되는가

확인은 측정보다 재현에 가깝다.

  1. 다른 출처의 정적 페이지를 하나 띄워 폼 POST를 보내고, SameSite 설정별로 쿠키가 실제로 붙는지 본다.
  2. 프리플라이트가 언제 발생하는지 Content-Type과 헤더를 바꿔 가며 확인한다. 불필요한 프리플라이트는 왕복 하나를 더한다.
  3. CORS 헤더를 붙이지 않은 채 요청을 보내고, 서버 로그에 그 요청이 남는지 확인한다. 남는다. CORS가 서버를 보호하지 않는다는 것이 여기서 확인된다.

실무와의 접점

ParityPay의 ADR-011은 CORS 설정을 추가하지 않는다는 규칙을 뒀다. 필요해졌다면 배포 형태를 어긴 것이라는 판단이다. 프런트엔드와 API를 같은 출처로 서빙하면 CORS 문제가 아예 생기지 않고, 설정으로 푸는 것보다 배치로 없애는 쪽이 낫다는 결론이었다. 보안 설정을 늘리기 전에 그 설정이 필요한 구조인지를 먼저 묻는 것이 이 규칙의 요지다.

정리

  • 동일 출처 정책이 막는 것은 응답 읽기이지 요청 보내기가 아니다. 여기서 두 문제가 갈린다.
  • CORS는 브라우저의 읽기 제약을 서버가 풀어 주는 방식이다. 서버를 보호하지 않는다.
  • 자격 증명을 포함하면 Allow-Origin: *를 쓸 수 없다. Origin 반사는 제약을 없애는 것과 같다.
  • CSRF는 쿠키가 자동 전송되기 때문에 생긴다. 응답을 못 읽어도 상태는 바뀐다.
  • 쿠키로 인증하면 CSRF 대책이 필요하고, Authorization 헤더면 대개 불필요하다. 토큰을 쿠키에 담으면 필요해진다.
  • XSS가 있으면 두 대책 모두 무력하다.

참고

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

댓글

아직 댓글이 없습니다