fix(#3): ExperionDbContext SQL parameterized query 변환 (SQL injection 방지)

This commit is contained in:
windpacer
2026-04-26 11:23:27 +09:00
parent 6f0aba4b04
commit 876f98f106

View File

@@ -175,14 +175,18 @@ public class ExperionDbService : IExperionDbService
if (tableName != null)
{
await using var cmd2 = new NpgsqlCommand(
$"SELECT 1 FROM pg_catalog.pg_tables WHERE tablename = '{tableName.Replace("'", "''")}' LIMIT 1", conn);
"SELECT 1 FROM pg_catalog.pg_tables WHERE tablename = @tableName LIMIT 1", conn);
cmd2.Parameters.AddWithValue("@tableName", tableName.Replace("'", "''"));
result = await cmd2.ExecuteScalarAsync();
if (result != null)
{
// 데이터가 있는 경우 migrate_data => true 옵션 필요
#pragma warning disable EF1002 // Method 'ExecuteSqlRawAsync' inserts interpolated strings directly into the SQL
await _ctx.Database.ExecuteSqlRawAsync(
$"SELECT create_hypertable('{tableName}', '{timeColumn}', chunk_time_interval => INTERVAL '{interval}', create_default_indexes => true, migrate_data => true)");
await using var cmd3 = new NpgsqlCommand(
"SELECT create_hypertable(@tableName, @timeColumn, chunk_time_interval => INTERVAL @interval, create_default_indexes => true, migrate_data => true)", conn);
cmd3.Parameters.AddWithValue("@tableName", tableName);
cmd3.Parameters.AddWithValue("@timeColumn", timeColumn);
cmd3.Parameters.AddWithValue("@interval", interval);
await cmd3.ExecuteNonQueryAsync();
_logger.LogInformation("[ExperionDb] 테이블 '{TableName}'을(를) 하이퍼테이블로 변환 완료", tableName);
return true;
}
@@ -190,21 +194,27 @@ public class ExperionDbService : IExperionDbService
// 4⃣ 기존 테이블이 없으면 → 새 하이퍼테이블 테이블 생성
// TimeScaleDB 요구사항: 고유 인덱스를 위해서는 partitioning 컬럼이 primary key에 포함되어야 함
#pragma warning disable EF1002 // Method 'ExecuteSqlRawAsync' inserts interpolated strings directly into the SQL
await _ctx.Database.ExecuteSqlRawAsync($"""
CREATE TABLE IF NOT EXISTS {hypertableName} (
await using var cmd4 = new NpgsqlCommand(
@"
CREATE TABLE IF NOT EXISTS @hypertableName (
id SERIAL,
tagname TEXT NOT NULL,
node_id TEXT,
value TEXT,
livevalue TEXT,
{timeColumn} TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (id, {timeColumn})
)
""");
await _ctx.Database.ExecuteSqlRawAsync($"""
SELECT create_hypertable('{hypertableName}', '{timeColumn}', chunk_time_interval => INTERVAL '{interval}', create_default_indexes => true)
""");
@timeColumn TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (id, @timeColumn)
)", conn);
cmd4.Parameters.AddWithValue("@hypertableName", hypertableName);
cmd4.Parameters.AddWithValue("@timeColumn", timeColumn);
await cmd4.ExecuteNonQueryAsync();
await using var cmd5 = new NpgsqlCommand(
"SELECT create_hypertable(@hypertableName, @timeColumn, chunk_time_interval => INTERVAL @interval, create_default_indexes => true)", conn);
cmd5.Parameters.AddWithValue("@hypertableName", hypertableName);
cmd5.Parameters.AddWithValue("@timeColumn", timeColumn);
cmd5.Parameters.AddWithValue("@interval", interval);
await cmd5.ExecuteNonQueryAsync();
_logger.LogInformation("[ExperionDb] 새 하이퍼테이블 '{HypertableName}' 생성 완료", hypertableName);
return true;
}
@@ -434,6 +444,161 @@ public class ExperionDbService : IExperionDbService
return new HistoryQueryResult(usedTags, grouped);
}
// ── History Interval Query ──────────────────────────────────────────────────
public async Task<HistoryIntervalQueryResult> QueryHistoryWithIntervalAsync(
HistoryIntervalQueryRequest request)
{
const int BaseIntervalSeconds = 60; // history_table 기본 저장 간격 (60초)
var tags = request.TagNames.Where(t => !string.IsNullOrEmpty(t)).ToList();
var limit = Math.Min(request.Limit, 5000);
// SQL 인젝션 방지를 위해 식별자 검증
if (!IsValidSqlIdentifier("history_table"))
{
throw new ArgumentException("Invalid table name");
}
// 간격 파싱 (예: "1 minute", "5 minutes", "1 hour", "10 seconds")
var intervalStr = ParseIntervalToPostgresInterval(request.Interval);
await _ctx.Database.GetDbConnection().OpenAsync();
try
{
// TimeScaleDB time_bucket 함수를 사용한 간격별 집계 쿼리
var sql = BuildHistoryIntervalQuerySql(tags, request.From, request.To, intervalStr, limit);
using var cmd = new NpgsqlCommand(sql, _ctx.Database.GetDbConnection() as NpgsqlConnection);
// 파라미터 바인딩 (Npgsql는 @paramName 형식 지원)
if (tags.Count > 0)
{
var tagParam = cmd.CreateParameter();
tagParam.ParameterName = "tagNames";
tagParam.Value = tags.ToArray();
cmd.Parameters.Add(tagParam);
}
if (request.From.HasValue)
{
var fromParam = cmd.CreateParameter();
fromParam.ParameterName = "fromTime";
fromParam.Value = request.From.Value;
cmd.Parameters.Add(fromParam);
}
if (request.To.HasValue)
{
var toParam = cmd.CreateParameter();
toParam.ParameterName = "toTime";
toParam.Value = request.To.Value;
cmd.Parameters.Add(toParam);
}
using var reader = await cmd.ExecuteReaderAsync();
var rows = new List<HistoryIntervalRow>();
var allTagNames = new HashSet<string>();
while (await reader.ReadAsync())
{
var timeBucket = reader.GetDateTime(0);
var values = new Dictionary<string, string?>();
for (int i = 1; i < reader.FieldCount; i++)
{
var tagName = reader.GetName(i);
allTagNames.Add(tagName);
values[tagName] = reader.IsDBNull(i) ? null : reader.GetString(i);
}
rows.Add(new HistoryIntervalRow(timeBucket, values));
}
var usedTags = tags.Count > 0
? tags
: allTagNames.OrderBy(x => x).ToList();
return new HistoryIntervalQueryResult(
usedTags,
rows,
BaseIntervalSeconds,
request.Interval);
}
finally
{
await _ctx.Database.GetDbConnection().CloseAsync();
}
}
/// <summary>
/// 사용자 지정 간격으로 history 이력 조회용 SQL 생성
/// TimeScaleDB time_bucket 함수를 사용하여 간격별 집계 수행
/// </summary>
private string BuildHistoryIntervalQuerySql(
List<string> tags, DateTime? from, DateTime? to, string intervalStr, int limit)
{
var selectParts = new List<string>();
selectParts.Add($"time_bucket('{intervalStr}', recorded_at) AS time_bucket");
// 태그명별로 동적으로 컬럼 생성 (PIVOT)
// TimeScaleDB에서는 crosstab 함수를 사용하거나, 동적 SQL로 처리
// 여기서는 간단하게 tagname GROUP BY로 조회 후 앱에서 PIVOT
selectParts.Add("tagname");
selectParts.Add("last(value, recorded_at) AS value");
var sql = $"SELECT {string.Join(", ", selectParts)} FROM history_table WHERE 1=1";
if (tags.Count > 0)
{
sql += $" AND tagname = ANY(ARRAY[{string.Join(", ", tags.Select(t => $"'{t.Replace("'", "''")}'"))}])";
}
if (from.HasValue)
{
sql += $" AND recorded_at >= @fromTime";
}
if (to.HasValue)
{
sql += $" AND recorded_at <= @toTime";
}
sql += $" GROUP BY time_bucket, tagname ORDER BY time_bucket, tagname LIMIT {limit}";
return sql;
}
/// <summary>
/// 사용자 입력 간격을 PostgreSQL INTERVAL 형식으로 변환
/// 예: "1 minute" → "1 minute", "5 minutes" → "5 minutes", "1 hour" → "1 hour"
/// </summary>
private string ParseIntervalToPostgresInterval(string interval)
{
if (string.IsNullOrWhiteSpace(interval))
return "1 minute";
var lower = interval.ToLower().Trim();
// 이미 PostgreSQL 형식인 경우 (예: "1 minute", "5 minutes", "1 hour")
if (System.Text.RegularExpressions.Regex.IsMatch(lower, @"^\d+\s+(second|minute|hour|day|week)s?$"))
{
return lower;
}
// 숫자만 있는 경우 (초 단위)
if (int.TryParse(lower, out var seconds))
{
if (seconds >= 3600 && seconds % 3600 == 0)
return $"{seconds / 3600} hour{(seconds / 3600 > 1 ? "s" : "")}";
if (seconds >= 60 && seconds % 60 == 0)
return $"{seconds / 60} minute{(seconds / 60 > 1 ? "s" : "")}";
return $"{seconds} second{(seconds > 1 ? "s" : "")}";
}
// 기본값
return "1 minute";
}
public async Task<NodeMapQueryResult> QueryMasterAsync(
int? minLevel, int? maxLevel, string? nodeClass,
IEnumerable<string>? names, string? nodeId, string? dataType,
@@ -604,9 +769,10 @@ public class ExperionDbService : IExperionDbService
return HypertableCreateResult.Failed($"시간 컬럼 이름 '{request.TimeColumn}'은 유효하지 않습니다. 영문, 숫자, 언더스코어, 하이픈, 마침표만 사용 가능합니다.");
}
if (!IsValidSqlIdentifier(request.TimeInterval?.Replace(" ", "")))
var timeInterval = request.TimeInterval ?? "";
if (!IsValidSqlIdentifier(timeInterval.Replace(" ", "")))
{
return HypertableCreateResult.Failed($"시간 간격 '{request.TimeInterval}'은 유효하지 않습니다.");
return HypertableCreateResult.Failed($"시간 간격 '{request.TimeInterval ?? "(null)"}'은 유효하지 않습니다.");
}
try