C.W.K.
Stream
Lesson 05 of 06 · published

CORS Deep Dive — Server-선언 정책, 브라우저-강제

~12 min · auth-security, cors, preflight, browser

Level 0HTTP Newbie
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"CORS 는 보안 아냐. 브라우저가 server-선언 정책 강제하는 것. 일단 내재화하면 '왜 CORS 어려워' 혼란 절반 사라지고 — 나머지 절반이 'server 가 뭘 선언하고 있는지에 대해 내가 틀림' 됨."

CORS 풀어주는 mental model

CORS 혼란 대부분 fix 하는 사실 둘:

  1. CORS 는 브라우저만 강제. curl request, Python httpx 호출, server-to-server fetch — 어느 것도 CORS 안 신경. 체크가 브라우저의 네트워크 층에서 일어남; 브라우저 아닌 HTTP client 가 완전 무시.
  2. Server 가 정책 선언; 브라우저가 강제. Server 가 "이 origin 에서, 이 method 로, 이 header 와 함께 호출 받을 의향" 말하는 Access-Control-Allow-* response header 추가. 브라우저가 그 header 읽고 request 만든 JavaScript 한테 response 노출할지 결정.

둘 다 동시 들고 있을 수 있으면 CORS 버그가 'server 가 내가 생각하는 거 선언 안 하고 있음' 됨 — configuration 문제, 미스터리 아님.

Origin 이 뭐 의미

Origin 이 triple (scheme, host, port). https://example.com, https://example.com:443, http://example.com 가 다른 origin 셋. 다른 거 cross-origin 이면 CORS 춤 필요.

Same-Origin Policy (SOP) 가 브라우저 기본: origin A 의 JavaScript 가 origin B 의 response 못 읽음. 없으면 악성 사이트가 너의 은행 로그인 데이터 읽을 수 있음. CORS 가 통제된 완화 — server B 가 "origin A 가 나 읽도록 허용" 말하고 브라우저가 존중.

Simple request vs preflighted request

브라우저가 cross-origin request 를 두 카테고리로 나눔.

Simple request (preflight 불필요) — 다 만족:

  • Method 가 GET, HEAD, POST.
  • Header 가 작은 "CORS-safelisted" set (Accept, Accept-Language, Content-Language, 몇 값 가진 Content-Type).
  • Content-Type 이 application/x-www-form-urlencoded, multipart/form-data, text/plain 중 하나.

브라우저가 request 정상 보냄; response 에 Access-Control-Allow-Origin: matching-origin 있으면 JS 한테 response 노출. 아니면 response 차단.

Preflighted request (나머지 다, Content-Type: application/json 가진 POST 포함):

  1. 실제 request 전 브라우저가 같은 URL 에 Origin, Access-Control-Request-Method, Access-Control-Request-Headers 가진 OPTIONS request 보냄.
  2. Server 가 허용 선언하는 Access-Control-Allow-Origin/Methods/Headers 로 응답.
  3. 브라우저가 비교. 실제 request 가 선언 정책 안에 맞으면 브라우저 진행. 아니면 차단.
  4. 실제 request 보냄; response 도 Access-Control-Allow-Origin 필요.

Preflight 가 round trip 하나 추가. 브라우저가 preflight 결과를 몇 초 캐시 (Access-Control-Max-Age response header) 라서 반복 호출이 재-preflight 안 함.

핵심 header 다섯

  • Access-Control-Allow-Origin — server 가 허용하는 specific origin (혹은 * 와일드카드). Allowlist 에 있으면 요청 origin echo; 임의 origin 절대 echo 마.
  • Access-Control-Allow-Credentials — 브라우저가 cookie 와 Authorization header 포함하길 원하면 true 설정. 핵심: Access-Control-Allow-Origin: * 와 조합 못 함 — 와일드카드와 credentials 상호 배타.
  • Access-Control-Allow-Methods — 이 URL 에 받는 method (preflight 만).
  • Access-Control-Allow-Headers — client 가 보낼 수 있는 request header (preflight 만).
  • Access-Control-Max-Age — 브라우저가 preflight 결과 캐시할 시간.
CORS 는 보안 아냐 — server 정책의 브라우저 강제. 보호가 악성 server 에 대한 게 아냐; 다른 origin 에 무권한 접근 위해 너의 브라우저를 proxy 로 쓰려는 악성 웹사이트에 대한 것. Cross-origin request 거부하는 server 가 정당 client 잠그는 거; 허용하는 server 는 진짜 auth 체크도 해야 — CORS 단독으로 절대 보안 경계 아냐.

와일드카드 + credentials 함정

가장 흔한 CORS 버그 둘:

1. Access-Control-Allow-Origin: * + cookie. 브라우저 거부. 와일드카드가 "어느 origin 이든 나 읽음" 의미, 자격 증명 보내는 거랑 호환 안 됨. Fix: 와일드카드 대신 (allowlist 에 있나 확인 후) specific 요청 origin echo.

2. Preflight 가 204 돌려주는데 CORS header 누락. Preflight 가 HTTP-200, 근데 Access-Control-Allow-Origin 선언 안 함, 그래서 브라우저가 어쨌든 차단. 항상 OPTIONS response 에 header 포함, 실제 request response 만 말고.

cwkPippa 의 CORS 현실

cwkPippa 가 CORS 빡빡 잠금: backend/main.py_allowed_origins 가 정확히 http://localhost:5173, http://127.0.0.1:5173, Tailscale IP origin 담음. 와일드카드 없음; credentials 허용 (cookie + Authorization). FastAPI 의 CORSMiddleware 가 preflight 춤 자동 처리 — handler 안 쓰고 모든 endpoint 가 OPTIONS 지원. 새 device 추가는 그 Tailscale IP 를 목록에 추가하고 재시작 의미. CLAUDE.md gotcha 섹션이 이거 정확히 호출 — 새 instance 의 내가 CORS allowlist 먼저 안 확인하고 "이 IP 에서 API 안 됨" debug 하려 해서.

Code

전체 CORS 춤 — preflight OPTIONS, 그 다음 실제 request·http
# Preflight request (브라우저가 실제 POST 전에 이거 보냄)
OPTIONS /api/chat HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type

# Preflight response (server 가 허용된 거 선언)
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: authorization, content-type, x-request-id
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400

# 이제 브라우저가 실제 POST 보냄 (response 도 CORS header 필요)
POST /api/chat HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Authorization: Bearer abc.def.ghi
Content-Type: application/json

{"message":"hi pippa"}

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Content-Type: application/json

{"reply":"hi 아빠"}
FastAPI CORSMiddleware — 빡빡 allowlist + credentials 활성·python
# FastAPI — CORSMiddleware 가 preflight + 실제 request header 처리
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

# 빡빡 allowlist — credentials 쓸 때 와일드카드 안 됨
_allowed_origins = [
    'http://localhost:5173',
    'http://127.0.0.1:5173',
    'http://100.x.x.x:5173',  # Tailscale IP
]

app.add_middleware(
    CORSMiddleware,
    allow_origins=_allowed_origins,
    allow_credentials=True,                # cookie + Authorization 허용
    allow_methods=['*'],                   # GET, POST, PUT, DELETE, PATCH, OPTIONS
    allow_headers=['*'],                   # 어느 request header 든
    expose_headers=['X-Request-ID'],       # JS 가 읽을 수 있는 response header
    max_age=86400,                         # 24h 동안 preflight 캐시
)

@app.get('/api/chat')
async def chat():
    return {'ok': True}
# OPTIONS /api/chat 가 CORSMiddleware 가 자동 처리
Client 쪽 — cross-origin cookie 위해 credentials: 'include'·javascript
// Client 쪽 — 다른 origin 에서 fetch
// CORS 가 브라우저 일; client 코드에서 'enable' 안 함.
const resp = await fetch('https://api.example.com/api/chat', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer abc.def.ghi',
  },
  credentials: 'include',  // cookie 보냄 — server 가 credentials 허용 필요
  body: JSON.stringify({ message: 'hi pippa' }),
});

// Server 의 preflight response 가 맞는 Origin/Methods/Headers 선언하면
// 브라우저가 실제 request 허용하고 response 노출.
// 아니면: TypeError: NetworkError when attempting to fetch resource.
// 브라우저 콘솔이 실패한 specific CORS 체크 보여줘.

External links

Exercise

Port 8000 에 작은 FastAPI 띄우고 port 5500 에 static HTML/JS (fetch('http://localhost:8000/data') 하는 index.html 가진 디렉토리에서 python -m http.server 5500). 브라우저가 request 차단하는 거 봐. 그 다음 FastAPI 에 allow_origins=['http://localhost:5500'] 가진 CORSMiddleware 추가하고 reload — request 성공. 보너스: fetch 에 credentials: 'include' 추가, CORSMiddleware 의 allow_credentials=True 설정, 둘 다 여전히 동작 검증. 그 다음 일부러 credentials 유지하면서 allow_origins=['*'] 설정 — 브라우저가 조합 거부하는 거 봐.
Hint
브라우저에서 실패하는 (CORS-차단) 같은 request 가 curl 이나 httpx 에서 돌리면 성공 — 그게 손에 잡힌 'CORS 는 브라우저만' lesson. 브라우저 network 탭 봐; request 가 trigger 하면 (content-type: application/json 이 함) 실제 OPTIONS preflight 보여. 콘솔의 실패 메시지 읽는 게 어느 tutorial 보다 CORS 에 대해 더 많이 가르쳐.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.