summaryrefslogtreecommitdiff
path: root/app/api/rfq-attachments
diff options
context:
space:
mode:
Diffstat (limited to 'app/api/rfq-attachments')
-rw-r--r--app/api/rfq-attachments/download/route.ts371
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 }
);