summaryrefslogtreecommitdiff
path: root/lib/email-template/security.ts
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-21 07:19:52 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-21 07:19:52 +0000
commit9da494b0e3bbe7b513521d0915510fe9ee376b8b (patch)
treef936f69626bf2808ac409ce7cad97433465b3672 /lib/email-template/security.ts
parente275618ff8a1ce6977d3e2567d943edb941897f9 (diff)
(대표님, 최겸) 작업사항 - 이메일 템플릿, 메일링, 기술영업 요구사항 반영
Diffstat (limited to 'lib/email-template/security.ts')
-rw-r--r--lib/email-template/security.ts178
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