안전한 웹 서비스 필수! CSRF(Cross-Site Request Forgery) 공격 방어 가이드
안녕하세요, 안전하고 견고한 웹 서비스를 구축하는 데 관심이 많은 개발자 여러분!
현대의 웹 애플리케이션은 사용자에게 편리함을 제공하지만, 그만큼 다양한 보안 위협에 노출되어 있습니다. 그중에서도 **CSRF(Cross-Site Request Forgery)**는 사용자의 의지와 무관하게 중요 작업을 수행하게 만드는 심각한 공격 유형입니다. 이 글에서는 CSRF 공격이 무엇인지, 어떻게 작동하는지, 그리고 실무에서 어떻게 효과적으로 방어할 수 있는지 자세히 다루어 보겠습니다. 초보 개발자분들도 쉽게 이해할 수 있도록 실제 예시와 함께 설명해 드릴게요.
목차
- CSRF(Cross-Site Request Forgery)란 무엇인가요?
- CSRF 공격의 동작 원리
- CSRF 공격의 위험성
- CSRF 방어 전략
- CSRF Token 사용
- SameSite Cookie 속성
- Referer 검증
- 개발자를 위한 팁: 실무 적용 가이드
- FAQ: 자주 묻는 질문
- 마무리하며
CSRF(Cross-Site Request Forgery)란 무엇인가요?
**CSRF(Cross-Site Request Forgery)**는 사용자가 로그인한 상태에서, 공격자가 만든 악성 웹 페이지를 방문하게 하여 사용자의 의지와 무관하게 특정 웹 사이트의 기능을 실행하도록 유도하는 공격입니다. 즉, “사이트 간 요청 위조”라고 불리며, 사용자가 현재 로그인되어 있는 세션을 악용합니다.
CSRF 공격의 동작 원리
CSRF 공격은 사용자의 브라우저가 특정 웹 사이트에 대해 자동으로 인증 정보를 포함하여 요청을 보낸다는 점을 악용합니다. 사용자가 이미 로그인되어 있다면, 해당 웹사이트로 보내는 모든 요청에는 자동으로 세션 쿠키가 포함됩니다.
CSRF 공격 흐름도
위 이미지는 CSRF 공격의 일반적인 흐름을 보여줍니다.
- **피해자(Victim)**가 정상적인 **은행 웹사이트(Legitimate Banking Site)**에 로그인하여 세션 쿠키를 발급받습니다. 이 쿠키는 피해자의 브라우저에 저장됩니다.
- **공격자(Attacker)**는 피해자를 속여 방문하게 할 **악성 웹사이트(Malicious Site)**를 만듭니다. 이 악성 웹사이트에는 은행 웹사이트로 돈을 이체하는
POST요청을 자동으로 보내는 숨겨진 폼(form)이나 이미지 태그 등이 포함되어 있습니다. 예를 들어,<img src="https://bank.example.com/transfer?account=attacker&amount=1000" style="display:none;" />와 같은 태그로 GET 요청을 유도하거나, 자바스크립트로 숨겨진 폼을 자동 제출합니다. - 피해자가 악성 웹사이트를 방문합니다. 이때 피해자의 브라우저는 여전히 은행 웹사이트에 로그인된 상태입니다.
- 악성 웹사이트의 숨겨진 폼이 자동으로 제출되면서, 피해자의 브라우저는 은행 웹사이트로 계좌 이체 요청을 보냅니다. 이때 브라우저는 이전에 저장된 유효한 세션 쿠키를 자동으로 요청에 포함시킵니다.
- 은행 웹사이트는 이 요청이 피해자로부터 온 유효한 요청으로 오인하여, 실제로 돈을 이체하는 등의 작업을 수행합니다. 피해자는 자신이 의도하지 않은 행동이 실행되었음을 뒤늦게 알게 됩니다.
CSRF 공격의 위험성
CSRF 공격의 위험성은 사용자에게 막대한 금전적, 개인 정보 피해를 줄 수 있습니다.
- 계좌 이체, 비밀번호 변경: 금융 서비스에서 공격자가 지정한 계좌로 돈을 이체하거나, 사용자의 비밀번호를 공격자가 아는 비밀번호로 변경할 수 있습니다.
- 게시글 작성, 댓글 달기: 사용자 계정으로 원치 않는 게시글이나 댓글을 작성하여 평판에 손상을 줄 수 있습니다.
- 정보 변경/삭제: 개인 정보나 게시물 등을 임의로 수정하거나 삭제할 수 있습니다.
- 관리자 권한 남용: 관리자 계정을 탈취하여 시스템 전체에 영향을 미칠 수 있는 중요한 설정을 변경하거나 악성 코드를 배포할 수 있습니다.
CSRF 방어 전략
CSRF 공격을 방어하는 핵심은 **“웹 서버가 요청이 ‘의도된’ 곳에서 왔는지 확인하는 것”**입니다. 단순히 세션 쿠키만으로는 요청의 출처를 신뢰할 수 없습니다.
1. CSRF Token 사용
**CSRF 토큰(CSRF Token)**은 CSRF 방어의 가장 보편적이고 효과적인 방법입니다.
- 사용자가 웹 페이지를 요청할 때, 서버는 랜덤한 CSRF 토큰을 생성하여 세션에 저장하고, 동시에 이 토큰을 HTML 폼의 숨겨진 필드나 HTTP 헤더에 포함시켜 클라이언트에 전달합니다.
- 클라이언트는 데이터를 제출할 때 이 토큰을 함께 서버로 보냅니다.
- 서버는 클라이언트로부터 받은 토큰과 세션에 저장된 토큰이 일치하는지 확인합니다. 일치하지 않으면 해당 요청을 거부합니다.
// 예시: 백엔드(Node.js + Express)에서 CSRF 토큰 사용
// (실제 서비스에서는 helmet, csurf 등의 미들웨어 사용이 일반적이지만, 이해를 위해 직접 구현 예시)
import express from 'express';
import session from 'express-session';
import crypto from 'crypto'; // 랜덤 토큰 생성을 위한 Node.js 내장 모듈
const app = express();
app.use(express.urlencoded({ extended: true })); // form 데이터 파싱
app.use(express.json()); // JSON 데이터 파싱
// 1. 세션 설정 (CSRF 토큰 저장을 위해 필수)
// 실제 서비스에서는 세션 스토어를 Redis 등으로 교체하고, 강력한 비밀 키를 사용해야 합니다.
app.use(session({
secret: 'a_very_secret_key_for_session_encryption', // 실제 서비스에서는 강력한 비밀 키 사용
resave: false, // 세션 데이터가 변경되지 않아도 다시 저장할지 여부
saveUninitialized: true, // 초기화되지 않은 세션도 저장할지 여부
cookie: {
secure: process.env.NODE_ENV === 'production', // 프로덕션 환경에서는 HTTPS에서만 쿠키 전송
httpOnly: true, // JavaScript에서 쿠키 접근 불가 (XSS 방어에도 도움)
maxAge: 1000 * 60 * 60 * 24 // 24시간
}
}));
// 2. CSRF 토큰 생성 및 검증 미들웨어
app.use((req, res, next) => {
// GET, HEAD, OPTIONS 요청은 상태를 변경하지 않으므로 CSRF 검증을 건너뜁니다.
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
return next();
}
// 세션에 CSRF 토큰이 없으면 새로 생성하여 세션에 저장
if (!req.session.csrfToken) {
req.session.csrfToken = crypto.randomBytes(32).toString('hex'); // 32바이트 랜덤 문자열 (16진수)
}
// 클라이언트로부터 받은 토큰 (body 또는 헤더에서)
const clientCsrfToken = req.body._csrf || req.headers['x-csrf-token'];
// 서버 세션의 토큰과 클라이언트 토큰 비교
if (!clientCsrfToken || clientCsrfToken !== req.session.csrfToken) {
console.warn(`CSRF token mismatch. Client: ${clientCsrfToken}, Server: ${req.session.csrfToken}`);
return res.status(403).send('CSRF token mismatch: Request forbidden.');
}
next(); // 토큰이 일치하면 다음 미들웨어로
});
// 3. 로그인 페이지 (CSRF 토큰을 폼에 포함하여 클라이언트에 전달)
app.get('/login', (req, res) => {
const csrfToken = req.session.csrfToken;
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login</title>
<style>body { font-family: sans-serif; padding: 20px; }</style>
</head>
<body>
<h1>Login to Your Bank</h1>
<form action="/transfer" method="POST">
<input type="hidden" name="_csrf" value="${csrfToken}" />
<label for="amount">Amount to Transfer:</label>
<input type="number" id="amount" name="amount" min="1" required />
<button type="submit">Transfer Money</button>
</form>
</body>
</html>
`);
});
// 4. 계좌 이체 API (CSRF 토큰 검증이 미들웨어에서 이미 이루어짐)
app.post('/transfer', (req, res) => {
const { amount } = req.body;
// 이곳에 실제 계좌 이체 로직을 구현합니다.
console.log(`User requested transfer of ${amount} units.`);
res.status(200).send(`Transfer of ${amount} units successful!`);
});
app.listen(3000, () => console.log('Server running with CSRF protection on port 3000'));
- 설명: 서버는
express-session을 사용하여 세션에csrfToken을 저장하고, 클라이언트에게 HTML 폼을 제공할 때 숨겨진 input 필드에 이 토큰을 포함시킵니다. 클라이언트가 데이터를 전송하면 서버는 해당 토큰을 검증하여, 세션에 저장된 토큰과 일치하는지 확인합니다. 공격자는 이 유효한 CSRF 토큰을 알 수 없으므로, 공격자가 만든 악성 페이지에서 전송하는 위조된 요청은 서버에서 거부됩니다.
개발자의 팁: 토큰 관리
CSRF 토큰은 세션마다 고유하게 생성하고, POST, PUT, DELETE와 같이 서버의 상태를 변경하는 요청에만 적용하는 것이 일반적입니다. GET 요청은 데이터 조회 목적이므로 CSRF 토큰을 적용하지 않습니다. csurf와 같은 검증된 미들웨어를 사용하는 것이 직접 구현하는 것보다 안전하고 좋습니다.
2. SameSite Cookie 속성
SameSite 쿠키 속성은 최근 CSRF 방어에 매우 강력하고 효과적인 방법으로 각광받고 있습니다. 브라우저가 크로스 사이트 요청에서 쿠키를 전송할지 여부를 제어합니다. 이는 CSRF 공격 시 브라우저가 인증 쿠키를 전송하지 못하게 함으로써 공격을 무력화합니다.
SameSite=Lax(기본값):GET요청(링크 클릭, 폼GET제출)에는 쿠키를 전송하지만,<img>,<script>태그,fetch()등의 크로스 사이트 요청에는 쿠키를 전송하지 않습니다.- 대부분의 CSRF 공격(주로
POST요청을 위조)을 방어할 수 있으며, 사용자 경험에 미치는 영향이 적어 권장되는 설정입니다.
SameSite=Strict:- 동일 사이트 내 요청에서만 쿠키를 전송합니다. 다른 사이트에서 어떤 방식으로든 링크를 타고 들어와도 초기 요청에는 쿠키를 보내지 않습니다.
- 가장 강력한 보안을 제공하지만, 외부 링크를 통한 로그인, 소셜 로그인 리다이렉션 등에서 문제가 발생할 수 있어 사용 시 신중해야 합니다.
SameSite=None:- 크로스 사이트 요청에도 쿠키를 전송합니다. 이 경우
Secure속성이 필수로 지정되어야 합니다 (HTTPS 환경에서만 동작). - 광고 추적, 외부 위젯 등 특수한 크로스 사이트 쿠키 사용 시에만 제한적으로 사용하며, CSRF 방어에는 도움이 되지 않습니다.
- 크로스 사이트 요청에도 쿠키를 전송합니다. 이 경우
// 예시: Node.js Express에서 SameSite 쿠키 설정
// express-session 미들웨어 설정 시
app.use(session({
secret: 'your_secret_key',
resave: false,
saveUninitialized: true,
cookie: {
secure: process.env.NODE_ENV === 'production