HTTP OPTIONS와 CORS Preflight 심층 리서치
OPTIONS 메서드와 CORS preflight를 스펙·브라우저 구현·실측으로 검증한 기술 노트
main
date
Sep 23, 2026
slug
http-options-cors-preflight
author
status
Public
tags
Development
Fetch
Server
summary
OPTIONS 메서드와 CORS preflight를 스펙·브라우저 구현·실측으로 검증한 기술 노트
type
Post
thumbnail
category
스터디
updatedAt
Sep 23, 2026 01:42 AM
스펙 1차 출처(Fetch Living Standard / RFC 9110) → 브라우저 구현(Chromium 소스) → 헤드리스 Chromium 실측 재현 순으로 검증한 기술 노트.
본문의 모든 "실측" 표기는 Node HTTP 서버 + Playwright Chromium 1194로 직접 재현한 결과입니다.
리서치 방법
레이어 | 사용한 출처 |
규범 | |
구현 | |
실측 | Node http 서버(localhost:8787) ↔ 페이지 오리진(localhost:8788), Playwright headless Chromium |
계측 | 서버 액세스 로그, performance.now(), performance.getEntriesByType('resource'), 서버 connection 이벤트, Service Worker fetch 이벤트 |
검증 대상 11개 가설 중 11개 재현 성공. 상세는 마지막 "재현 결과표" 참조.
1. 세 문장 요약
OPTIONS는 원래 "이 리소스에 어떤 통신 옵션이 가능한가"를 묻는 범용 메서드지만, 실무에서 보이는 OPTIONS의 대부분은 브라우저가 자동 생성하는 CORS preflight다.
- Preflight는 TCP/TLS 핸드셰이크와 다른 계층이다. 커넥션은 이미 열려 있고, 그 위의 애플리케이션 레벨 허가 요청 1왕복이다.
- Preflight가 실패하면 실제 요청은 네트워크로 나가지 않는다. 반대로 성공하면 실제 요청은 무조건 서버에서 실행되고, 그 응답을 JS에 넘길지만 따로 판정된다. 즉 CORS는 서버 보호 장치가 아니다.
2. OPTIONS 자체: RFC 9110
"The OPTIONS method requests information about the communication options available on the request chain identified by the effective request URI." — RFC 9110 §9.3.7
속성 | 값 |
Request body | 가능하나 정의된 의미 없음 (구현 의존) |
Response body | 가능 |
Safe | ✅ Yes |
Idempotent | ✅ Yes |
Cacheable | ❌ No |
HTML <form> 사용 | ❌ No |
OPTIONS /api/users HTTP/1.1 # 특정 리소스 OPTIONS * HTTP/1.1 # 서버 전체 (asterisk-form)
원조 용도는
Allow 헤더로 지원 메서드를 광고하는 것이다.HTTP/1.1 204 No Content Allow: OPTIONS, GET, HEAD, POST
함정: RFC상 "OPTIONS 응답은 캐시 불가"인데 preflight 결과는 브라우저가 캐싱한다. 모순이 아니다. HTTP 캐시(
Cache-Control)와 CORS preflight 캐시는 완전히 별개의 저장소다. Preflight 캐시는 Access-Control-Max-Age만 보고, HTTP 캐시 계층을 타지 않는다. Chromium도 판정 결과를 자체 PreflightCache에 별도 적재한다.3. 설계 의도: preflight는 왜 생겼나
동일 출처 정책(SOP)은 "다른 출처의 응답을 읽는 것"을 막는 정책이지, 요청 발신을 원천 차단하는 정책이 아니다. CORS 이전에도 아래는 전부 가능했다.
<img src="https://bank.example/transfer?to=evil&amt=1000"> <form method="POST" action="https://bank.example/transfer"> <!-- CSRF --> <script src="https://other.example/data.js">
그래서 요청을 두 부류로 갈랐다.
- 예전부터 HTML만으로 보낼 수 있던 요청 → 새로 위험해질 게 없다. 보내고, 응답 읽기 권한만 검사. → simple request
- HTML만으로는 보낼 수 없던 요청 (
PUT/DELETE,application/json, 커스텀 헤더) → CORS 이전 서버는 이런 요청이 브라우저에서 올 거라 가정한 적이 없다. 그 가정 위에 세워진 레거시 서버를 깨지 않으려면 보내기 전에 허락을 받아야 한다. → preflighted request
즉 preflight는 성능 장치가 아니라 "CORS를 모르는 옛날 서버를 보호하기 위한 하위 호환 장치"다.
text/plain이 safelist인 이유도 <form enctype="text/plain">으로 보낼 수 있었기 때문이다.4. 핸드셰이크 계층 분리 (실측)
L4 TCP 3-way handshake SYN → SYN/ACK → ACK (1 RTT) L5 TLS 1.3 handshake ClientHello → ServerHello... (1 RTT) ──────────────── 여기까지가 "커넥션 핸드셰이크" ──────────────── L7 CORS preflight OPTIONS → 204 (1 RTT) ← 열린 커넥션 위에서 L7 실제 요청 POST → 200 (1 RTT) ← 같은 커넥션 재사용
핵심: preflight는 새 커넥션을 열지 않는다. HTTP/1.1 keep-alive, HTTP/2·3 멀티플렉싱으로 OPTIONS와 실제 요청은 같은 커넥션을 탄다.
레이턴시 실측 (서버에 60ms 인위 지연 삽입)
시나리오 | 왕복 시간 |
simple request (preflight 없음) | 74 ms |
preflighted, Max-Age: 0 | 123 ms |
preflighted, Max-Age: 0 (2회차) | 125 ms |
preflighted, Max-Age: 600 첫 호출 | 126 ms |
같은 요청, 캐시 적중 | 62 ms |
→ preflight = 정확히 +1 RTT, API 레이턴시 2배. 캐시 적중 시 simple request와 동일.
커넥션 수 실측
측정 구간에서 HTTP 요청 9건 / 서버가 관측한 TCP 커넥션 2개. preflight마다 핸드셰이크를 새로 하지 않는다는 직접 증거.
비용 요약
- 레이턴시: +1 RTT (커넥션 셋업 비용 아님)
- 서버 부하: 요청 수 2배 → 서버리스 과금 환경에서 호출 건수 2배 = 비용 2배
- 최초 요청 시: DNS + TCP + TLS(1~2 RTT) + preflight(1 RTT) + 실제(1 RTT)
5. Preflight 판정 알고리즘 (Fetch Standard)
preflight 필요 ⟺ request의 unsafe-request flag가 set OR method가 CORS-safelisted method가 아님 OR CORS-unsafe request-header names가 비어있지 않음
5-1. CORS-safelisted method
GET, HEAD, POST — 이 셋뿐. PUT/PATCH/DELETE는 헤더가 하나도 없어도 무조건 preflight.5-2. CORS-safelisted request-header
헤더 | 조건 |
Accept | CORS-unsafe 바이트 없음 |
Accept-Language | 제한된 문자셋 |
Content-Language | 제한된 문자셋 |
Content-Type | essence가 application/x-www-form-urlencoded, multipart/form-data, text/plain 중 하나 |
Range | 유효한 단일 range ( bytes=256-) |
공통 제약: 값 길이가 128바이트를 넘으면 safelist에서 탈락한다. "왜 단순 GET인데 OPTIONS가 날아가지?"의 숨은 범인이 이 규칙인 경우가 있다.
5-3. Forbidden request-header
JS가 세팅해도 무시되고, preflight 판정 대상에서도 빠진다.
accept-charset, accept-encoding, access-control-request-headers, access-control-request-method, connection, content-length, cookie, cookie2, date, dnt, expect, host, keep-alive, origin, referer, set-cookie, te, trailer, transfer-encoding, upgrade, via + "proxy-" 로 시작하는 모든 헤더 + "sec-" 로 시작하는 모든 헤더
Origin이 여기 있다는 게 포인트다. Origin 헤더는 브라우저 독점이므로 서버가 신뢰할 수 있다 — 단, 브라우저를 거친 요청에 한해서. curl은 얼마든지 위조 가능하다.5-4. unsafe-request flag
메서드와 헤더가 전부 safelist여도 아래면 preflight가 뜬다.
XMLHttpRequest.upload에 이벤트 리스너 등록 (업로드 진행률 표시 코드)
- 요청 body로
ReadableStream사용 (fetch streaming upload)
5-5. 판정 실측
케이스 | JS | 서버가 본 것 |
A | fetch(url) | GET /a — preflight 없음 |
B | POST • text/plain | POST /b — preflight 없음 |
C | POST • application/json | OPTIONS /c (ACRM=POST, ACRH=content-type) → POST /c |
D | GET • Authorization | OPTIONS /d (ACRM=GET, ACRH=authorization) → GET /d |
E | PUT (헤더 없음) | OPTIONS /e (ACRM=PUT, ACRH 없음) → PUT /e |
E가 결정적이다. 커스텀 헤더가 없어
Access-Control-Request-Headers가 아예 붙지 않았는데도, 메서드가 PUT이라는 이유만으로 preflight가 발생했다.6. Preflight 요청 구성 (Chromium 구현)
CreatePreflightRequest()가 만드는 것:method = OPTIONS URL = 원 요청과 동일 (쿼리스트링 포함) Origin = initiator origin (tainted면 빈 origin) Access-Control-Request-Method = 원 요청 method Access-Control-Request-Headers = CorsUnsafeNotForbiddenRequestHeaderNames() → safelist/forbidden 제외, 중복 제거, 바이트 소문자화, **사전순 정렬** 후 콤마 조인 Sec-Fetch-Mode = cors credentials_mode = kOmit ← 쿠키/인증정보 절대 미포함 redirect = error ← "Preflight should not allow any redirect"
실무 직결 3가지:
- preflight에는 쿠키가 실리지 않는다. 세션 쿠키로 인증하는 미들웨어가 OPTIONS를 가로채면 무조건 401. "CORS 설정 다 했는데 왜 안 되지"의 1위 원인.
- preflight는 리다이렉트를 따라가지 않는다. 3xx면 즉시
kPreflightDisallowedRedirect. http→https, 트레일링 슬래시, 로그인 페이지 302가 전부 여기서 죽는다.
- 헤더 이름은 소문자로 정렬 전송된다. 실측 로그도
ACRH=content-type,ACRH=authorization. 서버에서 직접 파싱한다면 대소문자 무시 비교 필수.
7. 응답 검증 알고리즘
- CORS-check —
Access-Control-Allow-Origin이 요청 origin과 일치하거나*. credentials 모드면*불가 +Allow-Credentials: true필요
- status가 ok status(200–299)인가 —
204/200모두 가능.301/401/403/405는 실패
Access-Control-Allow-Methods파싱 → 원 요청 메서드 포함 여부
Access-Control-Allow-Headers파싱 → ACRH의 모든 이름 커버 여부
- 통과 시 preflight 캐시 적재 (
Access-Control-Max-Age기준)
와일드카드 *의 두 예외
- credentials 모드에서는
*가 전부 무효.Allow-Origin/Allow-Methods/Allow-Headers/Expose-Headers모두 명시 나열.
Authorization은Access-Control-Allow-Headers: *로 커버되지 않는다. 스펙상 유일한 CORS-non-wildcard request-header name이다.
# ❌ Authorization 쓰는 요청은 통과 못 함 Access-Control-Allow-Headers: * # ✅ Access-Control-Allow-Headers: *, Authorization
실패 시 관측 결과 (실측)
케이스 H: DELETE 요청, 서버는 Access-Control-Allow-Methods: POST, OPTIONS 만 응답 서버 로그: OPTIONS /method-denied ACRM=DELETE (끝. DELETE는 로그에 없음) JS 결과: TypeError: Failed to fetch
실제 DELETE는 전송조차 되지 않았다. "브라우저는 CORS 에러인데 서버 핸들러 로그가 비어 있다"는 현상의 정체다.
JS가 받는 건
Failed to fetch뿐이다. 어떤 헤더가 왜 거부됐는지 JS는 알 수 없다. 상세는 DevTools 콘솔에만 나온다. 의도된 설계로, 실패 사유가 JS로 새면 그 자체가 크로스 오리진 정보 누출 채널이 된다.8. 실제 요청 단계의 CORS-check — "서버는 이미 실행됐다"
케이스 I: preflight는 정상, 실제 응답에만 Access-Control-Allow-Origin 누락 서버 로그: OPTIONS /actual-missing-acao → 204 (CORS 헤더 정상) POST /actual-missing-acao → 200 (ACAO 없음) ← 실행됨! JS 결과: TypeError: Failed to fetch
POST는 서버에서 실제로 실행됐다. DB에 썼으면 썼다. 브라우저는 응답을 JS에 넘기기를 거부했을 뿐이다.
결론 두 가지:
- OPTIONS 핸들러에만 CORS 헤더를 달면 안 된다. 실제 메서드 응답에도
Access-Control-Allow-Origin이 있어야 한다.
- CORS는 서버 측 보안 장치가 아니다. 크로스 오리진 요청은 서버에 도달해 실행된다. 인증·인가·CSRF 방어는 CORS와 완전히 별개다.
9. Preflight 캐시
캐시 키
Chromium은
AppendToCache(request_initiator_origin, request_url, PreflightResult) 형태로 적재한다. 키는 대략 (요청 origin, 정확한 URL, credentials 모드), 값에 허용 메서드 집합 + 허용 헤더 집합이 들어있다. 최신 Chromium은 캐시 파티셔닝(NetworkIsolationKey)의 영향도 받는다.- URL이 다르면 캐시가 안 먹는다.
/users/1과/users/2는 별개 엔트리 → REST에서 ID별로 URL이 갈리면 적중률이 급락한다.
- 반대로 같은 URL이면 메서드/헤더가 달라도 캐시된 엔트리로 판정 가능하다.
실측
F. /cached (Max-Age: 100) 로 POST json 3회 OPTIONS /cached ← 1회만 POST /cached POST /cached POST /cached G. /uncached (Max-Age: 0) 로 POST json 3회 OPTIONS /uncached POST /uncached OPTIONS /uncached ← 매번 다시 POST /uncached OPTIONS /uncached POST /uncached J. /cached 에 이번엔 Authorization 헤더까지 붙여 POST POST /cached ← preflight 없음!
J가 흥미롭다. F에서
/cached의 preflight 응답이 Access-Control-Allow-Headers: Content-Type, Authorization이었으므로 캐시 엔트리가 이미 authorization을 허용 집합에 포함하고 있었다. 그래서 나중에 Authorization을 추가한 요청도 재-preflight 없이 통과했다. → Allow-Headers를 넉넉히 선언하면 캐시 적중률이 올라간다.Max-Age 상한
브라우저 | 상한 |
Firefox | 86400초 (24시간) |
Chromium v76+ | 7200초 (2시간) |
Chromium v76 이전 | 600초 (10분) |
헤더 없을 때 기본값 | 5초 |
기본값 5초가 핵심이다. 10초 주기로 폴링하는 API는 매번 preflight가 다시 날아간다. 값을 크게 줘도 브라우저 상한에 잘리므로
86400을 박아두면 된다.CDN/프록시 계층
브라우저 캐시와 별개로 OPTIONS 응답 자체를 CDN에서 캐싱할 수 있다. 단
Vary가 정확해야 한다.Cache-Control: public, max-age=86400 Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers
Vary: Origin이 없으면 A 오리진용으로 캐시된 Access-Control-Allow-Origin: https://a.example 응답이 B 오리진에 서빙되어 엉뚱한 CORS 실패가 난다. 오리진을 동적으로 에코백하는 모든 구현에서 Vary: Origin은 필수다.10. Preflight는 "보이지 않는다" (실측 4건)
1) JS에서 가로챌 수 없다. fetch/axios 인터셉터, XHR 후킹 — 어떤 것도 preflight를 보지 못한다. 브라우저 네트워크 스택이 JS 실행 컨텍스트 바깥에서 만든다.
2) 서비스 워커도 못 본다. SW 등록 후 preflight 대상 요청을 보낸 결과:
서버가 본 것: ["OPTIONS /x", "POST /x"] 서비스 워커 fetch 이벤트: [{ method: "POST", mode: "cors" }] ← OPTIONS 없음
→ SW로 오프라인 캐싱을 구현해도 preflight는 무조건 네트워크로 나간다. 오프라인에서 preflight 대상 요청은 SW 캐시로 구제 불가.
3) Resource Timing에 별도 엔트리가 없다. preflight가 있는 요청 1건의
performance.getEntriesByType('resource') 엔트리는 1개였고, duration(124ms)에 preflight 시간이 합산되어 있었다. RUM으로 API 레이턴시를 재면 preflight 비용은 "원인 불명의 추가 지연"으로 섞여 들어온다.4) 서버 로그가 유일하게 신뢰할 수 있는 관측점이다. DevTools Network 탭 필터를
Other/Fetch-XHR로 놓고 OPTIONS 라인을 보거나, 서버 액세스 로그를 직접 보는 게 가장 빠르다.11. credentials 모드
항목 | 규칙 |
preflight 자체 | 여전히 쿠키 없이 전송 ( credentials_mode = omit) |
preflight 응답 | Access-Control-Allow-Credentials: true 필요 |
실제 요청 | 쿠키/인증정보 포함 |
실제 응답 | Access-Control-Allow-Credentials: true 필요 |
Allow-Origin: * | 금지 — 명시적 오리진만 |
Allow-Methods/Allow-Headers/Expose-Headers의 * | 금지 — 명시 나열만 |
추가로 쿠키 자체의 제약이 별개로 걸린다. 크로스 사이트 쿠키는
SameSite=None; Secure가 필요하고 이는 HTTPS를 강제한다. CORS를 정확히 설정해도 SameSite 때문에 쿠키가 안 실리는 케이스가 따로 존재한다.12. 응답 헤더 읽기: Expose-Headers
CORS 응답에서 JS가 기본으로 읽을 수 있는 헤더는 7개뿐이다.
Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, Pragma
Access-Control-Expose-Headers: X-Total-Count, X-RateLimit-Remaining, Location
Location이 의외의 함정이다. 201 Created + Location 패턴의 REST API는 크로스 오리진에서 Location을 못 읽는다.13. 또 다른 preflight: Private / Local Network Access
공격자 사이트가 사용자 브라우저를 경유해 사내망/공유기/localhost를 찌르는 CSRF를 막기 위한 별도 장치.
Private Network Access (PNA) — public → private, private → local 방향:
# 요청 Access-Control-Request-Private-Network: true # 응답 Access-Control-Allow-Private-Network: true
일반 CORS preflight와 결정적 차이:
no-cors 모드, same-origin, simple request에도 전부 발동한다. <img>로 사설 IP를 찌르는 것까지 막아야 하기 때문이다.2026년 현재 방향은 PNA → Local Network Access(LNA) 퍼미션 모델로 이동 중이다. 서버 헤더 opt-in 대신 브라우저가 사용자에게 "이 사이트가 로컬 네트워크의 기기를 찾고 연결하려 합니다" 권한 프롬프트를 띄운다. Chrome 138 플래그 실험(
chrome://flags#local-network-access-check), Chrome 142 기본 활성화 일정. 개발자 입장의 변화는 서버 헤더가 아니라 사용자 권한에 의존하게 된다는 점이며, fetch(url, { targetAddressSpace: 'local' })로 의도를 명시할 수 있다.14. 실전 패턴: Next.js App Router
흔한 미완성 구현
// ⚠️ OPTIONS만 처리하면 실제 POST 응답에서 막힌다 export async function OPTIONS() { return new Response(null, { status: 204, headers: { /* ... */ } }) }
- 실제
POST응답에Access-Control-Allow-Origin없음 → §8 케이스 I
Access-Control-Max-Age없음 → 5초 캐시 → 요청 수 2배 고정
Vary: Origin없음 → CDN/프록시 앞단이면 오염
Allow-Headers에Authorization이 있는데Allow-Methods엔GET이 없으면 토큰 붙은 GET이 탈락
정리된 형태
// app/api/_lib/cors.ts const ALLOWED_ORIGINS = new Set([ 'https://xxx.github.io', ...(process.env.NODE_ENV === 'development' ? ['http://localhost:3000'] : []), ]) export function corsHeaders(req: Request): Headers { const h = new Headers() const origin = req.headers.get('origin') h.set('Vary', 'Origin') // 오리진 에코백 시 필수 if (origin && ALLOWED_ORIGINS.has(origin)) { h.set('Access-Control-Allow-Origin', origin) h.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') h.set('Access-Control-Allow-Headers', 'Content-Type, Authorization') h.set('Access-Control-Expose-Headers', 'X-Total-Count') h.set('Access-Control-Max-Age', '86400') // 쿠키 인증을 쓸 때만 // h.set('Access-Control-Allow-Credentials', 'true') } return h }
// app/api/items/route.ts import { corsHeaders } from '../_lib/cors' export async function OPTIONS(req: Request) { return new Response(null, { status: 204, headers: corsHeaders(req) }) } export async function POST(req: Request) { const body = await req.json() const res = Response.json({ ok: true }) corsHeaders(req).forEach((v, k) => res.headers.set(k, v)) // ← 실제 응답에도 return res }
Next.js 고유 주의점
// middleware.ts — preflight에는 쿠키도 Authorization도 없다 export function middleware(req: NextRequest) { if (req.method === 'OPTIONS') return NextResponse.next() // ... 인증 로직 }
next.config.js의trailingSlash,redirects()가 OPTIONS에도 적용되면 preflight가 3xx를 받고 즉사한다.
headers()로 CORS를 박으면 오리진 화이트리스트를 동적으로 판단할 수 없다. 다중 오리진이면 route handler나 middleware 쪽이 맞다.
Nest.js
app.enableCors({ origin: ['https://xxx.github.io'], methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'], exposedHeaders: ['X-Total-Count'], maxAge: 86400, credentials: true, preflightContinue: false, optionsSuccessStatus: 204, })
함정은 전역
Guard/Interceptor가 OPTIONS에도 돌 수 있다는 점이다. enableCors는 미들웨어 레벨에서 preflight를 가로채지만, 커스텀 미들웨어 순서를 바꿨거나 API Gateway/nginx가 앞단에 있으면 OPTIONS가 인증 가드까지 흘러가 401이 난다.15. 디버깅 순서
CORS 에러 발생 │ ├─ 서버 로그에 OPTIONS가 찍혔나? │ ├─ 안 찍힘 → 요청이 서버까지 못 감 │ │ · 앞단 프록시/WAF가 OPTIONS 차단 │ │ · 3xx 리다이렉트로 죽음 │ │ · 애초에 preflight 대상이 아니었음(다른 원인) │ │ │ └─ 찍힘 → 응답 코드는? │ ├─ 401/403 → 인증 미들웨어가 OPTIONS를 막고 있음 (최빈) │ ├─ 405 → 프레임워크에 OPTIONS 핸들러 없음 │ ├─ 3xx → 리다이렉트. preflight는 절대 안 따라감 │ └─ 2xx → 헤더 내용 확인 │ · Allow-Methods에 실제 메서드 있나? │ · Allow-Headers가 ACRH를 전부 커버하나? │ (Authorization은 * 로 커버 안 됨) │ · Allow-Origin이 Origin과 정확히 일치하나? │ (스킴/포트/트레일링 슬래시까지) │ └─ OPTIONS는 204인데 다음 요청에서 실패 → 실제 메서드 응답에 CORS 헤더 누락 (§8) → credentials인데 Allow-Credentials 없거나 Allow-Origin이 *
16. 체크리스트
정확성
OPTIONS 응답과 실제 메서드 응답 양쪽에
Access-Control-Allow-Origin이 있다오리진을 에코백한다면
Vary: Origin이 있다Allow-Methods에 실제 사용 메서드가 전부 있다Allow-Headers에 커스텀 헤더가 전부 있다 (Authorization 포함, *로 대체 불가)credentials면
Allow-Credentials: true + 명시적 오리진 + 쿠키 SameSite=None; SecureJS에서 읽어야 하는 응답 헤더는
Expose-Headers에 있다인증 미들웨어/가드가 OPTIONS를 통과시킨다
preflight 경로에 리다이렉트가 없다 (트레일링 슬래시 포함)
성능
Access-Control-Max-Age: 86400폴링/연속 호출 엔드포인트의 preflight 캐시 적중을 서버 로그로 확인했다
가벼운 GET이라면
Content-Type: application/json을 떼어 preflight 자체를 피할 수 있는지 검토했다CDN 앞단이면 OPTIONS 응답에
Cache-Control + 정확한 Vary가 있다보안
Access-Control-Allow-Origin을 요청 Origin으로 무조건 에코백하지 않는다오리진 비교를
startsWith/includes로 하지 않는다 (https://evil-xxx.github.io.attacker.com 통과 사고)CORS를 인증/인가 장치로 착각하지 않는다 — 크로스 오리진 요청은 서버에서 실제로 실행된다
CSRF 방어는 CORS와 별개로 존재한다 (SameSite 쿠키, CSRF 토큰, Origin 서버 검증)
17. 재현 결과표
검증 항목 | 결과 |
PUT은 헤더 없이도 preflight | ✅ OPTIONS ... ACRM=PUT (ACRH 없음) |
Content-Type: application/json은 preflight | ✅ ACRH=content-type |
Authorization은 GET에도 preflight | ✅ ACRH=authorization |
Max-Age: 100 → OPTIONS 1회 / POST 3회 | ✅ |
Max-Age: 0 → OPTIONS 3회 / POST 3회 | ✅ |
캐시 엔트리가 허용 헤더 집합을 보관 | ✅ 나중에 붙인 Authorization도 재-preflight 없음 |
preflight 실패 시 실제 요청 미전송 | ✅ 서버 로그에 DELETE 없음 |
preflight 성공 + 실제 응답 CORS 누락 → 서버는 실행됨 | ✅ POST 로그 존재, JS는 에러 |
preflight는 커넥션을 새로 열지 않음 | ✅ 9 요청 / TCP 커넥션 2개 |
서비스 워커는 preflight를 보지 못함 | ✅ SW 이벤트에 POST만 |
Resource Timing에 preflight 별도 엔트리 없음 | ✅ 1 fetch = 1 entry, duration에 합산 |
참고 자료
- Fetch Living Standard (WHATWG) — CORS-preflight fetch, CORS-check, safelist 정의 1차 출처