본문으로 건너뛰기
개발 머꼬
개발 노트JavaScript
hohyeon.dev3

blocked by CORS policy 뒤에 붙는 문장이 매번 달랐던 이유

  • #Common Pitfall
  • #CORS
  • #Engineering Note

문제 발생

프런트엔드를 app.example.com, API를 api.example.com으로 나누면서 CORS 설정을 처음 붙였습니다. 콘솔에 blocked by CORS policy가 떠서 하나 고치면, 다음 배포에서 또 blocked by CORS policy가 떴습니다. 같은 에러가 반복되는 줄 알았는데, 자세히 보니 뒤에 붙은 문장이 매번 달랐습니다.

Access to fetch at 'https://api.example.com/me' from origin 'https://app.example.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
... has been blocked by CORS policy: The value of the 'Access-Control-Allow-Origin' header in the response
must not be the wildcard '*' when the request's credentials mode is 'include'.
... has been blocked by CORS policy: Response to preflight request doesn't pass access control check:
It does not have HTTP ok status.

앞부분만 보고 "CORS 또 났네" 하며 같은 설정을 계속 만졌던 게 시간을 제일 많이 잡아먹었습니다. 결국 이 뒷문장이 원인을 거의 그대로 알려 주고 있었습니다.

원인 분석

코드에서는 원인을 알 수 없고, 콘솔에서만 보입니다. MDN의 CORS 문서에 따르면 CORS 실패는 에러로 나타나지만, 보안상의 이유로 에러의 구체적인 내용은 JavaScript에 전달되지 않습니다. 코드가 아는 건 에러가 났다는 사실뿐이고, 정확히 뭐가 잘못됐는지는 브라우저 콘솔을 봐야 한다고요. 실제로 원인이 무엇이든 fetch의 catch에 들어온 건 매번 똑같이 TypeError: Failed to fetch였습니다. 에러 수집 도구에 이것만 쌓여 있으면 원인이 하나처럼 보입니다.

그리고 서버는 요청을 이미 처리했을 수도 있습니다. 로컬에서 서버 두 개(페이지 localhost:8001, API 127.0.0.1:8002)를 띄우고 Chromium 141로 재현해 보니, preflight가 없는 단순 GET은 CORS 헤더가 없어도 서버 로그에 GET /none이 찍혔고 서버는 200을 돌려줬습니다. 브라우저가 막은 건 요청이 아니라 응답을 JavaScript에 넘겨주는 단계였습니다. 반면 Authorization 헤더가 붙은 요청은 서버 로그에 OPTIONS만 남고 실제 요청은 오지 않았습니다. preflight가 실패하면 실제 요청을 아예 보내지 않기 때문입니다. 이 흐름은 예전 글 "CORS 에러가 났는데 서버 로그에는 요청이 안 보이는 이유"에 정리해 두었습니다.

이번 글은 그다음 단계입니다. 같은 재현 환경에서 서버 응답만 바꿔 가며 Chromium이 실제로 찍은 메시지를 모았습니다. 메시지마다 원인과 고치는 방법이 다릅니다.

해결 방안

  1. No 'Access-Control-Allow-Origin' header is present는 응답에 그 헤더가 아예 없다는 뜻입니다. CORS 설정을 안 했거나, 했는데 이 응답에는 적용되지 않은 경우입니다. 흔한 건 두 번째입니다. 인증 실패 401, 없는 경로 404, 서버 오류 500처럼 CORS 미들웨어보다 앞에서 끝난 응답에는 헤더가 안 붙습니다. 그러면 진짜 원인(401이나 500)은 가려지고 CORS 에러만 보입니다. 앞에 Response to preflight request doesn't pass access control check:가 붙어 있으면, 실제 요청이 아니라 OPTIONS 응답에 헤더가 빠졌다는 뜻입니다. 확인할 곳은 개발자 도구 네트워크 탭에서 실패한 요청의 실제 상태 코드와 응답 헤더입니다. 상태 코드가 200이 아니라면 그것부터 고칩니다.

  2. not equal to the supplied origin이나 contains multiple values는 값이 틀렸다는 뜻입니다. 재현해 보니 Access-Control-Allow-Origin에 다른 출처를 적으면 has a value 'https://admin.example.com' that is not equal to the supplied origin이, 같은 헤더가 두 번 붙으면 contains multiple values 'http://localhost:8001, http://localhost:8001', but only one is allowed가 나왔습니다. MDN 문서대로 이 헤더에는 출처를 하나만 적을 수 있고, 여러 출처를 지원하려면 요청한 클라이언트의 출처를 골라서 돌려줘야 합니다. 그래서 허용 목록에 있는 Origin이면 그 값을 그대로 돌려주는 방식으로 만듭니다. 이때 MDN이 권하는 대로 Vary: Origin도 같이 보내야 캐시가 다른 출처의 응답을 섞어 주지 않습니다. 값이 두 개 붙는 건 대개 nginx와 애플리케이션이 둘 다 헤더를 넣을 때입니다. CORS 헤더는 한 곳에서만 붙입니다.

Access-Control-Allow-Origin: https://app.example.com
Vary: Origin
  1. credentials mode is 'include'가 들어간 메시지는 쿠키를 같이 보낼 때의 규칙입니다. fetch에 credentials: 'include'를 주면 규칙이 엄격해집니다. MDN 문서는 자격 증명이 포함된 요청에 응답할 때 Access-Control-Allow-Origin에 *를 쓰면 안 되고 출처를 명시해야 한다고 적습니다. Access-Control-Allow-Headers, Access-Control-Allow-Methods, Access-Control-Expose-Headers도 마찬가지로 * 대신 목록을 적어야 합니다. 재현해 보니 *를 주면 must not be the wildcard '*'가, 출처는 맞게 줬는데 Access-Control-Allow-Credentials: true를 빼먹으면 The value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true'가 나왔습니다. Spring을 쓴다면 allowedOrigins("*")와 allowCredentials(true)를 같이 쓸 수 없습니다. CorsConfiguration 문서에 따르면 이 조합은 IllegalArgumentException으로 거부되고, 대신 allowedOriginPatterns를 쓰면 매칭된 출처 값 자체가 응답 헤더에 들어갑니다.

  2. preflight가 들어간 메시지는 OPTIONS 응답을 봐야 합니다. 재현한 세 가지가 실무에서 제일 자주 만나는 것들이었습니다.

Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response.
It does not have HTTP ok status.
Redirect is not allowed for a preflight request.

첫 번째는 Authorization 같은 헤더를 보내는데 Access-Control-Allow-Headers에 그 이름이 없을 때 납니다. 두 번째는 OPTIONS 응답이 2xx가 아닐 때 나는데, 저는 OPTIONS에 404를 돌려줘서 재현했습니다. 실무에서는 인증 필터가 OPTIONS에 401을 돌려주는 경우가 많습니다. MDN 문서대로 CORS preflight 요청에는 자격 증명이 절대 포함되지 않으니, 토큰을 검사하는 필터가 OPTIONS를 통과시키지 않으면 항상 401입니다. 세 번째는 OPTIONS에 리다이렉트를 돌려줄 때 납니다. http에서 https로, 끝에 /가 붙은 주소로 넘기는 설정이 흔한 원인이라, 프런트엔드가 부르는 주소를 리다이렉트 없는 최종 주소로 맞춥니다.

  1. CORS 에러는 서버를 지켜 주지 않는다는 걸 기억합니다. 1번에서 본 것처럼 단순 요청은 서버에 도착해서 처리까지 끝난 뒤에 브라우저가 응답만 막습니다. CORS는 다른 사이트의 스크립트가 응답을 읽지 못하게 하는 브라우저 규칙이지, 서버로 오는 요청을 막는 장치가 아닙니다. curl이나 서버 간 호출에는 아예 적용되지 않습니다. 그래서 "CORS로 막았으니 안전하다"는 판단은 위험하고, 상태를 바꾸는 API의 인증과 CSRF 방어는 따로 해야 합니다. 디버깅할 때도 같은 이유로 순서를 정해 둡니다. 콘솔의 뒷문장을 먼저 읽고, 네트워크 탭에서 실패한 요청이 OPTIONS인지 실제 요청인지와 상태 코드를 보고, 마지막으로 서버 로그에서 그 요청이 정말 왔는지 확인합니다.

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.