로그인된 상태로 쇼핑몰에서 결제를 준비하던 중, 이상한 뉴스 사이트 한 곳만 방문했을 뿐인데 장바구니에 엉뚱한 상품이 담기거나 결제가 자동으로 이루어진다면 얼마나 당황스러울까요? 웹 브라우저는 기본 보안 정책으로 서로 다른 도메인 간 스크립트를 제한하지만, 여러 이유로 외부 리소드를 안전하게 받아와야 할 때가 있습니다. 이를 관리하는 것이 CORS(Cross-Origin Resource Sharing)이며, 동시에 사용자의 세션 쿠키를 노리는 악질 공격인 CSRF(Cross-Site Request Forgery) 에 주의해야 합니다.
이번 장에서는 교차 출처 리소스 공유 방법과 의도치 않은 요청이 내 세션을 악용하지 못하도록 차단하는 기법을 차근차근 살펴보겠습니다.
브라우저가 '서로 다른 출처'를 막는 이유는 사용자가 의도하지 않은 데이터 접근이나 조작을 방지하기 위해섭니다. 그러나 때로는 믿을 수 있는 외부 API에서 데이터를 가져와야할 필요가 있습니다. CORS는 그 균형을 맞추는 열쇠입니다.
브라우저가 외부 도메인에 요청을 보내면, 요청 헤더에 Origin:https://example.com 같은 정보를 추가합니다. 서버는 응답 헤더로 Access-Control-Allow-Origin: https://example.com 을 지정해, 이 출처에서 온 요청을 받아들이겠다고 명시하죠. 만약 서버가 허용하지 않은 출처에서 요청이 들어오면, 브라우저가 응답 내용을 차단하고 개발자 도구에 오류를 보여줍니다.
추가로, 복잡한 요청(커스텀 헤더나 비-단순 메서드)을 위해 브라우저는 사진 요청(preflight)이라는 OPTIONS 요청을 먼저 보내기도 합니다. 이때 Access-Control-Allow-Methods, Access-Control-Allow-Headers 등을 통해 허용 범위를 더욱 정교하게 설정할 수 있습니다.
# 클라이언트 요청 예시
GET /api/data HTTP/1.1
Host: api.external.com
Origin: https://myshop.com
# 서버 응답 예시
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://myshop.com
Access-Control-Allow-Methods: GET,POST
Access-Control-Allow-Headers: Content-Type,Authorization
Access-Control-Allow-Credentials: true
Content-Type: application/json
{ "data": [...] }
Origin헤더로 출처를 알리고, 서버가Access-Control-Allow-*헤더로 허용 범위를 명시합니다
💡 개발 시 * 와일드카드를 무분별하게 사용하면 보안이 약해집니다. 꼭 필요한 도메인만 명시하세요.
CSRF 공격은 사용자가 로그인한 세션을 이용해, 공격자가 의도한 동작을 실행하도록 만드는 기술입니다. 예를 들어, <img src="https://bank.example.com/transfer?amount=1000&to=attacker"/> 같은 코드를 몰래 삽입하면, 사용자가 페이지를 방문하는 순간 브라우저가 자동으로 해당 요청을 보냅니다.
이 공격을 막으려면, 서버가 “이 요청이 정말 내 애플리케이션에서 온 것인가?”를 확인해야 합니다. 가장 대표적인 방법이 CSRF 토큰입니다. 로그인 시 서버는 세션과 별도로 긴 무작위 문자열(CSRF 토큰)을 생성해 사용자에게 전달합니다. 사용자가 폼을 제출하거나 AJAX 요청을 보낼 때 이 토큰을 함께 전송하게 하고, 서버는 요청의 토큰과 세션에 저장된 토큰을 비교합니다. 일치하면 정상, 그렇지 않으면 거부하는 것이죠.
또 다른 방법은 SameSite 쿠키 설정입니다. 쿠키에 SameSite=Lax 또는 Strict 속성을 넣으면, 외부 출처에서 자동으로 쿠키가 전송되지 않아 CSRF 공격을 근본적으로 방지할 수 있습니다.
마지막으로, AJAX 요청에는 반드시 커스텀 헤더(X-CSRF-Token 등)를 추가하도록 강제하고, 서버는 해당 헤더가 없으면 요청을 차단하는 전략도 있습니다. 이 방식은 단순 GET 요청으로는 흉내 낼 수 없는 보안 장벽을 만듭니다.
실제 코드 동작 흐름을 보며 더 자세히 알아보도록 하겠습니다.
https://myshop.com에 로그인해 세션 쿠키를 저장합니다.<img> 태그나 <form> 자동 제출 스크립트가 포함되어 있어, 클릭 없이도 다음과 같은 요청이 전송됩니다 <img src="https://myshop.com/api/transfer?amount=1000&to=hacker" style="display:none;" />
로그인 시 서버는 세션에 csrfToken을 저장하고, 페이지 렌더링 시 <input type="hidden" name="csrfToken" value="{token}" /> 형태로 삽입합니다.
서버는 POST/PUT/DELETE 요청 시, 요청 바디나 헤더(X-CSRF-Token)에 담긴 토큰과 세션의 토큰을 비교합니다.
// 서버 측 (Express.js)
app.get('/form', (req, res) => {
const token = generateSecureToken();
req.session.csrfToken = token;
res.render('form', { csrfToken: token });
});
app.post('/transfer', (req, res) => {
if (req.body.csrfToken !== req.session.csrfToken) {
return res.status(403).send('CSRF token mismatch');
}
// 정상 처리 로직
});
SameSite=Lax 또는 Strict 속성을 추가하면, 크로스 사이트 컨텍스트에서 쿠키가 전송되지 않습니다.Set-Cookie: sessionId=XYZ; HttpOnly; Secure; SameSite=Strict
fetch('/api/secure', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
req.get('Origin') 혹은 req.get('Referer')를 확인해, 허용된 도메인이 아니면 요청을 차단합니다.SameSite 속성은 크로스 사이트 요청에서 쿠키 전송을 제어하는 중요한 수단입니다. Strict로 설정하면 외부 링크나 폼 전송을 포함한 모든 크로스 사이트 요청에서 쿠키를 전송하지 않고, Lax는 안전한 GET이나 탭 전환에서만 쿠키를 허용합니다. None을 사용하려면 반드시 Secure 속성을 함께 추가해 HTTPS 환경에서만 동작하도록 해야 합니다.
Strict: 모든 크로스 사이트 요청에서 쿠키 전송 차단 (가장 안전)
Lax: 안전한 HTTP 메서드(GET, HEAD, OPTIONS)만 쿠키 전송 허용
None: 모든 요청에 쿠키 전송 허용 (반드시 Secure와 함께 사용)
한편, Double Submit Cookie 방식은 CSRF 토큰을 쿠키와 요청 본문 또는 헤더에 동시에 포함해 검증합니다. 쿠키에 저장된 토큰과 요청에 담긴 토큰이 일치해야만 서버가 요청을 처리하죠. 이 방법은 서버에 별도 저장소를 두지 않고도 CSRF 방어를 할 수 있는 장점이 있습니다.
// 서버:
const token = generateSecureToken();
res.cookie('csrfToken', token, { SameSite: 'Lax', Secure: true });
res.send({ csrfToken: token });
// 클라이언트:
const token = getCookie('csrfToken');
fetch('/update', {
method: 'POST',
credentials: 'include',
headers: { 'X-CSRF-Token': token }
});
// 서버 검증:
if (req.cookies.csrfToken !== req.get('X-CSRF-Token')) {
return res.status(403);
}
마지막으로, Origin/Referer 헤더 검증도 유용합니다. HTTPS 사이트에서는 브라우저가 Origin 또는 Referer 헤더를 항상 보내므로, 서버는 이 값을 확인해 허용된 도메인에서 왔는지 검사할 수 있습니다. 다만, 프라이버시 모드나 프록시 사용 시 헤더가 누락될 수 있어 보완책이 필요합니다.
app.use((req, res, next) => {
const origin = req.get('Origin') || req.get('Referer');
if (!origin || !origin.startsWith('https://myshop.com')) {
return res.status(403).send('Forbidden');
}
next();
});