- 순찰/점검 기능 개선 (zone-detail 페이지 추가) - 출근/근태 시스템 개선 (연차 조회, 근무현황) - 작업분석 대분류 그룹화 및 마이그레이션 스크립트 - 모바일 네비게이션 UI 추가 - NAS 배포 도구 및 문서 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
260 lines
7.7 KiB
JavaScript
260 lines
7.7 KiB
JavaScript
/**
|
|
* Security Utilities - 보안 관련 유틸리티 함수
|
|
*
|
|
* XSS 방지, 입력값 검증, 안전한 DOM 조작을 위한 함수 모음
|
|
*
|
|
* @author TK-FB-Project
|
|
* @since 2026-02-04
|
|
*/
|
|
|
|
(function(global) {
|
|
'use strict';
|
|
|
|
const SecurityUtils = {
|
|
/**
|
|
* HTML 특수문자 이스케이프 (XSS 방지)
|
|
* innerHTML에 사용자 입력을 삽입할 때 반드시 사용
|
|
*
|
|
* @param {string} str - 이스케이프할 문자열
|
|
* @returns {string} 이스케이프된 문자열
|
|
*
|
|
* @example
|
|
* element.innerHTML = `<span>${SecurityUtils.escapeHtml(userInput)}</span>`;
|
|
*/
|
|
escapeHtml: function(str) {
|
|
if (str === null || str === undefined) return '';
|
|
if (typeof str !== 'string') str = String(str);
|
|
|
|
const htmlEntities = {
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
"'": ''',
|
|
'/': '/',
|
|
'`': '`',
|
|
'=': '='
|
|
};
|
|
|
|
return str.replace(/[&<>"'`=\/]/g, function(char) {
|
|
return htmlEntities[char];
|
|
});
|
|
},
|
|
|
|
/**
|
|
* URL 파라미터 이스케이프
|
|
* URL에 사용자 입력을 포함할 때 사용
|
|
*
|
|
* @param {string} str - 이스케이프할 문자열
|
|
* @returns {string} URL 인코딩된 문자열
|
|
*/
|
|
escapeUrl: function(str) {
|
|
if (str === null || str === undefined) return '';
|
|
return encodeURIComponent(String(str));
|
|
},
|
|
|
|
/**
|
|
* JavaScript 문자열 이스케이프
|
|
* 동적 JavaScript 생성 시 사용 (권장하지 않음)
|
|
*
|
|
* @param {string} str - 이스케이프할 문자열
|
|
* @returns {string} 이스케이프된 문자열
|
|
*/
|
|
escapeJs: function(str) {
|
|
if (str === null || str === undefined) return '';
|
|
return String(str)
|
|
.replace(/\\/g, '\\\\')
|
|
.replace(/'/g, "\\'")
|
|
.replace(/"/g, '\\"')
|
|
.replace(/\n/g, '\\n')
|
|
.replace(/\r/g, '\\r')
|
|
.replace(/\t/g, '\\t');
|
|
},
|
|
|
|
/**
|
|
* 안전한 텍스트 설정
|
|
* innerHTML 대신 textContent 사용 권장
|
|
*
|
|
* @param {Element} element - DOM 요소
|
|
* @param {string} text - 설정할 텍스트
|
|
*/
|
|
setTextSafe: function(element, text) {
|
|
if (element && element.nodeType === 1) {
|
|
element.textContent = text;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 안전한 HTML 삽입
|
|
* 사용자 입력이 포함된 HTML을 삽입할 때 사용
|
|
*
|
|
* @param {Element} element - DOM 요소
|
|
* @param {string} template - HTML 템플릿 ({{변수}} 형식)
|
|
* @param {Object} data - 삽입할 데이터 (자동 이스케이프됨)
|
|
*
|
|
* @example
|
|
* SecurityUtils.setHtmlSafe(div, '<span>{{name}}</span>', { name: userInput });
|
|
*/
|
|
setHtmlSafe: function(element, template, data) {
|
|
if (!element || element.nodeType !== 1) return;
|
|
|
|
const self = this;
|
|
const safeHtml = template.replace(/\{\{(\w+)\}\}/g, function(match, key) {
|
|
return data.hasOwnProperty(key) ? self.escapeHtml(data[key]) : '';
|
|
});
|
|
|
|
element.innerHTML = safeHtml;
|
|
},
|
|
|
|
/**
|
|
* 입력값 검증 - 숫자
|
|
*
|
|
* @param {any} value - 검증할 값
|
|
* @param {Object} options - 옵션 { min, max, allowFloat }
|
|
* @returns {number|null} 유효한 숫자 또는 null
|
|
*/
|
|
validateNumber: function(value, options) {
|
|
options = options || {};
|
|
const num = options.allowFloat ? parseFloat(value) : parseInt(value, 10);
|
|
|
|
if (isNaN(num)) return null;
|
|
if (options.min !== undefined && num < options.min) return null;
|
|
if (options.max !== undefined && num > options.max) return null;
|
|
|
|
return num;
|
|
},
|
|
|
|
/**
|
|
* 입력값 검증 - 이메일
|
|
*
|
|
* @param {string} email - 검증할 이메일
|
|
* @returns {boolean} 유효 여부
|
|
*/
|
|
validateEmail: function(email) {
|
|
if (!email || typeof email !== 'string') return false;
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
return emailRegex.test(email);
|
|
},
|
|
|
|
/**
|
|
* 입력값 검증 - 길이
|
|
*
|
|
* @param {string} str - 검증할 문자열
|
|
* @param {Object} options - 옵션 { min, max }
|
|
* @returns {boolean} 유효 여부
|
|
*/
|
|
validateLength: function(str, options) {
|
|
options = options || {};
|
|
if (!str || typeof str !== 'string') return false;
|
|
|
|
const len = str.length;
|
|
if (options.min !== undefined && len < options.min) return false;
|
|
if (options.max !== undefined && len > options.max) return false;
|
|
|
|
return true;
|
|
},
|
|
|
|
/**
|
|
* 안전한 JSON 파싱
|
|
*
|
|
* @param {string} jsonString - 파싱할 JSON 문자열
|
|
* @param {any} defaultValue - 파싱 실패 시 기본값
|
|
* @returns {any} 파싱된 객체 또는 기본값
|
|
*/
|
|
parseJsonSafe: function(jsonString, defaultValue) {
|
|
defaultValue = defaultValue === undefined ? null : defaultValue;
|
|
try {
|
|
return JSON.parse(jsonString);
|
|
} catch (e) {
|
|
console.warn('[SecurityUtils] JSON 파싱 실패:', e.message);
|
|
return defaultValue;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* localStorage에서 안전하게 데이터 가져오기
|
|
*
|
|
* @param {string} key - 키
|
|
* @param {any} defaultValue - 기본값
|
|
* @returns {any} 저장된 값 또는 기본값
|
|
*/
|
|
getStorageSafe: function(key, defaultValue) {
|
|
try {
|
|
const item = localStorage.getItem(key);
|
|
if (item === null) return defaultValue;
|
|
return this.parseJsonSafe(item, defaultValue);
|
|
} catch (e) {
|
|
console.warn('[SecurityUtils] localStorage 접근 실패:', e.message);
|
|
return defaultValue;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* URL 파라미터 안전하게 가져오기
|
|
*
|
|
* @param {string} name - 파라미터 이름
|
|
* @param {string} defaultValue - 기본값
|
|
* @returns {string} 파라미터 값 (이스케이프됨)
|
|
*/
|
|
getUrlParamSafe: function(name, defaultValue) {
|
|
defaultValue = defaultValue === undefined ? '' : defaultValue;
|
|
try {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const value = urlParams.get(name);
|
|
return value !== null ? value : defaultValue;
|
|
} catch (e) {
|
|
return defaultValue;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* ID 파라미터 안전하게 가져오기 (숫자 검증)
|
|
*
|
|
* @param {string} name - 파라미터 이름
|
|
* @returns {number|null} 유효한 ID 또는 null
|
|
*/
|
|
getIdParamSafe: function(name) {
|
|
const value = this.getUrlParamSafe(name);
|
|
return this.validateNumber(value, { min: 1 });
|
|
},
|
|
|
|
/**
|
|
* Content Security Policy 위반 리포터
|
|
*
|
|
* @param {string} reportUri - 리포트 전송 URL
|
|
*/
|
|
enableCspReporting: function(reportUri) {
|
|
document.addEventListener('securitypolicyviolation', function(e) {
|
|
console.error('[CSP Violation]', {
|
|
blockedUri: e.blockedURI,
|
|
violatedDirective: e.violatedDirective,
|
|
originalPolicy: e.originalPolicy
|
|
});
|
|
|
|
if (reportUri) {
|
|
fetch(reportUri, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
blocked_uri: e.blockedURI,
|
|
violated_directive: e.violatedDirective,
|
|
document_uri: e.documentURI,
|
|
timestamp: new Date().toISOString()
|
|
}),
|
|
headers: { 'Content-Type': 'application/json' }
|
|
}).catch(function() {});
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
// 전역 노출
|
|
global.SecurityUtils = SecurityUtils;
|
|
|
|
// 편의를 위한 단축 함수
|
|
global.escapeHtml = SecurityUtils.escapeHtml.bind(SecurityUtils);
|
|
global.escapeUrl = SecurityUtils.escapeUrl.bind(SecurityUtils);
|
|
|
|
console.log('[Module] common/security.js 로드 완료');
|
|
|
|
})(typeof window !== 'undefined' ? window : this);
|