CORS, Preflight, 인증 처리 관련 삽질

여러개의 마이크로 서비스로 구성된 서버사이드와 React 등을 이용한 Single Page App으로 서비스를 구성할 경우 CORS 이슈에 직면하게 됩니다. 이번 글에서는 CORS와 API의 Http 상태 정보와 관련하여 삽집한 내용을 공유하려고 합니다. 실제로는 Go언어와 React(Javascript)에는 초짜라서 발생한 문제라고 볼 수 있습니다.

CORS란?

CORS란 Cross Origin Resource Sharing의 약자로 브라우저의 현재 웹페이지가 이 페이지를 받은 서버가 아닌 다른 서버의 자원을 호출하는 것을 의미합니다. 가장 쉬운 예로 CDN에 배포되어 있거나 공용 이미지 등을 그냥 단순 Link를 걸어서 사용하는 것도 CORS라고 할 수 있습니다.

예: http://www.popit.kr/123 에서 다음과 같이 link를 사용하는 경우

  • <img src='http://www.naver.com/images/test.jpg'/>

CORS 무엇이 문제인가?

위와 같이 정상적인 상황이라면 큰 문제가 되지 않습니다. 하지만 웹 페이지 개발자가 악의적인 목적으로 이를 악용할 경우 문제가 발생합니다. 해당 페이지 내의 자바스크립트 코드 중에 브라우저의 취약점 또는 이미 노출되어 있는 정보를 해커의 사이트로 전송하게 할 수도 있고, 어떤 광고를 특정 URL(광고 등)의 Page View를 의도적으로 높이게 요청을 보낼 수도 있습니다. 즉,  악의적은 의도 또는 원래 목적과 다른 의도로 다른 사이트로 무언가 요청을 보내는 행위를 할 수 있다는 것입니다.  한마디로 요약하면

보안에 취약하다.

보안 취약 문제는 어떻게 해결하나?

HTTP 스펙에서는 이 문제에 대해 정의를 하고 있으며, 크롬, 파이어폭스, IE 등과 같은 대부분의 알려진 브라우저는 이 스펙을 준수하고 있습니다. CORS 상황이 발생했을 때 브라우저는 다음과 같은 절차를 사용합니다.

  • 일반적인 요청에 대해서는 아무런 처리도 하지 않음, 일반적인 요청이라고 하면 다음 사항에 부합되는 요청을 의미함
    • GET, HEAD, POST
    • Request Header에는 다음 속성만 허용:
      • Accept, Accept-Language,  Content-Language,  Content-Type
    • Content-Type은 다음만 허용
      • application/x-www-form-urlencoded
      • multipart/form-data
      • text/plain
  • 이런 일반적인 요청이 아닌 경우 브라우저는 접근할 리소스를 가지고 있는 서버에 preflighted 요청을  보냄
    • preflighted 요청은 특별한 목적을 가지는 요청으로 method = OPTIONS 으로 전송
    • OPTIONS 요청을 받은 서버는 Response Header에 서버가 허용할 옵션을 설정하여 브라우저에게 전달.
    • 브라우저는 서버가 보낸 Response 정보를 이용하여 허용되지 않은 요청인 경우 405 Method Not Allowed 에러를 발생시키고, 실제 페이지의 요청은 서버로 전송하지 않음
    • 허용된 요청인 경우 전송

이 과정을 그림으로 나타내면 다음과 같습니다.

CORS 처리 방식

CORS 처리 방식

자세한 내용은  https://developer.mozilla.org/ko/docs/Web/HTTP/Access_control_CORS 참고 하세요.

서비스 개발 중 발생한 이슈!

마이크로 서비스와 React 등을 이용한 Single Page App 환경에서는 이런 CORS는 일반적인 상황이 됩니다. A 도메인을 가지고 있는 웹서버로부터 html을 받았지만 대부분의 기능은 B 도메인, C 도메인 등으로 요청을 보내어 화면에서 기능을 처리하게 됩니다. 이 상황에서 다음과 같이 구현하렸지만 브라우저에서 응답을 받지 못하는 문제가 발생하였습니다.

  • Client는 API 호출 시 모든 요청에 대해 Header의 Authorization에 JWT 토큰을 전송
  • API 서버는 인증 처리를 위해 Client의 모든 요청에 대해 Header의 Authorization 정보를 이용하여 정당한 사용자의 요청인지 검증
    • 정당한 요청이 아닌 경우 Http Response의 상태 정보를 StatusUnauthorized(401) 로 설정하여 응답 전달
  • Client는 Response가 200이 아닌 경우 에러 메시지를 사용자에게 표시 또는 로그인 화면(StatusUnauthorized인 경우)으로 이동

405 Method Not Allowed

일단 가장 심플하게 아무런 설정을 하지 않고 화면에서 서버 API를 요청해 보았습니다. 요청은 다음 코드와 같이 Header에 인증 Token을 추가하였습니다.

1
2
3
fetch(apiPath, { 
  headers: { 'Authorization': 'XXXX' }   <- Preflighted 요청이 필요한 Header 
})

당연히 다음과 같은 문제가 발생하였습니다

cors_error_list_01

에러 메시지 세부 내용은 다음과 같습니다.

OPTIONS http://127.0.0.1:5000/services 405 (Method Not Allowed) Failed to load http://127.0.0.1:5000/services: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'http://127.0.0.1:2200' is therefore not allowed access. The response had HTTP status code 405. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

서버 CORS 설정

가장 먼저 의심한 것이 서버의 CORS 설정이라 다음과 같은 항목을 추가해 주었습니다. (필자의 경우 현재 go언어를 사용하고 있으며 웹 프레임워크는 echo 를 사용하고 있음)

1
2
e := echo.New()
e.Use(middleware.CORS())

대부분의 웹 서버나 Application 서버는 CORS  설정 기능이 있습니다.  이 설정만으로 일단 페이지 요청은 정상적으로 되었습니다.

인증 모듈 추가 후 진짜 문제 발생

다음으로 모든 요청에 대한 인증 확인 처리를 위해 웹프레임워크(여기서는 echo)에 사용자 정의 Filter 를 추가 했습니다. 참고로 echo 웹 프레임워크에서는 filter라는 용어가 아닌 middleware 라는 용어를 사용합니다.  실제 코드는 다음과 같습니다. 눈치가 빠르신 분이라면 여기서 무엇이 잘못되었는지 바로 찾을 수 있을겁니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
e := echo.New()
e.Use(AuthenticationMiddleware())  <- auth 확인 middleware 추가
e.Use(middleware.CORS())
func AuthenticationMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
  return func(c echo.Context) error {
    if assertAuth(c) {
      // 정상이면 다음 단계 수행 
      return next(c)
    } else {
      // auth 실패한 경우 Unauthorized(401) 상태 반환
      return echo.NewHTTPError(http.StatusUnauthorized,"Please provide valid credentials(cookie or header)")
    }
  }
}

클라이언트 측 코드는 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
fetch(apiPath, { 
    headers: { 'Authorization': 'XXXX' }   <- Preflighted 요청이 필요한 Header 
  })
  .then(response => {
    if (response.status == 200) {
      return response;                 <- 정상 처리
    } else if (response.status == 401) {
      window.location.href = '/login';  <- 로그인 화면으로 이동
      return respone;
    } else {
      alert("Error: " + response);    <- 비즈니스 로직 처리 중 발생한 에러에 대한 메시지 표시
    }
  })
  .then(response => response.json())
  .then(json => {
    //do something
  })
  catch(error => {
    alert("API Call error:" + error);    <- 네트워크 에러 등에 대한 처리
  });

위 코드에서 기대하는 동작은 서버에서 401 에러를 전달했으니 브라우저에서는 login 페이지로 이동하는 것이었습니다. 하지만 catch 절에 있는 alert 메시지인 "API Call error"가 나타나고 있었습니다.

cors_network_error

크롬의 디버그 창에는 분명 401 상태가 나타나고 있습니다. Network 전송 목록에도 401 에러가 나타나고 있습니다.

cors_option_401_error

디버그 창에 나타나는 에러 메시지는 다음과 같이 앞에서 CORS 설정을 하지 않았을 때 나타나는 에러 메시지와 거의 비슷한 메시지 입니다. 상태 코드는 401이지만 "preflight" 가 실패했다는 의미입니다.

OPTIONS http://127.0.0.1:5000/services 401 (Unauthorized) Failed to load http://127.0.0.1:5000/services: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'http://127.0.0.1:2200' is therefore not allowed access. The response had HTTP status code 401. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

CORS 문제이면 405 상태가 나타나야 하는데 Http Response 상태는 서버에서 전달한 401이 맞는데 Preflight 처리 중에 발생한 문제라는 것입니다.  여기까지 확인한 후 클라이언트 코드를 세밀하게 보기 시작했습니다. 이유는 일단 서버에서는 401 상태가 잘 전달되고 있으니 클라이언트 코드의 문제가 아닐까 하는 의심이었습니다.

Fetch 함수의 동작

클라이언트에는 다음과 같은 코드가 있습니다.

1
2
3
4
5
6
7
8
9
10
fetch(apiPath, ...
  .then(response => {
    //  <--- 여기로 오지 않고 catch 절로 바로 감
    if (response.status == 200) {
    }
    ...
  })
  .catch(error => {
    alert("API Call error:" + error);    <- preflight에서 에러가 발생하면 바로 이쪽으로 옴
  });

apiPath에 요청에 대한 응답을 정상으로 받으면 then(response) 으로 전달되어야 하지만 preflight 처리에서 문제가 발생하면 catch 절로 로직이 이동하게 됩니다.  여기서 두가지 생각을 하게 되었습니다.

  1. 왜 정상적인 HTTP Response를 전달했는데에도 catch 절로 이동을 하는 것인가?
  2. 그러면 catch에서 error의 세부 정보를 이용하여 login 페이지로 넘길 수는 없을까?

일단 ajax 호출을 위해 클라이언트에서 fetch를 사용하고 있었기 때문에 이 fetch 라이브러리의 문제인지 확인을 해 보았습니다. 다음과 같은 두가지 이슈를 발견할 수 있었습니다.

위 내용을 대충 정리하면 다음과 같습니다.

  • fetch는 2xx 뿐만 아니라 모든 http response에 대해 정상적인 상황으로 처리
  • 로컬 네트워크 문제나 잘못된 도메인 입력 등으로 발생한 에러는 catch() 또는 error()로 처리
  • CORS를 위해 브라우저가 OPTIONS  요청을 보내는 경우, 이 요청에 대한 결과로 서버에서 정상 상태(2xx)가 아닌 경우 error로 간주하여 catch() 상황으로 넘김. 이때 서버에서 전달된 상태 정보는 전달되지 않음

그리고 다음과 같은 내용이 있습니다.

I can't tell if this was done intentionally for security purposes to not expose the response. However, I still believe the response status code should be provided in the catch. I will reopen as it seems to be a common need to capture the status code.

즉, 이렇게 처리한 것이 보안의 의도적으로 이런 방식을 도입했다고 하며, 상태 값은 필요할 것 같아서 다시 이슈를 오픈한다고 되어 있는데 그 아래에 fetch 라이브러리 개발자처럼 보이는 개발자가 그 이슈를 바로 닫아 버린 것을 볼 수 있습니다. 즉, CORS의 OPTIONS 요청에 대해서는 Response 정보를 제공하지 않겠다는 것입니다.

fetch만의 문제인 것 같아서 axios를 사용해보았지만 역시 동일한 문제가 발생하였습니다. 그래서 다른 라이브러리를 사용하지 않고 "XMLHttpRequest" 를 직접 사용하여 호출해 보았습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var req = new XMLHttpRequest();
req.open('GET', apiPath, true);
req.setRequestHeader("Authorization", "Bearer XXXXX");
req.onreadystatechange = function (aEvt) {
  if (req.readyState == 4) {
    console.log("Status: ", req.status);
    console.log("Response message: ", req.responseText);
  }
};
req.send(null);
==============================
결과는 다음과 같이 정상 출력
Status: 401
Response message: {"message":"인증 에러"}

XMLHttpRequest로 직접 호출하는 경우 정상적으로 Http Response 정보를 잘 가져 올 수 있는 것을 볼 수 있습니다.

대체 뭐가 문제야!

여기까지 확인한 내용을 종합해보니 대략 다음과 같은 결론을 나왔습니다.

  • OPTIONS  요청에서는 다른 처리를 하지 않고 현재 서버에서 제공 가능한 옵션 정보만 내려주면서 무조건 2XX 상태로 전달해야 한다.

그래서 위에서 작성한 Auth 관련 Filter를 다음과 같이 수정하여 인증 처리를 하지 않도록 하였습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func AuthenticationMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
  return func(c echo.Context) error {
    if c.Request().Method == "OPTIONS" {  <-- OPTIONS 인 경우 처리 하지 않음
      return next(c)
    } 
    if assertAuth(c) {
      // 정상이면 다음 단계 수행 
      return next(c)
    } else {
      // auth 실패한 경우 Unauthorized(401) 상태 반환
      return echo.NewHTTPError(http.StatusUnauthorized,"Please provide valid credentials(cookie or header)")
    }
  }
}

이렇게 해도 다음과 같은 문제가 발생했습니다.

Failed to load http://127.0.0.1:5000/services: No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'http://127.0.0.1:2200' is therefore not allowed access. The response had HTTP status code 401. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

이 에러 메시지 내용은 Response에 "Access-Control-Allow-Origin" 설정이 안되어 있다는 것이었습니다.

서버 옵션에서 CORS 설정을 하면 "Access-Control-Allow-Origin: *" 이 옵션은 당연히 추가되는데 왜 없다고 하는 것일까?

라는 의심을 하면서 Middleware(Filter)를 추가한 순서를 확인해 보았습니다. 위에 Middleware  추가한 부분을 보면 다음과 같이 되어 있습니다.

1
2
3
e := echo.New()
e.Use(AuthenticationMiddleware())  <- auth 확인 middleware 추가
e.Use(middleware.CORS())

위 코드를 수정해서 다음과 같은 순서로 Middleware 를 추가해보았습니다.

1
2
3
e := echo.New()
e.Use(middleware.CORS())
e.Use(AuthenticationMiddleware())  <- auth 확인 middleware를 CORS 다음으로 이동

결과는 예상했던 시나리오대로 아주 잘 동작하였습니다. 즉,  삽질의 근본 원인은 Request Filter Chain의 순서 문제였습니다.

CORS 처리를 위한 Filter는 반드시 인증 처리하는 Filter 이전에 있어야 한다.

글을마치며

이렇게 문제의 원인을 찾고 나면 많은 경우에는 특별한 이슈가 아닌 단순한 실수인 경우가 많습니다. 하지만 이런 문제를 찾는 과정에서 많은 내용을 배울 수 있습니다. 이번 문제의 원인을 찾으면서 다음과 같은 내용을 배울 수 있었습니다.

  • CORS에 대한 정확한 이해
  • fetch 함수에서 개발된 코드 자체에 의한 에러가 아닌 브라우저에 의해 catch()가 발생하는 경우
  • 애플리케이션 서버에서 Request를 처리하기 위한 Filter Chain에서 순서의 중요성
  • API 테스트만으로는 해당 API가 잘 동작하는지 확인할 수 없다.
    • API 개발자와 화면 개발자가 다른 경우 API 개발자는 자신이 만든 API가 잘 동작하는지 확인하기 위해, 화면 개발자는 API가 어떤 형태로 값을 반환하는지 등을 확인하기 위해 Postman과 같은 도구를 사용한다. 이런 테스트 도구를 사용하는 경우에는 잘 동작하지만 실제 브라우저 위에서 다르게 동작할 수도 있기 때문에 "내가 만든 API는 문제 없어" 라는 식의 태도는 버려야 한다.

사족

그리고 이번 글을 정리하면서 왜  정리가 필요한지 다시 한번 느꼈습니다. 사람마다 다르겠지만 저 같은 경우 실제 개발 시에는 꼼꼼하게 보지 않는 경향이 있습니다. 이번 글에 소개된 문제도 이 글을 쓰기 전에는Filter에서 402에러가 아닌 200 정상 코드를 반환하고 response body에 json으로 상태 정보를 전달하여 클라이언트가 body의 상태를 확인하여 처리하도록 구현하였습니다.

1
2
3
4
if !assertAuth(c) {
  return echo.NewHTTPError(http.StatusOK,
    makeErrorResult("Please provide valid credentials(cookie or header)", http.StatusUnauthorized))
}

이렇게 구현한 후 이 문제에 대한 글을 쓰기 시작했습니다. 글을 쓰면서 여러 가지 상황에 대해 테스트 케이스를 만들어서 확인해보고, fetch 이슈에 대해서도 자세히 읽어 보았습니다. 이를 통해 위와 같은 꼼수 방식이 아니라, 정확하게 어디가 문제였는지 확인하여 간단하면서도, 정상적인 방식으로 해결 가능하다는 것을 알게 되었습니다.

글을 쓰게 되면 아무래도 내용을 조금 더 검증하게 되고, 좀 더 객관적인 입장에서 사실을 확인하려는 태도로 바뀌기 때문에 문제 해결 접근이 꼼수 방식이 아닌 근본적인 방법으로 바뀌는 것 같습니다.


Popit은 페이스북 댓글만 사용하고 있습니다. 페이스북 로그인 후 글을 보시면 댓글이 나타납니다.