왜 CORS와 CSP가 자주 문제를 일으키는가
브라우저는 “다른 출처의 리소스 접근”과 “위험한 스크립트 실행”을 강력히 제한합니다. 이때 대표적으로 작동하는 메커니즘이 두 가지입니다.
- CORS(Cross-Origin Resource Sharing): 다른 출처(origin)의 리소스에 접근할 수 있게 해주는 규칙 세트
- CSP(Content-Security-Policy): 어떤 리소스(스크립트, 스타일, 이미지 등)를 어디에서 로드할 수 있는지 제어하는 보안 정책
개발 단계에서는 잘 동작하던 코드가 배포 후 갑자기 막히는 이유가 여기에 있습니다. 예를 들어, 프론트엔드는 app.example.com에서 실행되는데 API는 api.example.com에 있다면, 브라우저는 이를 다른 출처로 간주하고 CORS 규칙을 적용합니다. 또한 개발 중엔 스크립트 인라인 삽입이 편리하지만, 운영 환경에서는 CSP가 이를 차단하여 보안을 강화하려 합니다.
이 글에서는 다음을 다룹니다.
- Access-Control-Allow-Origin과 기타 CORS 헤더를 올바르게 설정하는 법
- 자주 발생하는 CORS 오류의 근본 원인과 해결책
- CSP 위반(특히 스크립트/스타일 관련)의 실전 대응 전략
- 성능과 보안을 모두 잡는 최적화 팁, 운영 체크리스트
CORS 이해의 기본기
같은 출처와 다른 출처
출처(origin)는 스킴, 호스트, 포트의 조합입니다.
- https://app.example.com:443 와 https://api.example.com:443 는 “호스트”가 달라 다른 출처
- https://example.com:443 와 http://example.com:80 은 “스킴”이 달라 다른 출처
- https://example.com:443 와 https://example.com:8443 은 “포트”가 달라 다른 출처
CORS 요청 흐름: 단순 요청 vs 프리플라이트
- 단순 요청(Simple Request): 메서드가 GET/HEAD/POST이며, Content-Type이 application/x-www-form-urlencoded, multipart/form-data, text/plain이고, 커스텀 헤더가 없을 때. 이 경우 사전 OPTIONS 요청 없이 바로 요청을 시도합니다.
- 프리플라이트(Preflight) 요청: 위 조건을 벗어나면 브라우저가 먼저 OPTIONS 요청을 보내 서버가 허용하는지 확인합니다. 서버는 Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers 등을 응답해야 합니다.
핵심 응답 헤더 요약
- Access-Control-Allow-Origin: 허용할 Origin. 예: https://app.example.com 또는 *
- Access-Control-Allow-Credentials: 인증 정보(쿠키, Authorization 헤더 등) 허용 여부. true/false
- Access-Control-Allow-Methods: 허용할 메서드 목록. 예: GET, POST, PUT, DELETE
- Access-Control-Allow-Headers: 허용할 요청 헤더 목록. 예: Content-Type, Authorization
- Access-Control-Expose-Headers: 클라이언트 JS가 읽을 수 있도록 공개할 응답 헤더 목록
- Access-Control-Max-Age: 프리플라이트 결과 캐시 시간(초)
- Vary: Origin 헤더 기반 캐시 분리. CDN/캐시 환경에서 매우 중요
중요: Access-Control-Allow-Origin: * 과 Access-Control-Allow-Credentials: true 는 함께 사용할 수 없습니다.
실전 설정 예제: 서버별 CORS 구성
Node.js(Express)에서 안전하게 설정하기
가장 손쉬운 방법은 cors 미들웨어를 사용하는 것입니다. 다만, “무조건 허용”은 금물입니다. 허용 목록(whitelist)에 기초한 동적 Origin 설정을 권장합니다.
// npm i cors
const express = require('express');
const cors = require('cors');
const app = express();
const whitelist = new Set([
'https://app.example.com',
'https://admin.example.com',
]);
const corsOptionsDelegate = (req, callback) => {
const origin = req.header('Origin');
const isAllowed = origin && whitelist.has(origin);
const options = {
origin: isAllowed, // true면 요청 Origin을 그대로 반영
credentials: isAllowed, // 쿠키 등 인증 허용
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
exposedHeaders: ['X-Request-Id', 'X-RateLimit-Remaining'],
maxAge: 600, // 프리플라이트 10분 캐싱
};
callback(null, options);
};
app.use(cors(corsOptionsDelegate));
app.options('*', cors(corsOptionsDelegate)); // 프리플라이트 처리
app.get('/api/data', (req, res) => {
res.set('Vary', 'Origin'); // 캐시 분리
res.json({ ok: true });
});
app.listen(3000);
포인트:
- 허용 목록 기반 동적 Origin 반영
- Vary: Origin으로 CDN/프락시 캐시 안전성 확보
- credentials가 필요한 경우에만 true
Nginx 리버스 프록시에서 CORS 제어
Nginx는 add_header 지시어로 헤더를 추가합니다. 동적으로 Origin을 반영하려면 map을 사용한 화이트리스트 전략이 유용합니다.
# 허용 Origin 맵
map $http_origin $cors_origin {
default "";
"~^https://app\.example\.com$" $http_origin;
"~^https://admin\.example\.com$" $http_origin;
}
server {
listen 443 ssl;
server_name api.example.com;
location / {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET,POST,PUT,PATCH,DELETE,OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type,Authorization,X-Requested-With" always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Access-Control-Max-Age 600 always;
add_header Vary "Origin" always;
return 204;
}
proxy_pass http://upstream_backend;
# 실제 응답에도 헤더 적용
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Vary "Origin" always;
# 필요한 경우 노출 헤더
add_header Access-Control-Expose-Headers "X-Request-Id,X-RateLimit-Remaining" always;
}
}
주의:
- add_header는 기본적으로 성공(2xx, 3xx, 4xx, 5xx)에 동작 범위 제한이 있을 수 있으므로 always 옵션 사용
- 프리플라이트 OPTIONS는 204로 빠르게 응답
- whitelist에 맞지 않으면 Access-Control-Allow-Origin은 비어 CORS가 불허됨
Apache(.htaccess 또는 vhost) 설정
# 허용 Origin 화이트리스트
SetEnvIfNoCase Origin "^https://app\.example\.com$" ORIGIN_OK=1
SetEnvIfNoCase Origin "^https://admin\.example\.com$" ORIGIN_OK=1
Header always set Vary "Origin"
# 프리플라이트 요청 처리
<IfModule mod_headers.c>
<Location "/">
Header always set Access-Control-Allow-Origin "%{Origin}e" env=ORIGIN_OK
Header always set Access-Control-Allow-Credentials "true" env=ORIGIN_OK
Header always set Access-Control-Expose-Headers "X-Request-Id,X-RateLimit-Remaining" env=ORIGIN_OK
</Location>
RewriteEngine On
RewriteCond %{REQUEST_METHOD} =OPTIONS
RewriteRule ^(.*)$ - [R=204,L]
Header always set Access-Control-Allow-Methods "GET,POST,PUT,PATCH,DELETE,OPTIONS"
Header always set Access-Control-Allow-Headers "Content-Type,Authorization,X-Requested-With"
Header always set Access-Control-Max-Age "600"
</IfModule>
S3/CloudFront에서 정적 파일 CORS
S3 버킷 CORS 설정 예:
<CORSConfiguration>
<CORSRule>
<AllowedOrigin>https://app.example.com</AllowedOrigin>
<AllowedMethod>GET</AllowedMethod>
<AllowedHeader>*</AllowedHeader>
<ExposeHeader>ETag</ExposeHeader>
<MaxAgeSeconds>600</MaxAgeSeconds>
</CORSRule>
</CORSConfiguration>
CloudFront를 사용할 경우 Origin Response에서 헤더가 제거되지 않도록 확인하고, 캐시 키와 Vary: Origin 처리(Origin Request Policy, Cache Policy)도 조정하세요.
프런트엔드 관점: 올바른 요청 만들기
쿠키/세션과 함께 쓰는 CORS
크로스 사이트 쿠키를 사용하려면 다음이 모두 맞아야 합니다.
- 서버: Set-Cookie에 SameSite=None; Secure
- 클라이언트: fetch 또는 XHR에서 credentials: 'include'
- 서버 응답: Access-Control-Allow-Credentials: true
- 서버 응답: Access-Control-Allow-Origin은 와일드카드(*) 금지, 호출 Origin을 명시 또는 반영
예:
// 쿠키를 포함한 요청
await fetch('https://api.example.com/profile', {
method: 'GET',
credentials: 'include',
headers: { 'Accept': 'application/json' },
});
서버 응답 예:
- Access-Control-Allow-Origin: https://app.example.com
- Access-Control-Allow-Credentials: true
- Vary: Origin
프리플라이트를 줄이는 팁
- 가능한 단순 요청 규칙을 활용: Content-Type을 application/json 대신 text/plain으로 바꾸는 건 선호되지 않지만, 실제로는 JSON이 표준이므로 서버가 프리플라이트를 잘 지원하도록 하되, 커스텀 헤더 최소화가 더 현실적입니다.
- 커스텀 헤더 줄이기: X-Requested-With, X-Client-Version 같은 헤더를 꼭 필요한 경우에만 사용
- Access-Control-Max-Age 확대: 수백~수천 초로 설정해 프리플라이트 빈도 감소(브라우저별 상한 고려)
자주 보이는 실수와 위험 패턴
- Access-Control-Allow-Origin: * + Allow-Credentials: true 조합 → 브라우저가 차단
- 모든 Origin을 무차별 반사(reflect) → 오용 시 데이터 탈취 위험. 화이트리스트로 제한
- Access-Control-Allow-Headers: * 로 모두 허용 → 필요 최소한으로 제한
- 301/302 리다이렉트 응답에는 CORS 헤더 누락 → 리다이렉트 체인 전체에 헤더가 필요할 수 있음
- CDN 캐시가 Origin별로 분리되지 않음 → Vary: Origin 누락으로 엉뚱한 헤더가 섞여 오류 발생
디버깅 워크플로우: 빨리 원인 찾기
-
브라우저 DevTools Network 탭에서 실패한 요청 선택
- Headers → Request Headers에서 Origin과 Access-Control-Request-Headers, Access-Control-Request-Method 확인
- Response Headers에서 Access-Control-Allow-XXX 헤더 확인
- 프리플라이트(OPTIONS) 요청/응답을 같이 확인
-
콘솔 오류 메시지 해석
- “No ‘Access-Control-Allow-Origin’ header” → 서버에서 해당 헤더 미설정 또는 Origin 미허용
- “The value of the ‘Access-Control-Allow-Origin’ header in the response must not be the wildcard... when the request’s credentials mode is ‘include’” → *와 credentials 충돌
- “Request header field X is not allowed by Access-Control-Allow-Headers” → 서버 허용 헤더에 해당 값 추가 필요
-
curl로 프리플라이트 재현
curl -i -X OPTIONS https://api.example.com/resource \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type,Authorization"
- 프록시/CDN 확인
- 리다이렉트 응답/오류 응답에도 CORS 헤더가 전달되는지
- 캐시 계층이 Vary: Origin을 존중하는지
- 특정 지역/엣지에서만 발생하는지
CSP 이해와 전략: XSS와 공급망 위험을 줄이는 방법
CSP는 “어떤 리소스를 어디서 로드할 수 있는지”를 헤더 또는 meta 태그로 정의합니다. 잘 설계하면 XSS와 서드파티 스크립트 리스크를 크게 낮출 수 있습니다.
배포 방식
- HTTP 헤더 권장: Content-Security-Policy: ...
- 점진 도입: Content-Security-Policy-Report-Only 헤더로 먼저 위반 리포트만 수집
핵심 디렉티브 요약
- default-src: 기본 리소스 소스
- script-src: 스크립트 로드/실행 출처와 실행 제약(’nonce-...’, ’sha256-...’, ’strict-dynamic’)
- style-src: 스타일 로드/인라인 제약
- img-src, font-src, connect-src(Fetch/XHR/WebSocket), media-src, frame-src 등
- frame-ancestors: 어떤 부모 프레임이 이 페이지를 embed할 수 있는지
- upgrade-insecure-requests: http 리소스를 https로 자동 업그레이드
- block-all-mixed-content: 혼합 콘텐츠 전면 차단
- report-to / report-uri: 위반 보고 경로
- require-trusted-types-for 'script': Trusted Types 적용으로 DOM XSS 방어
안전한 스크립트 실행: nonce와 hash
인라인 스크립트를 허용하지 않는 것이 기본입니다. 대신 nonce 또는 해시 기반 허용을 사용합니다.
서버에서 난수 nonce를 매 요청 생성:
- 응답 헤더: Content-Security-Policy: script-src 'self' 'nonce-r4nd0m'; object-src 'none'; base-uri 'self'
- HTML:
<script nonce="r4nd0m">
// 이 인라인 스크립트는 허용됨
window.APP_CONFIG = { env: "prod" };
</script>
<script src="/static/app.js" nonce="r4nd0m"></script>
해시를 사용하는 경우:
<!-- 인라인 스크립트의 SHA-256 해시를 계산해 정책에 추가 -->
<!-- CSP: script-src 'self' 'sha256-AbCdEf...' -->
<script>
console.log('trusted inline');
</script>
strict-dynamic와의 조합:
- script-src 'self' 'nonce-xyz' 'strict-dynamic'
- nonce가 부여된 스크립트가 동적으로 추가하는 다른 스크립트도 신뢰
주의:
- 'unsafe-inline'은 가능한 피하십시오. 불가피할 경우 점진적으로 제거 계획 수립
- 라이브러리/광고/태그 매니저 등 서드파티 스크립트는 공급망 리스크가 큼. 출처를 최소화하고 SRI(Subresource Integrity)와 CSP를 병행
스타일 처리: style-src와 인라인 CSS
- style-src 'self' 'nonce-...' 또는 'sha256-...' 방식 권장
- 스타일 인라인 사용이 많은 프레임워크(예: 스타일 속성)에서는 'unsafe-inline'을 임시 허용 후 점진 제거. 가능한 CSS 파일로 분리
- 폰트, 아이콘은 font-src, img-src에 별도 출처 추가
네트워크 연결 제어: connect-src
프런트엔드에서 API 호출/웹소켓을 허용하려면 해당 출처를 connect-src에 명시해야 합니다.
예:
- connect-src 'self' https://api.example.com wss://ws.example.com
엄격한 CSP 예시(실전 템플릿)
아래 정책은 보안 우선의 예시입니다. 프로젝트에 맞게 출처를 조정하세요.
Content-Security-Policy:
default-src 'none';
script-src 'self' 'nonce-{RANDOM}' 'strict-dynamic';
style-src 'self' 'nonce-{RANDOM}';
img-src 'self' data: https://images.examplecdn.com;
font-src 'self' https://fonts.gstatic.com;
connect-src 'self' https://api.example.com https://telemetry.example.com;
frame-ancestors 'self';
base-uri 'self';
form-action 'self';
object-src 'none';
upgrade-insecure-requests;
report-to csp-endpoint; report-sample
report-to 그룹 정의 예:
Report-To: {"group":"csp-endpoint","max_age":10886400,"endpoints":[{"url":"https://report.example.com/csp"}]}
운영 팁:
- 먼저 Report-Only로 배포하여 실제 위반 로그를 수집
- 위반이 많은 규칙부터 완화/수정
- 도메인 와일드카드 사용 시 범위를 최소화(예: https://*.trustedcdn.com)
CSP 위반 사례와 해결
- 오류: Refused to load the script because it violates the following Content Security Policy directive: "script-src 'self'".
- 해결: 스크립트 출처를 script-src에 추가하거나 nonce/hash를 부여
- 오류: Refused to connect to https://api.example.com because it violates connect-src.
- 해결: connect-src에 해당 출처 추가
- 오류: Refused to apply inline style...
- 해결: style-src에 nonce/hash 추가, 인라인 스타일 제거/외부화
- 오류: ‘frame-ancestors’가 지정돼 외부 도메인이 embed 불가
- 해결: 필요한 경우 frame-ancestors에 허용 도메인 추가. X-Frame-Options 대신 frame-ancestors 권장
CORS와 CSP의 시너지: 추가 보안 헤더와 교차 격리
고급 기능(SharedArrayBuffer 등)을 사용하거나 공급망·클릭재킹 리스크를 낮추려면 다음 헤더와 함께 고려하세요.
- COOP(Cross-Origin-Opener-Policy): same-origin
- COEP(Cross-Origin-Embedder-Policy): require-corp
- CORP(Cross-Origin-Resource-Policy): same-site 또는 same-origin
교차 출처 격리(Cross-Origin Isolation)를 위해서는 COOP + COEP 조합이 필요하며, 이때 외부 리소스가 CORP 또는 CORS를 통해 임베드 가능해야 합니다.
기타 보안 헤더:
- Referrer-Policy: no-referrer, strict-origin-when-cross-origin 등
- Permissions-Policy: 카메라, 마이크, 지오로케이션 등 기능 권한 제어
- X-Content-Type-Options: nosniff
- Strict-Transport-Security(HSTS): HTTPS 강제
또한 Fetch Metadata 헤더(Sec-Fetch-Site, Sec-Fetch-Mode, Sec-Fetch-Dest)를 이용해 CSRF 방어를 강화할 수 있습니다. 예를 들어 cross-site에서 오는 state-changing 요청을 차단:
// Express 예시: cross-site POST 차단(예시 로직)
app.use((req, res, next) => {
const site = req.header('Sec-Fetch-Site');
if (req.method !== 'GET' && site && site !== 'same-origin' && site !== 'same-site') {
return res.status(403).send('Cross-site state-changing requests are not allowed');
}
next();
});
주의: CORS는 인증/인가가 아닙니다. CSRF 토큰, SameSite 쿠키, Fetch Metadata 기반 방어를 함께 적용하세요.
운영 환경에서 흔한 함정과 우회로
- 301/302 리다이렉트: 리다이렉트 응답 단계에서 CORS 헤더가 누락되면 브라우저가 이어지는 요청을 차단할 수 있습니다. 리다이렉트도 헤더를 포함하거나 리다이렉트를 제거/정상화하세요.
- CDN/캐시: Access-Control-Allow-Origin이 요청 Origin에 따라 달라질 때 Vary: Origin을 반드시 추가. 그렇지 않으면 다른 Origin 요청에 잘못된 헤더가 섞여 반환됩니다.
- file://, sandboxed iframe, data URL: Origin이 null로 표시됩니다. null Origin 허용이 필요한 경우 매우 신중하게 접근하세요.
- 폰트/Canvas: 교차 출처 폰트 로딩, 캔버스에 이미지를 그린 후 toDataURL 호출 시 CORS 설정이 필요합니다.
- WebSocket: 전통적인 CORS와 다르게 처리되지만, 업그레이드 전에 Origin 검사로 임의 도메인의 연결을 거부해야 합니다.
- 사파리/모바일 브라우저: 프리플라이트 캐시 동작과 SameSite 처리에서 브라우저 간 차이가 있습니다. 실제 대상 브라우저에서 테스트하세요.
- Postman/서버-서버 통신: CORS는 브라우저 보안 모델입니다. Postman에서는 오류가 보이지 않을 수 있으니, 브라우저 테스트가 필수입니다.
단계별 해결 전략: CORS
- 요구사항 명세
- 어떤 프런트엔드 출처가 어떤 API 경로에 접근해야 하는가?
- 쿠키/세션 또는 Authorization 헤더가 필요한가?
- 서버 구성
- 화이트리스트 기반으로 Access-Control-Allow-Origin 동적 설정
- credentials가 필요한 경우에만 허용
- 필요한 메서드/헤더만 최소 허용
- 프리플라이트에 Max-Age 적용
- 캐시/프록시 조정
- Vary: Origin 추가
- CDN Cache Policy, Origin Request Policy 검사
- 테스트
- DevTools로 OPTIONS/실제 요청 모두 확인
- 다양한 브라우저에서 시나리오별 점검
- 모니터링
- 서버 로그에 Origin, 요청 헤더 기록
- 에러 추적 도구에 CORS 실패 이벤트 수집
CORS 문제 해결 체크리스트
- Access-Control-Allow-Origin이 정확한 Origin을 반환하는가?
- Allow-Credentials와 Origin 와일드카드(*)를 함께 쓰지 않는가?
- 프리플라이트 응답에 Allow-Methods/Allow-Headers/Max-Age가 설정되었는가?
- 리다이렉트/오류 응답에도 CORS 헤더가 유지되는가?
- Vary: Origin이 설정되어 캐시 오염을 방지하는가?
- 쿠키 사용 시 SameSite=None; Secure, credentials: 'include'가 일치하는가?
단계별 해결 전략: CSP
- 리소스 인벤토리 작성
- 어떤 스크립트/스타일/이미지/폰트/연결이 필요한가?
- Report-Only로 시작
- 기존 서비스에 즉시 강제 적용하지 말고 보고만 수집
- 위반 로그 분석
- 우선순위: script-src → connect-src → style-src → 그 외
- 인라인 스크립트/스타일을 nonce/hash로 전환
- 점진적 강화
- 'unsafe-inline' 제거, 'strict-dynamic' 도입
- object-src 'none', base-uri 'self' 적용
- frame-ancestors로 임베드 제한
- 자동화
- 빌드 시 nonce 자동 삽입, 해시 계산 파이프라인 구성
- CI에서 CSP Evaluator 같은 도구로 정적 검증
CSP 문제 해결 체크리스트
- 정책을 헤더로 배포하고 있는가? (헤더 권장)
- Report-Only로 충분한 기간 로그를 수집했는가?
- 인라인 스크립트/스타일에 nonce 또는 해시를 적용했는가?
- script-src에 서드파티는 최소화했는가? SRI 병행했는가?
- connect-src에 필요한 API/WS 출처를 모두 명시했는가?
- frame-ancestors로 클릭재킹을 차단하고 있는가?
- report-to/report-uri로 위반을 수집하고 있는가?
액션 가능한 베스트 프랙티스 모음
-
CORS
- 서버에서 Origin 화이트리스트 → 동적 반영
- credentials는 필요한 엔드포인트에만
- 프리플라이트 응답 최적화: Max-Age, 최소 헤더
- 모든 응답 경로(리다이렉트/에러 포함)에 동일한 CORS 정책 적용
- CDN과 캐시 환경에서 Vary: Origin 준수
-
CSP
- Report-Only로 시작, 점진 강화
- nonce/hash 기반 실행, 'unsafe-inline' 제거
- script-src 'strict-dynamic'로 동적 로드 제어
- object-src 'none', base-uri 'self', frame-ancestors 설정
- 서드파티 스크립트 최소화, SRI 활용
-
추가 보안
- COOP/COEP/CORP로 교차 출처 격리
- Fetch Metadata 기반 CSRF 방어
- HSTS, Referrer-Policy, Permissions-Policy, X-Content-Type-Options
실전 Q&A: 자주 묻는 질문
-
Q: 프리플라이트가 자주 발생해 느립니다. 어떻게 줄일 수 있나요?
- A: Access-Control-Max-Age를 늘리고, 커스텀 헤더를 줄이며, 불필요한 메서드 사용을 피하세요. 가능하다면 동일 출처 프록시를 도입해 프런트엔드와 API를 같은 도메인(서브경로)로 노출하는 것도 방법입니다.
-
Q: 모든 Origin을 허용하면 편한데, 보안상 정말 문제인가요?
- A: 데이터가 민감하지 않은 정적 리소스라면 상대적으로 위험이 낮을 수 있지만, 인증 정보가 개입되거나 사용자 데이터가 있다면 악성 사이트에서 사용자의 브라우저를 통해 API를 호출하는 우회 경로가 됩니다. 반드시 제한하세요.
-
Q: CSP가 너무 엄격해 기능이 깨집니다. 타협점은?
- A: Report-Only로 데이터 수집 → 필요한 출처만 허용 → 인라인 스크립트를 nonce/hash로 점진 전환의 순서로 진행하세요. 운영 초기에 한시적으로 완화하되 기한을 정해 강화하세요.
-
Q: COEP require-corp를 켰더니 외부 리소스가 안 로드됩니다.
- A: 해당 리소스 서버가 CORP 또는 CORS 응답을 제공해야 합니다. 제3자 리소스라면 사용 가능 여부를 확인하거나 대체 CDN/호스팅을 검토해야 합니다.
-
Q: 개발 환경에서는 되고, 운영에서는 안 됩니다.
- A: 운영에서만 활성화된 CDN/리다이렉트/보안 헤더가 원인일 가능성이 큽니다. 네트워크 경로를 동일하게 맞추고, 각 단계의 응답 헤더를 모두 점검하세요.
마이그레이션 예시: 레거시 서비스 개선 로드맵
-
현황 파악
- 네트워크 캡처로 CORS 오류 유형 분류
- CSP는 Report-Only 도입 후 1~2주 위반 데이터 수집
-
빠른 안정화
- CORS 화이트리스트 적용, 리다이렉트/에러 응답까지 헤더 일관성 확보
- 프리플라이트 최적화와 캐시 헤더(Vary: Origin) 교정
-
보안 강화 1단계
- CSP에서 object-src 'none', base-uri 'self', frame-ancestors 'self'
- script-src/style-src에 nonce 도입, 주요 인라인 제거
-
보안 강화 2단계
- script-src 'strict-dynamic' 도입
- 서드파티 스크립트 정리, SRI 적용
- COOP/COEP/CORP 검토 및 Fetch Metadata 정책 적용
-
자동화/운영
- CI/CD 파이프라인에서 CSP/헤더 테스트
- 모니터링/알림(보고 엔드포인트) 구축
- 분기/릴리즈마다 정책 재평가
마무리: 보안과 DX(개발자 경험)의 균형 잡기
CORS와 CSP는 때로 번거롭지만, 올바른 설계와 자동화로 개발 속도를 해치지 않으면서도 강력한 보호막을 제공합니다. 핵심은 “최소 권한” 원칙과 “점진 도입”입니다. CORS는 필요한 출처와 헤더만 허용하고, CSP는 Report-Only로 시작해 실사용 패턴에 맞게 엄격도를 높이세요. CDN/프록시, 리다이렉트, 브라우저별 차이 같은 운영 현실을 감안해 테스트와 모니터링을 체계화하면, “한 번 설정하고 오래 가는” 안정적인 보안 기반을 구축할 수 있습니다.
지금 바로 다음 액션을 실행해 보세요.
- 서버에 Vary: Origin 추가 및 화이트리스트 기반 CORS 적용
- CSP Report-Only 도입 후 1주간 위반 수집
- 빌드 파이프라인에 nonce/hash 자동화 스텝 추가
이 세 가지만 해도 CORS와 CSP 관련 오류의 80% 이상이 사라지고, 보안 수준은 확실히 한 단계 올라갑니다.