CSRF 방어 (Preventing CSRF)
웹 애플리케이션에서 CSRF(Cross-Site Request Forgery)는 사용자의 의도와 상관없이 공격자가 원하는 요청을 보내게 만드는 보안 취약점이다. 인증된 사용자의 권한을 악용해 위험한 작업을 실행할 수 있어서, 이를 막는 방법은 필수적이다. 이번에는 CSRF 공격이 어떻게 이루어지는지부터 이를 막기 위한 다양한 접근법, 그리고 자바스크립트를 활용한 구체적인 구현까지 단계별로 다뤄보려고 한다.
CSRF를 잘 이해하고 방어하면 사용자의 데이터를 안전하게 지킬 수 있다.
CSRF 공격의 흐름
CSRF는 사용자가 이미 로그인한 상태에서 공격자가 악의적인 요청을 보내는 방식으로 작동한다. 예를 들어, 은행 사이트에 로그인한 사용자가 악성 웹페이지를 방문한다고 해보자. 그 페이지에서 은행 사이트로 돈을 이체하는 요청을 몰래 보내면, 사용자가 인증된 상태이기 때문에 은행은 이를 정상 요청으로 처리한다.
<!-- 악성 웹페이지의 예 -->
<form action="https://bank.com/transfer" method="POST" id="maliciousForm">
<input type="hidden" name="amount" value="1000">
<input type="hidden" name="toAccount" value="attackerAccount">
</form>
<script>
document.getElementById("maliciousForm").submit();
</script>
위 코드는 사용자가 페이지에 들어오자마자 은행으로 요청을 보내는 간단한 공격이다. 인증 쿠키가 자동으로 포함되면서 사용자는 모르게 돈이 이체될 수 있다.
1. Anti-CSRF 토큰으로 막기
CSRF를 막는 가장 흔한 방법은 Anti-CSRF 토큰을 사용하는 것이다. 서버가 고유한 토큰을 생성해서 요청마다 포함시키면, 공격자가 이 토큰을 모르기 때문에 요청이 차단된다.
HTML과 자바스크립트로 구현해보자:
<!-- 서버에서 생성된 토큰을 HTML에 포함 -->
<meta name="csrf-token" content="randomToken123">
<form id="transferForm" action="/transfer" method="POST">
<input type="text" name="amount">
<input type="hidden" name="csrfToken" id="csrfToken">
<button type="submit">이체</button>
</form>
<script>
const token = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
document.getElementById('csrfToken').value = token;
async function submitTransfer(url, data) {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': token
},
body: JSON.stringify(data)
});
return response.json();
}
submitTransfer('/transfer', { amount: 100 })
.then(data => console.log(data))
.catch(error => console.error(error));
</script>
서버는 요청에 포함된 토큰을 확인해서 유효하지 않으면 요청을 거부한다. 이렇게 하면 악성 사이트에서 토큰 없이 보낸 요청은 실패한다.
2. SameSite 쿠키 속성 활용
SameSite 속성은 쿠키가 동일한 사이트에서만 전송되도록 제한한다. 이를 설정하면 외부 사이트에서 시작된 요청에 쿠키가 포함되지 않는다.
서버에서 쿠키를 설정할 때 이렇게 할 수 있다:
// 서버 설정 (예: Express.js)
res.cookie('sessionID', 'abc123', {
sameSite: 'Strict', // 또는 'Lax'
httpOnly: true
});
Strict
는 동일 사이트에서만 쿠키를 보내고, Lax
는 일부 안전한 요청을 허용한다. 자바스크립트로는 쿠키를 직접 설정하지 않지만, 클라이언트에서 확인할 수 있다:
console.log(document.cookie); // SameSite 설정은 보이지 않음
이 속성은 CSRF 방어에 큰 도움이 되지만, 모든 브라우저가 지원하지 않을 수 있으니 추가적인 방법과 함께 사용해야 한다.
3. HTTP Referer 검증
Referer 헤더는 요청의 출처를 알려준다. 서버에서 이를 확인하면 신뢰할 수 없는 출처의 요청을 차단할 수 있다.
클라이언트에서 요청을 보내보자:
async function sendRequest(url) {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: 'test' })
});
return response.json();
}
sendRequest('/action');
서버에서 Referer를 확인하는 로직은 다음과 같다 (예: Node.js):
function verifyReferer(req, res, next) {
const referer = req.get('Referer');
if (!referer || !referer.startsWith('https://myapp.com')) {
return res.status(403).send('Forbidden');
}
next();
}
하지만 Referer는 조작될 수 있거나 브라우저 설정에 따라 누락될 수 있어서, 단독으로 의존하기보다는 보조 수단으로 활용하는 편이 낫다.
4. 사용자 상호작용 추가
중요한 요청 전에 사용자가 직접 확인하도록 요구하면 CSRF를 막을 수 있다. 예를 들어, 비밀번호 재입력 창을 띄워보자:
<form id="secureForm" action="/delete" method="POST">
<input type="password" name="password" placeholder="비밀번호 확인">
<button type="submit">삭제</button>
</form>
<script>
document.getElementById('secureForm').addEventListener('submit', async (e) => {
e.preventDefault();
const password = document.querySelector('input[name="password"]').value;
const response = await fetch('/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
console.log(await response.json());
});
</script>
이렇게 하면 공격자가 사용자의 비밀번호를 모르면 요청을 완료할 수 없다.
5. 자바스크립트로 복잡한 요청 처리
토큰을 동적으로 관리하며 복잡한 요청을 처리해보자:
async function getCsrfToken() {
const response = await fetch('/csrf-token');
const { token } = await response.json();
return token;
}
async function secureRequest(url, data) {
const csrfToken = await getCsrfToken();
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify(data)
});
if (!response.ok) throw new Error('요청 실패');
return response.json();
} catch (error) {
console.error('에러: ' + error.message);
}
}
secureRequest('/update-profile', { name: '홍길동' })
.then(data => console.log(data));
토큰을 서버에서 동적으로 가져와 요청에 포함시켰다. 에러 처리도 추가해서 안정성을 높였다.
6. 여러 방법 조합
단일 방법만으로는 완벽하지 않을 수 있다. Anti-CSRF 토큰과 SameSite를 함께 써보자:
<meta name="csrf-token" content="dynamicToken456">
<script>
async function combinedRequest(url, data) {
const token = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': token
},
body: JSON.stringify(data),
credentials: 'include' // SameSite 쿠키 포함
});
return response.json();
}
combinedRequest('/pay', { amount: 500 })
.then(data => console.log(data));
</script>
서버에서는 SameSite 쿠키와 토큰을 모두 검증하면 더 안전해진다.
7. 공격 시뮬레이션과 방어 확인
CSRF 공격을 시뮬레이션해서 방어가 잘 되는지 확인해보자:
<button id="attackBtn">공격 시뮬레이션</button>
<script>
document.getElementById('attackBtn').addEventListener('click', async () => {
const response = await fetch('/transfer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: 1000 })
});
if (response.status === 403) {
console.log('CSRF 방어 성공: 요청 차단됨');
} else {
console.log('CSRF 방어 실패');
}
});
</script>
토큰이 없으면 서버가 403을 반환하면서 요청이 막힌다. 이를 통해 방어 상태를 점검할 수 있다.
8. 성능과 보안의 균형
CSRF 방어는 보안을 높이지만 성능에도 영향을 줄 수 있다:
- 토큰 생성: 서버에서 매번 토큰을 만들면 부하가 늘어날 수 있다. 세션별로 한 번만 생성하는 식으로 최적화할 수 있다.
- 검증 속도: 토큰 확인 로직이 빠르게 처리되도록 단순하게 유지해야 한다.
Anti-CSRF 토큰과 SameSite를 조합하면 보안과 성능을 모두 잡을 수 있다.
마무리
CSRF는 사용자의 인증을 악용하는 위험한 공격이다. Anti-CSRF 토큰, SameSite 속성, Referer 검증, 사용자 상호작용 등 다양한 방법을 적절히 섞어서 적용하면 웹 애플리케이션을 더 안전하게 만들 수 있다. 자바스크립트를 활용한 동적 처리와 조합까지 다뤄봤으니, 이를 기반으로 보안을 강화해보자.
'코딩 공부 > 자바스크립트' 카테고리의 다른 글
90. 자바스크립트 입력 검증 (Input Validation) (0) | 2025.03.30 |
---|---|
89. 자바스크립트 HTTPS와 보안 헤더 (HTTPS and Security Headers) (1) | 2025.03.30 |
87. 자바스크립트 XSS 방어 (Preventing XSS) (2) | 2025.03.29 |
86. 자바스크립트 렌더링 최적화 (Rendering Optimization) (2) | 2025.03.29 |
85. 자바스크립트 메모리 관리 (Memory Management) (1) | 2025.03.28 |