diff options
Diffstat (limited to 'pages/api')
| -rw-r--r-- | pages/api/pdftron/createVendorDataReports.ts | 364 |
1 files changed, 330 insertions, 34 deletions
diff --git a/pages/api/pdftron/createVendorDataReports.ts b/pages/api/pdftron/createVendorDataReports.ts index f0c42926..e145b5d5 100644 --- a/pages/api/pdftron/createVendorDataReports.ts +++ b/pages/api/pdftron/createVendorDataReports.ts @@ -2,25 +2,238 @@ import type { NextApiRequest, NextApiResponse } from "next"; import type { File as FormidableFile } from "formidable"; import formidable from "formidable"; import fs from "fs/promises"; +import path from "path"; import { createReport } from "@/lib/pdftron/serverSDK/createReport"; export const config = { api: { - bodyParser: false, // ✅ 이게 false면 안 됨! + bodyParser: false, }, }; -// 서버 사이드용 DRM 복호화 함수 (API 라우트 내부에 정의) +// 보안 설정 +const SECURITY_CONFIG = { + ALLOWED_EXTENSIONS: new Set([ + 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', + 'txt', 'csv', 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp' + ]), + FORBIDDEN_EXTENSIONS: new Set([ + 'exe', 'bat', 'cmd', 'scr', 'vbs', 'js', 'jar', 'com', 'pif', + 'msi', 'reg', 'ps1', 'sh', 'php', 'asp', 'jsp', 'py', 'pl', + 'html', 'htm', 'xhtml', 'xml', 'svg' + ]), + MAX_FILE_SIZE: 100 * 1024 * 1024, // 100MB + MAX_FILENAME_LENGTH: 255, +}; + +// 간단한 보안 검증 함수들 +function validateExtension(fileName: string): { valid: boolean; error?: string } { + const extension = path.extname(fileName).toLowerCase().substring(1); + + if (!extension) { + return { valid: false, error: "파일 확장자가 없습니다" }; + } + + if (SECURITY_CONFIG.FORBIDDEN_EXTENSIONS.has(extension)) { + return { valid: false, error: `금지된 파일 형식입니다: .${extension}` }; + } + + if (!SECURITY_CONFIG.ALLOWED_EXTENSIONS.has(extension)) { + return { valid: false, error: `허용되지 않은 파일 형식입니다: .${extension}` }; + } + + return { valid: true }; +} + +function validateFileName(fileName: string): { valid: boolean; error?: string } { + if (fileName.length > SECURITY_CONFIG.MAX_FILENAME_LENGTH) { + return { valid: false, error: "파일명이 너무 깁니다" }; + } + + // 위험한 문자 체크 + const dangerousPatterns = [ + /[<>:"'|?*]/, + /[\x00-\x1f]/, + /^\./, + /\.\./, + /\/|\\$/, + /javascript:/i, + /data:/i, + /vbscript:/i, + /on\w+=/i, + /<script/i, + /<iframe/i, + ]; + + for (const pattern of dangerousPatterns) { + if (pattern.test(fileName)) { + return { valid: false, error: "안전하지 않은 파일명입니다" }; + } + } + + return { valid: true }; +} + +function validateFileSize(size: number): { valid: boolean; error?: string } { + if (size <= 0) { + return { valid: false, error: "파일이 비어있습니다" }; + } + + if (size > SECURITY_CONFIG.MAX_FILE_SIZE) { + const maxSizeMB = Math.round(SECURITY_CONFIG.MAX_FILE_SIZE / (1024 * 1024)); + return { valid: false, error: `파일 크기가 너무 큽니다 (최대 ${maxSizeMB}MB)` }; + } + + return { valid: true }; +} + +function validateDirectory(directory: string): { valid: boolean; error?: string } { + const normalizedDir = path.normalize(directory).replace(/^\/+/, ''); + + if (normalizedDir.includes('..') || normalizedDir.includes('//')) { + return { valid: false, error: "안전하지 않은 디렉터리 경로입니다" }; + } + + if (path.isAbsolute(directory)) { + return { valid: false, error: "절대 경로는 사용할 수 없습니다" }; + } + + return { valid: true }; +} + +async function validateFileContent(buffer: Buffer, fileName: string): Promise<{ valid: boolean; error?: string }> { + try { + // 실행 파일 패턴 검색 + const executablePatterns = [ + Buffer.from([0x4D, 0x5A]), // MZ (Windows executable) + Buffer.from([0x7F, 0x45, 0x4C, 0x46]), // ELF (Linux executable) + ]; + + for (const pattern of executablePatterns) { + if (buffer.subarray(0, pattern.length).equals(pattern)) { + return { valid: false, error: "실행 파일은 업로드할 수 없습니다" }; + } + } + + // 텍스트 기반 파일의 XSS 패턴 검사 + const textBasedExtensions = ['txt', 'csv', 'xml']; + const extension = path.extname(fileName).toLowerCase().substring(1); + + if (textBasedExtensions.includes(extension)) { + const content = buffer.toString('utf8', 0, Math.min(buffer.length, 8192)); + + const xssPatterns = [ + /<script[\s\S]*?>/i, + /<iframe[\s\S]*?>/i, + /on\w+\s*=\s*["'][^"']*["']/i, + /javascript\s*:/i, + /vbscript\s*:/i, + /data\s*:\s*text\/html/i, + ]; + + for (const pattern of xssPatterns) { + if (pattern.test(content)) { + return { valid: false, error: "파일에 잠재적으로 위험한 스크립트가 포함되어 있습니다" }; + } + } + } + + return { valid: true }; + } catch (error) { + console.error("파일 내용 검증 오류:", error); + return { valid: false, error: "파일 내용을 검증할 수 없습니다" }; + } +} + +function validateReportData(reportDatas: any[]): { valid: boolean; error?: string } { + try { + if (!Array.isArray(reportDatas)) { + return { valid: false, error: "리포트 데이터는 배열이어야 합니다" }; + } + + if (reportDatas.length === 0) { + return { valid: false, error: "리포트 데이터가 비어있습니다" }; + } + + // 데이터 개수 제한 (DoS 방지) + if (reportDatas.length > 1000) { + return { valid: false, error: "리포트 데이터가 너무 많습니다 (최대 1000개)" }; + } + + // 각 데이터 항목 검증 + for (let i = 0; i < reportDatas.length; i++) { + const item = reportDatas[i]; + + if (typeof item !== 'object' || item === null) { + return { valid: false, error: `리포트 데이터 ${i + 1}번째 항목이 유효하지 않습니다` }; + } + + // 객체 크기 제한 + const itemString = JSON.stringify(item); + if (itemString.length > 50000) { // 50KB per item + return { valid: false, error: `리포트 데이터 ${i + 1}번째 항목이 너무 큽니다` }; + } + + // XSS 방지 검증 + const dangerousPatterns = [ + /<script[\s\S]*?>/i, + /<iframe[\s\S]*?>/i, + /javascript\s*:/i, + /vbscript\s*:/i, + /on\w+\s*=/i, + ]; + + for (const pattern of dangerousPatterns) { + if (pattern.test(itemString)) { + return { valid: false, error: `리포트 데이터에 잠재적으로 위험한 스크립트가 포함되어 있습니다` }; + } + } + } + + return { valid: true }; + } catch (error) { + return { valid: false, error: "리포트 데이터 검증 중 오류가 발생했습니다" }; + } +} + +// 보안이 강화된 DRM 복호화 함수 async function decryptBufferWithDRM(buffer: Buffer, originalFileName: string): Promise<Buffer> { try { - // Buffer를 Blob으로 변환하여 FormData에 추가 + // 1. 기본 보안 검증 + const extensionCheck = validateExtension(originalFileName); + if (!extensionCheck.valid) { + console.error(`🚨 파일 확장자 보안 위반: ${originalFileName} - ${extensionCheck.error}`); + throw new Error(extensionCheck.error); + } + + const fileNameCheck = validateFileName(originalFileName); + if (!fileNameCheck.valid) { + console.error(`🚨 파일명 보안 위반: ${originalFileName} - ${fileNameCheck.error}`); + throw new Error(fileNameCheck.error); + } + + const sizeCheck = validateFileSize(buffer.length); + if (!sizeCheck.valid) { + console.error(`🚨 파일 크기 보안 위반: ${originalFileName} - ${sizeCheck.error}`); + throw new Error(sizeCheck.error); + } + + // 2. 파일 내용 기본 검증 + const contentCheck = await validateFileContent(buffer, originalFileName); + if (!contentCheck.valid) { + console.error(`🚨 파일 내용 보안 위반: ${originalFileName} - ${contentCheck.error}`); + throw new Error(contentCheck.error); + } + + console.log(`✅ 보안 검증 완료: ${originalFileName}`); + + // 3. DRM 복호화 진행 const blob = new Blob([buffer]); const file = new File([blob], originalFileName); const formData = new FormData(); formData.append('file', file); - // 로컬 6543 포트에 drm-proxy 서버가 실행되고 있어야 함 const backendUrl = "http://localhost:6543/api/drm-proxy/decrypt"; console.log(`[DRM] 서버에서 파일 복호화 시도: ${originalFileName} (크기: ${buffer.length} bytes)`); @@ -35,16 +248,21 @@ async function decryptBufferWithDRM(buffer: Buffer, originalFileName: string): P throw new Error(`DRM 서버 응답 오류 [${response.status}]: ${errorText}`); } - // 응답을 ArrayBuffer로 받아서 Buffer로 변환 const arrayBuffer = await response.arrayBuffer(); const decryptedBuffer = Buffer.from(arrayBuffer); + // 4. 복호화된 파일 재검증 + const decryptedContentCheck = await validateFileContent(decryptedBuffer, originalFileName); + if (!decryptedContentCheck.valid) { + console.warn(`⚠️ 복호화된 파일 내용 경고: ${originalFileName} - ${decryptedContentCheck.error}`); + // DRM 파일은 내용이 변형될 수 있으므로 경고만 하고 계속 진행 + } + console.log(`[DRM] 서버에서 파일 복호화 성공: ${originalFileName} (결과 크기: ${decryptedBuffer.length} bytes)`); return decryptedBuffer; } catch (error) { - // 오류 발생시 로깅하며, 폴백으로 복호화되지 않은 원본 버퍼를 리턴 const errorMessage = error instanceof Error ? `${error.name}: ${error.message}` : String(error); @@ -60,11 +278,17 @@ async function decryptBufferWithDRM(buffer: Buffer, originalFileName: string): P [발생 가능한 에러 케이스] 1. DRM 백엔드 서버가 없는 경우 - CONNECTION_REJECTED 발생 2. DRM 중앙 서버와 통신 불가한 경우 - PARENT CERT 속성 추가 불가로 인한 백엔드측 500 에러 + 3. 보안 검증 실패 - 파일 형식, 크기, 내용 검증 오류 `, error }); + + // 보안 검증 실패인 경우 원본도 반환하지 않음 + if (errorMessage.includes('보안') || errorMessage.includes('위반') || errorMessage.includes('금지된') || errorMessage.includes('허용되지')) { + throw error; // 보안 오류는 재throw + } - return buffer; // 원본 버퍼 반환 (폴백) + return buffer; // 일반적인 DRM 오류는 원본 버퍼 반환 (폴백) } } @@ -73,46 +297,99 @@ export default async function handler( res: NextApiResponse ) { if (req.method !== "POST") { - return res.status(405).end(); + return res.status(405).json({ error: "Method not allowed" }); } + // 요청 ID 생성 (로깅용) + const requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + console.log(`🚀 [${requestId}] 리포트 생성 요청 시작`); + try { - const form = formidable({ multiples: false }); + const form = formidable({ + multiples: false, + maxFileSize: SECURITY_CONFIG.MAX_FILE_SIZE, + maxFieldsSize: 10 * 1024 * 1024, // 10MB 필드 크기 제한 + maxFields: 50, // 최대 필드 수 제한 + }); form.parse(req, async (err, fields, files) => { if (err) { - console.error(err); - return res.status(500).json({ error: "Error parsing form" }); + console.error(`❌ [${requestId}] Form parsing 오류:`, err); + return res.status(500).json({ + error: "파일 업로드 처리 중 오류가 발생했습니다", + requestId + }); } try { const fileName = fields?.customFileName?.[0] ?? ""; - const reportDatas = JSON.parse(fields?.reportDatas?.[0] ?? "[]") as { - [key: string]: any; - }[]; const reportTempPath = fields?.reportTempPath?.[0] ?? ""; const reportCoverPage: FormidableFile | undefined = files?.file?.[0]; - if ( - !reportCoverPage || - fileName.length === 0 || - reportDatas.length === 0 || - reportTempPath.length === 0 - ) { - return res.status(400).json({ error: "Invalid Report Data" }); + // 1. 기본 필드 검증 + if (!reportCoverPage || fileName.length === 0 || reportTempPath.length === 0) { + return res.status(400).json({ + error: "필수 데이터가 누락되었습니다", + requestId + }); } - // 원본 파일 읽기 + // 2. 파일명 보안 검증 + const fileNameCheck = validateFileName(fileName); + if (!fileNameCheck.valid) { + console.error(`🚨 [${requestId}] 출력 파일명 보안 위반:`, fileNameCheck.error); + return res.status(400).json({ + error: `출력 파일명 오류: ${fileNameCheck.error}`, + requestId + }); + } + + // 3. 템플릿 경로 보안 검증 + const dirCheck = validateDirectory(reportTempPath); + if (!dirCheck.valid) { + console.error(`🚨 [${requestId}] 템플릿 경로 보안 위반:`, dirCheck.error); + return res.status(400).json({ + error: `템플릿 경로 오류: ${dirCheck.error}`, + requestId + }); + } + + // 4. 리포트 데이터 파싱 및 검증 + let reportDatas: any[]; + try { + const reportDataString = fields?.reportDatas?.[0] ?? "[]"; + reportDatas = JSON.parse(reportDataString); + } catch (parseError) { + console.error(`❌ [${requestId}] 리포트 데이터 파싱 오류:`, parseError); + return res.status(400).json({ + error: "리포트 데이터 형식이 올바르지 않습니다", + requestId + }); + } + + const dataValidation = validateReportData(reportDatas); + if (!dataValidation.valid) { + console.error(`🚨 [${requestId}] 리포트 데이터 보안 위반:`, dataValidation.error); + return res.status(400).json({ + error: `리포트 데이터 오류: ${dataValidation.error}`, + requestId + }); + } + + console.log(`✅ [${requestId}] 모든 보안 검증 완료 - 파일 처리 시작`); + + // 5. 원본 파일 읽기 const originalBuffer = await fs.readFile(reportCoverPage.filepath); - // DRM 복호화 처리 - console.log(`[DRM] 파일 복호화 시작: ${reportCoverPage.originalFilename || 'unknown'}`); + // 6. DRM 복호화 처리 (보안 검증 포함) + console.log(`🔐 [${requestId}] DRM 복호화 시작: ${reportCoverPage.originalFilename || 'unknown'}`); const decryptedBuffer = await decryptBufferWithDRM( originalBuffer, reportCoverPage.originalFilename || 'document.docx' ); - // 복호화된 버퍼로 리포트 생성 + // 7. 복호화된 버퍼로 리포트 생성 + console.log(`📄 [${requestId}] 리포트 생성 시작`); const { result, buffer: pdfBuffer, @@ -120,26 +397,45 @@ export default async function handler( } = await createReport(decryptedBuffer, reportTempPath, reportDatas); if (result && pdfBuffer) { + console.log(`✅ [${requestId}] 리포트 생성 성공: ${fileName}`); + + // 보안 헤더 설정 res.setHeader("Content-Type", "application/pdf"); - res.setHeader( - "Content-Disposition", - `attachment; filename="${fileName}"` - ); + res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`); + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("X-Frame-Options", "DENY"); + res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); return res.send(Buffer.from(pdfBuffer)); } - return res.status(200).json({ + console.error(`❌ [${requestId}] 리포트 생성 실패:`, error); + return res.status(500).json({ success: false, - message: "Report 생성에 실패하였습니다.", + message: "리포트 생성에 실패하였습니다.", error, + requestId, }); + } catch (e) { - console.log(e); - return res.status(400).json({ error: "Invalid additionalData" }); + console.error(`❌ [${requestId}] 처리 중 오류:`, e); + + // 보안 오류와 일반 오류 구분 + const errorMessage = e instanceof Error ? e.message : "처리 중 오류가 발생했습니다"; + const isSecurityError = errorMessage.includes('보안') || errorMessage.includes('위반') || + errorMessage.includes('금지된') || errorMessage.includes('허용되지'); + + return res.status(isSecurityError ? 400 : 500).json({ + error: isSecurityError ? errorMessage : "처리 중 오류가 발생했습니다", + requestId + }); } }); } catch (err) { - return res.status(401).end(); + console.error(`❌ [${requestId}] 전역 오류:`, err); + return res.status(500).json({ + error: "서버 내부 오류가 발생했습니다", + requestId + }); } }
\ No newline at end of file |
