- OpenLayers(ol) 패키지 제거 (미사용, import 0건) - common/ 디렉토리 생성: components, hooks, services, store, types, utils - 17개 공통 파일을 common/으로 이동 (git mv, blame 이력 보존) - MainTab 타입을 App.tsx에서 common/types/navigation.ts로 분리 - tsconfig path alias (@common/*, @tabs/*) + vite resolve.alias 설정 - 42개 import 경로를 @common/ alias 또는 상대경로로 수정 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
171 lines
5.0 KiB
TypeScript
Executable File
171 lines
5.0 KiB
TypeScript
Executable File
/**
|
|
* XSS 방지 및 입력값 살균 유틸리티
|
|
*
|
|
* 보안 목적:
|
|
* - 사용자 입력에서 위험한 HTML/JS 코드 제거
|
|
* - API 응답 데이터의 안전한 렌더링
|
|
* - localStorage 데이터의 안전한 파싱
|
|
*/
|
|
|
|
/**
|
|
* HTML 특수문자 이스케이프
|
|
* XSS 공격에 사용되는 문자를 안전한 HTML 엔티티로 변환
|
|
*/
|
|
export function escapeHtml(str: string): string {
|
|
const htmlEscapeMap: Record<string, string> = {
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
"'": ''',
|
|
'/': '/',
|
|
}
|
|
return str.replace(/[&<>"'/]/g, (char) => htmlEscapeMap[char] || char)
|
|
}
|
|
|
|
/**
|
|
* HTML 태그 제거 (텍스트만 추출)
|
|
* dangerouslySetInnerHTML 대신 사용
|
|
*/
|
|
export function stripHtmlTags(html: string): string {
|
|
return html.replace(/<[^>]*>/g, '')
|
|
}
|
|
|
|
/**
|
|
* 안전한 HTML 살균
|
|
* 허용된 태그만 남기고 위험한 태그/속성 제거
|
|
*/
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const ALLOWED_TAGS = new Set([
|
|
'b', 'i', 'u', 'strong', 'em', 'br', 'p', 'span',
|
|
'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
'ul', 'ol', 'li', 'a', 'img', 'table', 'thead',
|
|
'tbody', 'tr', 'th', 'td', 'sup', 'sub', 'hr',
|
|
'blockquote', 'pre', 'code',
|
|
])
|
|
|
|
const DANGEROUS_ATTRS = /\s*on\w+\s*=|javascript\s*:|vbscript\s*:|expression\s*\(/gi
|
|
|
|
export function sanitizeHtml(html: string): string {
|
|
// 1. script 태그 완전 제거
|
|
let sanitized = html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
|
|
|
// 2. 위험한 이벤트 핸들러 속성 제거 (onclick, onerror 등)
|
|
sanitized = sanitized.replace(DANGEROUS_ATTRS, '')
|
|
|
|
// 3. data: URI 스킴 제거 (base64 인코딩된 스크립트 방지)
|
|
sanitized = sanitized.replace(/\bdata\s*:\s*text\/html/gi, '')
|
|
|
|
// 4. style 속성에서 expression() 제거 (IE CSS XSS)
|
|
sanitized = sanitized.replace(/style\s*=\s*["'][^"']*expression\s*\([^)]*\)[^"']*["']/gi, '')
|
|
|
|
// 5. iframe, object, embed 태그 제거
|
|
sanitized = sanitized.replace(/<\s*(iframe|object|embed|form|input|textarea|button)\b[^>]*>/gi, '')
|
|
sanitized = sanitized.replace(/<\/\s*(iframe|object|embed|form|input|textarea|button)\s*>/gi, '')
|
|
|
|
return sanitized
|
|
}
|
|
|
|
/**
|
|
* 사용자 입력 텍스트 살균
|
|
* 게시판, 검색 등에서 사용
|
|
*/
|
|
export function sanitizeInput(input: string, maxLength = 1000): string {
|
|
if (typeof input !== 'string') return ''
|
|
return input
|
|
.trim()
|
|
.slice(0, maxLength)
|
|
.replace(/[<>"'`;]/g, '') // 위험 특수문자 제거
|
|
}
|
|
|
|
/**
|
|
* URL 파라미터 안전하게 인코딩
|
|
*/
|
|
export function sanitizeUrlParam(param: string): string {
|
|
return encodeURIComponent(param)
|
|
}
|
|
|
|
/**
|
|
* 안전한 JSON 파싱 (localStorage 등에서 사용)
|
|
* 파싱 실패 시 기본값 반환
|
|
*/
|
|
export function safeJsonParse<T>(json: string, defaultValue: T): T {
|
|
try {
|
|
const parsed = JSON.parse(json)
|
|
if (parsed === null || parsed === undefined) return defaultValue
|
|
return parsed as T
|
|
} catch {
|
|
return defaultValue
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 안전한 localStorage 읽기
|
|
*/
|
|
export function safeGetLocalStorage<T>(key: string, defaultValue: T): T {
|
|
try {
|
|
const raw = localStorage.getItem(key)
|
|
if (raw === null) return defaultValue
|
|
return safeJsonParse(raw, defaultValue)
|
|
} catch {
|
|
return defaultValue
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 안전한 localStorage 저장 (크기 제한 포함)
|
|
*/
|
|
export function safeSetLocalStorage(key: string, value: unknown, maxSizeKB = 5120): boolean {
|
|
try {
|
|
const json = JSON.stringify(value)
|
|
// 크기 제한 검사 (기본 5MB)
|
|
if (json.length > maxSizeKB * 1024) {
|
|
console.warn(`localStorage 저장 크기 초과: ${key}`)
|
|
return false
|
|
}
|
|
localStorage.setItem(key, json)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 안전한 PDF 내보내기 (document.write 대체)
|
|
* Blob URL을 사용하여 새 창에서 콘텐츠 표시
|
|
*/
|
|
export function safePrintHtml(htmlContent: string, title: string, styles: string = ''): void {
|
|
// HTML 콘텐츠에서 위험 요소 살균
|
|
const sanitizedContent = sanitizeHtml(htmlContent)
|
|
|
|
const fullHtml = `<!DOCTYPE html>
|
|
<html><head>
|
|
<meta charset="utf-8"/>
|
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:;">
|
|
<title>${escapeHtml(title)}</title>
|
|
${styles}
|
|
</head><body>${sanitizedContent}</body></html>`
|
|
|
|
// Blob URL 사용 (document.write 대체)
|
|
const blob = new Blob([fullHtml], { type: 'text/html; charset=utf-8' })
|
|
const url = URL.createObjectURL(blob)
|
|
const win = window.open(url, '_blank')
|
|
|
|
// Blob URL 정리 (메모리 누수 방지)
|
|
if (win) {
|
|
win.addEventListener('afterprint', () => URL.revokeObjectURL(url))
|
|
// 30초 후 자동 정리
|
|
setTimeout(() => URL.revokeObjectURL(url), 30000)
|
|
} else {
|
|
URL.revokeObjectURL(url)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 문자열에서 줄바꿈을 <br>로 변환 (안전하게)
|
|
* dangerouslySetInnerHTML 없이 React 엘리먼트로 반환
|
|
*/
|
|
export function splitByNewline(text: string): string[] {
|
|
return escapeHtml(text).split(/\n/)
|
|
}
|