## 백엔드 보안 수정 - 하드코딩된 비밀번호 및 JWT 시크릿 폴백 제거 - SQL Injection 방지를 위한 화이트리스트 검증 추가 - 인증 미적용 API 라우트에 requireAuth 미들웨어 적용 - CSRF 보호 미들웨어 구현 (csrf.js) - 파일 업로드 보안 유틸리티 추가 (fileUploadSecurity.js) - 비밀번호 정책 검증 유틸리티 추가 (passwordValidator.js) ## 프론트엔드 XSS 방지 - api-base.js에 전역 escapeHtml() 함수 추가 - 17개 주요 JS 파일에 escapeHtml 적용: - tbm.js, daily-patrol.js, daily-work-report.js - task-management.js, workplace-status.js - equipment-detail.js, equipment-management.js - issue-detail.js, issue-report.js - vacation-common.js, worker-management.js - safety-report-list.js, nonconformity-list.js - project-management.js, workplace-management.js ## 정리 - 백업 폴더 및 빈 파일 삭제 - SECURITY_GUIDE.md 문서 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
202 lines
4.8 KiB
JavaScript
202 lines
4.8 KiB
JavaScript
/**
|
|
* CSRF Protection Middleware
|
|
*
|
|
* Cross-Site Request Forgery 방지를 위한 토큰 기반 보호
|
|
*
|
|
* 구현 방식:
|
|
* 1. 서버에서 CSRF 토큰 생성 및 응답 헤더로 전송
|
|
* 2. 클라이언트는 state-changing 요청 시 토큰을 헤더에 포함
|
|
* 3. 서버에서 토큰 검증
|
|
*
|
|
* @author TK-FB-Project
|
|
* @since 2026-02-04
|
|
*/
|
|
|
|
const crypto = require('crypto');
|
|
const logger = require('../utils/logger');
|
|
|
|
// 토큰 저장소 (프로덕션에서는 Redis 사용 권장)
|
|
const tokenStore = new Map();
|
|
|
|
// 토큰 유효 시간 (1시간)
|
|
const TOKEN_EXPIRY = 60 * 60 * 1000;
|
|
|
|
// 토큰 정리 주기 (5분)
|
|
const CLEANUP_INTERVAL = 5 * 60 * 1000;
|
|
|
|
/**
|
|
* 만료된 토큰 정리
|
|
*/
|
|
const cleanupExpiredTokens = () => {
|
|
const now = Date.now();
|
|
for (const [token, data] of tokenStore.entries()) {
|
|
if (now > data.expiresAt) {
|
|
tokenStore.delete(token);
|
|
}
|
|
}
|
|
};
|
|
|
|
// 주기적 정리
|
|
setInterval(cleanupExpiredTokens, CLEANUP_INTERVAL);
|
|
|
|
/**
|
|
* CSRF 토큰 생성
|
|
*
|
|
* @param {string} sessionId - 세션 ID 또는 사용자 식별자
|
|
* @returns {string} 생성된 CSRF 토큰
|
|
*/
|
|
const generateToken = (sessionId) => {
|
|
const token = crypto.randomBytes(32).toString('hex');
|
|
const expiresAt = Date.now() + TOKEN_EXPIRY;
|
|
|
|
tokenStore.set(token, {
|
|
sessionId,
|
|
expiresAt,
|
|
createdAt: Date.now()
|
|
});
|
|
|
|
return token;
|
|
};
|
|
|
|
/**
|
|
* CSRF 토큰 검증
|
|
*
|
|
* @param {string} token - 검증할 토큰
|
|
* @param {string} sessionId - 세션 ID
|
|
* @returns {boolean} 유효 여부
|
|
*/
|
|
const validateToken = (token, sessionId) => {
|
|
if (!token || !tokenStore.has(token)) {
|
|
return false;
|
|
}
|
|
|
|
const data = tokenStore.get(token);
|
|
|
|
// 만료 체크
|
|
if (Date.now() > data.expiresAt) {
|
|
tokenStore.delete(token);
|
|
return false;
|
|
}
|
|
|
|
// 세션 일치 체크
|
|
if (data.sessionId !== sessionId) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
/**
|
|
* CSRF 토큰을 응답 헤더에 설정하는 미들웨어
|
|
*
|
|
* @param {Object} req - Express request
|
|
* @param {Object} res - Express response
|
|
* @param {Function} next - Next middleware
|
|
*/
|
|
const setCsrfToken = (req, res, next) => {
|
|
// 세션 ID 또는 사용자 ID 사용
|
|
const sessionId = req.user?.user_id?.toString() || req.sessionID || req.ip;
|
|
|
|
// 새 토큰 생성
|
|
const token = generateToken(sessionId);
|
|
|
|
// 응답 헤더에 토큰 설정
|
|
res.setHeader('X-CSRF-Token', token);
|
|
|
|
// 요청 객체에 저장 (다른 미들웨어에서 사용 가능)
|
|
req.csrfToken = token;
|
|
|
|
next();
|
|
};
|
|
|
|
/**
|
|
* CSRF 토큰 검증 미들웨어
|
|
* POST, PUT, DELETE, PATCH 요청에 적용
|
|
*
|
|
* @param {Object} options - 옵션
|
|
* @param {string[]} options.ignoreMethods - 검증 제외 메서드 (기본: GET, HEAD, OPTIONS)
|
|
* @param {string[]} options.ignorePaths - 검증 제외 경로 (정규식 패턴 가능)
|
|
* @returns {Function} Express 미들웨어
|
|
*/
|
|
const verifyCsrfToken = (options = {}) => {
|
|
const {
|
|
ignoreMethods = ['GET', 'HEAD', 'OPTIONS'],
|
|
ignorePaths = ['/api/auth/login', '/api/auth/register', '/api/health']
|
|
} = options;
|
|
|
|
return (req, res, next) => {
|
|
// 제외 메서드 체크
|
|
if (ignoreMethods.includes(req.method)) {
|
|
return next();
|
|
}
|
|
|
|
// 제외 경로 체크
|
|
for (const pattern of ignorePaths) {
|
|
if (typeof pattern === 'string' && req.path === pattern) {
|
|
return next();
|
|
}
|
|
if (pattern instanceof RegExp && pattern.test(req.path)) {
|
|
return next();
|
|
}
|
|
}
|
|
|
|
// 토큰 추출 (헤더 또는 body에서)
|
|
const token = req.headers['x-csrf-token'] ||
|
|
req.headers['csrf-token'] ||
|
|
req.body?._csrf ||
|
|
req.query?._csrf;
|
|
|
|
// 세션 ID
|
|
const sessionId = req.user?.user_id?.toString() || req.sessionID || req.ip;
|
|
|
|
// 토큰 검증
|
|
if (!validateToken(token, sessionId)) {
|
|
logger.warn('CSRF 토큰 검증 실패', {
|
|
path: req.path,
|
|
method: req.method,
|
|
ip: req.ip,
|
|
hasToken: !!token
|
|
});
|
|
|
|
return res.status(403).json({
|
|
success: false,
|
|
error: 'CSRF 토큰이 유효하지 않습니다. 페이지를 새로고침 후 다시 시도해주세요.',
|
|
code: 'CSRF_TOKEN_INVALID'
|
|
});
|
|
}
|
|
|
|
// 사용된 토큰 제거 (일회성 사용)
|
|
tokenStore.delete(token);
|
|
|
|
// 새 토큰 발급
|
|
const newToken = generateToken(sessionId);
|
|
res.setHeader('X-CSRF-Token', newToken);
|
|
req.csrfToken = newToken;
|
|
|
|
next();
|
|
};
|
|
};
|
|
|
|
/**
|
|
* CSRF 토큰 발급 엔드포인트 핸들러
|
|
* GET /api/csrf-token
|
|
*/
|
|
const getCsrfToken = (req, res) => {
|
|
const sessionId = req.user?.user_id?.toString() || req.sessionID || req.ip;
|
|
const token = generateToken(sessionId);
|
|
|
|
res.json({
|
|
success: true,
|
|
csrfToken: token,
|
|
expiresIn: TOKEN_EXPIRY / 1000 // 초 단위
|
|
});
|
|
};
|
|
|
|
module.exports = {
|
|
generateToken,
|
|
validateToken,
|
|
setCsrfToken,
|
|
verifyCsrfToken,
|
|
getCsrfToken
|
|
};
|