[Network] CORS (Cross Origin Resource Sharing)

CORS

 

Cross-Origin Resource Sharing (CORS) - HTTP | MDN

Cross-Origin Resource Sharing (CORS) is an HTTP-header based mechanism that allows a server to indicate any origins (domain, scheme, or port) other than its own from which a browser should permit loading resources. CORS also relies on a mechanism by which

developer.mozilla.org

웹 개발을 하다보면 언젠가 한번쯤은 CORS 관련 오류를 만날 수 밖에 없다

그러면 CORS란 뭘까?

 

CORS (Cross Origin Resource Sharing)
HTTP 헤더를 활용해서 A 출저에서 B 출저에 접근할 수 있는 권한을 부여하도록 브라우저에 알려주는 체제
  • 이렇게 교차되는 출저간에 자원을 공유하는 개념을 CORS라고 한다
    • 사실 CORS의 풀 네임을 통해서도 유추할 수 있다

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

  • 브라우저는 기본적으로 SOP(Same Origin Policy)라는 정책을 지킨다
  • 서로 다른 출처간에 특정 방법으로 리소스를 요청할 경우 SOP 정책을 위반 & CORS 정책까지 지키지 않으면 리소스 사용 불가

 

SOP 필요성

왜 브라우저는 기본적으로 SOP를 지키고 동일 출처가 아닌 접근을 차단할까?

여러가지 이유가 있겠지만 대표적인 이유는 출처가 다른 두 도메인간의 통신이 자유로워진다면 그만큼 보안적인 위험이 따르기 때문이다

SOP로부터 자유로운 세상이라면?
- 어느 곳이든 자유롭게 어디라도 요청을 보낼 수 있다
- 악의적인 사이트의 경우 다른 사이트를 모방할 가능성이 존재한다
- 완벽하게 모방한다면 사용자는 뭐가 다른지 모를거고 해당 사이트가 악의적인 의도로 만들어진 사이트를 눈치채지 못한다
- 악의적인 사이트에 로그인을 한 순간 로그인 세션, 쿠키, ..등 모든 정보들에 대해서 악의적으로 추출해서 어둠의 경로에서 사용 가능
  • 해커가 CSRF(Cross Site Request Forgery)나 XSS(Cross Site Scripting)등의 방법을 통해서 Application에 악의적인 공격 루트를 심어놓았다면 SOP가 없는 세상에서는 이러한 보안적 문제는 더욱 퍼지게 된다
  • 이러한 악의적인 공격을 할 수 없도록 브라우저단에서 보호하고 필요한 경우에만 허가를 받아서 리소스를 공유하도록 CORS 정책이 존재하는 것이다

 

출처란?

그러면 출처라는 개념은 무엇일까?

  • URL 프로토콜
  • 호스트/도메인
  • 포트

 

이 3가지가 모두 동일하면 동일 출처(Same Origin)라고 간주하고 하나라도 다르면 다른 출처이다

  https://sjiwon.com 기준 Same Origin 여부
https://sjiwon.com/auth?username=user Protocol 동일 = O (https & https)
Host 동일 = O (sjiwon.com & sjiwon.com)
Port 동일 = O (443 & 443)

→ 3가지 모두 동일하기 때문에 Same Origin O
http://sjiwon.com Protocol 동일 = X (https vs http)
Host 동일 = O (sjiwon.com & sjiwon.com)
Port 동일 = X (443 vs 80)

→ Protocol & Port가 다르기 때문에 Same Origin X
https://api.sjiwon.com Protocol 동일 = O (https & https)
Host 동일 = X (sjiwon.com vs api.sjiwon.com)
Port 동일 = O (443 & 443)

→ Host가 다르기 때문에 Same Orogin X
https://sjiwon.com:8080 Protocol 동일 = O (https & https)
Host 동일 = O (sjiwon.com & sjiwon.com)
Port 동일 = X (443 vs 8080)

→ Port가 다르기 때문에 Same Orogin X
라고 할 수 있지만 Internet Explorer의 경우 포트는 무시하기 때문에 Same Origin이라고 판단
그 외 브라우저는 Same Origin X

 

CORS 필요성

개발을 하다보면 사실 다른 출처와의 상호작용은 거의 필수이다

그러다보면 다른 출처의 리소스도 사용해야 하는 시점이 온다

따라서 SOP는 기본적으로 지키고 예외적으로 특정 리소스에 한해서 CORS 정책을 허용하는 것이다

 

CORS는 Same Origin이 아니면 무조건 발생?

그러면 어떤 자원이든 상관없이 출처만 다르면 CORS는 무조건 발생할까?

<!DOCTYPE html>
<html lang="ko">
<head>
    <title>Image Loading Example</title>
    <script>
      window.onload = function () {
        fetchImage();
      }

      async function fetchImage() {
        const response = await fetch('https://cdn.study-with-me.co.kr/basic-assets/cat.jpeg');
        const blob = await response.blob();
        const imageSrc = URL.createObjectURL(blob);
        const fetchedImage = document.getElementById('fetched-image');
        fetchedImage.src = imageSrc;
      }
    </script>
</head>
<body>
    <img src="https://cdn.study-with-me.co.kr/basic-assets/cat.jpeg" alt="Description"/>
    <img id="fetched-image" alt="Fetched"/>
</body>
</html>

동일한 이미지에 대해서 2가지 방법으로 로드를 시도했다

  • <img> 태그 = 성공
  • Fetch API = 실패
<img>, <script>, <video>와 같은 태그들은 기본적으로 Cross-Origin 정책을 지원하기 때문에 별도의 허가 없이 다른 출처의 리소스에 접근하는 것이 가능하다고 한다

반면에 Fetch API를 통해서 리소스를 요청하는 것은 Same-Origin 정책을 따르기 때문에 별도의 허가가 없다면 다른 출처의 리소스에 접근할 수 없다
→ XMLHttpRequest
→ Fetch API
→ CSS 파일 내부 @font-face에서 다른 도메인 폰트 사용

 

CORS는 어디서 판단?

이 주제에 대한 답은 간단하다

브라우저

 

왜냐하면 CORS에 의한 출처를 비교하는 로직 자체가 브라우저의 스펙이고 브라우저별로 다르게 구현되어 있기 때문이다

  • 간단한 예시로 위에서 살펴본 Port가 다른 경우 Internet Explorer는 포트를 무시하기 때문에 Same Origin이라고 판단하고 그 외 브라우저는 Cross Origin이라고 판단한다

 

그리고 위의 HTML 테스트에서도 봤듯이 CDN에서 이미지에 대한 리소스 응답은 정상적으로 이루어진 상태이다

하지만 Fetch API에 의한 CORS 정책 때문에 브라우저단에서 막았을 뿐이다

 

 

CORS Request

(1) Simple Request

1. 예비 요청(Preflight) 없이 바로 본 요청 진행
2. 서버에서 Access-Control-Allow-Origin과 같은 값 제공
3. 브라우저에서 CORS 정책 위반 여부 확인

 

Simple Request 조건

아래와 같은 특정 조건 하에서 예비 요청(Preflight)를 생략하고 Simple Request를 진행할 수 있다

  1. HTTP Method가 { GET | POST | HEAD } 중에 하나
  2. 허용된 헤더만 존재하는 경우
    • Accept
    • Accept-Language
    • Content-Language
    • Content-TYpe
    • DPR
    • Downlink
    • Save-Data
    • Viewport-Width
    • Width
  3. 허용된 Content-Type만 존재하는 경우
    • text/plain
    • application/x-www-form-urlencoded
    • multipart/form-data

 

이 3가지 조건중에 하나라도 만족하지 못한다면 예비 요청이 필수이다

 

(2) Preflight Request

1. 예비 요청(Preflight)를 통해서 CORS 정책 위반 여부 확인
2. Preflight를 성공헀다면 본 요청 진행

Preflight가 정상적으로 통과되어야 본 요청을 보낼 수 있다

  • 물론 예비 요청에도 본 요청에 필요한 정보들을 포함시킬 수 있다
  • 서버에서는 예비 요청에 대한 응답으로 어떤 것을 허용하고 금지하는지에 대한 정보를 헤더에 담아서 보낸다
    • Access-Control-Allow-Origin
    • Access-Control-Allow-Methods
    • Access-Control-Allow-Headers
    • Access-Control-Max-Age
      • 이 헤더는 예비 요청이 브라우저에 캐싱될 수 있는 시간 [초 단위]
    • ...
  • 위와 같은 예비 요청에 대한 응답을 토대로 브라우저는 정책을 비교해서 안전한 요청인지 확인한다
예비 요청은 HTTP Method → OPTIONS를 활용해서 진행한다

 

예비 요청 테스트

import React, {useState} from 'react';
import axios from 'axios';

function App() {
  const [data, setData] = useState(null);

  const fetchData = async () => {
    try {
      const response = await axios.get('http://localhost:8080/call', {
        headers: {
          'Custom-Header': 'HelloWorld' // 커스텀 헤더
        }
      });
      setData(response.data);
    } catch (error) {
      console.error('There was an error!', error);
    }
  };

  return (
    <div className="App">
      <header className="App-header">
        <button onClick={fetchData}>
          Fetch Data
        </button>
        {data && <div>{JSON.stringify(data)}</div>}
      </header>
    </div>
  );
}

export default App;
@RestController
public class ApiController {
    public record Hello(
            String name,
            int age
    ) {
    }

    @CrossOrigin(
            origins = "http://localhost:3000",
            maxAge = 10 // Preflight 요청 캐싱 [초 단위]
    )
    @GetMapping("/call")
    public Hello helloApi() {
        return new Hello("sjiwon", 23);
    }
}

  • Simple Request에서는 허용하지 않는 Custom-Header가 포함되어 있기 때문에 Preflight Request가 진행된다

Preflight Request/Response

  • Preflight Request를 통해서 Request/Origin & Response/Access-Control-Allow-Origin값이 같으므로 다른 출처라도 CORS가 허용되어서 본 요청이 진행된다

 

(3) Credentialed Request

Credentialed Request는 Client → Server로 자격 인증 정보(Credential)을 포함해서 요청할 때 사용되는 요청이다

  • 세션 ID가 포함된 쿠키
  • Authorization Header 토큰 값
  • ...

이 요청은 보안을 더욱 강화할 때 활용하는 요청이고 브라우저 쿠키 정보, 인증 관련 헤더를 포함해서 요청할 때는 credentials 옵션을 적용해줘야 한다

 

종류

  • same-origin (default) = Same Origin 요청에만 인증 정보 O
  • include = 모든 요청에 인증 정보 O
  • omit = 모든 요청에 인증 정보 X
fetch(
  'http://localhost:8080/call', {
    method: 'GET',
    headers: {
      'Custom-Header': 'HelloWorld'
    },
    credentials: 'include' // same-origin, include, omit
  }
)

axios.get('http://localhost:8080/call', {
  headers: {
    'Custom-Header': 'HelloWorld'
  },
  withCredentials: true, // = include
});
  • axios는 credentials에 대한 세밀한 정책을 지정할 수 없고 withCredentials: true는 include credentials이랑 동일한 메커니즘이다

 

이렇게 credentials를 포함하게 되면 단순하게 Access-Control-Allow-Origin만 확인해서 CORS 정책을 검사하는게 아니라 추가적인 검사를 진행하게 된다

  • Acess-Control-Allow-Origin은 *가 아닌 명시적인 도메인을 적용했는지
  • Access-Control-Allow-Credentials: true가 포함되었는지
const response = await axios.get('http://localhost:8080/call', {
  headers: {
    'Custom-Header': 'HelloWorld' // 커스텀 헤더
  },
  withCredentials: true,
});
// Case 1) allowCredentials 설정이 없는 경우
@CrossOrigin(origins = "http://localhost:3000")
@GetMapping("/call")
public Hello helloApi() {
    return new Hello("sjiwon", 23);
}

// Case 2) origin을 *로 설정한 경우
@CrossOrigin(
        origins = "*",
        allowCredentials = "true"
)
@GetMapping("/call")
public Hello helloApi() {
    return new Hello("sjiwon", 23);
}

  • 브라우저가 CORS 정책 위반을 판단하는 시점Preflight Request에 대한 응답 이후이므로 Preflight Request는 200 OK가 나왔지만 본 요청은 CORS 오류로 진행되지 않은 것이다

Case 1 결과
Case 2 관련 CorsConfiguration Validation

 

@CrossOrigin(
        origins = "http://localhost:3000",
        allowCredentials = "true"
)
@GetMapping("/call")
public Hello helloApi() {
    return new Hello("sjiwon", 23);
}

Credentials에 대한 Preflight Response