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:
@@ -44,20 +44,18 @@ function waitForAxiosConfig() {
|
||||
});
|
||||
}
|
||||
|
||||
// 자동 날짜/시간대 결정
|
||||
function getAutoPatrolDateTime() {
|
||||
const now = new Date();
|
||||
const patrolDate = now.toISOString().slice(0, 10);
|
||||
const hour = now.getHours();
|
||||
// 오전(~12시), 오후(12시~)
|
||||
const patrolTime = hour < 12 ? 'morning' : 'afternoon';
|
||||
return { patrolDate, patrolTime };
|
||||
}
|
||||
|
||||
// 페이지 초기화
|
||||
async function initializePage() {
|
||||
// 오늘 날짜 설정
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
document.getElementById('patrolDate').value = today;
|
||||
|
||||
// 시간대 버튼 이벤트
|
||||
document.querySelectorAll('.patrol-time-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.patrol-time-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// 데이터 로드
|
||||
await Promise.all([
|
||||
loadCategories(),
|
||||
@@ -72,15 +70,74 @@ async function loadCategories() {
|
||||
const response = await axios.get('/workplaces/categories');
|
||||
if (response.data.success) {
|
||||
categories = response.data.data;
|
||||
const select = document.getElementById('categorySelect');
|
||||
select.innerHTML = '<option value="">공장 선택...</option>' +
|
||||
categories.map(c => `<option value="${c.category_id}">${c.category_name}</option>`).join('');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('공장 목록 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 공장 선택 화면 표시
|
||||
function showFactorySelection() {
|
||||
const { patrolDate, patrolTime } = getAutoPatrolDateTime();
|
||||
const timeLabel = patrolTime === 'morning' ? '오전' : '오후';
|
||||
const dateObj = new Date(patrolDate);
|
||||
const dateLabel = dateObj.toLocaleDateString('ko-KR', { month: 'long', day: 'numeric', weekday: 'short' });
|
||||
|
||||
// 세션 정보 표시
|
||||
document.getElementById('patrolSessionInfo').textContent = `${dateLabel} ${timeLabel} 순회점검`;
|
||||
|
||||
// 공장 카드 렌더링
|
||||
const container = document.getElementById('factoryCardsContainer');
|
||||
container.innerHTML = categories.map(c => `
|
||||
<div class="factory-card" onclick="selectFactory(${parseInt(c.category_id) || 0})">
|
||||
<div class="factory-card-icon">
|
||||
${c.layout_image ? `<img src="${escapeHtml(getImageUrl(c.layout_image))}" alt="${escapeHtml(c.category_name)}">` : '<span>🏭</span>'}
|
||||
</div>
|
||||
<div class="factory-card-name">${escapeHtml(c.category_name)}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// 시작 버튼 숨기고 공장 선택 영역 표시
|
||||
document.getElementById('startPatrolBtn').style.display = 'none';
|
||||
document.getElementById('factorySelectionArea').style.display = 'block';
|
||||
}
|
||||
|
||||
// 공장 선택 후 점검 시작
|
||||
async function selectFactory(categoryId) {
|
||||
const { patrolDate, patrolTime } = getAutoPatrolDateTime();
|
||||
|
||||
try {
|
||||
// 세션 생성 또는 조회
|
||||
const response = await axios.post('/patrol/sessions', {
|
||||
patrol_date: patrolDate,
|
||||
patrol_time: patrolTime,
|
||||
category_id: categoryId
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
currentSession = response.data.data;
|
||||
currentSession.patrol_date = patrolDate;
|
||||
currentSession.patrol_time = patrolTime;
|
||||
currentSession.category_id = categoryId;
|
||||
|
||||
// 작업장 목록 로드
|
||||
await loadWorkplaces(categoryId);
|
||||
|
||||
// 체크리스트 항목 로드
|
||||
await loadChecklistItems(categoryId);
|
||||
|
||||
// 공장 선택 영역 숨기고 점검 영역 표시
|
||||
document.getElementById('factorySelectionArea').style.display = 'none';
|
||||
document.getElementById('patrolArea').style.display = 'block';
|
||||
renderSessionInfo();
|
||||
renderWorkplaceMap();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('순회점검 시작 실패:', error);
|
||||
alert('순회점검을 시작할 수 없습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// 물품 유형 로드
|
||||
async function loadItemTypes() {
|
||||
try {
|
||||
@@ -133,63 +190,18 @@ function renderTodayStatus(statusList) {
|
||||
<div class="status-value ${morning?.status === 'completed' ? 'completed' : 'pending'}">
|
||||
${morning ? (morning.status === 'completed' ? '완료' : '진행중') : '미점검'}
|
||||
</div>
|
||||
${morning ? `<div class="status-sub">${morning.inspector_name || ''}</div>` : ''}
|
||||
${morning ? `<div class="status-sub">${escapeHtml(morning.inspector_name || '')}</div>` : ''}
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="status-label">오후</div>
|
||||
<div class="status-value ${afternoon?.status === 'completed' ? 'completed' : 'pending'}">
|
||||
${afternoon ? (afternoon.status === 'completed' ? '완료' : '진행중') : '미점검'}
|
||||
</div>
|
||||
${afternoon ? `<div class="status-sub">${afternoon.inspector_name || ''}</div>` : ''}
|
||||
${afternoon ? `<div class="status-sub">${escapeHtml(afternoon.inspector_name || '')}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 순회점검 시작
|
||||
async function startPatrol() {
|
||||
const patrolDate = document.getElementById('patrolDate').value;
|
||||
const patrolTime = document.querySelector('.patrol-time-btn.active')?.dataset.time;
|
||||
const categoryId = document.getElementById('categorySelect').value;
|
||||
|
||||
if (!patrolDate || !patrolTime || !categoryId) {
|
||||
alert('점검 일자, 시간대, 공장을 모두 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 세션 생성 또는 조회
|
||||
const response = await axios.post('/patrol/sessions', {
|
||||
patrol_date: patrolDate,
|
||||
patrol_time: patrolTime,
|
||||
category_id: categoryId
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
currentSession = response.data.data;
|
||||
currentSession.patrol_date = patrolDate;
|
||||
currentSession.patrol_time = patrolTime;
|
||||
currentSession.category_id = categoryId;
|
||||
|
||||
// 작업장 목록 로드
|
||||
await loadWorkplaces(categoryId);
|
||||
|
||||
// 체크리스트 항목 로드
|
||||
await loadChecklistItems(categoryId);
|
||||
|
||||
// 점검 영역 표시
|
||||
document.getElementById('patrolArea').style.display = 'block';
|
||||
renderSessionInfo();
|
||||
renderWorkplaceMap();
|
||||
|
||||
// 시작 버튼 비활성화
|
||||
document.getElementById('startPatrolBtn').textContent = '점검 진행중...';
|
||||
document.getElementById('startPatrolBtn').disabled = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('순회점검 시작 실패:', error);
|
||||
alert('순회점검을 시작할 수 없습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// 작업장 목록 로드
|
||||
async function loadWorkplaces(categoryId) {
|
||||
@@ -227,7 +239,7 @@ function renderSessionInfo() {
|
||||
<div class="session-info">
|
||||
<div class="session-info-item">
|
||||
<span class="session-info-label">점검일자</span>
|
||||
<span class="session-info-value">${formatDate(currentSession.patrol_date)}</span>
|
||||
<span class="session-info-value">${escapeHtml(formatDate(currentSession.patrol_date))}</span>
|
||||
</div>
|
||||
<div class="session-info-item">
|
||||
<span class="session-info-label">시간대</span>
|
||||
@@ -235,14 +247,14 @@ function renderSessionInfo() {
|
||||
</div>
|
||||
<div class="session-info-item">
|
||||
<span class="session-info-label">공장</span>
|
||||
<span class="session-info-value">${category?.category_name || ''}</span>
|
||||
<span class="session-info-value">${escapeHtml(category?.category_name || '')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: ${progress}%"></div>
|
||||
<div class="progress-fill" style="width: ${parseInt(progress) || 0}%"></div>
|
||||
</div>
|
||||
<span class="progress-text">${progress}%</span>
|
||||
<span class="progress-text">${parseInt(progress) || 0}%</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -255,18 +267,19 @@ function renderWorkplaceMap() {
|
||||
|
||||
// 지도 이미지가 있으면 지도 표시
|
||||
if (category?.layout_image) {
|
||||
mapContainer.innerHTML = `<img src="${getImageUrl(category.layout_image)}" alt="${category.category_name} 지도">`;
|
||||
mapContainer.innerHTML = `<img src="${escapeHtml(getImageUrl(category.layout_image))}" alt="${escapeHtml(category.category_name)} 지도">`;
|
||||
mapContainer.style.display = 'block';
|
||||
listContainer.style.display = 'none';
|
||||
|
||||
// 작업장 마커 추가
|
||||
// 좌표가 있는 작업장만 마커 추가
|
||||
const hasMarkers = workplaces.some(wp => wp.x_percent && wp.y_percent);
|
||||
|
||||
workplaces.forEach(wp => {
|
||||
if (wp.x_percent && wp.y_percent) {
|
||||
const marker = document.createElement('div');
|
||||
marker.className = 'workplace-marker';
|
||||
marker.style.left = `${wp.x_percent}%`;
|
||||
marker.style.top = `${wp.y_percent}%`;
|
||||
marker.textContent = wp.workplace_name;
|
||||
marker.style.left = `${parseFloat(wp.x_percent) || 0}%`;
|
||||
marker.style.top = `${parseFloat(wp.y_percent) || 0}%`;
|
||||
marker.textContent = wp.workplace_name; // textContent는 자동 이스케이프
|
||||
marker.dataset.workplaceId = wp.workplace_id;
|
||||
marker.onclick = () => selectWorkplace(wp.workplace_id);
|
||||
|
||||
@@ -279,30 +292,43 @@ function renderWorkplaceMap() {
|
||||
mapContainer.appendChild(marker);
|
||||
}
|
||||
});
|
||||
|
||||
// 좌표가 없는 작업장이 있으면 카드 목록도 표시
|
||||
if (!hasMarkers || workplaces.some(wp => !wp.x_percent || !wp.y_percent)) {
|
||||
listContainer.style.display = 'grid';
|
||||
renderWorkplaceCards(listContainer);
|
||||
} else {
|
||||
listContainer.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
// 지도 없으면 카드 목록으로 표시
|
||||
mapContainer.style.display = 'none';
|
||||
listContainer.style.display = 'grid';
|
||||
|
||||
listContainer.innerHTML = workplaces.map(wp => {
|
||||
const records = checkRecords[wp.workplace_id];
|
||||
const isCompleted = records && records.length > 0 && records.every(r => r.is_checked);
|
||||
const isInProgress = records && records.some(r => r.is_checked);
|
||||
|
||||
return `
|
||||
<div class="workplace-card ${isCompleted ? 'completed' : ''} ${selectedWorkplace?.workplace_id === wp.workplace_id ? 'selected' : ''}"
|
||||
data-workplace-id="${wp.workplace_id}"
|
||||
onclick="selectWorkplace(${wp.workplace_id})">
|
||||
<div class="workplace-card-name">${wp.workplace_name}</div>
|
||||
<div class="workplace-card-status">
|
||||
${isCompleted ? '점검완료' : (isInProgress ? '점검중' : '미점검')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
renderWorkplaceCards(listContainer);
|
||||
}
|
||||
}
|
||||
|
||||
// 작업장 카드 렌더링
|
||||
function renderWorkplaceCards(container) {
|
||||
container.innerHTML = workplaces.map(wp => {
|
||||
const records = checkRecords[wp.workplace_id];
|
||||
const isCompleted = records && records.length > 0 && records.every(r => r.is_checked);
|
||||
const isInProgress = records && records.some(r => r.is_checked);
|
||||
const workplaceId = parseInt(wp.workplace_id) || 0;
|
||||
|
||||
return `
|
||||
<div class="workplace-card ${isCompleted ? 'completed' : ''} ${isInProgress && !isCompleted ? 'in-progress' : ''} ${selectedWorkplace?.workplace_id === wp.workplace_id ? 'selected' : ''}"
|
||||
data-workplace-id="${workplaceId}"
|
||||
onclick="selectWorkplace(${workplaceId})">
|
||||
<div class="workplace-card-name">${escapeHtml(wp.workplace_name)}</div>
|
||||
<div class="workplace-card-status">
|
||||
${isCompleted ? '점검완료' : (isInProgress ? '점검중' : '미점검')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 작업장 선택
|
||||
async function selectWorkplace(workplaceId) {
|
||||
selectedWorkplace = workplaces.find(w => w.workplace_id === workplaceId);
|
||||
@@ -345,7 +371,7 @@ function renderChecklist(workplaceId) {
|
||||
const workplace = workplaces.find(w => w.workplace_id === workplaceId);
|
||||
|
||||
header.innerHTML = `
|
||||
<h3>${workplace?.workplace_name || ''} 체크리스트</h3>
|
||||
<h3>${escapeHtml(workplace?.workplace_name || '')} 체크리스트</h3>
|
||||
<p class="checklist-subtitle">각 항목을 점검하고 체크해주세요</p>
|
||||
`;
|
||||
|
||||
@@ -362,32 +388,34 @@ function renderChecklist(workplaceId) {
|
||||
|
||||
content.innerHTML = Object.entries(grouped).map(([category, items]) => `
|
||||
<div class="checklist-category">
|
||||
<div class="checklist-category-title">${getCategoryName(category)}</div>
|
||||
<div class="checklist-category-title">${escapeHtml(getCategoryName(category))}</div>
|
||||
${items.map(item => {
|
||||
const record = records.find(r => r.check_item_id === item.item_id);
|
||||
const isChecked = record?.is_checked;
|
||||
const checkResult = record?.check_result;
|
||||
const itemId = parseInt(item.item_id) || 0;
|
||||
const wpId = parseInt(workplaceId) || 0;
|
||||
|
||||
return `
|
||||
<div class="check-item ${isChecked ? 'checked' : ''}"
|
||||
data-item-id="${item.item_id}"
|
||||
onclick="toggleCheckItem(${workplaceId}, ${item.item_id})">
|
||||
data-item-id="${itemId}"
|
||||
onclick="toggleCheckItem(${wpId}, ${itemId})">
|
||||
<div class="check-item-checkbox">
|
||||
${isChecked ? '✓' : ''}
|
||||
</div>
|
||||
<div class="check-item-content">
|
||||
<div class="check-item-text">
|
||||
${item.check_item}
|
||||
${escapeHtml(item.check_item)}
|
||||
${item.is_required ? '<span class="check-item-required">*</span>' : ''}
|
||||
</div>
|
||||
${isChecked ? `
|
||||
<div class="check-result-selector" onclick="event.stopPropagation()">
|
||||
<button class="check-result-btn good ${checkResult === 'good' ? 'active' : ''}"
|
||||
onclick="setCheckResult(${workplaceId}, ${item.item_id}, 'good')">양호</button>
|
||||
onclick="setCheckResult(${wpId}, ${itemId}, 'good')">양호</button>
|
||||
<button class="check-result-btn warning ${checkResult === 'warning' ? 'active' : ''}"
|
||||
onclick="setCheckResult(${workplaceId}, ${item.item_id}, 'warning')">주의</button>
|
||||
onclick="setCheckResult(${wpId}, ${itemId}, 'warning')">주의</button>
|
||||
<button class="check-result-btn bad ${checkResult === 'bad' ? 'active' : ''}"
|
||||
onclick="setCheckResult(${workplaceId}, ${item.item_id}, 'bad')">불량</button>
|
||||
onclick="setCheckResult(${wpId}, ${itemId}, 'bad')">불량</button>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
@@ -551,19 +579,21 @@ function renderItemsSection(workplaceId) {
|
||||
|
||||
// 작업장 레이아웃 이미지가 있으면 표시
|
||||
if (workplace?.layout_image) {
|
||||
container.innerHTML = `<img src="${getImageUrl(workplace.layout_image)}" alt="${workplace.workplace_name}">`;
|
||||
container.innerHTML = `<img src="${escapeHtml(getImageUrl(workplace.layout_image))}" alt="${escapeHtml(workplace.workplace_name)}">`;
|
||||
|
||||
// 물품 마커 추가
|
||||
workplaceItems.forEach(item => {
|
||||
if (item.x_percent && item.y_percent) {
|
||||
const marker = document.createElement('div');
|
||||
marker.className = `item-marker ${item.item_type}`;
|
||||
marker.style.left = `${item.x_percent}%`;
|
||||
marker.style.top = `${item.y_percent}%`;
|
||||
marker.style.width = `${item.width_percent || 5}%`;
|
||||
marker.style.height = `${item.height_percent || 5}%`;
|
||||
marker.innerHTML = item.icon || getItemTypeIcon(item.item_type);
|
||||
marker.title = `${item.item_name || item.type_name} (${item.quantity}개)`;
|
||||
// item_type은 화이트리스트로 검증
|
||||
const safeItemType = ['container', 'plate', 'material', 'tool', 'other'].includes(item.item_type) ? item.item_type : 'other';
|
||||
marker.className = `item-marker ${safeItemType}`;
|
||||
marker.style.left = `${parseFloat(item.x_percent) || 0}%`;
|
||||
marker.style.top = `${parseFloat(item.y_percent) || 0}%`;
|
||||
marker.style.width = `${parseFloat(item.width_percent) || 5}%`;
|
||||
marker.style.height = `${parseFloat(item.height_percent) || 5}%`;
|
||||
marker.textContent = item.icon || getItemTypeIcon(item.item_type); // textContent 사용
|
||||
marker.title = `${item.item_name || item.type_name} (${parseInt(item.quantity) || 0}개)`;
|
||||
marker.dataset.itemId = item.item_id;
|
||||
marker.onclick = () => openItemModal(item);
|
||||
container.appendChild(marker);
|
||||
@@ -593,7 +623,7 @@ function renderItemTypesSelect() {
|
||||
const select = document.getElementById('itemType');
|
||||
if (!select) return;
|
||||
select.innerHTML = itemTypes.map(t =>
|
||||
`<option value="${t.type_code}">${t.icon} ${t.type_name}</option>`
|
||||
`<option value="${escapeHtml(t.type_code)}">${escapeHtml(t.icon)} ${escapeHtml(t.type_name)}</option>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
@@ -601,14 +631,19 @@ function renderItemTypesSelect() {
|
||||
function renderItemsLegend() {
|
||||
const container = document.getElementById('itemsLegend');
|
||||
if (!container) return;
|
||||
container.innerHTML = itemTypes.map(t => `
|
||||
// 색상 값 검증 (hex color만 허용)
|
||||
const isValidColor = (color) => /^#[0-9A-Fa-f]{3,6}$/.test(color);
|
||||
container.innerHTML = itemTypes.map(t => {
|
||||
const safeColor = isValidColor(t.color) ? t.color : '#888888';
|
||||
return `
|
||||
<div class="item-legend-item">
|
||||
<div class="item-legend-icon" style="background: ${t.color}20; border: 1px solid ${t.color};">
|
||||
${t.icon}
|
||||
<div class="item-legend-icon" style="background: ${safeColor}20; border: 1px solid ${safeColor};">
|
||||
${escapeHtml(t.icon)}
|
||||
</div>
|
||||
<span>${t.type_name}</span>
|
||||
<span>${escapeHtml(t.type_name)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 편집 모드 토글
|
||||
|
||||
Reference in New Issue
Block a user