From 9da494b0e3bbe7b513521d0915510fe9ee376b8b Mon Sep 17 00:00:00 2001
From: dujinkim <dujin.kim@dtsolution.co.kr>
Date: Mon, 21 Jul 2025 07:19:52 +0000
Subject: (대표님, 최겸) 작업사항 - 이메일 템플릿, 메일링, 기술영업 요구사항
 반영
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 components/mail/mail-template-editor-client.tsx | 205 ++++++++++++++++++++----
 1 file changed, 173 insertions(+), 32 deletions(-)

(limited to 'components/mail/mail-template-editor-client.tsx')

diff --git a/components/mail/mail-template-editor-client.tsx b/components/mail/mail-template-editor-client.tsx
index dfbeb4e0..c9770aec 100644
--- a/components/mail/mail-template-editor-client.tsx
+++ b/components/mail/mail-template-editor-client.tsx
@@ -5,7 +5,7 @@ import { useRouter, useParams } from 'next/navigation';
 import { Button } from '@/components/ui/button';
 import { Label } from '@/components/ui/label';
 import { Textarea } from '@/components/ui/textarea';
-import { Save, Eye } from 'lucide-react';
+import { Save, Eye, AlertTriangle } from 'lucide-react';
 import { toast } from 'sonner';
 import Link from 'next/link';
 import { getTemplateAction, updateTemplateAction, previewTemplateAction, TemplateFile } from '@/lib/mail/service';
@@ -17,13 +17,81 @@ interface MailTemplateEditorClientProps {
   initialTemplate?: Template | null;
 }
 
-export default function MailTemplateEditorClient({ 
-  templateName, 
-  initialTemplate 
+// 보안: 허용된 Handlebars 헬퍼와 변수만 정의
+const ALLOWED_VARIABLES = [
+  'userName', 'companyName', 'email', 'date', 'projectName', 
+  'message', 'currentYear', 'language', 'name', 'loginUrl'
+];
+
+const ALLOWED_HELPERS = ['if', 'unless', 'each', 'with'];
+
+// 보안: 위험한 패턴 탐지
+const DANGEROUS_PATTERNS = [
+  /\{\{\s*(constructor|prototype|__proto__|process|global|require|import|eval|Function)\s*\}\}/gi,
+  /\{\{\s*.*\.(constructor|prototype|__proto__)\s*.*\}\}/gi,
+  /\{\{\s*.*\[\s*['"`]constructor['"`]\s*\]\s*.*\}\}/gi,
+  /\{\{\s*.*require\s*\(.*\)\s*.*\}\}/gi,
+  /\{\{\s*.*process\s*\..*\}\}/gi,
+  /\{\{\s*.*global\s*\..*\}\}/gi,
+  /\{\{\s*.*this\s*\..*\}\}/gi,
+  /\{\{\s*#with\s+.*\.\.\s*\}\}/gi, // path traversal
+];
+
+// 보안: 템플릿 내용 검증
+const validateTemplateContent = (content: string): { isValid: boolean; errors: string[] } => {
+  const errors: string[] = [];
+
+  // 위험한 패턴 검사
+  for (const pattern of DANGEROUS_PATTERNS) {
+    if (pattern.test(content)) {
+      errors.push('보안상 위험한 구문이 감지되었습니다.');
+      break;
+    }
+  }
+
+  // 허용되지 않은 변수 검사
+  const variableMatches = content.match(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g);
+  if (variableMatches) {
+    for (const match of variableMatches) {
+      const variable = match.replace(/\{\{\s*|\s*\}\}/g, '');
+      if (!ALLOWED_VARIABLES.includes(variable)) {
+        errors.push(`허용되지 않은 변수 '${variable}'가 사용되었습니다.`);
+      }
+    }
+  }
+
+  // 허용되지 않은 헬퍼 검사
+  const helperMatches = content.match(/\{\{\s*#([a-zA-Z_][a-zA-Z0-9_]*)/g);
+  if (helperMatches) {
+    for (const match of helperMatches) {
+      const helper = match.replace(/\{\{\s*#/, '');
+      if (!ALLOWED_HELPERS.includes(helper)) {
+        errors.push(`허용되지 않은 헬퍼 '${helper}'가 사용되었습니다.`);
+      }
+    }
+  }
+
+  return {
+    isValid: errors.length === 0,
+    errors
+  };
+};
+
+// 보안: HTML 출력 무력화 ({{{html}}} 형태 방지)
+const sanitizeTripleBraces = (content: string): string => {
+  return content.replace(/\{\{\{([^}]+)\}\}\}/g, (match, variable) => {
+    // HTML 출력을 일반 변수 출력으로 변환
+    return `{{${variable.trim()}}}`;
+  });
+};
+
+export default function MailTemplateEditorClient({
+  templateName,
+  initialTemplate
 }: MailTemplateEditorClientProps) {
   const router = useRouter();
   const params = useParams();
-  
+
   const lng = (params?.lng as string) || 'ko';
 
   const [template, setTemplate] = useState<Template | null>(initialTemplate || null);
@@ -32,8 +100,15 @@ export default function MailTemplateEditorClient({
   const [saving, setSaving] = useState(false);
   const [previewLoading, setPreviewLoading] = useState(false);
   const [previewHtml, setPreviewHtml] = useState<string | null>(null);
+  const [validationErrors, setValidationErrors] = useState<string[]>([]);
   const [, startTransition] = useTransition();
 
+  // 보안: 실시간 검증
+  useEffect(() => {
+    const validation = validateTemplateContent(content);
+    setValidationErrors(validation.errors);
+  }, [content]);
+
   // 템플릿 조회
   const fetchTemplate = async () => {
     if (!templateName) {
@@ -71,10 +146,19 @@ export default function MailTemplateEditorClient({
       return;
     }
 
+    // 보안: 저장 전 검증
+    const validation = validateTemplateContent(content);
+    if (!validation.isValid) {
+      toast.error('보안 검증에 실패했습니다. 오류를 확인해주세요.');
+      return;
+    }
+
     try {
       setSaving(true);
       startTransition(async () => {
-        const result = await updateTemplateAction(templateName, content);
+        // 보안: HTML 출력 방지 처리
+        const sanitizedContent = sanitizeTripleBraces(content);
+        const result = await updateTemplateAction(templateName, sanitizedContent);
 
         if (result.success && result.data) {
           toast.success('템플릿이 성공적으로 저장되었습니다.');
@@ -93,9 +177,19 @@ export default function MailTemplateEditorClient({
 
   // 미리보기 생성
   const handlePreview = async () => {
+    // 보안: 미리보기 전 검증
+    const validation = validateTemplateContent(content);
+    if (!validation.isValid) {
+      toast.error('보안 검증에 실패했습니다. 오류를 확인해주세요.');
+      return;
+    }
+
     try {
       setPreviewLoading(true);
       startTransition(async () => {
+        // 보안: HTML 출력 방지 처리
+        const sanitizedContent = sanitizeTripleBraces(content);
+        
         const result = await previewTemplateAction(
           templateName,
           {
@@ -109,7 +203,8 @@ export default function MailTemplateEditorClient({
             language: 'ko',
             name: '홍길동',
             loginUrl: 'https://example.com/login'
-          }
+          },
+          sanitizedContent // 보안: 검증된 내용만 전달
         );
 
         if (result.success && result.data) {
@@ -152,6 +247,8 @@ export default function MailTemplateEditorClient({
     );
   }
 
+  const hasValidationErrors = validationErrors.length > 0;
+
   return (
     <div className="space-y-8">
       {/* 헤더 */}
@@ -169,12 +266,29 @@ export default function MailTemplateEditorClient({
         </div>
       </div>
 
+      {/* 보안 경고 */}
+      {hasValidationErrors && (
+        <div className="bg-red-50 border border-red-200 rounded-lg p-4">
+          <div className="flex items-start gap-3">
+            <AlertTriangle className="h-5 w-5 text-red-600 mt-0.5" />
+            <div>
+              <h3 className="font-semibold text-red-800">보안 검증 오류</h3>
+              <ul className="mt-2 text-sm text-red-700 space-y-1">
+                {validationErrors.map((error, index) => (
+                  <li key={index}>• {error}</li>
+                ))}
+              </ul>
+            </div>
+          </div>
+        </div>
+      )}
+
       <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
         {/* 편집 영역 */}
         <div className="space-y-6">
           <div className="bg-white p-6 rounded-lg shadow">
             <h2 className="text-xl font-semibold mb-4">템플릿 내용</h2>
-            
+
             <div className="space-y-4">
               <div>
                 <Label htmlFor="content">Handlebars 템플릿</Label>
@@ -182,16 +296,27 @@ export default function MailTemplateEditorClient({
                   id="content"
                   value={content}
                   onChange={(e) => setContent(e.target.value)}
-                  className="min-h-[500px] font-mono text-sm"
+                  className={`min-h-[500px] font-mono text-sm ${
+                    hasValidationErrors ? 'border-red-300 focus:border-red-500' : ''
+                  }`}
                 />
               </div>
 
               <div className="flex gap-2">
-                <Button onClick={handleSave} disabled={saving}>
+                <Button 
+                  onClick={handleSave} 
+                  disabled={saving || hasValidationErrors}
+                  className={hasValidationErrors ? 'opacity-50 cursor-not-allowed' : ''}
+                >
                   <Save className="h-4 w-4 mr-2" />
                   {saving ? '저장 중...' : '저장'}
                 </Button>
-                <Button variant="outline" onClick={handlePreview} disabled={previewLoading}>
+                <Button 
+                  variant="outline" 
+                  onClick={handlePreview} 
+                  disabled={previewLoading || hasValidationErrors}
+                  className={hasValidationErrors ? 'opacity-50 cursor-not-allowed' : ''}
+                >
                   <Eye className="h-4 w-4 mr-2" />
                   {previewLoading ? '생성 중...' : '미리보기'}
                 </Button>
@@ -204,13 +329,18 @@ export default function MailTemplateEditorClient({
         <div className="space-y-6">
           <div className="bg-white p-6 rounded-lg shadow">
             <h2 className="text-xl font-semibold mb-4">빠른 미리보기</h2>
-            
+
             <div className="border rounded-lg p-4 min-h-[500px] bg-gray-50 overflow-auto">
               {previewHtml ? (
-                <div 
-                  className="preview-content"
-                  dangerouslySetInnerHTML={{ __html: previewHtml }}
-                />
+                <div className="preview-content">
+                  {/* 보안: 더 안전한 HTML 렌더링 */}
+                  <iframe
+                    srcDoc={previewHtml}
+                    sandbox="allow-same-origin"
+                    className="w-full h-96 border-0"
+                    title="Template Preview"
+                  />
+                </div>
               ) : (
                 <div className="text-center text-gray-500 py-20">
                   미리보기 버튼을 클릭하여 결과를 확인하세요.
@@ -219,14 +349,25 @@ export default function MailTemplateEditorClient({
             </div>
           </div>
 
-          {/* 도움말 */}
+          {/* 보안 가이드라인 */}
+          <div className="bg-amber-50 border border-amber-200 p-4 rounded-lg">
+            <h3 className="font-semibold text-amber-900 mb-2">보안 가이드라인</h3>
+            <div className="text-sm text-amber-800 space-y-1">
+              <p>• 허용된 변수만 사용하세요: {ALLOWED_VARIABLES.join(', ')}</p>
+              <p>• 허용된 헬퍼만 사용하세요: {ALLOWED_HELPERS.join(', ')}</p>
+              <p>• HTML 출력({`{{{}}}`})은 자동으로 일반 출력으로 변환됩니다</p>
+              <p>• 시스템 관련 변수나 함수 접근은 차단됩니다</p>
+            </div>
+          </div>
+
+          {/* Handlebars 문법 도움말 */}
           <div className="bg-blue-50 p-4 rounded-lg">
             <h3 className="font-semibold text-blue-900 mb-2">Handlebars 문법 도움말</h3>
             <div className="text-sm text-blue-800 space-y-1">
-              <p><code>{`{{variable}}`}</code> - 변수 출력</p>
-              <p><code>{`{{{html}}}`}</code> - HTML 출력 (이스케이프 없음)</p>
+              <p><code>{`{{variable}}`}</code> - 변수 출력 (자동 이스케이프)</p>
               <p><code>{`{{#if condition}}`}</code> - 조건문</p>
               <p><code>{`{{#each items}}`}</code> - 반복문</p>
+              <p><code>{`{{#unless condition}}`}</code> - 부정 조건문</p>
             </div>
           </div>
 
@@ -234,22 +375,22 @@ export default function MailTemplateEditorClient({
           <div className="bg-gray-50 p-4 rounded-lg">
             <h3 className="font-semibold text-gray-900 mb-2">미리보기 샘플 데이터</h3>
             <pre className="text-xs text-gray-600 overflow-auto">
-{JSON.stringify({
-  userName: '홍길동',
-  companyName: 'EVCP',
-  email: 'user@example.com',
-  date: new Date().toLocaleDateString('ko-KR'),
-  projectName: '샘플 프로젝트',
-  message: '이것은 샘플 메시지입니다.',
-  currentYear: new Date().getFullYear(),
-  language: 'ko',
-  name: '홍길동',
-  loginUrl: 'https://example.com/login'
-}, null, 2)}
+              {JSON.stringify({
+                userName: '홍길동',
+                companyName: 'EVCP',
+                email: 'user@example.com',
+                date: new Date().toLocaleDateString('ko-KR'),
+                projectName: '샘플 프로젝트',
+                message: '이것은 샘플 메시지입니다.',
+                currentYear: new Date().getFullYear(),
+                language: 'ko',
+                name: '홍길동',
+                loginUrl: 'https://example.com/login'
+              }, null, 2)}
             </pre>
           </div>
         </div>
       </div>
     </div>
   );
-} 
\ No newline at end of file
+}
\ No newline at end of file
-- 
cgit v1.2.3

