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/tech-sales-rfq-download | |
| parent | 2ef02e27dbe639876fa3b90c30307dda183545ec (diff) | |
(대표님) 파일관리변경, 클라IP추적, 실시간알림, 미들웨어변경, 알림API
Diffstat (limited to 'app/api/tech-sales-rfq-download')
| -rw-r--r-- | app/api/tech-sales-rfq-download/route.ts | 436 |
1 files changed, 365 insertions, 71 deletions
diff --git a/app/api/tech-sales-rfq-download/route.ts b/app/api/tech-sales-rfq-download/route.ts index b9dd14d1..e0219787 100644 --- a/app/api/tech-sales-rfq-download/route.ts +++ b/app/api/tech-sales-rfq-download/route.ts @@ -1,85 +1,379 @@ -import { NextRequest } from "next/server" -import { join } from "path" -import { readFile } from "fs/promises" +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"; -export async function GET(request: NextRequest) { - try { - const searchParams = request.nextUrl.searchParams - const filePath = searchParams.get("path") +// 허용된 파일 확장자 +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' +]); - if (!filePath) { - return new Response("File path is required", { status: 400 }) - } +// 최대 파일 크기 (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 (filePath.includes("..") || !filePath.startsWith("/techsales-rfq/")) { - return new Response("Invalid file path", { status: 400 }) + 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; +} - // 파일 경로 구성 (public 폴더 기준) - const fullPath = join(process.cwd(), "public", filePath) +// 파일 확장자 검증 +function validateFileExtension(fileName: string): boolean { + const extension = fileName.split('.').pop()?.toLowerCase() || ''; + return ALLOWED_EXTENSIONS.has(extension); +} - try { - // 파일 읽기 - const fileBuffer = await readFile(fullPath) +// 안전한 파일명 생성 +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<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', + }; + + 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 + }); - // 파일명 추출 - const fileName = filePath.split("/").pop() || "download" + 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") + }); - // MIME 타입 결정 - const ext = fileName.split(".").pop()?.toLowerCase() - let contentType = "application/octet-stream" + 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 + }); - switch (ext) { - case "pdf": - contentType = "application/pdf" - break - case "doc": - contentType = "application/msword" - break - case "docx": - contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - break - case "xls": - contentType = "application/vnd.ms-excel" - break - case "xlsx": - contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - break - case "jpg": - case "jpeg": - contentType = "image/jpeg" - break - case "png": - contentType = "image/png" - break - case "gif": - contentType = "image/gif" - break - case "txt": - contentType = "text/plain" - break - case "zip": - contentType = "application/zip" - break - default: - contentType = "application/octet-stream" + 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, + } + }); - // 응답 헤더 설정 - const headers = new Headers({ - "Content-Type": contentType, - "Content-Disposition": `attachment; filename="${encodeURIComponent(fileName)}"`, - "Content-Length": fileBuffer.length.toString(), - }) - - return new Response(fileBuffer, { headers }) - } catch (fileError) { - console.error("File read error:", fileError) - return new Response("File not found", { status: 404 }) + 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) { - console.error("Download error:", error) - return new Response("Internal server error", { status: 500 }) + 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 } + ); } -}
\ No newline at end of file +}
\ No newline at end of file |
