CORS 설정 오류(CWE-942): Origin 반사가 데이터를 유출하는 방식과 해결 방법
어떤 Origin이든 그 값을 그대로 넣은 Access-Control-Allow-Origin과 Access-Control-Allow-Credentials: true로 응답하는 API는 모든 웹사이트가 방문자의 쿠키를 실어 응답을 읽게 해 줍니다. 접미사 검사, null 오리진, 반사된 오리진이 동일 출처 정책을 어떻게 무너뜨리는지, 그리고 이를 고치는 정확히 일치하는 허용 목록을 Express와 cors 예제로 설명합니다.
알기 쉬운 설명 (ELI5)
은행 창구 직원은 당신이 보낸 사람에게 잔액을 읽어 주는데, 심부름꾼이 누구를 대신해 왔는지 적힌 편지를 보여 주고 그 이름이 직원의 명단에서 승인될 때만 그렇게 합니다. 어느 직원의 규칙은 '이름이 김으로 끝나면 누구든 승인'입니다. 낯선 사람이 '박김'이라는 편지를 들려 심부름꾼을 보내면 직원은 승인하고, 당신이 보낸 적 없는 사람에게 잔액이 읽혀집니다. Origin 반사는 직원이 모든 이름을 승인하는 더 게으른 방식입니다. 해결책은 글자 하나하나까지 대조하는 짧고 정확한 명단입니다.
핵심 개념 및 용어
- 동일 출처 정책
- 브라우저는 스크립트가 자신의 출처(스킴, 호스트, 포트)의 응답만 읽도록 허용합니다. CORS 응답 헤더는 서버가 지정한 출처에 대해 이 규칙을 의도적으로 완화하는 방법입니다.
- 자격 증명과 함께 쓰는 Access-Control-Allow-Origin
- 응답이
Access-Control-Allow-Origin에 호출자의 출처를 적고Access-Control-Allow-Credentials: true를 보내면, 그 출처의 스크립트는 사용자 쿠키가 실린 요청의 응답을 읽을 수 있습니다. 브라우저는 자격 증명과*의 조합을 거부하므로, 잘못 설정된 서버는 출처를 그대로 돌려줍니다. - Origin 반사와 접미사 비교
Origin헤더를 응답에 복사하면 모든 사이트를 신뢰하게 됩니다.endsWith('example.com')같은 검사, 부분 문자열 비교, 앵커가 없고 점을 이스케이프하지 않은 정규식도https://attacker-example.com을 받아들입니다.- null 오리진
- 샌드박스 iframe,
data:URL, 일부 리디렉션은Origin: null을 보냅니다. 어떤 공격자든 만들어 낼 수 있으므로null을 허용하는 것은 모두를 허용하는 것과 같습니다. - Vary: Origin
Access-Control-Allow-Origin이 요청에 따라 달라지면, 공유 캐시가 한 출처의 CORS 헤더를 다른 출처에 내주지 않도록 응답에Vary: Origin이 있어야 합니다.cors패키지는 동적 출처에 이를 추가합니다.
단계별 공격 실행 흐름
피해자가 API 사이트에 로그인한 상태
브라우저에는 교차 사이트 요청에도 전송되는 api.example.com의 세션 쿠키가 있습니다.
공격자 도메인의 페이지를 엶
스크립트가 fetch('https://api.example.com/api/account', { credentials: 'include' })를 호출합니다. 응답 길이만 콘솔에 찍는 https://attacker-example.com의 페이지가 이 경우를 보여 줍니다.
API가 출처를 승인함
접미사 검사가 attacker-example.com을 받아들이므로, 응답은 Access-Control-Allow-Origin에 그 출처를 넣고 Access-Control-Allow-Credentials: true도 함께 보냅니다.
스크립트가 개인 데이터를 읽음
브라우저는 계정 JSON을 공격자의 스크립트에 넘기고, 스크립트는 이를 어디로든 보낼 수 있습니다. 2016년 James Kettle은 여러 비트코인 거래소에서 이 패턴을 발견했으며, 이를 통해 API 키와 지갑 데이터가 노출되었습니다.
소스 코드 비교: 취약한 구현 vs 보안 패치
// server.js: example.com으로 끝나는 모든 출처와 null이 쿠키와 함께 읽을 수 있다
import express from 'express';
import cors from 'cors';
import { accountFor, requireSession } from './account.js';
const app = express();
// true를 반환하면 cors가 호출자의 Origin을 자격 증명과 함께 반사한다
app.use(cors({
origin: (origin, callback) => callback(null, !origin || origin === 'null' || origin.endsWith('example.com')),
credentials: true,
}));
// 승인된 출처가 읽을 수 있는 개인 계정 데이터
app.get('/api/account', requireSession, (req, res) => {
res.json(accountFor(req.session.userId));
});
// server.js: 정확한 출처 두 개만 허용
import express from 'express';
import cors from 'cors';
import { accountFor, requireSession } from './account.js';
const app = express();
// 스킴과 호스트를 포함한 전체 출처, 정확히 비교
const ALLOWED_ORIGINS = new Set(['https://app.example.com', 'https://admin.example.com']);
// 모르거나 없는 Origin에는 CORS 헤더를 주지 않는다; cors가 Vary: Origin을 추가한다
app.use(cors({
origin: (origin, callback) => callback(null, origin !== undefined && ALLOWED_ORIGINS.has(origin)),
credentials: true,
methods: ['GET', 'POST'],
maxAge: 600,
}));
// 개인 데이터는 공유 캐시에 저장되지 않는다
app.get('/api/account', requireSession, (req, res) => {
res.set('Cache-Control', 'no-store');
res.json(accountFor(req.session.userId));
});
엔지니어링 및 시스템 보안 강화 체크리스트
- 전체 출처(스킴, 호스트, 포트)를 정확히 일치시키는 허용 목록을 두고
Set조회로 비교합니다.Origin을 반사하거나 접미사, 부분 문자열, 앵커 없는 정규식 검사를 쓰지 않습니다. null오리진은 절대 허용하지 않고, 통제하지 않는 출처에는 자격 증명을 허용하지 않습니다.Access-Control-Allow-Origin을 요청마다 정할 때는 항상Vary: Origin을 보내고, 개인 데이터가 담긴 응답에는Cache-Control: no-store를 보냅니다.- 교차 사이트 요청에 세션 쿠키가 실리지 않도록
SameSite=Lax또는Strict로 설정합니다. - 인증이 필요한 라우트에
Origin: https://attacker.example과Origin: null을 보내고,Access-Control-Allow-Origin이 돌아오면 실패하는 CI 테스트를 추가합니다.