← Posts

Security · Browser · SOP

같은 출처만 믿는다

브라우저가 출처를 스킴·호스트·포트로 가르고, 다른 출처의 응답을 스크립트에 넘기지 않는 이유. 이 규칙 하나에서 CORS·XSS·CSRF가 갈라져 나온다.

목차
  1. 같은 코드인데 이 요청만 막힌다
  2. 출처는 스킴·호스트·포트 셋이다
  3. 왜 남을 못 믿게 만들어야 했나
  4. 막는 건 읽기지 보내기가 아니다
  5. 태그로 불러오는 것은 원래 허용된다
  6. 출처와 사이트는 다른 말이다
  7. 실무에서 이 정책과 부딪히는 자리
  8. 여기서 셋이 갈라져 나온다
  9. 정리

브라우저에는 서버에 없는 규칙이 하나 있다. 그 규칙을 모르면 앞으로 볼 세 가지 문제가 전부 따로 노는 이야기가 된다.

같은 코드인데 이 요청만 막힌다

https://shop.example.com에서 돌아가는 페이지가 두 곳에 요청을 보낸다.

js
// 이건 잘 된다
const a = await fetch('https://shop.example.com/api/cart');

// 이건 콘솔에 빨간 줄이 뜬다
const b = await fetch('https://api.example.com/cart');

코드는 똑같다. 주소만 다르다. 서버는 둘 다 살아 있고, 터미널에서 curl로 두 번째 주소를 두드리면 멀쩡히 JSON이 온다. 그런데 브라우저에서만 막힌다.

막은 건 서버가 아니라 브라우저다. 브라우저는 페이지가 어디서 왔는지를 보고, 거기서 나가는 요청을 다르게 취급한다. 그 “어디서 왔는지”가 출처(origin)다.

출처는 스킴·호스트·포트 셋이다

출처는 느낌이 아니라 정의다. URL에서 스킴(프로토콜) · 호스트 · 포트 세 조각을 떼어낸 것이 출처이고, 셋이 모두 같아야 같은 출처다.

plaintext
https :// shop.example.com : 443 /products?page=2
─────    ────────────────   ───  ──────────────
스킴          호스트         포트   ← 출처에 안 들어감

https://shop.example.com/products를 기준으로 놓고 비교하면 이렇다.

상대 URL같은 출처인가이유
https://shop.example.com/cart같다경로는 출처가 아니다
https://shop.example.com/?q=1같다쿼리스트링도 아니다
http://shop.example.com/products다르다스킴이 다르다
https://api.example.com/products다르다호스트가 다르다
https://example.com/products다르다서브도메인도 다른 호스트다
https://shop.example.com:8443/다르다포트가 다르다

여기서 초중급자가 가장 많이 걸리는 두 칸이 아래쪽 셋이다.

  • 서브도메인은 남이다. shop.example.comapi.example.com은 회사도 같고 인증서도 같이 살 수 있지만, 브라우저 입장에서는 완전히 다른 출처다.
  • 포트만 달라도 남이다. 프런트를 localhost:5173, 서버를 localhost:8080에 띄우고 개발하다 처음 막히는 게 이 때문이다. 같은 컴퓨터라는 건 아무 상관이 없다.
참고

포트를 안 적으면 스킴의 기본 포트가 적용된다. https://shop.example.comhttps://shop.example.com:443과 같은 출처다. 반대로 http://(80)와 https://(443)는 호스트가 같아도 스킴과 포트가 둘 다 달라서 확실히 남이다.

왜 남을 못 믿게 만들어야 했나

브라우저는 좀 특이한 프로그램이다. 서로 아무 관계 없는 남의 코드를 한 프로그램 안에서 동시에 실행한다. 지금 이 순간에도 탭 하나엔 은행 페이지가, 옆 탭엔 처음 보는 사이트가 떠 있고, 둘 다 자바스크립트를 돌리고 있다.

여기에 브라우저의 또 다른 습관이 겹친다. 브라우저는 그 사이트에 저장해둔 쿠키를 요청에 알아서 붙여 보낸다. 로그인 상태가 유지되는 게 그 덕분이다.

그러면 규칙이 없을 때 무슨 일이 벌어지는지가 바로 나온다.

diagramdiagram

낯선 사이트의 스크립트가 은행 API를 부르고, 브라우저가 은행 쿠키를 알아서 붙여주고, 돌아온 응답을 그 스크립트가 읽는다. 사용자는 아무것도 안 했는데 잔고가 새어 나간다.

그래서 브라우저는 기본값을 불신으로 잡았다. 이게 동일 출처 정책(Same-Origin Policy) 이다. 한 문장으로 줄이면 이렇다.

다른 출처의 자원은 스크립트가 읽지 못한다.

읽기가 갈리는 대상은 네트워크 응답만이 아니다. 다른 출처의 iframe 안쪽 DOM, 그쪽 출처에 저장된 쿠키와 localStorage도 전부 같은 선으로 갈린다. 출처가 곧 격리 단위인 셈이다.

참고

동일 출처 정책은 “이 문서 하나에 다 적혀 있는 규격”이 아니다. 브라우저가 여러 명세에 걸쳐 집행하는 규칙 묶음에 붙은 이름이다. 그래서 대상(네트워크 응답·DOM·저장소)마다 세부 동작이 조금씩 다르고, 브라우저 구현 차이도 여기서 나온다.

막는 건 읽기지 보내기가 아니다

여기가 이 시리즈 전체에서 가장 중요한 대목이다. 앞의 fetch가 막혔을 때 흔히 이렇게 생각한다. “요청이 안 나갔구나.”

대개 나갔다. 서버 로그를 보면 요청이 찍혀 있고 응답도 200으로 나갔다. 브라우저가 그 응답을 받아놓고, 스크립트에 넘겨주지 않은 것뿐이다.

diagramdiagram

이 한 칸의 차이가 앞으로 볼 모든 것을 가른다.

  • 응답을 못 읽게 하는 것이라서, 서버 쪽에서 이미 벌어진 일은 되돌려지지 않는다. 다른 출처로 폼을 제출해 글이 하나 등록됐다면, 그 결과를 읽지 못할 뿐 등록은 이미 끝난 뒤다.
  • 그래서 동일 출처 정책은 정보가 새는 것을 막는 장치지, 서버가 뭔가 하는 것을 막는 장치가 아니다.
참고

“요청은 나간다”에는 예외가 하나 있다. 브라우저가 먼저 물어보고 나서 보내는 요청이 있어서, 그 물음이 거절되면 본 요청은 아예 안 나간다. 폼으로 보낼 수 없던 종류의 요청이 거기 해당한다. 그 장치가 다음 글의 주제다.

이 빈틈이 나중에 CSRF라는 이름으로 다시 나타난다.

태그로 불러오는 것은 원래 허용된다

여기서 이상한 점이 하나 보일 것이다. 다른 출처를 못 읽는다면서, 웹페이지는 남의 CDN에서 이미지도 받고 스크립트도 받고 폰트도 받는다.

맞다. 태그로 가져오는 것은 막지 않는다.

태그다른 출처에서 가져오기스크립트가 내용을 읽기
<img src="...">된다안 된다 (픽셀을 읽으려 하면 막힌다)
<script src="...">된다소스 텍스트는 못 읽는다
<link rel="stylesheet">된다규칙을 훑는 건 대체로 막힌다
<iframe src="...">된다안쪽 DOM은 못 읽는다
<form action="...">된다 (제출까지)결과 페이지는 못 읽는다
fetch / XMLHttpRequest나가긴 한다기본은 안 된다

규칙이 일관돼 보이는 지점은 오른쪽 칸이다. 가져와서 화면에 쓰는 것은 되고, 내용을 코드로 읽는 것은 안 된다. 웹이 처음부터 그렇게 만들어져 있었고, 동일 출처 정책은 나중에 그 위에 얹힌 규칙이라 이미 열려 있던 문을 닫지 못했다.

왼쪽 칸의 마지막 줄을 잘 봐두자. 다른 출처로 폼을 제출하는 건 지금도 정상 동작이다. 응답을 못 읽을 뿐, 요청은 완전히 나간다. 쿠키까지 실려서.

출처와 사이트는 다른 말이다

같이 나오는데 뜻이 다른 단어가 하나 있다. 사이트(site) 다.

  • 출처 = 스킴 + 호스트 + 포트. shop.example.comapi.example.com다른 출처.
  • 사이트 = 대체로 등록 가능한 도메인 한 칸(example.com)까지. 저 둘은 같은 사이트.

즉 사이트가 출처보다 헐겁다. 서브도메인끼리는 출처는 다르지만 사이트는 같다.

diagramdiagram

왜 굳이 구분하냐면, 쿠키를 다루는 규칙이 출처가 아니라 사이트를 기준으로 도는 자리가 있기 때문이다. 나중에 SameSite라는 쿠키 속성을 볼 때 이 차이가 그대로 문제가 된다.

주의

“같은 사이트”의 정확한 판정 기준은 브라우저가 관리하는 공개 접미사 목록에 걸려 있고, 스킴까지 볼지 여부도 브라우저·버전에 따라 다르게 굴러온 이력이 있다. .co.kr 같은 다단계 도메인에서 직관과 어긋나기 쉬우니 “대충 이 정도”로만 잡고, 실제 판정은 쓰는 브라우저 문서를 확인하는 게 맞다.

실무에서 이 정책과 부딪히는 자리

개념보다 먼저 몸으로 만나는 쪽이 대부분이라, 어디서 부딪히는지를 모아두면 진단이 빨라진다.

로컬 개발. 프런트 개발 서버와 API 서버의 포트가 다르면 그 순간부터 교차 출처다. 개발 서버의 프록시 설정으로 요청을 같은 출처처럼 흘려보내는 방식이 흔히 쓰이는데, 이건 정책을 끈 게 아니라 출처를 하나로 맞춘 것이다.

서브도메인 분리. 프런트는 www, API는 api로 나눠 배포하는 순간 교차 출처가 된다. 아키텍처를 나눈 대가로 브라우저 규칙을 하나 더 다뤄야 한다.

콘솔 에러 메시지가 서버 에러처럼 보인다. 브라우저는 응답을 스크립트에 안 넘기기 때문에 상태 코드도 본문도 안 알려준다. 그래서 fetchcatch에 들어오는 건 “실패했다” 수준의 정보뿐이다. 네트워크 탭에서는 200으로 보이는데 코드에서는 에러인 상황이 정상이다. 여기서 서버를 뒤지기 시작하면 하루를 버린다.

막혔을 때 첫 질문은 “서버가 죽었나”가 아니라 “이 요청이 교차 출처인가” 다. 페이지 주소와 요청 주소의 스킴·호스트·포트를 나란히 놓고 비교하면 대부분 거기서 끝난다. 그다음 질문이 “그럼 서버가 허용해줄 방법이 있나”인데, 그게 다음 글이다.

여기서 셋이 갈라져 나온다

이제 이 시리즈의 지도를 그릴 수 있다. 앞으로 볼 세 가지는 서로 다른 주제가 아니라, 방금 본 규칙 하나를 서로 다른 방향에서 건드린 결과다.

diagramdiagram
  • CORS 는 이 정책을 우회하는 공격이 아니라, 서버가 예외를 승인해주는 절차다. 정책을 지키면서 문을 여는 공식 통로다.
  • XSS 는 정책을 뚫는 게 아니라 정책 안쪽으로 들어가는 공격이다. 내 페이지에서 남의 스크립트가 돌면 그 스크립트의 출처는 내 출처다. 동일 출처 정책은 그걸 막을 근거가 없다.
  • CSRF 는 정책이 애초에 막지 않는 것을 이용한다. 앞에서 본 그 빈틈이다. 응답은 못 읽어도 요청은 나가고, 쿠키는 알아서 붙는다.

셋을 따로 외우면 헷갈리지만, 이 그림 위에 놓으면 각자 자리가 있다. 뭘 완화하는 장치인지, 뭘 안쪽에서 무력화하는지, 뭘 원래 안 막았는지.

정리

출처란스킴 + 호스트 + 포트. 셋이 다 같아야 같은 출처
경로·쿼리출처에 안 들어간다
서브도메인·포트하나만 달라도 다른 출처
동일 출처 정책다른 출처의 자원을 스크립트가 못 읽게 한다
막는 것응답·DOM·저장소를 읽는 것
안 막는 것요청이 나가는 것, 태그로 불러오는 것, 폼 제출
사이트 ≠ 출처사이트가 더 헐겁다. 서브도메인끼리는 같은 사이트
셋의 관계CORS는 완화, XSS는 안쪽 침투, CSRF는 원래 안 막던 틈

브라우저는 서버를 지켜주는 물건이 아니다. 한 프로그램 안에 뒤섞인 남들끼리 서로의 데이터를 못 읽게 갈라두는 물건이다. 이 문장이 앞으로 볼 셋을 전부 설명한다.

이제 첫 번째 가지로 간다. 서브도메인으로 API를 분리해놓고 개발하면 매일 이 정책에 부딪히는데, 그렇다고 정책을 끄고 살 수는 없다. 서버가 브라우저에게 “저 출처는 내가 허락한 곳이다”라고 알려주는 공식 절차가 있다. 다음 글이 그것이고, 거기서 가장 먼저 할 일은 CORS가 보안 기능이라는 오해를 걷어내는 것이다.

관련 글