// app/api/tbe-download/route.ts import { NextRequest, NextResponse } from 'next/server'; 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 { // 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: "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, fileType: vendorResponseAttachments.fileType, }) .from(vendorResponseAttachments) .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 } ); } 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 || !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, 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 = dbRecord.fileType || 'application/octet-stream'; // 확장자에 따른 MIME 타입 매핑 (fallback) if (!contentType || contentType === 'application/octet-stream') { const mimeTypes: Record = { '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*=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) { 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: 'Internal server error', details: process.env.NODE_ENV === 'development' ? errorMessage : undefined }, { status: 500 } ); } }