diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-21 07:19:52 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-21 07:19:52 +0000 |
| commit | 9da494b0e3bbe7b513521d0915510fe9ee376b8b (patch) | |
| tree | f936f69626bf2808ac409ce7cad97433465b3672 /lib/email-template/security.ts | |
| parent | e275618ff8a1ce6977d3e2567d943edb941897f9 (diff) | |
(대표님, 최겸) 작업사항 - 이메일 템플릿, 메일링, 기술영업 요구사항 반영
Diffstat (limited to 'lib/email-template/security.ts')
| -rw-r--r-- | lib/email-template/security.ts | 178 |
1 files changed, 178 insertions, 0 deletions
diff --git a/lib/email-template/security.ts b/lib/email-template/security.ts new file mode 100644 index 00000000..57eb4314 --- /dev/null +++ b/lib/email-template/security.ts @@ -0,0 +1,178 @@ + + +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<string>(); + 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<string>(); + 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<string, any>): Record<string, any> { + const safeContext: Record<string, any> = {}; + + // 허용된 변수만 컨텍스트에 포함 + 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; + }
\ No newline at end of file |
