diff --git a/src/utils/databaseValidator.ts b/src/utils/databaseValidator.ts new file mode 100644 index 00000000..7017342e --- /dev/null +++ b/src/utils/databaseValidator.ts @@ -0,0 +1,384 @@ +/** + * 데이터베이스 관련 검증 유틸리티 + * + * 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)); + } +} + diff --git a/src/utils/queryBuilder.ts b/src/utils/queryBuilder.ts new file mode 100644 index 00000000..1ab532d7 --- /dev/null +++ b/src/utils/queryBuilder.ts @@ -0,0 +1,290 @@ +/** + * SQL 쿼리 빌더 유틸리티 + * + * Raw Query 방식에서 안전하고 효율적인 쿼리 생성을 위한 헬퍼 + */ + +export interface SelectOptions { + columns?: string[]; + where?: Record; + joins?: JoinClause[]; + orderBy?: string; + limit?: number; + offset?: number; + groupBy?: string[]; + having?: Record; +} + +export interface JoinClause { + type: "INNER" | "LEFT" | "RIGHT" | "FULL"; + table: string; + on: string; +} + +export interface InsertOptions { + returning?: string[]; + onConflict?: { + columns: string[]; + action: "DO NOTHING" | "DO UPDATE"; + updateSet?: string[]; + }; +} + +export interface UpdateOptions { + returning?: string[]; +} + +export interface QueryResult { + query: string; + params: any[]; +} + +export class QueryBuilder { + /** + * SELECT 쿼리 생성 + */ + static select(table: string, options: SelectOptions = {}): QueryResult { + const { + columns = ["*"], + where = {}, + joins = [], + orderBy, + limit, + offset, + groupBy = [], + having = {}, + } = options; + + let query = `SELECT ${columns.join(", ")} FROM ${table}`; + const params: any[] = []; + let paramIndex = 1; + + // JOIN 절 추가 + for (const join of joins) { + query += ` ${join.type} JOIN ${join.table} ON ${join.on}`; + } + + // WHERE 절 추가 + const whereConditions = Object.keys(where); + if (whereConditions.length > 0) { + const whereClause = whereConditions + .map((key) => { + params.push(where[key]); + return `${key} = $${paramIndex++}`; + }) + .join(" AND "); + query += ` WHERE ${whereClause}`; + } + + // GROUP BY 절 추가 + if (groupBy.length > 0) { + query += ` GROUP BY ${groupBy.join(", ")}`; + } + + // HAVING 절 추가 + const havingConditions = Object.keys(having); + if (havingConditions.length > 0) { + const havingClause = havingConditions + .map((key) => { + params.push(having[key]); + return `${key} = $${paramIndex++}`; + }) + .join(" AND "); + query += ` HAVING ${havingClause}`; + } + + // ORDER BY 절 추가 + if (orderBy) { + query += ` ORDER BY ${orderBy}`; + } + + // LIMIT 절 추가 + if (limit !== undefined) { + params.push(limit); + query += ` LIMIT $${paramIndex++}`; + } + + // OFFSET 절 추가 + if (offset !== undefined) { + params.push(offset); + query += ` OFFSET $${paramIndex++}`; + } + + return { query, params }; + } + + /** + * INSERT 쿼리 생성 + */ + static insert( + table: string, + data: Record, + options: InsertOptions = {} + ): QueryResult { + const { returning = [], onConflict } = options; + + const columns = Object.keys(data); + const values = Object.values(data); + const placeholders = values.map((_, index) => `$${index + 1}`).join(", "); + + let query = `INSERT INTO ${table} (${columns.join( + ", " + )}) VALUES (${placeholders})`; + + // ON CONFLICT 절 추가 + if (onConflict) { + query += ` ON CONFLICT (${onConflict.columns.join(", ")})`; + + if (onConflict.action === "DO NOTHING") { + query += " DO NOTHING"; + } else if (onConflict.action === "DO UPDATE" && onConflict.updateSet) { + const updateSet = onConflict.updateSet + .map((col) => `${col} = EXCLUDED.${col}`) + .join(", "); + query += ` DO UPDATE SET ${updateSet}`; + } + } + + // RETURNING 절 추가 + if (returning.length > 0) { + query += ` RETURNING ${returning.join(", ")}`; + } + + return { query, params: values }; + } + + /** + * UPDATE 쿼리 생성 + */ + static update( + table: string, + data: Record, + where: Record, + options: UpdateOptions = {} + ): QueryResult { + const { returning = [] } = options; + + const dataKeys = Object.keys(data); + const dataValues = Object.values(data); + const whereKeys = Object.keys(where); + const whereValues = Object.values(where); + + let paramIndex = 1; + + // SET 절 생성 + const setClause = dataKeys + .map((key) => `${key} = $${paramIndex++}`) + .join(", "); + + // WHERE 절 생성 + const whereClause = whereKeys + .map((key) => `${key} = $${paramIndex++}`) + .join(" AND "); + + let query = `UPDATE ${table} SET ${setClause} WHERE ${whereClause}`; + + // RETURNING 절 추가 + if (returning.length > 0) { + query += ` RETURNING ${returning.join(", ")}`; + } + + const params = [...dataValues, ...whereValues]; + + return { query, params }; + } + + /** + * DELETE 쿼리 생성 + */ + static delete(table: string, where: Record): QueryResult { + const whereKeys = Object.keys(where); + const whereValues = Object.values(where); + + const whereClause = whereKeys + .map((key, index) => `${key} = $${index + 1}`) + .join(" AND "); + + const query = `DELETE FROM ${table} WHERE ${whereClause}`; + + return { query, params: whereValues }; + } + + /** + * COUNT 쿼리 생성 + */ + static count(table: string, where: Record = {}): QueryResult { + const whereKeys = Object.keys(where); + const whereValues = Object.values(where); + + let query = `SELECT COUNT(*) as count FROM ${table}`; + + if (whereKeys.length > 0) { + const whereClause = whereKeys + .map((key, index) => `${key} = $${index + 1}`) + .join(" AND "); + query += ` WHERE ${whereClause}`; + } + + return { query, params: whereValues }; + } + + /** + * EXISTS 쿼리 생성 + */ + static exists(table: string, where: Record): QueryResult { + const whereKeys = Object.keys(where); + const whereValues = Object.values(where); + + const whereClause = whereKeys + .map((key, index) => `${key} = $${index + 1}`) + .join(" AND "); + + const query = `SELECT EXISTS(SELECT 1 FROM ${table} WHERE ${whereClause}) as exists`; + + return { query, params: whereValues }; + } + + /** + * 동적 WHERE 절 생성 (복잡한 조건) + */ + static buildWhereClause( + conditions: Record, + startParamIndex: number = 1 + ): { clause: string; params: any[]; nextParamIndex: number } { + const keys = Object.keys(conditions); + const params: any[] = []; + let paramIndex = startParamIndex; + + if (keys.length === 0) { + return { clause: "", params: [], nextParamIndex: paramIndex }; + } + + const clause = keys + .map((key) => { + const value = conditions[key]; + + // 특수 연산자 처리 + if (key.includes(">>") || key.includes("->")) { + // JSON 쿼리 + params.push(value); + return `${key} = $${paramIndex++}`; + } else if (Array.isArray(value)) { + // IN 절 + const placeholders = value.map(() => `$${paramIndex++}`).join(", "); + params.push(...value); + return `${key} IN (${placeholders})`; + } else if (value === null) { + // NULL 체크 + return `${key} IS NULL`; + } else { + // 일반 조건 + params.push(value); + return `${key} = $${paramIndex++}`; + } + }) + .join(" AND "); + + return { clause, params, nextParamIndex: paramIndex }; + } +} +