diff options
Diffstat (limited to 'components/mail')
| -rw-r--r-- | components/mail/mail-template-editor-client.tsx | 205 |
1 files changed, 173 insertions, 32 deletions
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 |
