thenullpage.com

출처라는 단위

브라우저는 페이지가 어디서 왔는지를 출처라는 단위로 따진다. 출처는 프로토콜과 호스트와 포트 세 가지를 묶은 것이고, 셋이 전부 같아야 같은 출처다. https로 연 페이지에서 http 주소를 부르면 프로토콜이 달라 남남이고, example.com에서 api.example.com을 부르면 호스트가 달라 남남이다. 포트만 8080으로 바뀌어도 마찬가지다. 경로와 쿼리는 출처에 들어가지 않으므로 아무리 달라도 상관없다.


브라우저의 기본 규칙은 다른 출처에서 받아온 응답을 스크립트가 읽지 못하게 막는 것이다. 이것을 동일 출처 정책이라 부른다. 이 규칙이 없으면 사용자가 무심코 연 낯선 페이지의 스크립트가 은행 사이트로 요청을 보내고 돌아온 화면을 그대로 읽어 갈 수 있다. 쿠키는 조건만 맞으면 브라우저가 알아서 붙이므로 로그인 상태까지 딸려 간다. CORS는 이 차단을 서버가 스스로 풀어 주기 위해 만들어진 절차다.


막히는 것은 읽는 일이다

콘솔에 CORS 오류가 뜨면 요청이 나가지 못한 것처럼 보이지만 대개 그렇지 않다. 요청은 서버까지 갔고 서버는 그것을 처리했으며 응답도 브라우저까지 돌아왔다. 다만 응답에 이 출처가 읽어도 된다는 표시가 없어서 브라우저가 결과를 자바스크립트에 넘기지 않고 버린 것이다.


그래서 화면에는 실패로 보이는데 서버 로그에는 200이 남고 데이터베이스에는 행이 하나 늘어 있는 상황이 생긴다. 이 성질을 모르면 실패한 줄 알고 버튼을 다시 눌러 같은 데이터를 두 번 만들게 된다. 정리하면 CORS는 응답을 읽을 권한을 통제할 뿐 서버가 요청을 처리하는 것 자체는 막지 않는다. 요청을 아예 받지 않아야 한다면 그 판단은 서버에서 해야 한다.


바로 나가는 요청과 먼저 묻는 요청

모든 요청이 같은 절차를 밟지는 않는다. 조건이 단순한 요청은 그냥 나간다. 메서드가 GET, HEAD, POST 중 하나이고, 코드에서 직접 붙인 헤더가 표준 목록 안에 있고, Content-Type이 application/x-www-form-urlencoded와 multipart/form-data와 text/plain 셋 중 하나일 때다. 예전부터 HTML 폼이 보낼 수 있던 범위와 거의 같다고 보면 된다.


조건을 하나라도 벗어나면 브라우저가 본 요청을 보내기 전에 허가부터 묻는다. 이 사전 요청을 프리플라이트라 하고 메서드는 OPTIONS다. 본문에 JSON을 실으면 Content-Type이 application/json이 되어 조건을 벗어나므로 요즘 API 호출은 대부분 여기에 해당한다. Authorization 헤더를 붙이거나 PUT과 DELETE를 쓰는 경우도 같다.


OPTIONS /articles HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, DELETE
Access-Control-Allow-Headers: Content-Type
Access-Control-Max-Age: 86400


브라우저는 이 응답을 보고 쓰려는 메서드와 헤더가 허용 목록에 들어 있는지 대조한다. 하나라도 어긋나면 본 요청은 아예 보내지 않는다. Access-Control-Max-Age는 이 허가를 몇 초 동안 기억할지 정하는 값이다. 적어 두지 않으면 요청마다 왕복이 두 번씩 생겨 느려진다. 브라우저마다 인정하는 상한이 있어서 큰 값을 적어도 그 한도까지만 적용된다.


쿠키를 함께 보낼 때

다른 출처로 나가는 요청에는 쿠키가 기본으로 실리지 않는다. 보내려면 요청하는 쪽이 자격 증명을 포함하라고 지정하고 응답하는 쪽도 허용한다고 밝혀야 한다.


fetch("https://api.example.com/me", { credentials: "include" })

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin


여기에는 제약이 하나 붙는다. 자격 증명을 허용하는 응답에는 Access-Control-Allow-Origin 자리에 전부 허용을 뜻하는 별표를 쓸 수 없다. 출처를 하나 정확히 적어야 한다. 허용할 곳이 여럿이면 요청에 담긴 Origin 헤더를 읽어 미리 정해 둔 목록에 있을 때만 그 값을 그대로 되돌려 준다. 이때 목록 대조를 빼먹고 받은 값을 무조건 되비추면 어느 사이트에서 온 요청이든 통과하므로 정책이 없는 것과 같아진다.


Vary에 Origin을 적는 일도 함께 따라와야 한다. 이 헤더가 없으면 중간 캐시가 먼저 저장해 둔 응답 하나를 출처와 상관없이 계속 돌려주고, 그 안에 적힌 허용 출처가 엉뚱한 곳으로 굳어 버린다.


어긋나기 쉬운 자리

가장 흔한 것은 서버가 오류를 낸 경우다. 코드가 예외로 죽거나 주소를 잘못 적어 404가 나면 허용 헤더를 붙이는 단계까지 가지 못한다. 그러면 브라우저에는 진짜 원인 대신 CORS 오류만 보인다. 헤더를 붙이는 처리를 오류 응답에도 걸어 두어야 실제 상태코드가 눈에 들어온다.


프리플라이트에는 쿠키도 Authorization 헤더도 실리지 않는다. 인증 검사를 모든 요청 앞단에 걸어 두면 OPTIONS가 401로 막히고 본 요청은 시작조차 못 한다. OPTIONS는 인증 검사보다 앞에서 응답하도록 빼 두어야 한다.


응답 헤더를 읽는 쪽에도 제한이 있다. 다른 출처의 응답에서 스크립트가 기본으로 읽을 수 있는 헤더는 몇 개뿐이라, 그 밖의 헤더는 서버가 Access-Control-Expose-Headers에 이름을 적어 열어 주어야 보인다. 전체 개수 같은 값을 헤더로 내려보내는데 클라이언트에서 계속 비어 있다면 이 자리를 확인한다.


확인하는 방법

개발자 도구 Network 탭에서 해당 요청을 찾는다. 프리플라이트가 일어났다면 같은 주소로 OPTIONS 한 줄이 본 요청 바로 위에 따로 잡힌다. OPTIONS만 있고 본 요청이 없으면 허가 단계에서 끊긴 것이고, 둘 다 있는데 코드에서만 결과를 못 받는다면 본 요청의 응답 헤더가 문제다.


콘솔 문구도 원인을 갈라 준다. 허용 헤더가 아예 없다는 말과, 값이 요청한 출처와 다르다는 말과, 보내려는 헤더가 허용 목록에 없다는 말은 각각 고칠 자리가 다르다.


명령줄에서는 프리플라이트를 직접 흉내 내 볼 수 있다.


curl -i -X OPTIONS https://api.example.com/articles
-H "Origin: https://app.example.com"
-H "Access-Control-Request-Method: POST"
-H "Access-Control-Request-Headers: content-type"


curl은 브라우저가 아니라서 스스로 차단하지 않는다. 여기서 볼 것은 돌아온 응답에 Access-Control로 시작하는 헤더가 무엇 무엇 붙어 있느냐다. 하나도 없으면 서버가 설정을 안 한 것이고, 붙어 있는데도 브라우저에서만 막힌다면 출처 문자열의 끝 슬래시나 포트 번호처럼 눈에 잘 안 띄는 차이를 대조해 본다. 반대로 curl로 잘 된다고 해서 문제가 없다는 뜻은 아니다. 차단은 브라우저 안에서만 일어난다.