CORS 에러를 해결해보자

riveroverflow·2024년 9월 10일

CORS란?

CORS(Cross-Origin-Resource Sharing, 교차 출처 리소스 공유)는 서버에게 다른 오리진으로부터의 요청을 승인하게 하는 보안 매커니즘이다.

여기서 오리진이란, URL에서 프로토콜(스킴), 도메인(주소), 포트의 결합을 말한다.

https://example.com:3000
https://example.com:8080
-> 같은 프로토콜과 도메인을 사용하지만, 포트번호가 다르므로 서로 다른 오리진이다.

http://naver.com(:80 생략)
https://naver.com(:443 생략)
-> 같은 도메인을 사용하지만, 프로토콜과 포트번호가 다르므로 서로 다른 오리진이다. 

https://google.com/search?query=cors
https://google.com/user
-> 같은 프로토콜, 도메인, 포트번호를 사용하므로 서로 같은 오리진이다.

SOP: 동일 출처 원칙

보안상의 이유로, 서로 다른 출처(오리진)간의 공유는 기본적으로 금지되어있다.
악의적인 웹 사이트가 데이터를 탈취하는 것을 막기 위해서이다.
이를 SOP(Same-Origin Policy)라고 한다.
클라이언트 측에서 응답 HTTP를 읽고 응답을 받을지 기각할지 정한다.
응답을 받을 화이트리스트를 서버가 관리하고, 클라이언트는 응답의 화이트리스트에 자신이 없으면 응답을 받지 않는다.

웹의 초기에서는, 동일한 서버에서 웹 애플리케이션 서비스와 정적 파일들을 제공하는 것을 모두 하였기 때문인데, 서로 다른 오리진 간에 데이터를 공유할 이유가 없었다.

CORS의 등장

그러나 오늘날은 여러 서비스들과 API를 공유하기도 하고, 모바일 앱도 있고, SPA(Single Page Application)가 발달하면서,
서로 다른 출처의 클라이언트와 API서버간에 통신이 필요해져서 CORS와 관련된 HTTP 응답 헤더
를 통해서 특정 클라이언트가 다른 오리진으로부터 응답을 받을 수 있도록 SOP의 제한을 완화하는 것이다.

CORS는 W3C와 IETF에 의해 표준화되었으며, 이를 통해서 서로 다른 출처간의 자원 공유를 안전하게 수행하도록 한다.

CORS의 시나리오

Simple requests의 경우

일부 요청은 CORS Preflight가 필요없다.
이러한 요청들을 simple requests라 한다.

simple requests는 다음의 HTTP 메소드를 가져야 가진다:

  • GET
  • HEAD
  • POST

그리고, 아래와 같이 자동으로 유저 에이전트에 의해 의해 붙는 헤더들(CORS-safelisted request header)만을 가진다:

  • Accept
  • Accpet-Language
  • Content-Language
  • Content-Type
  • Range(단순 범위 헤더 값인 경우만)

Content-Type의 경우, 다음의 유형만을 가져야 한다:

  • application/x-www-form-urlencoded
  • multipart/form-data
  • text/plain

아래는 simple requests의 CORS 요청-응답의 사이클의 예시이다:

  1. 클라이언트가 서버에게 값을 요청한다. 이때 클라이언트는 HTTP 헤더에 Origin을 붙여서 현재 클라이언트의 오리진을 제공한다.
  2. 서버는 요청을 받아서 처리한다. 응답에 Access-Control-Allow-Origin: *을 통해서 모든 오리진으로부터 허용함을 의미하는 헤더를 붙인다.
  3. 클라이언트는 받은 응답에서 Access-Control-Allow-Origin: *을 확인하고, 클라이언트는 자신이 받아도 되는 응답임을 확인한 뒤 응답을 받아서 처리한다.

HTTP 요청 예시:

GET /resources/public-data/ HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
Origin: https://foo.example

HTTP 응답 예시:

HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 00:23:53 GMT
Server: Apache/2
Access-Control-Allow-Origin: *
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Transfer-Encoding: chunked
Content-Type: application/xml

[…XML Data…]

위의 예시에서는 Access-Control-Allow-Origin의 값을 *로 설정해서 모든 오리진에 대해 허용했지만, 오리진을 직접 명시해서 화이트리스트를 만들 수 있다.

// 모든 오리진에 대해 허용
Access-Control-Allow-Origin: * 

// https://foo.example에 대해서만 허용
Access-Control-Allow-Origin: https://foo.example

🔔주의
credential requests, 즉 민감한 요청에 대한 응답을 할 때에는, 반드시 직접 허용할 오리진을 명시시켜야 한다.
*와일드카드는 사용할 수 없다.

이외의 복잡한 요청에 대한 경우(Preflighted requests를 동반하는 Simple requests가 아닌 요청들)

이외에 preflighted requests, 즉 사전 요청이 필요한 HTTP 요청은, 요청 본문을 바로 보내지 않는다.
우선, OPTIONS 메서드로 다른 오리진에게 실제 요청을 보내도 안전할지에 대해서 미리 요청을 보낸다.


위 그림에서의 예시를 보면, X-PINGOTHER라는 비표준 HTTP요청 헤더가 있는 걸 볼 수 있는데, 일반적으로 이러한 헤더들은 웹 애플리케이션에서 응용하기에 좋다.
요청의 Content-Typeapplication/json이기에, 사전 요청을 미리 보낸 모습이다.

실제 Main request에서는 Access-Control-Request-*가 붙지 않은 것을 볼 수 있다.

Preflighted request로는 OPTIONS가 사용되는데, 이는 리소스를 변경할 수 없는 안전한 메소드이기 때문이다.
그와 동시에, 두 개의 헤더가 최소한 더 붙는다:

  • Access-Control-Request-Method: 실제 보낼 요청의 HTTP 메서드를 담는다.
  • Access-Control-Request-Headers: 실제 보낼 요청에서 HTTP 요청에 붙일 요청 헤더들의 정보를 알린다.

그에 대한 응답으로는

  • Access-Control-Allow-Origin: 서버가 보낸 응답을 받을 수 있는 오리진
  • Access-Control-Allow-Methods: 허용할 메서드. ,으로 구분되어 복수개가 올 수 있다.
  • Access-Control-Allow-Headers: 허용할 요청 헤더. ,으로 구분되어 복수개가 올 수 있다.
  • Access-Control-Max-Age: Preflight request의 응답에 대한 캐싱 기간.
    ex) 86400인 경우, 24시간 동안은 해당 클라이언트에서는 Preflight request를 안보내도 된다.
    이는 네트워크의 성능개선에 도움을 준다. 잦은 Preflight request는 안전할지 몰라도, 자주 보내서 얻는 보안 측면에서의 이득보다, 네트워크의 성능 손실이 더 크기 때문이지 않나 싶다.

Preflight Request의 요청-응답 사이클이 정상적으로 동작하면, 실제 요청-응답 사이클이 돌아간다.

Preflight requests와 리다이렉션

모든 브라우저가 preflighted request를 리다이렉션 시키는 것을 지원하지는 않는다.
일부 브라우저는 다음과 같은 에러 메시지를 던진다:

The request was redirected to https://example.com/foo, which is disallowed for cross-origin requests that require preflight. Request requires preflight, which is disallowed to follow cross-origin redirects.

이를 해결하기 위해서는, 서버 사이드에서 preflight의 리다이렉션을 잘 핸들링하도록 바꾸거나,
가능하다면 요청을 simple request로 바꾸는 방법이 있다.

민감한 정보(Credentials)를 담은 요청

쿠기를 보낼 수도 있고, Authorization 관련 정보를 보낼 수도 있다.
이러한 경우에는, 민감한 정보를 교환하기 위해
Access-Control-Allow-Credentials: true 헤더를 응답에 붙여야 한다.

비록 요청의 쿠키 헤더가 서버 측 오리진의 콘텐츠를 가리키더라도, Access-Control-Allow-Credentials가 true로 되어있지 않으면, 응답을 받을수는 없다.

Preflight requests와 민감한 정보

CORS Preflight request는 민감한 정보를 담지 않는다.
대신, Main에서는 민감한 정보가 오갈 것이므로, Preflight Request에 대한 응답에서는 Access-Control-Allow-Credentials를 true로 설정해야 한다.
그래야 Main Request에서 정상적으로 정보를 교환한다.

Credential request와 와일드카드

민감한 요청(Credential Request)을 응답할 때는, 다음의 규칙을 지키자:

  • 서버는 Access-Control-Allow-Origin에서 *을 붙이면 안된다.
    대신, 직접 오리진을 명시해야 한다.
    이는 Access-Control-Allow-headers, Access-Controls-Allow-Mehtods에도 적용된다.
    다 직접 명시해야 한다.
  • 서버는 Access-Control-Expose-Headers*을 사용할 수 없다.
    대신, 직접 헤더들을 명시해야 한다.

Gin 애플리케이션에서 CORS 해결하기

https://brownbears.tistory.com/337
글을 참고했다.
사실 Gin에서 공식적으로 지원해주는 CORS 설정 패키지가 있는데, 내 프로젝트에서는 동작하지 않았다.

package middlewares

import (
	"os"
	"github.com/gin-gonic/gin"
)

// CORSMiddleware - Append Cors-related Headers
func CORSMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {

		c.Header("Access-Control-Allow-Origin", os.Getenv("CLIENT_ORIGIN"))
		c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, Origin, Accept, Host, User-Agent, Referer, Access-Control-Request-Headers, Access-Control-Request-Method, Sec-Fetch-Mode, Refresh-Token, Accept-Encoding, Accept-Language, Connection, Content-Length")
		c.Header("Access-Control-Allow-Credentials", "true")
		c.Header("Access-Control-Allow-Methods", "GET, DELETE, POST, PUT, OPTIONS")

		if c.Request.Method == "OPTIONS" {
			c.AbortWithStatus(204)
			return
		}

		c.Next()
	}
}

다음의 미들웨어를 모든 라우터의 전역으로 최우선 미들웨어로 설정해주었다.
이 미들웨어에서는 허용에 필요한 모든 조건들을 충족시키고 있다.

  • 클라이언트의 오리진을 허용하고,
  • 클라이언트에서 붙이는 각종 헤더들을 허용하고,
  • 민감한 정보를 허용하고,
  • 클라이언트가 사용하는 메소드들을 허용하였다.

만약, 모든 오리진에 허용하고 싶은데, 민감한 정보를 다루고 싶다면, 다음의 편법이 있다:

func CORSMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {

		origin := c.Request.Header.Get("Origin")
		if origin != "" {
			c.Header("Access-Control-Allow-Origin", origin)
		}
		c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, Origin, Accept, Host, User-Agent, Referer, Access-Control-Request-Headers, Access-Control-Request-Method, Sec-Fetch-Mode, Refresh-Token, Accept-Encoding, Accept-Language, Connection, Content-Length")
		c.Header("Access-Control-Allow-Credentials", "true")
		c.Header("Access-Control-Allow-Methods", "GET, DELETE, POST, PUT, OPTIONS")

		if c.Request.Method == "OPTIONS" {
			c.AbortWithStatus(204)
			return
		}

		c.Next()
	}
}

요청 헤더에서 클라이언트의 오리진을 가져와서, 허용할 오리진에 넣는 방법이다.

Access-Control-Allow-Origin에는 하나의 오리진 또는 와일드카드만 가능한데, 여러 오리진으로부터 허용하고 싶다면, 다음과 같은 방법이 있다:
"허용할 오리진들"을 따로 저장해 두었다가(메모리에 저장할지, DB에 저장할지 등은 알아서 각자의 판단에..), 목록에서 요청의 오리진과 대조해서 클라이언트의 오리진이 발견되면, 허용할 오리진으로 요청의 오리진에 넣으면 되겠다.
코드는 생략한다.

CORS 관련 팁

  • 리다이렉션에 주의하자: 리다이렉션이 생기는 경우, CORS에러가 발생할 수 있다.
  • 어떤 헤더들을 허용해야 할지 모르겠다면, 클라이언트가 어떤 헤더들을 붙여서 요청하는지 확인해보자.
    웹 프레임워크를 사용하는 경우는, 자동으로 붙는 헤더들이 많아서 의도치 않은 헤더들에 의해 CORS 에러가 생길 수도 있다.

참조

https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS

0개의 댓글