385 lines
8.2 KiB
TypeScript
385 lines
8.2 KiB
TypeScript
|
|
/**
|
||
|
|
* 데이터베이스 관련 검증 유틸리티
|
||
|
|
*
|
||
|
|
* 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));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|