Node.js 웹 앱을 동기화 토큰으로 CSRF 공격으로부터 강화하기
Olivia Novak
Dev Intern · Leapcell

강력한 Node.js 웹 애플리케이션 구축: CSRF 방어를 위한 동기화 토큰 패턴
디지털 환경은 끊임없이 변화하는 전장이며, 개발자들은 안전하고 신뢰할 수 있는 애플리케이션을 구축하기 위해 끊임없이 노력하고 있습니다. 웹 개발 영역에서 자주 간과되는 하나의 만연한 위협은 사이트 간 요청 위조(CSRF)입니다. 이 교활한 공격은 인증된 사용자가 의도하지 않게 웹 애플리케이션에서 원치 않는 작업을 실행하도록 속여 데이터 조작, 무단 거래 또는 계정 탈취로 이어질 수 있습니다. Node.js 웹 애플리케이션의 경우, 응답성과 효율성이 가장 중요하게 여겨지는 곳에서 CSRF 취약점을 해결하는 것은 단순한 모범 사례가 아니라 사용자 신뢰와 데이터 무결성을 유지하기 위한 근본적인 요구 사항입니다. 이 글에서는 CSRF에 대한 강력하고 널리 채택된 방어 메커니즘인 동기화 토큰 패턴에 대해 알아보겠습니다. 이 패턴의 원칙을 분석하고, Node.js 환경에서의 구현을 시연하며, 이 일반적인 위협으로부터 웹 애플리케이션을 어떻게 강화하는지 보여줄 것입니다.
CSRF 보호의 핵심 기둥 이해하기
동기화 토큰 패턴의 자세한 내용에 들어가기 전에 CSRF 공격과 그 방어를 뒷받침하는 몇 가지 핵심 개념을 간략하게 명확히 하겠습니다.
- 사이트 간 요청 위조(CSRF): 웹 애플리케이션이 신뢰하는 사용자가 무단 명령을 전송하는 일종의 악성 악용입니다. 공격자는 사용자의 활성 세션과 쿠키를 활용하여 취약한 웹 애플리케이션으로 합법적인 요청을 보내도록 사용자의 브라우저를 속입니다.
- 동일 출처 정책(SOP): 웹 브라우저가 시행하는 중요한 보안 메커니즘입니다. 이는 웹 브라우저가 첫 번째 웹 페이지에 포함된 스크립트가 두 번째 웹 페이지에 포함된 스크립트의 데이터에 액세스할 수 있도록 허용하는 데, 두 웹 페이지가 동일한 출처(프로토콜, 호스트 및 포트)를 공유하는 경우에만 해당합니다. SOP는 많은 사이트 간 상호 작용을 효과적으로 차단하지만, CSRF는 다른 출처로부터 요청을 보내고 대상 도메인에 합법적인 쿠키를 첨부하도록 브라우저에 의존함으로써 SOP의 한계를 악용합니다.
- 무상태(Stateless) vs. 상태 유지(Stateful) 인증: 무상태 시스템에서는 요청 간에 서버 측에 세션 정보가 저장되지 않습니다(예: JWT). 상태 유지 시스템에서는 서버에서 세션 정보가 유지됩니다(예: 기존 세션 쿠키). CSRF 공격은 종종 쿠키가 요청과 함께 자동으로 전송되는 상태 유지 시스템을 대상으로 합니다.
- 동기화 토큰 패턴: CSRF에 대한 방어 메커니즘입니다. 이는 서버 측 상태를 변경하는 각 HTTP 요청(예: 숨겨진 폼 필드 또는 사용자 정의 헤더)에 무작위로 생성된 암호화적으로 안전한 토큰을 포함하는 것을 포함합니다. 그런 다음 서버는 요청을 처리하기 전에 이 토큰의 존재와 유효성을 확인합니다.
작동하는 동기화 토큰 패턴
동기화 토큰 패턴은 간단하지만 효과적인 원칙을 기반으로 작동합니다. 즉, 상태를 변경하는 각 요청에는 고유한 서버 생성 토큰이 동반되어야 합니다. 이 토큰은 사용자 세션과 연결되며 서버는 요청을 처리하기 전에 이를 검증합니다. 악의적인 공격자는 합법적인 사용자 세션에서 이 고유 토큰을 예측하거나 얻을 수 없으므로 위조된 요청에는 유효한 토큰이 없어 거부될 것입니다.
이 패턴은 일반적으로 다음과 같은 단계를 포함합니다.
- 토큰 생성: 사용자가 상태 변경 작업을 시작하는 폼이나 페이지를 요청하면 서버는 고유하고 예측 불가능한 CSRF 토큰을 생성합니다. 이 토큰은 종종 암호화적으로 임의이며 무차별 대입 추측을 방지하기에 충분히 길어야 합니다.
- 토큰 연결 및 포함: 생성된 토큰은 사용자 활성 세션과 연결됩니다(예:
express-session을 사용하는 경우req.session에 저장됨). 또한 HTML 폼에 숨겨진 입력 필드로 포함되거나 AJAX 요청의 경우 사용자 지정 HTTP 헤더로 전송됩니다. - 토큰 제출: 사용자가 폼을 제출하거나 AJAX 요청을 보내면 CSRF 토큰은 다른 요청 매개변수와 함께 서버로 전송됩니다.
- 토큰 유효성 검사: 요청을 받은 후 서버는 요청에서 토큰을 검색하여 사용자의 세션에 저장된 토큰과 비교합니다. 토큰이 일치하면 요청이 합법적인 것으로 간주되고 처리됩니다. 일치하지 않거나 토큰이 누락된 경우 요청은 CSRF 시도로 간주되고 거부됩니다.
Express를 사용한 Node.js에서의 실제 구현
간단한 Node.js Express 애플리케이션을 사용하여 이를 설명해 보겠습니다. 세션 관리를 위해 express-session을 사용하고 CSRF 토큰 생성 및 유효성 검사를 위해 사용자 지정 미들웨어를 사용합니다.
먼저 필요한 패키지가 설치되었는지 확인하십시오.
npm install express express-session csurf
csurf는 이를 단순화하는 인기 있는 패키지이지만, 기본 메커니즘을 명확하게 시연하기 위해 간단한 수동 구현을 만들어 보겠습니다.
1. 서버 설정 (app.js):
const express = require('express'); const session = require('express-session'); const bodyParser = require('body-parser'); const crypto = require('crypto'); const app = express(); const port = 3000; // 세션 미들웨어 구성 app.use(session({ secret: 'a_very_secret_key_for_session_encryption', // 프로덕션에서는 강력하고 무작위적인 키 사용 resave: false, saveUninitialized: true, cookie: { secure: false, // HTTPS를 사용하는 경우 true로 설정 httpOnly: true, // 클라이언트 측 JavaScript 액세스 방지 maxAge: 3600000 // 1시간 } })); // URL-인코딩된 본문 파싱 (폼 제출용) app.use(bodyParser.urlencoded({ extended: false })); // JSON 본문 파싱 (API 요청용) app.use(bodyParser.json()); // 시연을 위한 간단한 인메모리 "데이터베이스" const users = [ { id: 1, username: 'testuser', password: 'password123' } // 프로덕션에서는 평문 암호를 절대 저장하지 마십시오! ]; // 로그인 경로 (시연용 간소화) app.post('/login', (req, res) => { const { username, password } = req.body; const user = users.find(u => u.username === username && u.password === password); if (user) { req.session.userId = user.id; console.log(`User ${username} logged in. Session ID: ${req.sessionID}`); return res.redirect('/dashboard'); } res.send('Invalid credentials'); }); // CSRF 토큰 생성 및 유효성 검사를 위한 미들웨어 const csrfProtection = (req, res, next) => { // 세션에 토큰이 없으면 생성 if (!req.session.csrfToken) { req.session.csrfToken = crypto.randomBytes(32).toString('hex'); console.log('CSRF Token generated:', req.session.csrfToken); } // POST/PUT/DELETE 요청의 경우 토큰 유효성 검사 if (['POST', 'PUT', 'DELETE'].includes(req.method)) { const receivedToken = req.body._csrf || req.headers['x-csrf-token']; console.log('Received CSRF Token:', receivedToken); console.log('Session CSRF Token:', req.session.csrfToken); if (!receivedToken || receivedToken !== req.session.csrfToken) { console.warn('CSRF token validation failed for:', req.method, req.url); return res.status(403).send('CSRF Token validation failed.'); } console.log('CSRF token validated successfully.'); } next(); }; app.use(csrfProtection); // 대시보드 경로 (로그인 및 작업에 대한 CSRF 보호 필요) app.get('/dashboard', (req, res) => { if (!req.session.userId) { return res.redirect('/'); } // CSRF 토큰이 포함된 폼 렌더링 res.send(` <html> <head><title>Dashboard</title></head> <body> <h1>Welcome to your Dashboard!</h1> <p>Your user ID: ${req.session.userId}</p> <form action="/update-profile" method="POST"> <input type="hidden" name="_csrf" value="${req.session.csrfToken}"> <label for="newEmail">New Email:</label> <input type="email" id="newEmail" name="newEmail" value="user@example.com"> <button type="submit">Update Profile</button> </form> <form action="/delete-account" method="POST"> <input type="hidden" name="_csrf" value="${req.session.csrfToken}"> <button type="submit" style="color: red;">Delete Account</button> </form> <p><a href="/logout">Logout</a></p> </body> </html> `); }); // 예시 상태 변경 경로 app.post('/update-profile', (req, res) => { if (!req.session.userId) { return res.status(401).send('Unauthorized'); } console.log(`User ${req.session.userId} updated profile with new email: ${req.body.newEmail}`); res.send('Profile updated successfully!'); }); app.post('/delete-account', (req, res) => { if (!req.session.userId) { return res.status(401).send('Unauthorized'); } console.log(`User ${req.session.userId} account deleted.`); delete req.session.userId; // 사용자 로그아웃 res.send('Account deleted successfully!'); }); app.get('/logout', (req, res) => { req.session.destroy(err => { if (err) { console.error('Error destroying session:', err); } res.redirect('/'); }); }); // 루트 경로 (로그인 폼) app.get('/', (req, res) => { res.send(` <html> <head><title>Login</title></head> <body> <h1>Login</h1> <form action="/login" method="POST"> <label for="username">Username:</label> <input type="text" id="username" name="username" value="testuser"> <br> <label for="password">Password:</label> <input type="password" id="password" name="password" value="password123"> <br> <button type="submit">Login</button> </form> </body> </html> `); }); app.listen(port, () => { console.log(`Server running at http://localhost:${port}`); });
코드 설명:
express-session: 사용자 세션을 관리하여 각 사용자와 관련된csrfToken을 저장하고 검색할 수 있습니다.csrfProtection미들웨어:req.session.csrfToken을 확인합니다. 현재 세션에 대한 토큰이 없으면crypto.randomBytes(32).toString('hex')를 사용하여 새 토큰을 생성하고 세션에 저장합니다.- 일반적으로 서버 상태를 수정하는
POST,PUT또는DELETE요청의 경우,req.body._csrf(폼 제출의 경우) 또는req.headers['x-csrf-token'](AJAX 요청의 경우)에서 CSRF 토큰을 검색하려고 시도합니다. - 그런 다음 수신된 토큰을
req.session.csrfToken에 저장된 토큰과 비교합니다. 일치하지 않으면403 Forbidden응답을 보내 CSRF 공격을 효과적으로 방지합니다.
- 대시보드 경로 (
/dashboard): 이 경로는 숨겨진 입력 필드 (<input type="hidden" name="_csrf" value="${req.session.csrfToken}">)를 사용하여 CSRF 토큰을 HTML 폼에 포함하는 방법을 보여줍니다. - 상태 변경 경로 (
/update-profile,/delete-account): 이러한 경로는csrfProtection미들웨어가 보호하므로 유효한 토큰 없는 악의적인 요청이 거부됩니다.
AJAX 요청에 패턴 적용하기
단일 페이지 애플리케이션(SPA) 또는 AJAX를 사용하는 API 기반 애플리케이션의 경우 프로세스가 약간 다릅니다. 토큰을 숨겨진 폼 필드에 포함하는 대신 서버는 사용자 지정 HTTP 헤더 또는 초기 HTML 페이지의 메타 태그에 토큰을 제공할 수 있습니다. 그런 다음 클라이언트 측 JavaScript는 이 토큰을 검색하여 후속 AJAX 요청에 사용자 지정 헤더, 일반적으로 X-CSRF-Token으로 포함합니다.
서버 측 (이미 csrfProtection 미들웨어에서 req.headers['x-csrf-token'] 확인으로 다루어짐):
// ... REST API 엔드포인트에서 ... app.post('/api/data', (req, res) => { // ... csrfProtection 미들웨어가 이 시점 이전에 토큰 유효성을 검사합니다 ... if (!req.session.userId) { return res.status(401).send('Unauthorized'); } // 요청 처리 res.json({ message: 'Data successfully processed!' }); });
클라이언트 측 (Fetch API 사용 예시):
// 토큰이 초기 페이지 로드 시 메타 태그에 있거나 JSON을 통해 전달된다고 가정 // 예: <meta name="csrf-token" content="GENERATED_TOKEN_HERE"> const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); fetch('/api/data', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken // 여기에 CSRF 토큰 포함 }, body: JSON.stringify({ item: 'new item data' }) }) .then(response => response.json()) .then(data => console.log(data)) .catch(error => console.error('Error:', error));
강력한 CSRF 보호를 위한 주요 고려 사항:
- 토큰 고유성과 무작위성: 토큰이 실제로 무작위적이고 세션별로 고유한지 확인하십시오.
crypto.randomBytes를 사용하는 것이 중요합니다. - 무상태 토큰(이중 제출 쿠키 패턴): 이 문서는 서버 측 세션 저장소를 사용한 동기화 토큰에 중점을 두지만, 또 다른 일반적인 패턴은 이중 제출 쿠키입니다. 여기서는 무작위 토큰이 쿠키로 전송되고 폼에도 포함됩니다. 서버는 이 두 값을 비교합니다. 이는 기존 세션을 사용하지 않는 무상태 API에 유용할 수 있습니다.
- 엄격한
SameSite쿠키: 쿠키의SameSite속성(예:Lax,Strict)은 사이트 간 요청과 함께 쿠키를 보내지 않도록 브라우저에 지시하여 CSRF 공격을 크게 완화할 수 있습니다. 그러나 제한 사항과 브라우저 호환성 문제가 있으므로 완전한 방어는 아닙니다. 동기화 토큰과 함께 사용해야 하며 대체해서는 안 됩니다. 가능한 경우session구성의cookie에서{ sameSite: 'Lax', httpOnly: true, secure: true }로 설정하십시오. - 토큰 수명: 토큰에는 합리적인 수명이 있어야 하며 민감한 작업(예: 비밀번호 변경) 시 주기적으로 또는 즉시 회전해야 합니다.
- 오류 처리: CSRF 유효성 검사가 실패할 때 내부 정보를 너무 많이 노출하지 않고 명확하고 사용자 친화적인 오류 메시지를 제공하십시오.
결론
동기화 토큰 패턴은 Node.js 웹 애플리케이션에서 사이트 간 요청 위조 공격에 대한 강력하고 효과적인 방어를 제공합니다. 모든 상태 변경 요청에 고유한 비밀 토큰을 요구함으로써 개발자는 애플리케이션 자체에서 시작된 합법적인 요청만 처리되도록 보장하여 사용자 데이터를 보호하고 애플리케이션 무결성을 유지할 수 있습니다. 이 패턴을 구현하는 것은 안전하고 신뢰할 수 있는 웹 경험을 구축하기 위한 중요한 단계입니다.

