diff --git a/backend-node/src/services/reportService.ts b/backend-node/src/services/reportService.ts index 3416c2ea..77087f25 100644 --- a/backend-node/src/services/reportService.ts +++ b/backend-node/src/services/reportService.ts @@ -22,6 +22,61 @@ import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory"; import { ExternalDbConnectionService } from "./externalDbConnectionService"; export class ReportService { + /** + * SQL 쿼리 검증 (SELECT만 허용) + */ + private validateQuerySafety(sql: string): void { + // 위험한 SQL 명령어 목록 + const dangerousKeywords = [ + "DELETE", + "DROP", + "TRUNCATE", + "INSERT", + "UPDATE", + "ALTER", + "CREATE", + "REPLACE", + "MERGE", + "GRANT", + "REVOKE", + "EXECUTE", + "EXEC", + "CALL", + ]; + + // SQL을 대문자로 변환하여 검사 + const upperSql = sql.toUpperCase().trim(); + + // 위험한 키워드 검사 + for (const keyword of dangerousKeywords) { + // 단어 경계를 고려하여 검사 (예: DELETE, DELETE FROM 등) + const regex = new RegExp(`\\b${keyword}\\b`, "i"); + if (regex.test(upperSql)) { + throw new Error( + `보안상의 이유로 ${keyword} 명령어는 사용할 수 없습니다. SELECT 쿼리만 허용됩니다.` + ); + } + } + + // SELECT 쿼리인지 확인 + if (!upperSql.startsWith("SELECT") && !upperSql.startsWith("WITH")) { + throw new Error( + "SELECT 쿼리만 허용됩니다. 데이터 조회 용도로만 사용할 수 있습니다." + ); + } + + // 세미콜론으로 구분된 여러 쿼리 방지 + const semicolonCount = (sql.match(/;/g) || []).length; + if ( + semicolonCount > 1 || + (semicolonCount === 1 && !sql.trim().endsWith(";")) + ) { + throw new Error( + "보안상의 이유로 여러 개의 쿼리를 동시에 실행할 수 없습니다." + ); + } + } + /** * 리포트 목록 조회 */ @@ -674,6 +729,9 @@ export class ReportService { connectionId = queryResult.external_connection_id; } + // SQL 쿼리 안전성 검증 (SELECT만 허용) + this.validateQuerySafety(sql_query); + // 파라미터 배열 생성 ($1, $2 순서대로) const paramArray: any[] = []; for (const param of queryParameters) { diff --git a/frontend/components/report/designer/QueryManager.tsx b/frontend/components/report/designer/QueryManager.tsx index 1e07dfed..74123d25 100644 --- a/frontend/components/report/designer/QueryManager.tsx +++ b/frontend/components/report/designer/QueryManager.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; @@ -16,6 +16,65 @@ import { reportApi } from "@/lib/api/reportApi"; import { useToast } from "@/hooks/use-toast"; import type { ExternalConnection } from "@/types/report"; +// SQL 쿼리 안전성 검증 함수 (컴포넌트 외부에 선언) +const validateQuerySafety = (sql: string): { isValid: boolean; errorMessage: string | null } => { + if (!sql || sql.trim() === "") { + return { isValid: false, errorMessage: "쿼리를 입력해주세요." }; + } + + // 위험한 SQL 명령어 목록 + const dangerousKeywords = [ + "DELETE", + "DROP", + "TRUNCATE", + "INSERT", + "UPDATE", + "ALTER", + "CREATE", + "REPLACE", + "MERGE", + "GRANT", + "REVOKE", + "EXECUTE", + "EXEC", + "CALL", + ]; + + // SQL을 대문자로 변환하여 검사 + const upperSql = sql.toUpperCase().trim(); + + // 위험한 키워드 검사 + for (const keyword of dangerousKeywords) { + // 단어 경계를 고려하여 검사 + const regex = new RegExp(`\\b${keyword}\\b`, "i"); + if (regex.test(upperSql)) { + return { + isValid: false, + errorMessage: `보안상의 이유로 ${keyword} 명령어는 사용할 수 없습니다. SELECT 쿼리만 허용됩니다.`, + }; + } + } + + // SELECT 쿼리인지 확인 + if (!upperSql.startsWith("SELECT") && !upperSql.startsWith("WITH")) { + return { + isValid: false, + errorMessage: "SELECT 쿼리만 허용됩니다. 데이터 조회 용도로만 사용할 수 있습니다.", + }; + } + + // 세미콜론으로 구분된 여러 쿼리 방지 + const semicolonCount = (sql.match(/;/g) || []).length; + if (semicolonCount > 1 || (semicolonCount === 1 && !sql.trim().endsWith(";"))) { + return { + isValid: false, + errorMessage: "보안상의 이유로 여러 개의 쿼리를 동시에 실행할 수 없습니다.", + }; + } + + return { isValid: true, errorMessage: null }; +}; + export function QueryManager() { const { queries, setQueries, reportId, setQueryResult, getQueryResult } = useReportDesigner(); const [selectedQueryId, setSelectedQueryId] = useState(null); @@ -29,6 +88,12 @@ export function QueryManager() { const selectedQuery = queries.find((q) => q.id === selectedQueryId); const testResult = selectedQuery ? getQueryResult(selectedQuery.id) : null; + // 선택된 쿼리의 안전성 검증 결과 + const queryValidation = useMemo( + () => (selectedQuery ? validateQuerySafety(selectedQuery.sqlQuery) : { isValid: false, errorMessage: null }), + [selectedQuery], + ); + // 외부 DB 연결 목록 조회 useEffect(() => { const fetchConnections = async () => { @@ -139,6 +204,17 @@ export function QueryManager() { return; } + // SQL 쿼리 안전성 검증 + const validation = validateQuerySafety(selectedQuery.sqlQuery); + if (!validation.isValid) { + toast({ + title: "쿼리 검증 실패", + description: validation.errorMessage || "잘못된 쿼리입니다.", + variant: "destructive", + }); + return; + } + setIsTestRunning(true); try { // new 리포트는 임시 ID 사용하고 SQL 쿼리 직접 전달 @@ -381,13 +457,21 @@ export function QueryManager() { )} + {/* SQL 검증 경고 메시지 */} + {!queryValidation.isValid && queryValidation.errorMessage && ( + + + {queryValidation.errorMessage} + + )} + {/* 테스트 실행 */}