diff options
Diffstat (limited to 'app/api/rfq-download')
| -rw-r--r-- | app/api/rfq-download/route.ts | 262 |
1 files changed, 193 insertions, 69 deletions
diff --git a/app/api/rfq-download/route.ts b/app/api/rfq-download/route.ts index 19991128..92607e05 100644 --- a/app/api/rfq-download/route.ts +++ b/app/api/rfq-download/route.ts @@ -1,85 +1,174 @@ // app/api/rfq-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 } from '@/db/schema/rfq'; import { eq } from 'drizzle-orm'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import rateLimit from '@/lib/rate-limit'; // Rate limiting 함수 +import { createFileDownloadLog } from '@/lib/file-download-log/service'; + +// 허용된 파일 확장자 +const ALLOWED_EXTENSIONS = new Set([ + 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', + 'txt', 'csv', 'png', 'jpg', 'jpeg', 'gif' +]); + +// 최대 파일 크기 (50MB) +const MAX_FILE_SIZE = 50 * 1024 * 1024; + +// 파일 경로 검증 함수 +function validateFilePath(filePath: string): boolean { + // null, undefined, 빈 문자열 체크 + if (!filePath || typeof filePath !== 'string') { + return false; + } + + // 위험한 패턴 체크 + const dangerousPatterns = [ + /\.\./, // 상위 디렉토리 접근 + /\/\//, // 이중 슬래시 + /[<>:"'|?*]/, // 특수문자 + /[\x00-\x1f]/, // 제어문자 + /^\/+/, // 절대경로 + /\\+/ // 백슬래시 + ]; + + return !dangerousPatterns.some(pattern => pattern.test(filePath)); +} + +// 파일 확장자 검증 +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) { try { - // 파일 경로 파라미터 받기 - const path = request.nextUrl.searchParams.get("path"); - - if (!path) { + // Rate limiting 체크 + const limiterResult = await rateLimit(request); + if (!limiterResult.success) { + return NextResponse.json( + { error: "Too many requests" }, + { status: 429 } + ); + } + + // 사용자 인증 확인 + const session = await getServerSession(authOptions); + if (!session || !session.user) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + + // 파일 경로 파라미터 받기 및 검증 + const rawPath = request.nextUrl.searchParams.get("path"); + + if (!rawPath) { return NextResponse.json( { error: "File path is required" }, { status: 400 } ); } - - // DB에서 파일 정보 조회 (정확히 일치하는 filePath로 검색) + + // 파일 경로 검증 + if (!validateFilePath(rawPath)) { + return NextResponse.json( + { error: "Invalid file path" }, + { status: 400 } + ); + } + + // 경로 정규화 + const normalizedPath = normalize(rawPath.replace(/^\/+/, "")); + + // DB에서 파일 정보 조회 const [dbRecord] = await db .select({ + id: rfqAttachments.id, fileName: rfqAttachments.fileName, - filePath: rfqAttachments.filePath + filePath: rfqAttachments.filePath, }) .from(rfqAttachments) - .where(eq(rfqAttachments.filePath, path)); - - // 파일 정보 설정 - let fileName; - - if (dbRecord) { - // DB에서 찾은 경우 원본 파일명 사용 - fileName = dbRecord.fileName; - console.log("DB에서 원본 파일명 찾음:", fileName); - } else { - // DB에서 찾지 못한 경우 경로에서 파일명 추출 - fileName = path.split('/').pop() || 'download'; + .where(eq(rfqAttachments.filePath, normalizedPath)); + + if (!dbRecord) { + 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) { - try { - await access(testPath, constants.R_OK); - actualPath = testPath; - break; - } catch (err) { - console.log("❌ 경로에 파일 없음:", testPath); - } + + // 파일 확장자 검증 + if (!validateFileExtension(dbRecord.fileName)) { + return NextResponse.json( + { error: "File type not allowed" }, + { status: 403 } + ); + } + + // 사용자 권한 확인 + const userId = session.user?.id || session.user?.email; + if (!userId) { + return NextResponse.json( + { error: "User ID not found" }, + { status: 400 } + ); + } + + // 안전한 파일 경로 구성 + const publicDir = resolve(process.cwd(), "public"); + const fullPath = resolve(publicDir, normalizedPath); + + // 경로 탐색 공격 방지 - public 디렉토리 외부 접근 차단 + if (!fullPath.startsWith(publicDir)) { + return NextResponse.json( + { error: "Access denied" }, + { status: 403 } + ); } - - if (!actualPath) { + + // 파일 존재 및 읽기 권한 확인 + try { + await access(fullPath, constants.R_OK); + } catch (error) { return NextResponse.json( - { - error: "File not found on server", - details: { - path: path, - triedPaths: possiblePaths - } - }, + { error: "File not accessible" }, { status: 404 } ); } - - const fileBuffer = await readFile(actualPath); - + + // 파일 크기 확인 + const stats = await stat(fullPath); + if (stats.size > MAX_FILE_SIZE) { + return NextResponse.json( + { error: "File too large" }, + { status: 413 } + ); + } + + // 파일 읽기 + const fileBuffer = await readFile(fullPath); + + // 안전한 파일명 생성 + const safeFileName = sanitizeFileName(dbRecord.fileName); + // MIME 타입 결정 - const fileExtension = fileName.split('.').pop()?.toLowerCase() || ''; - - let contentType = 'application/octet-stream'; // 기본 바이너리 - - // 확장자에 따른 MIME 타입 매핑 + const fileExtension = safeFileName.split('.').pop()?.toLowerCase() || ''; const mimeTypes: Record<string, string> = { 'pdf': 'application/pdf', 'doc': 'application/msword', @@ -88,33 +177,68 @@ 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', 'gif': 'image/gif', }; - - contentType = mimeTypes[fileExtension] || contentType; - - // 다운로드용 헤더 설정 + + const contentType = mimeTypes[fileExtension] || 'application/octet-stream'; + + // 보안 헤더 설정 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('X-Content-Type-Options', 'nosniff'); + headers.set('X-Frame-Options', 'DENY'); + headers.set('X-XSS-Protection', '1; mode=block'); + headers.set('Cache-Control', 'no-cache, no-store, must-revalidate'); + headers.set('Pragma', 'no-cache'); + headers.set('Expires', '0'); + + // 다운로드 로그 기록 (보안 감사를 위해) + console.log(`파일 다운로드: ${userId} - ${safeFileName} - ${new Date().toISOString()}`); + + // 감사 로그 기록 + await createFileDownloadLog({ fileId: dbRecord.id, success: true, fileInfo: { fileName: dbRecord.fileName, filePath: dbRecord.filePath } }); + return new NextResponse(fileBuffer, { status: 200, headers, }); + } catch (error) { console.error('❌ RFQ 파일 다운로드 오류:', error); + + // 실패 감사 로그 기록 + try { + const rawPath = request.nextUrl.searchParams.get("path"); + if (rawPath) { + const normalizedPath = normalize(rawPath.replace(/^\/+/, "")); + const [dbRecord] = await db + .select({ + id: rfqAttachments.id, + fileName: rfqAttachments.fileName, + filePath: rfqAttachments.filePath, + }) + .from(rfqAttachments) + .where(eq(rfqAttachments.filePath, normalizedPath)); + + if (dbRecord) { + await createFileDownloadLog({ fileId: dbRecord.id, success: false, fileInfo: { fileName: dbRecord.fileName, filePath: dbRecord.filePath } }); + + } + } + } catch (logError) { + console.error('감사 로그 기록 실패:', logError); + } + + // 에러 정보 최소화 (정보 노출 방지) return NextResponse.json( - { - error: 'Failed to download file', - details: String(error) - }, + { error: 'Internal server error' }, { status: 500 } ); } |
