summaryrefslogtreecommitdiff
path: root/pages/api
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-22 02:57:00 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-22 02:57:00 +0000
commitee57cc221ff2edafd3c0f12a181214c602ed257e (patch)
tree148f552f503798f7a350d6eff936b889f16be49f /pages/api
parent14f61e24947fb92dd71ec0a7196a6e815f8e66da (diff)
(대표님, 최겸) 이메일 템플릿, 벤더데이터 변경사항 대응, 기술영업 변경요구사항 구현
Diffstat (limited to 'pages/api')
-rw-r--r--pages/api/pdftron/createVendorDataReports.ts364
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