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>
This commit is contained in:
315
api.hyungi.net/utils/fileUploadSecurity.js
Normal file
315
api.hyungi.net/utils/fileUploadSecurity.js
Normal file
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
173
api.hyungi.net/utils/passwordValidator.js
Normal file
173
api.hyungi.net/utils/passwordValidator.js
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Password Validator - 비밀번호 정책 검증
|
||||
*
|
||||
* 강력한 비밀번호 정책:
|
||||
* - 최소 12자 이상
|
||||
* - 대문자 포함
|
||||
* - 소문자 포함
|
||||
* - 숫자 포함
|
||||
* - 특수문자 포함
|
||||
*
|
||||
* @author TK-FB-Project
|
||||
* @since 2026-02-04
|
||||
*/
|
||||
|
||||
/**
|
||||
* 비밀번호 강도 검증
|
||||
*
|
||||
* @param {string} password - 검증할 비밀번호
|
||||
* @param {Object} options - 옵션 (기본값 사용 권장)
|
||||
* @returns {Object} { valid: boolean, errors: string[], strength: string }
|
||||
*/
|
||||
const validatePassword = (password, options = {}) => {
|
||||
const config = {
|
||||
minLength: options.minLength || 12,
|
||||
requireUppercase: options.requireUppercase !== false,
|
||||
requireLowercase: options.requireLowercase !== false,
|
||||
requireNumbers: options.requireNumbers !== false,
|
||||
requireSpecialChars: options.requireSpecialChars !== false,
|
||||
maxLength: options.maxLength || 128
|
||||
};
|
||||
|
||||
const errors = [];
|
||||
let strength = 0;
|
||||
|
||||
// 필수 검증
|
||||
if (!password || typeof password !== 'string') {
|
||||
return {
|
||||
valid: false,
|
||||
errors: ['비밀번호를 입력해주세요.'],
|
||||
strength: 'invalid'
|
||||
};
|
||||
}
|
||||
|
||||
// 길이 검증
|
||||
if (password.length < config.minLength) {
|
||||
errors.push(`비밀번호는 최소 ${config.minLength}자 이상이어야 합니다.`);
|
||||
} else {
|
||||
strength += 1;
|
||||
}
|
||||
|
||||
if (password.length > config.maxLength) {
|
||||
errors.push(`비밀번호는 ${config.maxLength}자를 초과할 수 없습니다.`);
|
||||
}
|
||||
|
||||
// 대문자 검증
|
||||
if (config.requireUppercase && !/[A-Z]/.test(password)) {
|
||||
errors.push('대문자를 1개 이상 포함해야 합니다.');
|
||||
} else if (/[A-Z]/.test(password)) {
|
||||
strength += 1;
|
||||
}
|
||||
|
||||
// 소문자 검증
|
||||
if (config.requireLowercase && !/[a-z]/.test(password)) {
|
||||
errors.push('소문자를 1개 이상 포함해야 합니다.');
|
||||
} else if (/[a-z]/.test(password)) {
|
||||
strength += 1;
|
||||
}
|
||||
|
||||
// 숫자 검증
|
||||
if (config.requireNumbers && !/\d/.test(password)) {
|
||||
errors.push('숫자를 1개 이상 포함해야 합니다.');
|
||||
} else if (/\d/.test(password)) {
|
||||
strength += 1;
|
||||
}
|
||||
|
||||
// 특수문자 검증
|
||||
const specialChars = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/;
|
||||
if (config.requireSpecialChars && !specialChars.test(password)) {
|
||||
errors.push('특수문자를 1개 이상 포함해야 합니다. (!@#$%^&*()_+-=[]{};\':"|,.<>/?)');
|
||||
} else if (specialChars.test(password)) {
|
||||
strength += 1;
|
||||
}
|
||||
|
||||
// 공백 검증
|
||||
if (/\s/.test(password)) {
|
||||
errors.push('비밀번호에 공백을 포함할 수 없습니다.');
|
||||
}
|
||||
|
||||
// 연속된 문자 검증 (선택적)
|
||||
if (/(.)\1{2,}/.test(password)) {
|
||||
errors.push('동일한 문자를 3회 이상 연속 사용할 수 없습니다.');
|
||||
}
|
||||
|
||||
// 강도 계산
|
||||
let strengthLabel;
|
||||
if (strength <= 2) {
|
||||
strengthLabel = 'weak';
|
||||
} else if (strength <= 3) {
|
||||
strengthLabel = 'medium';
|
||||
} else if (strength <= 4) {
|
||||
strengthLabel = 'strong';
|
||||
} else {
|
||||
strengthLabel = 'very_strong';
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
strength: strengthLabel,
|
||||
score: strength
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 간단한 비밀번호 검증 (기존 호환용)
|
||||
* 모든 조건을 만족하면 true, 아니면 false
|
||||
*
|
||||
* @param {string} password - 검증할 비밀번호
|
||||
* @returns {boolean} 유효 여부
|
||||
*/
|
||||
const isValidPassword = (password) => {
|
||||
return validatePassword(password).valid;
|
||||
};
|
||||
|
||||
/**
|
||||
* 비밀번호 검증 결과를 한국어 메시지로 반환
|
||||
*
|
||||
* @param {string} password - 검증할 비밀번호
|
||||
* @returns {string|null} 오류 메시지 (유효하면 null)
|
||||
*/
|
||||
const getPasswordError = (password) => {
|
||||
const result = validatePassword(password);
|
||||
if (result.valid) {
|
||||
return null;
|
||||
}
|
||||
return result.errors.join(' ');
|
||||
};
|
||||
|
||||
/**
|
||||
* Express 미들웨어: 요청 body의 password 또는 newPassword 필드 검증
|
||||
*
|
||||
* @param {string} fieldName - 검증할 필드명 (기본: 'password')
|
||||
* @returns {Function} Express 미들웨어
|
||||
*/
|
||||
const validatePasswordMiddleware = (fieldName = 'password') => {
|
||||
return (req, res, next) => {
|
||||
const password = req.body[fieldName] || req.body.newPassword;
|
||||
|
||||
if (!password) {
|
||||
return next(); // 비밀번호 필드가 없으면 다음 미들웨어로
|
||||
}
|
||||
|
||||
const result = validatePassword(password);
|
||||
|
||||
if (!result.valid) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '비밀번호가 보안 요구사항을 충족하지 않습니다.',
|
||||
details: result.errors,
|
||||
code: 'WEAK_PASSWORD'
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
validatePassword,
|
||||
isValidPassword,
|
||||
getPasswordError,
|
||||
validatePasswordMiddleware
|
||||
};
|
||||
@@ -2,6 +2,41 @@
|
||||
|
||||
const { getDb } = require('../dbPool');
|
||||
|
||||
/**
|
||||
* SQL Injection 방지를 위한 화이트리스트 검증
|
||||
*/
|
||||
const ALLOWED_ORDER_DIRECTIONS = ['ASC', 'DESC'];
|
||||
const ALLOWED_TABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
const ALLOWED_COLUMN_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_.]*$/;
|
||||
|
||||
const validateOrderDirection = (direction) => {
|
||||
const normalized = (direction || 'DESC').toUpperCase();
|
||||
if (!ALLOWED_ORDER_DIRECTIONS.includes(normalized)) {
|
||||
throw new Error(`Invalid order direction: ${direction}`);
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const validateIdentifier = (identifier, type = 'column') => {
|
||||
if (!identifier || typeof identifier !== 'string') {
|
||||
throw new Error(`Invalid ${type} name`);
|
||||
}
|
||||
if (!ALLOWED_COLUMN_NAME_PATTERN.test(identifier)) {
|
||||
throw new Error(`Invalid ${type} name: ${identifier}`);
|
||||
}
|
||||
return identifier;
|
||||
};
|
||||
|
||||
const validateTableName = (tableName) => {
|
||||
if (!tableName || typeof tableName !== 'string') {
|
||||
throw new Error('Invalid table name');
|
||||
}
|
||||
if (!ALLOWED_TABLE_NAME_PATTERN.test(tableName)) {
|
||||
throw new Error(`Invalid table name: ${tableName}`);
|
||||
}
|
||||
return tableName;
|
||||
};
|
||||
|
||||
/**
|
||||
* 페이지네이션 헬퍼
|
||||
*/
|
||||
@@ -24,6 +59,10 @@ const executePagedQuery = async (baseQuery, countQuery, params = [], options = {
|
||||
const { page = 1, limit = 10, orderBy = 'id', orderDirection = 'DESC' } = options;
|
||||
const { limit: limitNum, offset, page: pageNum } = paginate(page, limit);
|
||||
|
||||
// SQL Injection 방지: 컬럼명과 정렬방향 검증
|
||||
const safeOrderBy = validateIdentifier(orderBy, 'column');
|
||||
const safeOrderDirection = validateOrderDirection(orderDirection);
|
||||
|
||||
try {
|
||||
const db = await getDb();
|
||||
|
||||
@@ -31,8 +70,8 @@ const executePagedQuery = async (baseQuery, countQuery, params = [], options = {
|
||||
const [countResult] = await db.execute(countQuery, params);
|
||||
const totalCount = countResult[0]?.total || 0;
|
||||
|
||||
// 데이터 조회 (ORDER BY와 LIMIT 추가)
|
||||
const pagedQuery = `${baseQuery} ORDER BY ${orderBy} ${orderDirection} LIMIT ${limitNum} OFFSET ${offset}`;
|
||||
// 데이터 조회 (ORDER BY와 LIMIT 추가) - 검증된 값만 사용
|
||||
const pagedQuery = `${baseQuery} ORDER BY ${safeOrderBy} ${safeOrderDirection} LIMIT ${limitNum} OFFSET ${offset}`;
|
||||
const [rows] = await db.execute(pagedQuery, params);
|
||||
|
||||
// 페이지네이션 메타데이터 계산
|
||||
@@ -59,14 +98,17 @@ const executePagedQuery = async (baseQuery, countQuery, params = [], options = {
|
||||
* 인덱스 최적화 제안
|
||||
*/
|
||||
const suggestIndexes = async (tableName) => {
|
||||
// SQL Injection 방지: 테이블명 검증
|
||||
const safeTableName = validateTableName(tableName);
|
||||
|
||||
try {
|
||||
const db = await getDb();
|
||||
|
||||
// 현재 인덱스 조회
|
||||
const [indexes] = await db.execute(`SHOW INDEX FROM ${tableName}`);
|
||||
// 현재 인덱스 조회 - 검증된 테이블명 사용
|
||||
const [indexes] = await db.execute(`SHOW INDEX FROM \`${safeTableName}\``);
|
||||
|
||||
// 테이블 구조 조회
|
||||
const [columns] = await db.execute(`DESCRIBE ${tableName}`);
|
||||
// 테이블 구조 조회 - 검증된 테이블명 사용
|
||||
const [columns] = await db.execute(`DESCRIBE \`${safeTableName}\``);
|
||||
|
||||
const suggestions = [];
|
||||
|
||||
@@ -80,7 +122,7 @@ const suggestIndexes = async (tableName) => {
|
||||
type: 'INDEX',
|
||||
column: col.Field,
|
||||
reason: '외래키 컬럼에 인덱스 추가로 JOIN 성능 향상',
|
||||
sql: `CREATE INDEX idx_${tableName}_${col.Field} ON ${tableName}(${col.Field});`
|
||||
sql: `CREATE INDEX idx_${safeTableName}_${col.Field} ON \`${safeTableName}\`(\`${col.Field}\`);`
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,12 +137,12 @@ const suggestIndexes = async (tableName) => {
|
||||
type: 'INDEX',
|
||||
column: col.Field,
|
||||
reason: '날짜 범위 검색 성능 향상',
|
||||
sql: `CREATE INDEX idx_${tableName}_${col.Field} ON ${tableName}(${col.Field});`
|
||||
sql: `CREATE INDEX idx_${safeTableName}_${col.Field} ON \`${safeTableName}\`(\`${col.Field}\`);`
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
tableName,
|
||||
tableName: safeTableName,
|
||||
currentIndexes: indexes.map(idx => ({
|
||||
name: idx.Key_name,
|
||||
column: idx.Column_name,
|
||||
@@ -179,6 +221,9 @@ const batchInsert = async (tableName, data, batchSize = 100) => {
|
||||
throw new Error('삽입할 데이터가 없습니다.');
|
||||
}
|
||||
|
||||
// SQL Injection 방지: 테이블명 검증
|
||||
const safeTableName = validateTableName(tableName);
|
||||
|
||||
try {
|
||||
const db = await getDb();
|
||||
const connection = await db.getConnection();
|
||||
@@ -186,8 +231,11 @@ const batchInsert = async (tableName, data, batchSize = 100) => {
|
||||
await connection.beginTransaction();
|
||||
|
||||
const columns = Object.keys(data[0]);
|
||||
const placeholders = columns.map(() => '?').join(', ');
|
||||
const insertQuery = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders})`;
|
||||
// 컬럼명도 검증
|
||||
const safeColumns = columns.map(col => validateIdentifier(col, 'column'));
|
||||
const placeholders = safeColumns.map(() => '?').join(', ');
|
||||
const columnList = safeColumns.map(col => `\`${col}\``).join(', ');
|
||||
const insertQuery = `INSERT INTO \`${safeTableName}\` (${columnList}) VALUES (${placeholders})`;
|
||||
|
||||
let insertedCount = 0;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user