summaryrefslogtreecommitdiff
path: root/lib/email-template/security.ts
blob: 57eb43140ceab1359a0fa6884b9de35fcbe3046a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
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;
  }