● CWE-942 · OWASP A05:2021

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 패키지는 동적 출처에 이를 추가합니다.

단계별 공격 실행 흐름

단계 1

피해자가 API 사이트에 로그인한 상태

브라우저에는 교차 사이트 요청에도 전송되는 api.example.com의 세션 쿠키가 있습니다.

단계 2

공격자 도메인의 페이지를 엶

스크립트가 fetch('https://api.example.com/api/account', { credentials: 'include' })를 호출합니다. 응답 길이만 콘솔에 찍는 https://attacker-example.com의 페이지가 이 경우를 보여 줍니다.

단계 3

API가 출처를 승인함

접미사 검사가 attacker-example.com을 받아들이므로, 응답은 Access-Control-Allow-Origin에 그 출처를 넣고 Access-Control-Allow-Credentials: true도 함께 보냅니다.

단계 4

스크립트가 개인 데이터를 읽음

브라우저는 계정 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));
});

엔지니어링 및 시스템 보안 강화 체크리스트

출처

← 전체 보안 디렉터리 보기 모든 취약점 가이드 →