/** * 데이터베이스 관련 검증 유틸리티 * * SQL 인젝션 방지 및 데이터 무결성 보장을 위한 검증 함수들 */ export class DatabaseValidator { // PostgreSQL 예약어 목록 (주요 키워드만) private static readonly RESERVED_WORDS = new Set([ "SELECT", "INSERT", "UPDATE", "DELETE", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "FULL", "ON", "GROUP", "BY", "ORDER", "HAVING", "LIMIT", "OFFSET", "UNION", "ALL", "DISTINCT", "AS", "AND", "OR", "NOT", "NULL", "TRUE", "FALSE", "CASE", "WHEN", "THEN", "ELSE", "END", "IF", "EXISTS", "IN", "BETWEEN", "LIKE", "ILIKE", "SIMILAR", "TO", "CREATE", "DROP", "ALTER", "TABLE", "INDEX", "VIEW", "FUNCTION", "PROCEDURE", "TRIGGER", "DATABASE", "SCHEMA", "USER", "ROLE", "GRANT", "REVOKE", "COMMIT", "ROLLBACK", "BEGIN", "TRANSACTION", "SAVEPOINT", "RELEASE", "CONSTRAINT", "PRIMARY", "FOREIGN", "KEY", "UNIQUE", "CHECK", "DEFAULT", "REFERENCES", "CASCADE", "RESTRICT", "SET", "ACTION", "DEFERRABLE", "INITIALLY", "DEFERRED", "IMMEDIATE", "MATCH", "PARTIAL", "SIMPLE", "FULL", ]); // 유효한 PostgreSQL 데이터 타입 패턴 private static readonly DATA_TYPE_PATTERNS = [ /^(SMALLINT|INTEGER|BIGINT|DECIMAL|NUMERIC|REAL|DOUBLE\s+PRECISION|SMALLSERIAL|SERIAL|BIGSERIAL)$/i, /^(MONEY)$/i, /^(CHARACTER\s+VARYING|VARCHAR|CHARACTER|CHAR|TEXT)(\(\d+\))?$/i, /^(BYTEA)$/i, /^(TIMESTAMP|TIME)(\s+(WITH|WITHOUT)\s+TIME\s+ZONE)?(\(\d+\))?$/i, /^(DATE|INTERVAL)(\(\d+\))?$/i, /^(BOOLEAN|BOOL)$/i, /^(POINT|LINE|LSEG|BOX|PATH|POLYGON|CIRCLE)$/i, /^(CIDR|INET|MACADDR|MACADDR8)$/i, /^(BIT|BIT\s+VARYING)(\(\d+\))?$/i, /^(TSVECTOR|TSQUERY)$/i, /^(UUID)$/i, /^(XML)$/i, /^(JSON|JSONB)$/i, /^(ARRAY|INTEGER\[\]|TEXT\[\]|VARCHAR\[\])$/i, /^(DECIMAL|NUMERIC)\(\d+,\d+\)$/i, ]; /** * 테이블명 검증 */ static validateTableName(tableName: string): boolean { if (!tableName || typeof tableName !== "string") { return false; } // 길이 제한 (PostgreSQL 최대 63자) if (tableName.length === 0 || tableName.length > 63) { return false; } // 유효한 식별자 패턴 (문자 또는 밑줄로 시작, 문자/숫자/밑줄만 포함) const validPattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/; if (!validPattern.test(tableName)) { return false; } // 예약어 체크 if (this.RESERVED_WORDS.has(tableName.toUpperCase())) { return false; } return true; } /** * 컬럼명 검증 */ static validateColumnName(columnName: string): boolean { if (!columnName || typeof columnName !== "string") { return false; } // 길이 제한 if (columnName.length === 0 || columnName.length > 63) { return false; } // JSON 연산자 포함 컬럼명 허용 (예: config->>'type', data->>path) if (columnName.includes("->") || columnName.includes("->>")) { const baseName = columnName.split(/->|->>/)[0]; return this.validateColumnName(baseName); } // 유효한 식별자 패턴 const validPattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/; if (!validPattern.test(columnName)) { return false; } // 예약어 체크 if (this.RESERVED_WORDS.has(columnName.toUpperCase())) { return false; } return true; } /** * 데이터 타입 검증 */ static validateDataType(dataType: string): boolean { if (!dataType || typeof dataType !== "string") { return false; } const normalizedType = dataType.trim().toUpperCase(); return this.DATA_TYPE_PATTERNS.some((pattern) => pattern.test(normalizedType) ); } /** * WHERE 조건 검증 */ static validateWhereClause(whereClause: Record): boolean { if (!whereClause || typeof whereClause !== "object") { return false; } // 모든 키가 유효한 컬럼명인지 확인 for (const key of Object.keys(whereClause)) { if (!this.validateColumnName(key)) { return false; } } return true; } /** * 페이지네이션 파라미터 검증 */ static validatePagination(page: number, pageSize: number): boolean { // 페이지 번호는 1 이상 if (!Number.isInteger(page) || page < 1) { return false; } // 페이지 크기는 1 이상 1000 이하 if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 1000) { return false; } return true; } /** * ORDER BY 절 검증 */ static validateOrderBy(orderBy: string): boolean { if (!orderBy || typeof orderBy !== "string") { return false; } // 기본 패턴: column_name [ASC|DESC] const orderPattern = /^[a-zA-Z_][a-zA-Z0-9_]*(\s+(ASC|DESC))?$/i; // 여러 컬럼 정렬의 경우 콤마로 분리하여 각각 검증 const orderClauses = orderBy.split(",").map((clause) => clause.trim()); return orderClauses.every((clause) => { return ( orderPattern.test(clause) && this.validateColumnName(clause.split(/\s+/)[0]) ); }); } /** * UUID 형식 검증 */ static validateUUID(uuid: string): boolean { if (!uuid || typeof uuid !== "string") { return false; } const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; return uuidPattern.test(uuid); } /** * 이메일 형식 검증 */ static validateEmail(email: string): boolean { if (!email || typeof email !== "string") { return false; } const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailPattern.test(email) && email.length <= 254; } /** * SQL 인젝션 위험 문자열 검사 */ static containsSqlInjection(input: string): boolean { if (!input || typeof input !== "string") { return false; } // 위험한 SQL 패턴들 const dangerousPatterns = [ /('|\\')|(;)|(--)|(\s+(OR|AND)\s+\d+\s*=\s*\d+)/i, /(UNION|SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|EXEC|EXECUTE)/i, /(\bxp_\w+|\bsp_\w+)/i, // SQL Server 확장 프로시저 /(script|javascript|vbscript|onload|onerror)/i, // XSS 패턴 ]; return dangerousPatterns.some((pattern) => pattern.test(input)); } /** * 숫자 범위 검증 */ static validateNumberRange( value: number, min?: number, max?: number ): boolean { if (typeof value !== "number" || !Number.isFinite(value)) { return false; } if (min !== undefined && value < min) { return false; } if (max !== undefined && value > max) { return false; } return true; } /** * 문자열 길이 검증 */ static validateStringLength( value: string, minLength?: number, maxLength?: number ): boolean { if (typeof value !== "string") { return false; } if (minLength !== undefined && value.length < minLength) { return false; } if (maxLength !== undefined && value.length > maxLength) { return false; } return true; } /** * JSON 형식 검증 */ static validateJSON(jsonString: string): boolean { try { JSON.parse(jsonString); return true; } catch { return false; } } /** * 날짜 형식 검증 (ISO 8601) */ static validateDateISO(dateString: string): boolean { if (!dateString || typeof dateString !== "string") { return false; } const date = new Date(dateString); return !isNaN(date.getTime()) && dateString === date.toISOString(); } /** * 배열 요소 검증 */ static validateArray( array: any[], validator: (item: T) => boolean, minLength?: number, maxLength?: number ): boolean { if (!Array.isArray(array)) { return false; } if (minLength !== undefined && array.length < minLength) { return false; } if (maxLength !== undefined && array.length > maxLength) { return false; } return array.every((item) => validator(item)); } }