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 /app/api/tbe-download | |
| parent | 2ef02e27dbe639876fa3b90c30307dda183545ec (diff) | |
(대표님) 파일관리변경, 클라IP추적, 실시간알림, 미들웨어변경, 알림API
Diffstat (limited to 'app/api/tbe-download')
| -rw-r--r-- | app/api/tbe-download/route.ts | 432 |
1 files changed, 364 insertions, 68 deletions
diff --git a/app/api/tbe-download/route.ts b/app/api/tbe-download/route.ts index 12e42920..93eb62db 100644 --- a/app/api/tbe-download/route.ts +++ b/app/api/tbe-download/route.ts @@ -1,119 +1,415 @@ -// app/api/rfq-download/route.ts +// app/api/tbe-download/route.ts import { NextRequest, NextResponse } from 'next/server'; -import { readFile, access, constants } from 'fs/promises'; -import { join } from 'path'; +import { readFile, access, constants, stat } from 'fs/promises'; +import { join, normalize, resolve } from 'path'; import db from '@/db/db'; import { rfqAttachments, vendorResponseAttachments } from '@/db/schema/rfq'; import { eq } from 'drizzle-orm'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { createFileDownloadLog } from '@/lib/file-download-log/service'; +import rateLimit from '@/lib/rate-limit'; +import { z } from 'zod'; +import { getRequestInfo } from '@/lib/network/get-client-ip'; + +// 허용된 파일 확장자 +const ALLOWED_EXTENSIONS = new Set([ + 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', + 'txt', 'csv', 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', + 'dwg', 'dxf', 'zip', 'rar', '7z' +]); + +// 최대 파일 크기 (50MB) +const MAX_FILE_SIZE = 50 * 1024 * 1024; + +// 다운로드 요청 검증 스키마 +const downloadRequestSchema = z.object({ + path: z.string().min(1, 'File path is required'), +}); + +// 파일 정보 타입 +interface FileRecord { + id: number; + fileName: string; + filePath: string; + fileSize?: number; + fileType?: string; +} + + +// 강화된 파일 경로 검증 함수 +function validateFilePath(filePath: string): boolean { + // null, undefined, 빈 문자열 체크 + if (!filePath || typeof filePath !== 'string') { + return false; + } + + // 위험한 패턴 체크 + const dangerousPatterns = [ + /\.\./, // 상위 디렉토리 접근 + /\/\//, // 이중 슬래시 + /[<>:"'|?*]/, // 특수문자 + /[\x00-\x1f]/, // 제어문자 + /\\+/ // 백슬래시 + ]; + + if (dangerousPatterns.some(pattern => pattern.test(filePath))) { + return false; + } + + // 시스템 파일 접근 방지 + const dangerousPaths = ['/etc', '/proc', '/sys', '/var', '/usr', '/root', '/home']; + for (const dangerousPath of dangerousPaths) { + if (filePath.toLowerCase().startsWith(dangerousPath)) { + return false; + } + } + + return true; +} + +// 파일 확장자 검증 +function validateFileExtension(fileName: string): boolean { + const extension = fileName.split('.').pop()?.toLowerCase() || ''; + return ALLOWED_EXTENSIONS.has(extension); +} + +// 안전한 파일명 생성 +function sanitizeFileName(fileName: string): string { + return fileName + .replace(/[^\w\s.-]/g, '_') // 안전하지 않은 문자 제거 + .replace(/\s+/g, '_') // 공백을 언더스코어로 + .substring(0, 255); // 파일명 길이 제한 +} export async function GET(request: NextRequest) { + const startTime = Date.now(); + const requestInfo = getRequestInfo(request); + let fileRecord: FileRecord | null = null; + try { - // 파일 경로 파라미터 받기 - const path = request.nextUrl.searchParams.get("path"); - - if (!path) { + // Rate limiting 체크 + const limiterResult = await rateLimit(request); + if (!limiterResult.success) { + console.warn('🚨 Rate limit 초과:', { + ip: requestInfo.ip, + userAgent: requestInfo.userAgent + }); + + return NextResponse.json( + { error: "Too many requests" }, + { status: 429 } + ); + } + + // 세션 확인 + const session = await getServerSession(authOptions); + if (!session?.user) { + console.warn('🚨 인증되지 않은 다운로드 시도:', { + ip: requestInfo.ip, + userAgent: requestInfo.userAgent, + path: request.nextUrl.searchParams.get("path") + }); + + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + + // 파라미터 검증 + const searchParams = { + path: request.nextUrl.searchParams.get("path"), + }; + + const validatedParams = downloadRequestSchema.parse(searchParams); + const { path } = validatedParams; + + // 파일 경로 보안 검증 + if (!validateFilePath(path)) { + console.warn(`🚨 의심스러운 파일 경로 접근 시도: ${path}`, { + userId: session.user.id, + ip: requestInfo.ip, + userAgent: requestInfo.userAgent + }); + return NextResponse.json( - { error: "File path is required" }, + { error: "Invalid file path" }, { status: 400 } ); } - + + // 경로 정규화 + const normalizedPath = normalize(path.replace(/^\/+/, "")); + // DB에서 파일 정보 조회 (정확히 일치하는 filePath로 검색) const [dbRecord] = await db .select({ + id: vendorResponseAttachments.id, fileName: vendorResponseAttachments.fileName, - filePath: vendorResponseAttachments.filePath + filePath: vendorResponseAttachments.filePath, + fileType: vendorResponseAttachments.fileType, }) .from(vendorResponseAttachments) - .where(eq(vendorResponseAttachments.filePath, path)); - - // 파일 정보 설정 - let fileName; - - if (dbRecord) { - // DB에서 찾은 경우 원본 파일명 사용 - fileName = dbRecord.fileName; - console.log("DB에서 원본 파일명 찾음:", fileName); - } else { - // DB에서 찾지 못한 경우 경로에서 파일명 추출 - fileName = path.split('/').pop() || 'download'; + .where(eq(vendorResponseAttachments.filePath, normalizedPath)); + + // DB에서 파일 정보를 찾지 못한 경우 + if (!dbRecord) { + console.warn("⚠️ DB에서 파일 정보를 찾지 못함:", { + path, + normalizedPath, + userId: session.user.id, + ip: requestInfo.ip + }); + + return NextResponse.json( + { error: "File not found in database" }, + { status: 404 } + ); } - - // 파일 경로 구성 - const storedPath = path.replace(/^\/+/, ""); // 앞쪽 슬래시 제거 - - // 파일 경로 시도 - const possiblePaths = [ - join(process.cwd(), "public", storedPath) - ]; - - // 실제 파일 찾기 - let actualPath = null; - for (const testPath of possiblePaths) { + + fileRecord = dbRecord; + + // 파일명 설정 + const fileName = dbRecord.fileName; + + // 파일 확장자 검증 + if (!validateFileExtension(fileName)) { + console.warn(`🚨 허용되지 않은 파일 타입 다운로드 시도: ${fileName}`, { + userId: session.user.id, + ip: requestInfo.ip + }); + + // 실패 로그 기록 + await createFileDownloadLog({ + fileId: dbRecord.id, + success: false, + errorMessage: 'File type not allowed', + requestId: requestInfo.requestId, + fileInfo: { + fileName, + filePath: path, + fileSize: 0, + } + }); + + return NextResponse.json( + { error: "File type not allowed" }, + { status: 403 } + ); + } + + // 안전한 파일 경로 구성 + const allowedDirs = ["public", "uploads", "storage"]; + let actualPath: string | null = null; + let baseDir: string | null = null; + + // 각 허용된 디렉터리에서 파일 찾기 + for (const dir of allowedDirs) { + baseDir = resolve(process.cwd(), dir); + const testPath = resolve(baseDir, normalizedPath); + + // 경로 탐색 공격 방지 - 허용된 디렉터리 외부 접근 차단 + if (!testPath.startsWith(baseDir)) { + continue; + } + try { await access(testPath, constants.R_OK); actualPath = testPath; + console.log("✅ 파일 발견:", testPath); break; } catch (err) { console.log("❌ 경로에 파일 없음:", testPath); } } - - if (!actualPath) { + + if (!actualPath || !baseDir) { + console.error("❌ 모든 경로에서 파일을 찾을 수 없음:", { + normalizedPath, + userId: session.user.id, + requestedPath: path, + triedDirs: allowedDirs + }); + + // 실패 로그 기록 + await createFileDownloadLog({ + fileId: dbRecord.id, + success: false, + errorMessage: 'File not found on server', + requestId: requestInfo.requestId, + fileInfo: { + fileName, + filePath: path, + fileSize: dbRecord.fileSize || 0, + } + }); + return NextResponse.json( - { + { error: "File not found on server", details: { path: path, - triedPaths: possiblePaths + fileName: fileName, } }, { status: 404 } ); } - + + // 파일 크기 확인 + const stats = await stat(actualPath); + if (stats.size > MAX_FILE_SIZE) { + console.warn(`🚨 파일 크기 초과: ${fileName} (${stats.size} bytes)`, { + userId: session.user.id, + ip: requestInfo.ip + }); + + // 실패 로그 기록 + await createFileDownloadLog({ + fileId: dbRecord.id, + success: false, + errorMessage: 'File too large', + requestId: requestInfo.requestId, + fileInfo: { + fileName, + filePath: path, + fileSize: stats.size, + } + }); + + return NextResponse.json( + { error: "File too large" }, + { status: 413 } + ); + } + + // 파일 읽기 const fileBuffer = await readFile(actualPath); - + // MIME 타입 결정 const fileExtension = fileName.split('.').pop()?.toLowerCase() || ''; - - let contentType = 'application/octet-stream'; // 기본 바이너리 - - // 확장자에 따른 MIME 타입 매핑 - const mimeTypes: Record<string, string> = { - 'pdf': 'application/pdf', - 'doc': 'application/msword', - 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'xls': 'application/vnd.ms-excel', - 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'ppt': 'application/vnd.ms-powerpoint', - 'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'txt': 'text/plain', - 'csv': 'text/csv', - 'png': 'image/png', - 'jpg': 'image/jpeg', - 'jpeg': 'image/jpeg', - 'gif': 'image/gif', - }; - - contentType = mimeTypes[fileExtension] || contentType; - - // 다운로드용 헤더 설정 + let contentType = dbRecord.fileType || 'application/octet-stream'; + + // 확장자에 따른 MIME 타입 매핑 (fallback) + if (!contentType || contentType === 'application/octet-stream') { + const mimeTypes: Record<string, string> = { + 'pdf': 'application/pdf', + 'doc': 'application/msword', + 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'xls': 'application/vnd.ms-excel', + 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'ppt': 'application/vnd.ms-powerpoint', + 'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'txt': 'text/plain; charset=utf-8', + 'csv': 'text/csv; charset=utf-8', + 'png': 'image/png', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'gif': 'image/gif', + 'bmp': 'image/bmp', + 'svg': 'image/svg+xml', + 'dwg': 'application/acad', + 'dxf': 'application/dxf', + 'zip': 'application/zip', + 'rar': 'application/x-rar-compressed', + '7z': 'application/x-7z-compressed', + }; + + contentType = mimeTypes[fileExtension] || 'application/octet-stream'; + } + + // 안전한 파일명 생성 + const safeFileName = sanitizeFileName(fileName); + + // 보안 헤더와 다운로드용 헤더 설정 const headers = new Headers(); headers.set('Content-Type', contentType); - headers.set('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"`); + headers.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(safeFileName)}`); headers.set('Content-Length', fileBuffer.length.toString()); + // 보안 헤더 + headers.set('Cache-Control', 'no-cache, no-store, must-revalidate'); + headers.set('Pragma', 'no-cache'); + headers.set('Expires', '0'); + headers.set('X-Content-Type-Options', 'nosniff'); + headers.set('X-Frame-Options', 'DENY'); + headers.set('X-XSS-Protection', '1; mode=block'); + headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); + + // 성공 로그 기록 + await createFileDownloadLog({ + fileId: dbRecord.id, + success: true, + requestId: requestInfo.requestId, + fileInfo: { + fileName: safeFileName, + filePath: path, + fileSize: fileBuffer.length, + } + }); + + console.log("✅ TBE 파일 다운로드 성공:", { + fileName: safeFileName, + contentType, + size: fileBuffer.length, + actualPath, + userId: session.user.id, + ip: requestInfo.ip, + downloadDurationMs: Date.now() - startTime + }); + return new NextResponse(fileBuffer, { status: 200, headers, }); + } catch (error) { - console.error('❌ RFQ 파일 다운로드 오류:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + + console.error('❌ TBE 파일 다운로드 오류:', { + error: errorMessage, + userId: (await getServerSession(authOptions))?.user?.id, + ip: requestInfo.ip, + path: request.nextUrl.searchParams.get("path"), + downloadDurationMs: Date.now() - startTime + }); + + // 에러 로그 기록 + if (fileRecord?.id) { + try { + await createFileDownloadLog({ + fileId: fileRecord.id, + success: false, + errorMessage, + requestId: requestInfo.requestId, + fileInfo: { + fileName: fileRecord.fileName || 'unknown', + filePath: request.nextUrl.searchParams.get("path") || '', + fileSize: fileRecord.fileSize || 0, + } + }); + } catch (logError) { + console.error('로그 기록 실패:', logError); + } + } + + // Zod 검증 에러 처리 + if (error instanceof z.ZodError) { + return NextResponse.json( + { + error: 'Invalid request parameters', + details: error.errors.map(e => e.message).join(', ') + }, + { status: 400 } + ); + } + + // 에러 정보 최소화 (정보 노출 방지) return NextResponse.json( - { - error: 'Failed to download file', - details: String(error) + { + error: 'Internal server error', + details: process.env.NODE_ENV === 'development' ? errorMessage : undefined }, { status: 500 } ); |
