Post

CORS(Cross-Origin Resource Sharing) 이해하기 (2)

CORS(Cross-Origin Resource Sharing) 이해하기 (2)

CORS(Cross-Origin Resource Sharing)

CORS(Cross-Origin Resource Sharing)는 브라우저가 다른 출처(Cross-Origin)로부터 리소스를 가져올 수 있도록 서버가 허가 해주는 HTTP 헤더 기반 메커니즘입니다.
다시말해, SOP의 기본 보안 철학을 유지하면서 신뢰할 수 있는 출처에 한해 특정 리소스를 공유할 수 있도록 허용하는 정책입니다.

Image

CORS를 사용하는 요청

1. fetch, XMLHttpRequest

  • fetch()XMLHttpRequest는 자바스크립트로 HTTP 요청을 보낼 때 사용하는 메서드입니다.
  • SOP의 영향으로 기본적으로 다른 출처로 요청을 보내거나 응답 데이터를 읽는 것은 제한됩니다.
  • 그러나 서버가 적절한 CORS 헤더(Access-Control-Allow-Origin)를 Response Header에 포함하면 다른 출처에서도 요청과 응답을 처리할 수 있습니다.

2. 웹 폰트(Web Fonts)

  • CSS의 @font-face 규칙을 사용하여 다른 출처(Cross-Origin)에서 폰트를 로드할 수 있습니다.
  • 하지만 교차 출처(Cross-Origin)에서 폰트를 가져오려면 서버에서 CORS 헤더를 설정해야 합니다.
    • 예를 들어, Access-Control-Allow-Origin 헤더가 없으면 브라우저는 해당 폰트를 로드하지 않습니다.
  • 이 방식은 특정 웹사이트에서만 사용할 수 있는 폰트를 보호하는 데 유용합니다.

3. WebGL 텍스처(WebGL Textures)

  • WebGL은 3D 그래픽을 렌더링하기 위한 브라우저 API입니다.
  • WebGL에서 외부 이미지를 텍스처로 사용하려면 해당 이미지가 있는 서버에 CORS 헤더가 설정되어 있어야 합니다.
  • CORS 설정이 없으면 WebGL은 이미지를 렌더링하지 못합니다.

4. Canvas에서 그린 이미지/비디오 프레임

  • HTML <canvas> 요소의 drawImage() 메서드를 사용해 이미지나 비디오를 캔버스에 그릴 때, 외부 리소스를 사용하려면 서버가 CORS 헤더를 설정해야 합니다.
  • 만약 CORS 헤더가 없으면 캔버스에 이미지를 그리더라도 toDataURL() 같은 메서드를 사용할 때 오류가 발생하여 데이터를 추출할 수 없습니다.
    • 이는 보안상의 이유로, 외부 데이터를 악용하지 못하게 하기 위한 제한입니다.

5. CSS Shapes

  • CSS Shapes는 콘텐츠를 특정 형태로 감싸거나 Clipping(잘라내기)하는 데 사용됩니다.
  • 외부 이미지를 사용하여 이러한 형태를 정의하려면 이미지가 로드되는 서버에 CORS 헤더가 필요합니다.
  • CORS가 설정되지 않으면 외부 이미지를 CSS Shapes에 사용할 수 없습니다.

CORS 동작 방식

Simple Requests(단순 요청)

Simple Request(단순 요청) 은 CORS에서 Preflight Request(사전 요청) OPTIONS을 트리거하지 않는 요청을 의미합니다.
이 요청은 특정 조건을 충족해야 하며, 서버가 CORS 관련 헤더(Access-Control-Allow-Origin)로 응답하면 브라우저가 이를 허용합니다.

Simple Requests라는 용어는 과거 CORS 스펙초기에는 사용했지만 현재 CORS를 정의하고 있는 fetch 스펙에는 사용되고 있지 않습니다.

💡 왜 “Simple Requests(단순 요청)”용어를 현재 공식적으로 사용하지 않을까요?

  • 단순 요청이라는 표현은 사람들이 CORS의 동작 방식을 잘못 이해하게 만들 수 있었습니다.
  • 예를 들어, “단순 요청은 CORS와 무관하다”라고 오해할 수 있는데, 사실은 여전히 CORS 정책의 영향을 받으며, 서버가 Access-Control-Allow-Origin 헤더를 설정하지 않으면 브라우저는 스크립트가 응답 데이터에 접근하지 못하게 합니다.

Simple Request(단순 요청) 조건

Simple Request(단순 요청)는 일반적으로 다음 조건을 모두 충족해야 합니다.
(브라우저마다 추가 제한 사항이 다를 수 있습니다.)

1. 허용된 HTTP 메서드

단순 요청은 아래의 세 가지 메서드 중 하나여야 합니다

  • GET
  • HEAD
  • POST

예시 코드

1
2
3
fetch("https://hajeonghun.com/api/data", {
  method: "GET", // 허용된 메서드
});

만약 PUT, DELETE 등의 메서드를 사용한다면 Preflight Request(사전 요청)이 트리거됩니다.

2. 허용된 요청 헤더

단순 요청에서는 아래와 같은 CORS-safelisted request-header만 수동으로 설정할 수 있습니다:

  • Accept
  • Accept-Language
  • Content-Language
  • Content-Type (추가 조건 있음, 아래 참고)
  • Range (단, 특정 형식의 값이어야 함, 예: bytes=256- 또는 bytes=127-255)

브라우저가 자동으로 추가하는 기본 헤더(User-Agent, Connection 등)는 허용 여부에 영향을 미치지 않습니다.

예시 코드

1
2
3
4
5
6
7
fetch("https://hajeonghun.com/api/data", {
  method: "POST",
  headers: {
    "Accept-Language": "en-US", // 허용된 헤더
    "Content-Type": "application/x-www-form-urlencoded", // 허용된 Content-Type
  },
});

만약 Authorization 같은 사용자 정의 헤더를 추가하면 사전 요청이 필요합니다.

3. Content-Type 헤더의 추가 조건

단순 요청에서 Content-Type 헤더를 사용할 경우, 아래의 MIME 타입만 허용됩니다:

  • application/x-www-form-urlencoded: 주로 HTML <form> 데이터 전송에 사용.
  • multipart/form-data: 파일 업로드 시 사용.
  • text/plain: 일반 텍스트 전송.

예시 코드(허용된 Content-Type)
아래는 단순 요청으로 간주되는 경우 입니다.

1
2
3
4
5
6
7
fetch("https://hajeonghun.com/api/data", {
  method: "POST",
  headers: {
    "Content-Type": "application/x-www-form-urlencoded", // 허용된 MIME 타입
  },
  body: "name=John&age=30",
});

예시 코드(허용되지 않은 Content-Type)
Content-Type: application/json 같은 값은 단순 요청이 아니며, 사전 요청을 필요로 합니다.

1
2
3
4
5
6
fetch("https://hajeonghun.com/api/data", {
  method: "POST",
  headers: {
    "Content-Type": "application/json", // 사전 요청 트리거
  },
});
4. XMLHttpRequest.upload 객체의 이벤트 리스너

XMLHttpRequest 객체를 사용하여 요청을 보낼 때, xhr.upload 객체에 이벤트 리스너를 등록하지 않아야 합니다.

예시 코드(허용되는 경우)

1
2
3
const xhr = new XMLHttpRequest();
xhr.open("POST", "https://hajeonghun.com/api/data", true);
xhr.send("name=Ha&age=30");

Image

예시 코드(허용되지 않는 경우)

1
2
3
4
5
6
7
8
const xhr = new XMLHttpRequest();
xhr.open("POST", "https://hajeonghun.com/api/data", true);

// `xhr.upload`에 이벤트 리스너를 추가하면 사전 요청이 트리거 됨
xhr.upload.addEventListener("progress", (e) => {
  console.log("progress: ", e.loaded);
});
xhr.send("name=Ha&age=30");

Image

예제 1 - Simple Request(CORS 허용)

GitHub Exampel Code - Simple Request(CORS 허용)

Image

1
2
3
4
5
6
// server
app.use(cors()); // CORS 설정 (모든 도메인 허용)

// client
const res = await fetch('http://localhost:3000/api/data');
const data = await res.json();

Request Header

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
GET /api/data HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
Cache-Control: no-cache
Connection: keep-alive
Host: localhost:3000
Origin: http://localhost:63342
Pragma: no-cache
Referer: http://localhost:63342/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-site
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36
sec-ch-ua: "Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"

클라이언트(port: 63342)에서 다른 출처의 서버(port: 3000)로 요청 시 헤더에 Origin 을 포함하여 현재 요청의 출처를 밝힙니다.

Response Header

1
2
3
4
5
6
7
8
9
HTTP/1.1 200 OK
X-Powered-By: Express
Access-Control-Allow-Origin: *
Content-Type: application/json; charset=utf-8
Content-Length: 54
ETag: W/"36-MVOqRDG61kU8vexdwYFRo8aCLRc"
Date: Fri, 24 Jan 2025 04:10:09 GMT
Connection: keep-alive
Keep-Alive: timeout=5

서버에서 응답받은 헤더를 살펴보면 Access-Control-Allow-Origin: * 가 있습니다.
여기서 와일드카드(*)의 의미는 모든 출처에서 접근을 허용한다는 뜻으로 클라이언트는 서버와 출처가 다름에도 정상적인 응답 값을 사용할 수 있습니다.

  • 응답 결과

Image

예제 2 - Simple Request(CORS 불허)

GitHub Exampel Code - Simple Request(CORS 불허)

Image

1
2
3
4
5
6
7
8
// server
app.use(cors({
  origin: 'http://localhost:3000', // CORS 설정 (특정 출처만 허용)
}));

// client
const res = await fetch('http://localhost:3000/api/data');
const data = await res.json();

Request Header

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
GET /api/data HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
Cache-Control: no-cache
Connection: keep-alive
Host: localhost:3000
Origin: http://localhost:63342
Pragma: no-cache
Referer: http://localhost:63342/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-site
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36
sec-ch-ua: "Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"

요청 헤더는 예제 1과 동일합니다.

Response Header

1
2
3
4
5
6
7
8
9
10
HTTP/1.1 200 OK
X-Powered-By: Express
Access-Control-Allow-Origin: http://localhost:3000
Vary: Origin
Content-Type: application/json; charset=utf-8
Content-Length: 54
ETag: W/"36-MVOqRDG61kU8vexdwYFRo8aCLRc"
Date: Fri, 24 Jan 2025 05:22:15 GMT
Connection: keep-alive
Keep-Alive: timeout=5

이번에 서버에서 응답받은 헤더를 살펴보면 Access-Control-Allow-Originhttp://localhost:3000로 특정되어 있습니다.
이 의미는 조금 전 와일드카드(*)가 모든 출처의 접근을 허용했다면 오직 요청의 출처가 http://localhost:3000인 경우에만 접근을 허용하겠다는 뜻 입니다.
이로 인해 클라이언트의 출처 http://localhost:63342는 접근에 허용되지 않으므로 응답 값을 사용할 수 없습니다.

  • 응답 결과

Image Image

Preflight Requests(사전 요청)

Preflight Request(사전 요청) 은 클라이언트의 실제 요청을 서버에 보내기 전에 안전한지 판단하기 위해
브라우저가 중간에서 OPTIONS메서드를 이용해 안전한 요청인지 서버에 먼저 확인하는 과정을 거칩니다.
만일, 서버가 올바른 CORS 헤더를 응답하지 않으면 브라우저는 실제 요청을 보내지 않습니다.

Preflight Request(사전 요청) 조건

Preflight Request(사전 요청)의 조건은 위에서 언급한 Simple Request(단순 요청) 조건 중 1개라도 해당하지 않으면
즉, 단순 요청 조건에 부합하지 않을때 발생합니다.

예제 1 - Preflight Request(CORS 허용)

GitHub Exampel Code - Preflight Request(CORS 허용)

Image

1
2
3
4
5
6
7
8
9
10
11
// server
app.use(cors()); // CORS 설정 (모든 도메인 허용)

// client
const res = await fetch('http://localhost:3000/api/data', {
  method: 'GET',
  headers: {
    'X-Custom-Header': 'test', // 사용자 지정 헤더
  }
});
const data = await res.json();

GET 메서드는 단순요청 조건을 만족하지만, 비표준 HTTP 헤더인 X-Custom-Header를 추가하여 사전요청을 트리거 합니다.

  • 사전요청(OPTIONS) Request Header
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
OPTIONS /api/data HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
Access-Control-Request-Method: GET
Access-Control-Request-Headers: x-custom-header
Cache-Control: no-cache
Connection: keep-alive
Host: localhost:3000
Origin: http://localhost:63342
Pragma: no-cache
Referer: http://localhost:63342/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-site

OPTIONS 메서드를 통해 브라우저는 서버에 허용된 요청인지 확인하는 과정을 거칩니다. 이 요청에서 주목해야 할 요청헤더는 Access-Control-* 형식을 가진 2개의 헤더입니다.

  • Access-Control-Request-Method는 서버에게 클라이언트의 실제 요청(Main Request)이 어떤 메서드를 사용하는지 알려주는 역할을 합니다.(여기선 GET)
  • Access-Control-Request-Headers는 서버에게 클라이언트의 실제 요청(Main Request)에 사용할 헤더를 알려주는 역할을 합니다.(여기선 x-custom-header)

사전요청(OPTIONS) Response Header

1
2
3
4
5
6
7
8
9
HTTP/1.1 204 No Content
X-Powered-By: Express
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET,HEAD,POST,DELETE
Access-Control-Allow-Headers: x-custom-header
Content-Length: 0
Date: Sun, 26 Jan 2025 02:39:22 GMT
Connection: keep-alive
Keep-Alive: timeout=5

OPTIONS요청에 대한 서버의 응답 헤더를 살펴보겠습니다.

  • Access-Control-Allow-Origin와일드카드(*)로 모든 출처에 대한 접근을 허용합니다.
  • Access-Control-Allow-Methods헤더는 요청한 리소스에 대해 유효한 메서드를 나타냅니다. (여기선 GET,HEAD,POST,DELETE)
  • Access-Control-Allow-Headers헤더는 요청한 리소스에 사용할 수 있는 허용된 헤더들을 나타냅니다. (여기선 x-custom-header)

사전 요청이 완료되고 CORS 정책을 통과했다면 브라우저는 클라이언트의 실제 요청을 서버에 전송하게 됩니다.

실제요청(GET) Request Header

1
2
3
4
5
6
7
8
9
10
11
12
13
14
GET /api/data HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
Cache-Control: no-cache
Connection: keep-alive
Host: localhost:3000
Origin: http://localhost:63342
Pragma: no-cache
Referer: http://localhost:63342/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-site
X-Custom-Header: test

블록의 제일 마지막 항목을 보면 클라이언트가 지정한 헤더인 X-Custom-Header: test 가 포함되어 실제요청이 전송된 걸 확인할 수 있습니다.

  • 실제요청(GET) Response Header
1
2
3
4
5
6
7
8
9
HTTP/1.1 200 OK
X-Powered-By: Express
Access-Control-Allow-Origin: *
Content-Type: application/json; charset=utf-8
Content-Length: 54
ETag: W/"36-MVOqRDG61kU8vexdwYFRo8aCLRc"
Date: Sun, 26 Jan 2025 02:39:22 GMT
Connection: keep-alive
Keep-Alive: timeout=5

서버는 클라이언트가 요청한 리소스에 대해 정상적으로 응답한 걸 확인할 수 있습니다.

  • 응답 결과

Image

예제 2 - Preflight Request(CORS 불허)

GitHub Exampel Code - Preflight Request(CORS 불허)

Image

1
2
3
4
5
6
7
8
9
10
11
12
13
// server
app.use(cors({
  origin: 'http://localhost:3000', // CORS 설정 (특정 출처만 허용)
}));

// client
const res = await fetch('http://localhost:3000/api/data', {
  method: 'GET',
  headers: {
    'X-Custom-Header': 'test', // 사용자 지정 헤더
  }
});
const data = await res.json();

예제 1(CORS 허용)과 클라이언트 요청은 동일하되, 서버쪽 설정만 3000포트에 대해서만 접근을 허용하게 변경했습니다.

  • 사전요청(OPTIONS) Request Header
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
OPTIONS /api/data HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
Access-Control-Request-Method: GET
Access-Control-Request-Headers: x-custom-header
Cache-Control: no-cache
Connection: keep-alive
Host: localhost:3000
Origin: http://localhost:63342
Pragma: no-cache
Referer: http://localhost:63342/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-site

아직 브라우저는 CORS 허용 여부를 모르기 떄문에 예제 1과 동일하게 OPTIONS 메서드를 통해 허용된 요청인지 확인하는 과정을 거칩니다.

사전요청(OPTIONS) Response Header

1
2
3
4
5
6
7
8
9
10
HTTP/1.1 204 No Content
X-Powered-By: Express
Access-Control-Allow-Origin: http://localhost:3000
Vary: Origin, Access-Control-Request-Headers
Access-Control-Allow-Methods: GET,HEAD,PUT,PATCH,POST,DELETE
Access-Control-Allow-Headers: x-custom-header
Content-Length: 0
Date: Tue, 28 Jan 2025 05:16:02 GMT
Connection: keep-alive
Keep-Alive: timeout=5

Access-Control-Allow-Origin: http://localhost:3000 응답헤더를 통해 브라우저는 클라이언트 요청이 CORS 정책에 위반되는 것을 확인한 후 실제 요청을 서버에 전송하지 않고 통신을 종료시킵니다.

Image

인증 정보가 포함된 요청

GitHub Exampel Code - 인증 정보가 포함된 요청

보통 웹사이트를 만들게 되면, fetch 등으로 서버측의 데이터를 받아오게 됩니다.
이때 요청 시 Cookie, Authorization Headers, TLS 클라이언트 인증서등의 인증정보를 포함한다면 추가적인 조치가 필요합니다.

Client(클라이언트)

기본적으로 다른 출처에 대한 요청시 fetch 혹은 XMLHttpRequest는 인증정보를 포함하지 않습니다.
그래서 쿠키 등의 인증 정보를 포함하려면 아래와 같이 설정을 필요로 합니다.

  • fetchcredentials: "include" 옵션 설정
  • XMLHttpRequest는 객체에 withCredentials = true 설정
  • 기타 axios 라이브러리의 경우 withCredentials: true 설정
1
2
3
4
// client
const request = new Request('http://localhost:3000/api/data', { credentials: "include" });
const res = await fetch(request);
const data = await res.json();

Server(서버)

서버측은 인증정보를 포함한 요청을 허용하기 위해 반드시 Access-Control-Allow-Credentials 헤더에 true를 추가해줘야 합니다.
만일, 해당 헤더를 포함하지 않거나 false를 설정하면, 응답은 무시되고 클라이언트에 제공되지 않습니다.

1
2
3
4
5
// server
app.use(cors({
  origin: 'http://localhost:63342',
  credentials: true, // Access-Control-Allow-Credentials 설정
}));

주의사항 - 와일드카드(*) 사용 불가

Access-Control-Allow-Origin

  • 자격 증명 요청에서 Access-Control-Allow-Origin 값에 *(와일드카드)를 사용할 수 없습니다.
  • 반드시, 특정 출처를 명시해야 합니다.

❌ Access-Control-Allow-Origin: *
✅ Access-Control-Allow-Origin: http://localhost:3000

Access-Control-Allow-Headers

  • Access-Control-Allow-Headers 값에도 *를 사용할 수 없습니다.
  • 대신, 클라이언트 요청에서 허용할 헤더의 이름을 명시해야 합니다.

❌ Access-Control-Allow-Headers: *
✅ Access-Control-Allow-Headers: x-custom-header

Access-Control-Allow-Methods

  • Access-Control-Allow-Methods 값에서도 *를 사용할 수 없습니다.
  • 허용할 HTTP 메서드를 명시적으로 설정해야 합니다.

❌ Access-Control-Allow-Methods: *
✅ Access-Control-Allow-Methods: POST, GET

Access-Control-Expose-Headers

  • 클라이언트가 접근할 수 있는 응답 헤더를 명시적으로 설정해야 합니다.
  • *를 사용할 수 없으며, 헤더 이름을 나열해야 합니다.

❌ Access-Control-Expose-Headers: *
✅ Access-Control-Expose-Headers: Content-Encoding

자문자답(Q&A)

Question
🤷: 사전 요청으로 인해 실제 요청은 1번인데 2번씩 서버에 요청하게 되면 서버측에 부담이 되지 않나요?
(e.g., OPTIONS 요청 1번, GET 요청 1번)

Answer
🙎: Access-Control-Max-Age 헤더 설정을 통해 사전 요청(Preflight request)의 결과를 캐싱할 수 있는 시간을 설정할 수 있습니다.
그리고 해당 헤더가 설정되지 않더라도 브라우저의 기본 캐싱 메커니즘에 따라 자동으로 캐싱됩니다. (브라우저마다 캐싱 방식과 캐싱 기간은 다를 수 있습니다.)
이를 확인해보려면, Chrome(크롬) 브라우저에서 개발자 도구의 Disable cache 옵션을 활성화(캐시 미사용) 시, 요청 때 마다 사전요청(Preflight Request)이 전송되지만 옵션을 비활성화(캐시 사용) 하는 경우엔, 첫 요청에만 사전요청(Preflight Request)이 전송되고 그 이후의 요청엔 실제 요청들만 전송되는 걸 확인 할 수 있습니다.

캐시 활성화 시 Preflight Request 요청

Image

캐시 비활성화 시 Preflight Request 요청

Image

This post is licensed under CC BY 4.0 by the author.