export interface ValidationResult { isValid: boolean; errors: string[]; warnings: string[]; } // 허용된 변수 목록 (환경변수나 설정파일에서 관리하는 것을 권장) const ALLOWED_VARIABLES = [ 'userName', 'companyName', 'email', 'date', 'projectName', 'message', 'currentYear', 'language', 'name', 'loginUrl' ]; // 허용된 Handlebars 헬퍼 const ALLOWED_HELPERS = ['if', 'unless', 'each', 'with']; // 위험한 패턴들 const DANGEROUS_PATTERNS = [ // JavaScript 생성자 및 프로토타입 접근 /\{\{\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, /\{\{\s*.*\.\.\/.*\}\}/gi, // 함수 호출 패턴 /\{\{\s*.*\(\s*.*\)\s*.*\}\}/gi, // 특수 문자를 이용한 우회 시도 /\{\{\s*.*[\[\](){}].*\}\}/gi, ]; // 의심스러운 패턴들 (경고용) const SUSPICIOUS_PATTERNS = [ /\{\{\s*.*password.*\}\}/gi, /\{\{\s*.*secret.*\}\}/gi, /\{\{\s*.*key.*\}\}/gi, /\{\{\s*.*token.*\}\}/gi, ]; /** * 템플릿 내용의 보안성을 검증합니다 */ export function validateTemplateContent(content: string): ValidationResult { const errors: string[] = []; const warnings: string[] = []; // 1. 위험한 패턴 검사 for (const pattern of DANGEROUS_PATTERNS) { if (pattern.test(content)) { errors.push('보안상 위험한 구문이 감지되었습니다.'); break; } } // 2. 의심스러운 패턴 검사 (경고) for (const pattern of SUSPICIOUS_PATTERNS) { if (pattern.test(content)) { warnings.push('민감한 정보가 포함될 수 있는 변수가 감지되었습니다.'); break; } } // 3. 허용되지 않은 변수 검사 const variableMatches = content.match(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g); if (variableMatches) { const usedVariables = new Set(); for (const match of variableMatches) { const variable = match.replace(/\{\{\s*|\s*\}\}/g, ''); usedVariables.add(variable); } for (const variable of usedVariables) { if (!ALLOWED_VARIABLES.includes(variable)) { errors.push(`허용되지 않은 변수 '${variable}'가 사용되었습니다.`); } } } // 4. 허용되지 않은 헬퍼 검사 const helperMatches = content.match(/\{\{\s*#([a-zA-Z_][a-zA-Z0-9_]*)/g); if (helperMatches) { const usedHelpers = new Set(); for (const match of helperMatches) { const helper = match.replace(/\{\{\s*#/, ''); usedHelpers.add(helper); } for (const helper of usedHelpers) { if (!ALLOWED_HELPERS.includes(helper)) { errors.push(`허용되지 않은 헬퍼 '${helper}'가 사용되었습니다.`); } } } // 5. HTML 출력 검사 ({{{...}}} 형태) const htmlOutputMatches = content.match(/\{\{\{[^}]+\}\}\}/g); if (htmlOutputMatches) { warnings.push('HTML 출력 구문이 감지되었습니다. 보안상 일반 출력으로 변환됩니다.'); } // 6. 템플릿 길이 제한 (DOS 방지) if (content.length > 50000) { // 50KB 제한 errors.push('템플릿 크기가 제한을 초과했습니다.'); } // 7. 중첩 깊이 제한 const nestingDepth = (content.match(/\{\{#/g) || []).length; if (nestingDepth > 10) { errors.push('템플릿 중첩 깊이가 제한을 초과했습니다.'); } return { isValid: errors.length === 0, errors, warnings }; } /** * HTML 출력을 일반 출력으로 변환합니다 */ export function sanitizeTripleBraces(content: string): string { return content.replace(/\{\{\{([^}]+)\}\}\}/g, (match, variable) => { return `{{${variable.trim()}}}`; }); } /** * 템플릿을 안전하게 전처리합니다 */ export function preprocessTemplate(content: string): string { // 1. HTML 출력을 일반 출력으로 변환 let processed = sanitizeTripleBraces(content); // 2. 의심스러운 공백 문자 제거 processed = processed.replace(/[\u200B-\u200D\uFEFF]/g, ''); // 3. 과도한 공백 정리 processed = processed.replace(/\s+/g, ' ').trim(); return processed; } /** * Handlebars 컨텍스트를 제한합니다 */ export function createSafeContext(data: Record): Record { const safeContext: Record = {}; // 허용된 변수만 컨텍스트에 포함 for (const key of ALLOWED_VARIABLES) { if (key in data) { // 값도 안전하게 처리 const value = data[key]; if (typeof value === 'string') { // HTML 태그 제거 (선택적) safeContext[key] = value.replace(/<[^>]*>/g, ''); } else if (typeof value === 'number' || typeof value === 'boolean') { safeContext[key] = value; } else { // 객체나 함수는 제외 safeContext[key] = String(value); } } } return safeContext; }