Files
TK-FB-Project/api.hyungi.net/utils/fileUploadSecurity.js
Hyungi Ahn 36f110c90a fix: 보안 취약점 수정 및 XSS 방지 적용
## 백엔드 보안 수정
- 하드코딩된 비밀번호 및 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>
2026-02-05 06:33:10 +09:00

316 lines
8.1 KiB
JavaScript

/**
* File Upload Security - 파일 업로드 보안 유틸리티
*
* - Magic number (파일 시그니처) 검증
* - 파일명 sanitize
* - 확장자 화이트리스트 검증
* - 파일 크기 제한
*
* @author TK-FB-Project
* @since 2026-02-04
*/
const path = require('path');
const crypto = require('crypto');
const fs = require('fs').promises;
/**
* 파일 시그니처 (Magic Numbers)
* 파일의 실제 타입을 확인하기 위한 바이너리 시그니처
*/
const FILE_SIGNATURES = {
// 이미지
'ffd8ff': { mime: 'image/jpeg', ext: ['.jpg', '.jpeg'] },
'89504e47': { mime: 'image/png', ext: ['.png'] },
'47494638': { mime: 'image/gif', ext: ['.gif'] },
'52494646': { mime: 'image/webp', ext: ['.webp'] }, // RIFF (WebP 시작)
// 문서
'25504446': { mime: 'application/pdf', ext: ['.pdf'] },
'504b0304': { mime: 'application/zip', ext: ['.zip', '.xlsx', '.docx', '.pptx'] },
// 주의: BMP, TIFF 등 추가 가능
};
/**
* 허용된 이미지 확장자
*/
const ALLOWED_IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
/**
* 허용된 문서 확장자
*/
const ALLOWED_DOCUMENT_EXTENSIONS = ['.pdf', '.xlsx', '.docx', '.pptx', '.zip'];
/**
* 위험한 확장자 (절대 허용 안 함)
*/
const DANGEROUS_EXTENSIONS = [
'.exe', '.bat', '.cmd', '.sh', '.ps1', '.vbs', '.js', '.jar',
'.php', '.asp', '.aspx', '.jsp', '.cgi', '.pl', '.py', '.rb',
'.htaccess', '.htpasswd', '.ini', '.config', '.env'
];
/**
* 파일 시그니처(Magic Number) 검증
*
* @param {Buffer} buffer - 파일 버퍼 (최소 8바이트)
* @returns {Object|null} 매칭된 파일 정보 또는 null
*/
const checkMagicNumber = (buffer) => {
if (!buffer || buffer.length < 4) {
return null;
}
// 처음 8바이트를 hex로 변환
const hex = buffer.slice(0, 8).toString('hex').toLowerCase();
// 시그니처 매칭
for (const [signature, info] of Object.entries(FILE_SIGNATURES)) {
if (hex.startsWith(signature)) {
return info;
}
}
return null;
};
/**
* 파일 버퍼에서 실제 MIME 타입 검증
*
* @param {Buffer} buffer - 파일 버퍼
* @param {string} declaredMime - 선언된 MIME 타입
* @returns {Object} { valid: boolean, actualType: string|null, message: string }
*/
const validateFileType = (buffer, declaredMime) => {
const detected = checkMagicNumber(buffer);
if (!detected) {
return {
valid: false,
actualType: null,
message: '알 수 없는 파일 형식입니다.'
};
}
// MIME 타입이 일치하는지 확인
if (detected.mime !== declaredMime) {
return {
valid: false,
actualType: detected.mime,
message: `파일 형식이 일치하지 않습니다. (선언: ${declaredMime}, 실제: ${detected.mime})`
};
}
return {
valid: true,
actualType: detected.mime,
message: 'OK'
};
};
/**
* 파일명 sanitize
* 경로 조작 및 특수문자 제거
*
* @param {string} filename - 원본 파일명
* @returns {string} 안전한 파일명
*/
const sanitizeFilename = (filename) => {
if (!filename || typeof filename !== 'string') {
return 'unnamed';
}
// 경로 구분자 제거 (path traversal 방지)
let safe = path.basename(filename);
// 특수문자 제거 (영문, 숫자, -, _, . 만 허용)
safe = safe.replace(/[^a-zA-Z0-9가-힣._-]/g, '_');
// 연속된 점 제거 (이중 확장자 방지)
safe = safe.replace(/\.{2,}/g, '.');
// 앞뒤 점/공백 제거
safe = safe.replace(/^[\s.]+|[\s.]+$/g, '');
// 빈 파일명 처리
if (!safe || safe === '') {
safe = 'unnamed';
}
// 최대 길이 제한 (255자)
if (safe.length > 255) {
const ext = path.extname(safe);
const name = path.basename(safe, ext);
safe = name.slice(0, 255 - ext.length) + ext;
}
return safe;
};
/**
* 확장자 검증
*
* @param {string} filename - 파일명
* @param {string[]} allowedExtensions - 허용된 확장자 배열
* @returns {Object} { valid: boolean, extension: string, message: string }
*/
const validateExtension = (filename, allowedExtensions = ALLOWED_IMAGE_EXTENSIONS) => {
const ext = path.extname(filename).toLowerCase();
// 위험한 확장자 체크
if (DANGEROUS_EXTENSIONS.includes(ext)) {
return {
valid: false,
extension: ext,
message: `보안상 허용되지 않는 파일 형식입니다: ${ext}`
};
}
// 허용된 확장자 체크
if (!allowedExtensions.includes(ext)) {
return {
valid: false,
extension: ext,
message: `허용된 파일 형식: ${allowedExtensions.join(', ')}`
};
}
return {
valid: true,
extension: ext,
message: 'OK'
};
};
/**
* 안전한 랜덤 파일명 생성
*
* @param {string} originalFilename - 원본 파일명 (확장자 추출용)
* @returns {string} 랜덤 파일명
*/
const generateSafeFilename = (originalFilename) => {
const ext = path.extname(originalFilename).toLowerCase();
const randomName = crypto.randomBytes(16).toString('hex');
const timestamp = Date.now();
return `${timestamp}_${randomName}${ext}`;
};
/**
* 안전한 업로드 경로 생성
* 경로 조작(path traversal) 방지
*
* @param {string} baseDir - 기본 업로드 디렉토리
* @param {string} filename - 파일명
* @returns {string} 안전한 전체 경로
*/
const getSafeUploadPath = (baseDir, filename) => {
const safeName = sanitizeFilename(filename);
const fullPath = path.join(baseDir, safeName);
// 결과 경로가 baseDir 안에 있는지 확인
const resolvedBase = path.resolve(baseDir);
const resolvedFull = path.resolve(fullPath);
if (!resolvedFull.startsWith(resolvedBase)) {
throw new Error('경로 조작이 감지되었습니다.');
}
return resolvedFull;
};
/**
* Multer 파일 필터 생성
*
* @param {Object} options - 옵션
* @param {string[]} options.allowedExtensions - 허용된 확장자
* @param {string[]} options.allowedMimes - 허용된 MIME 타입
* @param {boolean} options.checkMagicNumber - Magic number 검증 여부
* @returns {Function} Multer fileFilter 함수
*/
const createFileFilter = (options = {}) => {
const {
allowedExtensions = ALLOWED_IMAGE_EXTENSIONS,
allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
checkMagicNumber = false // Multer에서는 버퍼 접근이 제한적이므로 기본 false
} = options;
return (req, file, cb) => {
// 확장자 검증
const extResult = validateExtension(file.originalname, allowedExtensions);
if (!extResult.valid) {
return cb(new Error(extResult.message), false);
}
// MIME 타입 검증
if (!allowedMimes.includes(file.mimetype)) {
return cb(new Error(`허용된 MIME 타입: ${allowedMimes.join(', ')}`), false);
}
cb(null, true);
};
};
/**
* 업로드된 파일 검증 (후처리용)
* Multer 업로드 후 파일 내용을 검증
*
* @param {string} filePath - 업로드된 파일 경로
* @param {string} declaredMime - 선언된 MIME 타입
* @returns {Promise<Object>} 검증 결과
*/
const validateUploadedFile = async (filePath, declaredMime) => {
try {
// 파일 시작 부분 읽기
const fd = await fs.open(filePath, 'r');
const buffer = Buffer.alloc(8);
await fd.read(buffer, 0, 8, 0);
await fd.close();
// Magic number 검증
const typeResult = validateFileType(buffer, declaredMime);
if (!typeResult.valid) {
// 위험한 파일이면 삭제
await fs.unlink(filePath);
return {
valid: false,
deleted: true,
message: typeResult.message
};
}
return {
valid: true,
deleted: false,
message: 'OK',
actualType: typeResult.actualType
};
} catch (error) {
return {
valid: false,
deleted: false,
message: `파일 검증 중 오류: ${error.message}`
};
}
};
module.exports = {
// 상수
ALLOWED_IMAGE_EXTENSIONS,
ALLOWED_DOCUMENT_EXTENSIONS,
DANGEROUS_EXTENSIONS,
FILE_SIGNATURES,
// 함수
checkMagicNumber,
validateFileType,
sanitizeFilename,
validateExtension,
generateSafeFilename,
getSafeUploadPath,
createFileFilter,
validateUploadedFile
};