페이즈1 완료
This commit is contained in:
parent
ed78ef184d
commit
bcc79b185c
|
|
@ -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<string, any>): 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<T>(
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,290 @@
|
|||
/**
|
||||
* SQL 쿼리 빌더 유틸리티
|
||||
*
|
||||
* Raw Query 방식에서 안전하고 효율적인 쿼리 생성을 위한 헬퍼
|
||||
*/
|
||||
|
||||
export interface SelectOptions {
|
||||
columns?: string[];
|
||||
where?: Record<string, any>;
|
||||
joins?: JoinClause[];
|
||||
orderBy?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
groupBy?: string[];
|
||||
having?: Record<string, any>;
|
||||
}
|
||||
|
||||
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<string, any>,
|
||||
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<string, any>,
|
||||
where: Record<string, any>,
|
||||
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<string, any>): 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<string, any> = {}): 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<string, any>): 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<string, any>,
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue