diff options
Diffstat (limited to 'app/api/rfq-attachments/download/route.ts')
| -rw-r--r-- | app/api/rfq-attachments/download/route.ts | 371 |
1 files changed, 311 insertions, 60 deletions
diff --git a/app/api/rfq-attachments/download/route.ts b/app/api/rfq-attachments/download/route.ts index 05e87906..5a07bc0b 100644 --- a/app/api/rfq-attachments/download/route.ts +++ b/app/api/rfq-attachments/download/route.ts @@ -1,44 +1,161 @@ // app/api/rfq-attachments/download/route.ts import { NextRequest, NextResponse } from 'next/server'; -import { readFile, access, constants } from 'fs/promises'; -import { join } from 'path'; -import db from '@/db/db'; +import { readFile, access, constants, stat } from 'fs/promises'; +import { join, normalize, resolve } from 'path'; +import db from '@/db/db'; import { bRfqAttachmentRevisions, vendorResponseAttachmentsB } from '@/db/schema'; -import { eq, or } from 'drizzle-orm'; +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'), + type: z.enum(['client', 'vendor']).optional(), + revisionId: z.string().optional(), + responseFileId: z.string().optional(), +}); + +// 파일 정보 타입 +interface FileRecord { + id: number; + fileName: string; + originalFileName?: 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 path = request.nextUrl.searchParams.get("path"); - const type = request.nextUrl.searchParams.get("type"); // "client" | "vendor" - const revisionId = request.nextUrl.searchParams.get("revisionId"); - const responseFileId = request.nextUrl.searchParams.get("responseFileId"); + // 파라미터 검증 + const searchParams = { + path: request.nextUrl.searchParams.get("path"), + type: request.nextUrl.searchParams.get("type"), + revisionId: request.nextUrl.searchParams.get("revisionId"), + responseFileId: request.nextUrl.searchParams.get("responseFileId"), + }; + + const validatedParams = downloadRequestSchema.parse(searchParams); + const { path, type, revisionId, responseFileId } = validatedParams; - if (!path) { + // 파일 경로 보안 검증 + 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에서 파일 정보 조회 - let dbRecord = null; + let dbRecord: FileRecord | null = null; if (type === "client" && revisionId) { // 발주처 첨부파일 리비전 const [record] = await db .select({ + id: bRfqAttachmentRevisions.id, fileName: bRfqAttachmentRevisions.fileName, originalFileName: bRfqAttachmentRevisions.originalFileName, filePath: bRfqAttachmentRevisions.filePath, @@ -54,6 +171,7 @@ export async function GET(request: NextRequest) { // 벤더 응답 파일 const [record] = await db .select({ + id: vendorResponseAttachmentsB.id, fileName: vendorResponseAttachmentsB.fileName, originalFileName: vendorResponseAttachmentsB.originalFileName, filePath: vendorResponseAttachmentsB.filePath, @@ -66,9 +184,10 @@ export async function GET(request: NextRequest) { dbRecord = record; } else { - // filePath로 직접 검색 (fallback) + // filePath로 직접 검색 (fallback) - 정규화된 경로로 검색 const [clientRecord] = await db .select({ + id: bRfqAttachmentRevisions.id, fileName: bRfqAttachmentRevisions.fileName, originalFileName: bRfqAttachmentRevisions.originalFileName, filePath: bRfqAttachmentRevisions.filePath, @@ -76,7 +195,7 @@ export async function GET(request: NextRequest) { fileType: bRfqAttachmentRevisions.fileType, }) .from(bRfqAttachmentRevisions) - .where(eq(bRfqAttachmentRevisions.filePath, path)); + .where(eq(bRfqAttachmentRevisions.filePath, normalizedPath)); if (clientRecord) { dbRecord = clientRecord; @@ -84,6 +203,7 @@ export async function GET(request: NextRequest) { // 벤더 파일에서도 검색 const [vendorRecord] = await db .select({ + id: vendorResponseAttachmentsB.id, fileName: vendorResponseAttachmentsB.fileName, originalFileName: vendorResponseAttachmentsB.originalFileName, filePath: vendorResponseAttachmentsB.filePath, @@ -91,72 +211,142 @@ export async function GET(request: NextRequest) { fileType: vendorResponseAttachmentsB.fileType, }) .from(vendorResponseAttachmentsB) - .where(eq(vendorResponseAttachmentsB.filePath, path)); + .where(eq(vendorResponseAttachmentsB.filePath, normalizedPath)); dbRecord = vendorRecord; } } - // 파일 정보 설정 - let fileName; - let fileType; + // DB에서 파일 정보를 찾지 못한 경우 + if (!dbRecord) { + console.warn("⚠️ DB에서 파일 정보를 찾지 못함:", { + path, + normalizedPath, + userId: session.user.id, + ip: requestInfo.ip + }); - if (dbRecord) { - // DB에서 찾은 경우 원본 파일명 사용 - fileName = dbRecord.originalFileName || dbRecord.fileName; - fileType = dbRecord.fileType; - console.log("✅ DB에서 파일 정보 찾음:", { fileName, fileType, path: dbRecord.filePath }); - } else { - // DB에서 찾지 못한 경우 경로에서 파일명 추출 - fileName = path.split('/').pop() || 'download'; - console.log("⚠️ DB에서 파일 정보를 찾지 못함, 경로에서 추출:", fileName); + return NextResponse.json( + { error: "File not found in database" }, + { status: 404 } + ); + } + + fileRecord = dbRecord; + + // 파일명 설정 + const fileName = dbRecord.originalFileName || 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 storedPath = path.replace(/^\/+/, ""); // 앞쪽 슬래시 제거 + // 안전한 파일 경로 구성 + const allowedDirs = ["public", "uploads", "storage"]; + let actualPath: string | null = null; + let baseDir: string | null = null; - // 가능한 파일 경로들 - const possiblePaths = [ - join(process.cwd(), "public", storedPath), - join(process.cwd(), "uploads", storedPath), - join(process.cwd(), "storage", storedPath), - join(process.cwd(), storedPath), // 절대 경로인 경우 - ]; + // 각 허용된 디렉터리에서 파일 찾기 + for (const dir of allowedDirs) { + baseDir = resolve(process.cwd(), dir); + const testPath = resolve(baseDir, normalizedPath); + + // 경로 탐색 공격 방지 - 허용된 디렉터리 외부 접근 차단 + if (!testPath.startsWith(baseDir)) { + continue; + } - // 실제 파일 찾기 - let actualPath = null; - for (const testPath of possiblePaths) { try { await access(testPath, constants.R_OK); actualPath = testPath; console.log("✅ 파일 발견:", testPath); break; } catch (err) { - // 조용히 다음 경로 시도 + // 조용히 다음 디렉터리 시도 } } - if (!actualPath) { - console.error("❌ 모든 경로에서 파일을 찾을 수 없음:", possiblePaths); + if (!actualPath || !baseDir) { + console.error("❌ 모든 경로에서 파일을 찾을 수 없음:", { + normalizedPath, + userId: session.user.id, + requestedPath: path + }); + + // 실패 로그 기록 + 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, - triedPaths: possiblePaths - } - }, + { error: "File not found on server" }, { 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 = fileType || 'application/octet-stream'; // DB의 fileType 우선 사용 + let contentType = dbRecord.fileType || 'application/octet-stream'; // 확장자에 따른 MIME 타입 매핑 (fallback) if (!contentType || contentType === 'application/octet-stream') { @@ -168,8 +358,8 @@ export async function GET(request: NextRequest) { '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', + 'txt': 'text/plain; charset=utf-8', + 'csv': 'text/csv; charset=utf-8', 'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', @@ -186,23 +376,44 @@ export async function GET(request: NextRequest) { contentType = mimeTypes[fileExtension] || 'application/octet-stream'; } - // 안전한 파일명 생성 (특수문자 처리) - const safeFileName = fileName.replace(/[^\w\s.-]/gi, '_'); + // 안전한 파일명 생성 + 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("✅ 파일 다운로드 성공:", { fileName: safeFileName, contentType, size: fileBuffer.length, - actualPath + actualPath, + userId: session.user.id, + ip: requestInfo.ip, + downloadDurationMs: Date.now() - startTime }); return new NextResponse(fileBuffer, { @@ -211,11 +422,51 @@ export async function GET(request: NextRequest) { }); } catch (error) { - console.error('❌ RFQ 첨부파일 다운로드 오류:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + + console.error('❌ RFQ 첨부파일 다운로드 오류:', { + 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: error instanceof Error ? error.message : String(error) + error: 'Internal server error', + details: process.env.NODE_ENV === 'development' ? errorMessage : undefined }, { status: 500 } ); |
