브라우저는 서로 다른 출처(origin)의 코드가 같은 공간에서 실행되는 환경이다. 어떻게 격리할 것인가?
브라우저 보안의 가장 기본적인 원칙이다.
Origin(출처) = 프로토콜 + 호스트 + 포트
https://opndoctor.com:443 ← 이게 하나의 origin
| URL A | URL B | 같은 origin? | 이유 |
|---|---|---|---|
https://opndoctor.com |
https://opndoctor.com/map |
O | 경로만 다름 |
https://opndoctor.com |
http://opndoctor.com |
X | 프로토콜 다름 |
https://opndoctor.com |
https://api.opndoctor.com |
X | 호스트 다름 |
https://opndoctor.com |
https://opndoctor.com:8080 |
X | 포트 다름 |
SOP가 막는 것:
- A 출처의 JavaScript가 B 출처의 DOM에 접근
- A 출처의 JavaScript가 B 출처의 쿠키/스토리지에 접근
- A 출처의 JavaScript가 B 출처의 API 응답을 읽기 (CORS 없이)
SOP가 막지 않는 것:
<img>,<script>,<link>태그로 다른 출처의 리소스 로드 (읽기는 불가)<iframe>으로 다른 출처 페이지 표시 (내부 DOM 접근은 불가)<form>으로 다른 출처에 데이터 전송
SOP의 제한을 서버가 명시적으로 허용하는 메커니즘이다.
[브라우저] GET https://api.opndoctor.com/users
Origin: https://opndoctor.com
[서버 응답]
Access-Control-Allow-Origin: https://opndoctor.com ← "이 출처는 허용"
Preflight 요청 (OPTIONS): 단순하지 않은 요청(PUT, DELETE, 커스텀 헤더 등)은 브라우저가 먼저 OPTIONS 요청을 보내 서버에 "이거 해도 돼?"라고 물어본다.
1. [브라우저] OPTIONS /users (preflight)
2. [서버] Access-Control-Allow-Methods: GET, POST, PUT
3. [브라우저] PUT /users (실제 요청)
Set-Cookie: token=abc123;
Secure; ← HTTPS에서만 전송
HttpOnly; ← JavaScript에서 접근 불가 (XSS 방어)
SameSite=Lax; ← cross-site 요청 시 쿠키 전송 제한 (CSRF 방어)
Domain=.opndoctor.com; ← 서브도메인 포함 전송
Path=/;
| 속성 | 방어 대상 | 설명 |
|---|---|---|
Secure |
네트워크 도청 | HTTPS에서만 쿠키 전송 |
HttpOnly |
XSS | document.cookie로 접근 불가 |
SameSite |
CSRF | cross-site 요청 시 쿠키 포함 여부 제어 |
| 스토리지 | 격리 단위 | 용량 | JS 접근 |
|---|---|---|---|
| Cookie | origin + path | 4KB | document.cookie (HttpOnly 제외) |
| localStorage | origin | 5~10MB | window.localStorage |
| sessionStorage | origin + 탭 | 5~10MB | window.sessionStorage |
| IndexedDB | origin | 수백 MB~ | window.indexedDB |
핵심: 모든 웹 스토리지는 origin 단위로 격리된다. https://a.com의 localStorage는 https://b.com에서 절대 접근 불가.
Apple Safari에만 있는 추가적인 보안/프라이버시 정책이다. SOP 위에 한 층 더 얹은 것.
ITP가 하는 일:
- Third-party 쿠키 전면 차단
- Third-party iframe의 스토리지를 파티셔닝 (부모 사이트별로 격리)
- 스토리지를 에페머럴 처리 (세션 종료 시 삭제)
Third-party란?
[부모 페이지: https://opndoctor.com]
└── [iframe: https://next.opndoctor.com] ← 호스트가 다르면 third-party
ITP의 스토리지 차단 방식:
| 스토리지 | ITP 차단 방식 | 결과 |
|---|---|---|
| Cookie | 전면 차단 | 아예 전송 안 됨 |
| localStorage | 파티셔닝 + 에페머럴 | 값이 격리되고 세션 후 삭제 |
| IndexedDB | 이벤트 미발화 | open() 호출 후 응답 없음 → hang |
IndexedDB가 hang하는 이유:
indexedDB.open('mydb')
→ IDBOpenDBRequest 객체 반환
→ onsuccess / onerror 이벤트를 기다림
→ Safari ITP: 이벤트를 dispatch하지 않음 (침묵 차단)
→ Promise가 pending 상태로 영원히 대기
Safari가 에러를 던지지 않는 이유: 차단 사실 자체를 third-party에 알려주지 않기 위해서. 에러를 던지면 트래커가 우회를 시도할 수 있으므로, "침묵하는 실패(silent failure)"를 설계 원칙으로 채택.
iframe은 다른 페이지를 현재 페이지 안에 삽입하는 HTML 요소다.
<!-- opndoctor.com 페이지 안에 -->
<iframe src="https://next.opndoctor.com/signup"></iframe>iframe의 보안 제약:
- 부모 ↔ iframe 간 DOM 접근: SOP에 의해 차단 (다른 origin이면)
- 통신 방법:
window.postMessage()— 명시적으로 메시지를 보내야 함 - iframe 내 스토리지: origin 기준이지만, Safari ITP는 추가 제한
postMessage 동작:
// iframe → 부모
window.parent.postMessage({ type: 'auth', token: 'abc' }, 'https://opndoctor.com');
// 부모에서 수신
window.addEventListener('message', (event) => {
if (event.origin !== 'https://next.opndoctor.com') return; // 출처 검증 필수!
console.log(event.data);
});오픈닥터의 구조:
Flutter-web (https://opndoctor.com) — 부모
└── Next.js (https://next.opndoctor.com) — iframe (third-party)
- Next.js에서 회원가입 완료 →
postMessage로 Flutter에 토큰 전달 - Flutter가
FlutterSecureStorage로 토큰 저장 시도 FlutterSecureStoragev1.2.1 → 내부적으로 IndexedDB 사용- Safari ITP → iframe 내 IndexedDB 침묵 차단 → hang
- 해결: IndexedDB를 우회하고 인메모리(
AuthManager().opn_token)로 전환