diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-18 07:52:02 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-18 07:52:02 +0000 |
| commit | 48a2255bfc45ffcfb0b39ffefdd57cbacf8b36df (patch) | |
| tree | 0c88b7c126138233875e8d372a4e999e49c38a62 /lib/file-download.ts | |
| parent | 2ef02e27dbe639876fa3b90c30307dda183545ec (diff) | |
(대표님) 파일관리변경, 클라IP추적, 실시간알림, 미들웨어변경, 알림API
Diffstat (limited to 'lib/file-download.ts')
| -rw-r--r-- | lib/file-download.ts | 471 |
1 files changed, 420 insertions, 51 deletions
diff --git a/lib/file-download.ts b/lib/file-download.ts index 1e8536b5..5e0350a0 100644 --- a/lib/file-download.ts +++ b/lib/file-download.ts @@ -1,5 +1,5 @@ // lib/file-download.ts -// 공용 파일 다운로드 유틸리티 +// 공용 파일 다운로드 유틸리티 (보안 및 로깅 강화) import { toast } from "sonner"; @@ -14,6 +14,240 @@ export interface FileInfo { } /** + * 파일 다운로드 옵션 + */ +export interface FileDownloadOptions { + /** 다운로드 액션 타입 */ + action?: 'download' | 'preview'; + /** 에러 시 토스트 표시 여부 */ + showToast?: boolean; + /** 성공 시 토스트 표시 여부 */ + showSuccessToast?: boolean; + /** 커스텀 에러 핸들러 */ + onError?: (error: string) => void; + /** 커스텀 성공 핸들러 */ + onSuccess?: (fileName: string, fileSize?: number) => void; + /** 진행률 콜백 (큰 파일용) */ + onProgress?: (progress: number) => void; + /** 로깅 비활성화 */ + disableLogging?: boolean; +} + +/** + * 파일 다운로드 결과 + */ +export interface FileDownloadResult { + success: boolean; + error?: string; + fileSize?: number; + fileInfo?: FileInfo; + downloadDuration?: number; +} + +/** + * 보안 설정 + */ +const SECURITY_CONFIG = { + // 허용된 파일 확장자 + ALLOWED_EXTENSIONS: new Set([ + 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', + 'txt', 'csv', 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', + 'dwg', 'dxf', 'zip', 'rar', '7z', 'webp' + ]), + + // 최대 파일 크기 (100MB) + MAX_FILE_SIZE: 100 * 1024 * 1024, + + // 허용된 도메인 (선택적) + ALLOWED_DOMAINS: [ + window.location.hostname, + 'localhost', + '127.0.0.1' + ], + + // Rate limiting (클라이언트 사이드) + MAX_DOWNLOADS_PER_MINUTE: 10, + + // 타임아웃 설정 + FETCH_TIMEOUT: 30000, // 30초 +}; + +/** + * Rate limiting 추적 + */ +class RateLimiter { + private downloadAttempts: number[] = []; + + canDownload(): boolean { + const now = Date.now(); + const oneMinuteAgo = now - 60000; + + // 1분 이전 기록 제거 + this.downloadAttempts = this.downloadAttempts.filter(time => time > oneMinuteAgo); + + if (this.downloadAttempts.length >= SECURITY_CONFIG.MAX_DOWNLOADS_PER_MINUTE) { + return false; + } + + this.downloadAttempts.push(now); + return true; + } + + getRemainingDownloads(): number { + const now = Date.now(); + const oneMinuteAgo = now - 60000; + this.downloadAttempts = this.downloadAttempts.filter(time => time > oneMinuteAgo); + + return Math.max(0, SECURITY_CONFIG.MAX_DOWNLOADS_PER_MINUTE - this.downloadAttempts.length); + } +} + +const rateLimiter = new RateLimiter(); + +/** + * 클라이언트 사이드 로깅 서비스 + */ +class ClientLogger { + private static async logToServer(event: string, data: any) { + try { + // 로깅이 비활성화된 경우 서버 전송 안함 + if (data.disableLogging) { + console.log(`[CLIENT LOG] ${event}:`, data); + return; + } + + await fetch('/api/client-logs', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + event, + data: { + ...data, + timestamp: new Date().toISOString(), + userAgent: navigator.userAgent, + url: window.location.href, + sessionId: this.getSessionId(), + }, + }), + }).catch(error => { + // 로깅 실패해도 메인 기능에 영향 없도록 + console.warn('로깅 실패:', error); + }); + } catch (error) { + console.warn('로깅 실패:', error); + } + } + + private static getSessionId(): string { + let sessionId = sessionStorage.getItem('client-session-id'); + if (!sessionId) { + sessionId = crypto.randomUUID(); + sessionStorage.setItem('client-session-id', sessionId); + } + return sessionId; + } + + static logDownloadAttempt(filePath: string, fileName: string, action: string, options?: any) { + this.logToServer('download_attempt', { + filePath, + fileName, + action, + fileExtension: fileName.split('.').pop()?.toLowerCase(), + ...options, + }); + } + + static logDownloadSuccess(filePath: string, fileName: string, fileSize?: number, duration?: number) { + this.logToServer('download_success', { + filePath, + fileName, + fileSize, + duration, + fileExtension: fileName.split('.').pop()?.toLowerCase(), + }); + } + + static logDownloadError(filePath: string, fileName: string, error: string, duration?: number) { + this.logToServer('download_error', { + filePath, + fileName, + error, + duration, + fileExtension: fileName.split('.').pop()?.toLowerCase(), + }); + } + + static logSecurityViolation(type: string, details: any) { + this.logToServer('security_violation', { + type, + ...details, + }); + } +} + +/** + * 보안 검증 함수들 + */ +const SecurityValidator = { + validateFileExtension(fileName: string): boolean { + const extension = fileName.split('.').pop()?.toLowerCase(); + return extension ? SECURITY_CONFIG.ALLOWED_EXTENSIONS.has(extension) : false; + }, + + validateFileName(fileName: string): boolean { + // 위험한 문자 체크 + const dangerousPatterns = [ + /[<>:"'|?*]/, // 특수문자 + /[\x00-\x1f]/, // 제어문자 + /^\./, // 숨김 파일 + /\.(exe|bat|cmd|scr|vbs|js|jar)$/i, // 실행 파일 + ]; + + return !dangerousPatterns.some(pattern => pattern.test(fileName)); + }, + + validateFilePath(filePath: string): boolean { + // 경로 탐색 공격 방지 + const dangerousPatterns = [ + /\.\./, // 상위 디렉토리 접근 + /\/\//, // 이중 슬래시 + /\\+/, // 백슬래시 + /[<>:"'|?*]/, // 특수문자 + ]; + + return !dangerousPatterns.some(pattern => pattern.test(filePath)); + }, + + validateUrl(url: string): boolean { + try { + const urlObj = new URL(url); + + // HTTPS 강제 (개발 환경 제외) + if (window.location.protocol === 'https:' && urlObj.protocol !== 'https:') { + if (!['localhost', '127.0.0.1'].includes(urlObj.hostname)) { + return false; + } + } + + // 허용된 도메인 검사 (상대 URL은 허용) + if (urlObj.hostname && !SECURITY_CONFIG.ALLOWED_DOMAINS.includes(urlObj.hostname)) { + return false; + } + + return true; + } catch { + return false; + } + }, + + validateFileSize(size: number): boolean { + return size <= SECURITY_CONFIG.MAX_FILE_SIZE; + }, +}; + +/** * 파일 정보 가져오기 */ export const getFileInfo = (fileName: string): FileInfo => { @@ -57,32 +291,24 @@ export const formatFileSize = (bytes: number): string => { }; /** - * 파일 다운로드 옵션 + * 타임아웃이 적용된 fetch */ -export interface FileDownloadOptions { - /** 다운로드 액션 타입 */ - action?: 'download' | 'preview'; - /** 에러 시 토스트 표시 여부 */ - showToast?: boolean; - /** 성공 시 토스트 표시 여부 */ - showSuccessToast?: boolean; - /** 커스텀 에러 핸들러 */ - onError?: (error: string) => void; - /** 커스텀 성공 핸들러 */ - onSuccess?: (fileName: string, fileSize?: number) => void; - /** 진행률 콜백 (큰 파일용) */ - onProgress?: (progress: number) => void; -} - -/** - * 파일 다운로드 결과 - */ -export interface FileDownloadResult { - success: boolean; - error?: string; - fileSize?: number; - fileInfo?: FileInfo; -} +const fetchWithTimeout = async (url: string, options: RequestInit = {}): Promise<Response> => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), SECURITY_CONFIG.FETCH_TIMEOUT); + + try { + const response = await fetch(url, { + ...options, + signal: controller.signal, + }); + clearTimeout(timeoutId); + return response; + } catch (error) { + clearTimeout(timeoutId); + throw error; + } +}; /** * 파일 메타데이터 확인 @@ -95,7 +321,12 @@ export const checkFileMetadata = async (url: string): Promise<{ error?: string; }> => { try { - const response = await fetch(url, { + // URL 보안 검증 + if (!SecurityValidator.validateUrl(url)) { + return { exists: false, error: "허용되지 않은 URL입니다" }; + } + + const response = await fetchWithTimeout(url, { method: 'HEAD', headers: { 'Cache-Control': 'no-cache' } }); @@ -110,6 +341,9 @@ export const checkFileMetadata = async (url: string): Promise<{ case 403: error = "파일 접근 권한이 없습니다"; break; + case 429: + error = "요청이 너무 많습니다. 잠시 후 다시 시도해주세요"; + break; case 500: error = "서버 오류가 발생했습니다"; break; @@ -123,18 +357,26 @@ export const checkFileMetadata = async (url: string): Promise<{ const contentLength = response.headers.get('Content-Length'); const contentType = response.headers.get('Content-Type'); const lastModified = response.headers.get('Last-Modified'); + + const size = contentLength ? parseInt(contentLength, 10) : undefined; + + // 파일 크기 검증 + if (size && !SecurityValidator.validateFileSize(size)) { + return { + exists: false, + error: `파일이 너무 큽니다 (최대 ${formatFileSize(SECURITY_CONFIG.MAX_FILE_SIZE)})` + }; + } return { exists: true, - size: contentLength ? parseInt(contentLength, 10) : undefined, + size, contentType: contentType || undefined, lastModified: lastModified ? new Date(lastModified) : undefined, }; } catch (error) { - return { - exists: false, - error: error instanceof Error ? error.message : "네트워크 오류가 발생했습니다" - }; + const errorMessage = error instanceof Error ? error.message : "네트워크 오류가 발생했습니다"; + return { exists: false, error: errorMessage }; } }; @@ -146,85 +388,199 @@ export const downloadFile = async ( fileName: string, options: FileDownloadOptions = {} ): Promise<FileDownloadResult> => { - const { action = 'download', showToast = true, onError, onSuccess } = options; + const startTime = Date.now(); + const { action = 'download', showToast = true, onError, onSuccess, disableLogging = false } = options; + + // 로깅 + if (!disableLogging) { + ClientLogger.logDownloadAttempt(filePath, fileName, action, { + userInitiated: true, + disableLogging + }); + } try { - // ✅ URL에 다운로드 강제 파라미터 추가 + // Rate limiting 체크 + if (!rateLimiter.canDownload()) { + const error = `다운로드 제한 초과. ${rateLimiter.getRemainingDownloads()}회 남음`; + if (showToast) toast.error(error); + if (onError) onError(error); + if (!disableLogging) { + ClientLogger.logSecurityViolation('rate_limit_exceeded', { filePath, fileName }); + } + return { success: false, error }; + } + + // 보안 검증 + if (!SecurityValidator.validateFileName(fileName)) { + const error = "안전하지 않은 파일명입니다"; + if (showToast) toast.error(error); + if (onError) onError(error); + if (!disableLogging) { + ClientLogger.logSecurityViolation('invalid_filename', { filePath, fileName }); + } + return { success: false, error }; + } + + if (!SecurityValidator.validateFilePath(filePath)) { + const error = "안전하지 않은 파일 경로입니다"; + if (showToast) toast.error(error); + if (onError) onError(error); + if (!disableLogging) { + ClientLogger.logSecurityViolation('invalid_filepath', { filePath, fileName }); + } + return { success: false, error }; + } + + if (!SecurityValidator.validateFileExtension(fileName)) { + const error = "허용되지 않은 파일 형식입니다"; + if (showToast) toast.error(error); + if (onError) onError(error); + if (!disableLogging) { + ClientLogger.logSecurityViolation('invalid_extension', { filePath, fileName }); + } + return { success: false, error }; + } + + // URL 구성 const baseUrl = filePath.startsWith('http') ? filePath : `${window.location.origin}${filePath}`; const url = new URL(baseUrl); if (action === 'download') { - url.searchParams.set('download', 'true'); // 🔑 핵심! + url.searchParams.set('download', 'true'); } const fullUrl = url.toString(); + // URL 보안 검증 + if (!SecurityValidator.validateUrl(fullUrl)) { + const error = "허용되지 않은 URL입니다"; + if (showToast) toast.error(error); + if (onError) onError(error); + if (!disableLogging) { + ClientLogger.logSecurityViolation('invalid_url', { fullUrl, fileName }); + } + return { success: false, error }; + } + // 파일 정보 확인 const metadata = await checkFileMetadata(fullUrl); if (!metadata.exists) { const error = metadata.error || "파일을 찾을 수 없습니다"; if (showToast) toast.error(error); if (onError) onError(error); - return { success: false, error }; + const duration = Date.now() - startTime; + if (!disableLogging) { + ClientLogger.logDownloadError(filePath, fileName, error, duration); + } + return { success: false, error, downloadDuration: duration }; } const fileInfo = getFileInfo(fileName); - // 미리보기 처리 (download=true 없이) + // 미리보기 처리 if (action === 'preview' && fileInfo.canPreview) { const previewUrl = filePath.startsWith('http') ? filePath : `${window.location.origin}${filePath}`; - window.open(previewUrl, '_blank', 'noopener,noreferrer'); - if (showToast) toast.success(`${fileInfo.icon} 파일을 새 탭에서 열었습니다`); - if (onSuccess) onSuccess(fileName, metadata.size); - return { success: true, fileSize: metadata.size, fileInfo }; + // 안전한 새 창 열기 + const newWindow = window.open('', '_blank', 'noopener,noreferrer'); + if (newWindow) { + newWindow.location.href = previewUrl; + if (showToast) toast.success(`${fileInfo.icon} 파일을 새 탭에서 열었습니다`); + if (onSuccess) onSuccess(fileName, metadata.size); + const duration = Date.now() - startTime; + if (!disableLogging) { + ClientLogger.logDownloadSuccess(filePath, fileName, metadata.size, duration); + } + return { success: true, fileSize: metadata.size, fileInfo, downloadDuration: duration }; + } else { + throw new Error("팝업이 차단되었습니다. 팝업 차단을 해제해주세요."); + } } - // ✅ 안전한 다운로드 방식 (fetch + Blob) - console.log(`📥 안전한 다운로드: ${fullUrl}`); + // 안전한 다운로드 + console.log(`📥 보안 검증된 다운로드: ${fullUrl}`); - const response = await fetch(fullUrl); + const response = await fetchWithTimeout(fullUrl); if (!response.ok) { throw new Error(`다운로드 실패: ${response.status}`); } + // Content-Type 검증 + const contentType = response.headers.get('Content-Type'); + if (contentType && fileInfo.mimeType && !contentType.includes(fileInfo.mimeType.split(';')[0])) { + console.warn('⚠️ MIME 타입 불일치:', { expected: fileInfo.mimeType, actual: contentType }); + } + // Blob으로 변환 const blob = await response.blob(); - // ✅ 브라우저 호환성을 고려한 다운로드 + // 최종 파일 크기 검증 + if (!SecurityValidator.validateFileSize(blob.size)) { + const error = `파일이 너무 큽니다 (최대 ${formatFileSize(SECURITY_CONFIG.MAX_FILE_SIZE)})`; + if (showToast) toast.error(error); + if (onError) onError(error); + const duration = Date.now() - startTime; + if (!disableLogging) { + ClientLogger.logDownloadError(filePath, fileName, error, duration); + } + return { success: false, error, downloadDuration: duration }; + } + + // 브라우저 호환성을 고려한 안전한 다운로드 const downloadUrl = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = downloadUrl; link.download = fileName; - link.style.display = 'none'; // 화면에 표시되지 않도록 + link.style.display = 'none'; + link.setAttribute('data-download-source', 'secure-download-utility'); document.body.appendChild(link); link.click(); document.body.removeChild(link); - // 메모리 정리 (중요!) + // 메모리 정리 setTimeout(() => URL.revokeObjectURL(downloadUrl), 100); // 성공 처리 + const duration = Date.now() - startTime; if (showToast) { - const sizeText = metadata.size ? ` (${formatFileSize(metadata.size)})` : ''; + const sizeText = blob.size ? ` (${formatFileSize(blob.size)})` : ''; toast.success(`${fileInfo.icon} 파일 다운로드 완료: ${fileName}${sizeText}`); } - if (onSuccess) onSuccess(fileName, metadata.size); + if (onSuccess) onSuccess(fileName, blob.size); + if (!disableLogging) { + ClientLogger.logDownloadSuccess(filePath, fileName, blob.size, duration); + } - return { success: true, fileSize: metadata.size, fileInfo }; + return { + success: true, + fileSize: blob.size, + fileInfo, + downloadDuration: duration + }; } catch (error) { + const duration = Date.now() - startTime; const errorMessage = error instanceof Error ? error.message : "파일 처리 중 오류가 발생했습니다"; + console.error("❌ 다운로드 오류:", error); if (showToast) toast.error(errorMessage); if (onError) onError(errorMessage); - return { success: false, error: errorMessage }; + if (!disableLogging) { + ClientLogger.logDownloadError(filePath, fileName, errorMessage, duration); + } + + return { + success: false, + error: errorMessage, + downloadDuration: duration + }; } }; @@ -257,4 +613,17 @@ export const smartFileAction = (filePath: string, fileName: string) => { const action = fileInfo.canPreview ? 'preview' : 'download'; return downloadFile(filePath, fileName, { action }); +}; + +/** + * 보안 정보 조회 + */ +export const getSecurityInfo = () => { + return { + allowedExtensions: Array.from(SECURITY_CONFIG.ALLOWED_EXTENSIONS), + maxFileSize: SECURITY_CONFIG.MAX_FILE_SIZE, + maxFileSizeFormatted: formatFileSize(SECURITY_CONFIG.MAX_FILE_SIZE), + remainingDownloads: rateLimiter.getRemainingDownloads(), + allowedDomains: SECURITY_CONFIG.ALLOWED_DOMAINS, + }; };
\ No newline at end of file |
