import { NextRequest, NextResponse } from "next/server"; import { join, normalize, resolve } from "path"; import { readFile, access, constants, stat } from "fs/promises"; 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') .refine(path => path.startsWith("/techsales-rfq/"), { message: 'Invalid file path - must start with /techsales-rfq/' }) .refine(path => !path.includes(".."), { message: 'Invalid file path - directory traversal not allowed' }), }); // 강화된 파일 경로 검증 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; } // techsales-rfq 경로만 허용 if (!filePath.startsWith('/techsales-rfq/')) { return false; } // 시스템 파일 접근 방지 const dangerousPaths = ['/etc', '/proc', '/sys', '/var', '/usr', '/root', '/home']; for (const dangerousPath of dangerousPaths) { if (filePath.toLowerCase().includes(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); // 파일명 길이 제한 } // MIME 타입 결정 function getMimeType(fileName: string): string { const ext = fileName.split(".").pop()?.toLowerCase(); 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', }; return mimeTypes[ext || ''] || 'application/octet-stream'; } export async function GET(request: NextRequest) { const startTime = Date.now(); const requestInfo = getRequestInfo(request); 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 = request.nextUrl.searchParams; const filePath = searchParams.get("path"); const validatedParams = downloadRequestSchema.parse({ path: filePath }); 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); // 파일명 추출 및 검증 const fileName = path.split("/").pop() || "download"; // 파일 확장자 검증 if (!validateFileExtension(fileName)) { console.warn(`🚨 허용되지 않은 파일 타입 다운로드 시도: ${fileName}`, { userId: session.user.id, ip: requestInfo.ip }); // 실패 로그 기록 await createFileDownloadLog({ fileId: 0, // techsales 파일은 별도 ID가 없으므로 0으로 기록 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"]; 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.substring(1)); // leading slash 제거 // 경로 탐색 공격 방지 - 허용된 디렉터리 외부 접근 차단 if (!testPath.startsWith(baseDir)) { continue; } try { await access(testPath, constants.R_OK); actualPath = testPath; console.log("✅ 파일 발견:", testPath); break; } catch (err) { // 조용히 다음 경로 시도 } } if (!actualPath || !baseDir) { console.error("❌ 파일 접근 불가:", { normalizedPath, userId: session.user.id, requestedPath: path, triedDirs: allowedDirs }); // 실패 로그 기록 await createFileDownloadLog({ fileId: 0, success: false, errorMessage: 'File not found on server', requestId: requestInfo.requestId, fileInfo: { fileName, filePath: path, fileSize: 0, } }); return NextResponse.json( { error: "File not found" }, { 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: 0, 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 contentType = getMimeType(fileName); // 안전한 파일명 생성 (특수문자 처리) const safeFileName = sanitizeFileName(fileName); // 보안 헤더와 다운로드용 헤더 설정 const headers = new Headers({ 'Content-Type': contentType, 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(safeFileName)}`, 'Content-Length': fileBuffer.length.toString(), // 보안 헤더 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache', 'Expires': '0', 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY', 'X-XSS-Protection': '1; mode=block', 'Referrer-Policy': 'strict-origin-when-cross-origin', }); // 성공 로그 기록 await createFileDownloadLog({ fileId: 0, // techsales 파일용 ID success: true, requestId: requestInfo.requestId, fileInfo: { fileName: safeFileName, filePath: path, fileSize: fileBuffer.length, } }); console.log("✅ 파일 다운로드 성공:", { fileName: safeFileName, contentType, size: fileBuffer.length, actualPath, userId: session.user.id, ip: requestInfo.ip, downloadDurationMs: Date.now() - startTime }); return new Response(fileBuffer, { headers }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error('❌ Techsales 파일 다운로드 오류:', { error: errorMessage, userId: (await getServerSession(authOptions))?.user?.id, ip: requestInfo.ip, path: request.nextUrl.searchParams.get("path"), downloadDurationMs: Date.now() - startTime }); // 에러 로그 기록 try { const filePath = request.nextUrl.searchParams.get("path") || ''; const fileName = filePath.split("/").pop() || 'unknown'; await createFileDownloadLog({ fileId: 0, success: false, errorMessage, requestId: requestInfo.requestId, fileInfo: { fileName, filePath, 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 } ); } }