fix: security, data-integrity, and doc-drift findings from repo audit
Consolidates duplicate KIS API client implementations (governance tests were exercising an unused class instead of the one actually running in production), closes a SQL injection path in the DB admin page, fixes a migration that used MySQL-only syntax and had never actually applied (confirmed against production), resyncs docs/db/quantengine.dbml with all migrations, and removes a duplicate OMS·WMS·ERP frontend tree in favor of src/frontend/. Also corrects several unverifiable/inflated claims in the OMS planning docs and realigns CI/CD and architecture documentation with what's actually in the repo. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
using System.Reflection;
|
||||
using QuantEngine.Infrastructure.External;
|
||||
using QuantEngine.Infrastructure.Data;
|
||||
using QuantEngine.Infrastructure.Services;
|
||||
|
||||
namespace QuantEngine.Core.Tests;
|
||||
|
||||
@@ -12,9 +11,7 @@ public class SecurityTests
|
||||
[InlineData("/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice", "FHKST03010100")]
|
||||
public void AssertReadOnly_AllowsReadOnlyQuotationPaths(string path, string trId)
|
||||
{
|
||||
var client = CreateClient();
|
||||
|
||||
var ex = Record.Exception(() => InvokeAssertReadOnly(client, path, trId));
|
||||
var ex = Record.Exception(() => InvokeAssertReadOnly(path, trId));
|
||||
|
||||
Assert.Null(ex);
|
||||
}
|
||||
@@ -25,45 +22,34 @@ public class SecurityTests
|
||||
[InlineData("/uapi/domestic-stock/v1/trading/order-cash", "FHKST01010100")]
|
||||
public void AssertReadOnly_BlocksTradingPathsOrIds(string path, string trId)
|
||||
{
|
||||
var client = CreateClient();
|
||||
|
||||
var ex = Assert.Throws<TargetInvocationException>(() => InvokeAssertReadOnly(client, path, trId));
|
||||
var ex = Assert.Throws<TargetInvocationException>(() => InvokeAssertReadOnly(path, trId));
|
||||
Assert.IsType<InvalidOperationException>(ex.InnerException);
|
||||
Assert.Contains("BLOCKED", ex.InnerException!.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AssertReadOnly_BlocksKnownTradingTrIdPrefixes()
|
||||
[Theory]
|
||||
[InlineData("VTTC8434R00")]
|
||||
[InlineData("TTTC9912U")]
|
||||
[InlineData("VTTC5555X")]
|
||||
public void AssertReadOnly_BlocksEntireTradingTrIdFamily_NotJustHardcodedCodes(string trId)
|
||||
{
|
||||
var client = CreateClient();
|
||||
|
||||
var ex = Assert.Throws<TargetInvocationException>(() => InvokeAssertReadOnly(client, "/uapi/domestic-stock/v1/quotations/inquire-price", "VTTC8434R00"));
|
||||
// These TR_IDs are not among the previously hardcoded exact-match list — they only get
|
||||
// blocked once the guard checks the true TTTC*/VTTC* prefix family instead of a fixed
|
||||
// set of known order codes.
|
||||
var ex = Assert.Throws<TargetInvocationException>(() =>
|
||||
InvokeAssertReadOnly("/uapi/domestic-stock/v1/quotations/inquire-price", trId));
|
||||
Assert.IsType<InvalidOperationException>(ex.InnerException);
|
||||
Assert.Contains("TR_ID", ex.InnerException!.Message);
|
||||
}
|
||||
|
||||
private static KisApiClient CreateClient()
|
||||
private static void InvokeAssertReadOnly(string path, string trId)
|
||||
{
|
||||
Environment.SetEnvironmentVariable("KIS_APP_Key_TEST", "mock-key");
|
||||
Environment.SetEnvironmentVariable("KIS_APP_Secret_TEST", "mock-secret");
|
||||
return new KisApiClient(new HttpClient(new DummyHandler()), new NoopConnectionFactory());
|
||||
}
|
||||
|
||||
private static void InvokeAssertReadOnly(KisApiClient client, string path, string trId)
|
||||
{
|
||||
var method = typeof(KisApiClient).GetMethod("AssertReadOnly", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
// QuantEngine.Infrastructure.Services.KisApiClient is the class actually DI-registered
|
||||
// in Program.cs and running in production — a second, unused KisApiClient used to live
|
||||
// under Infrastructure.External with its own independent AssertReadOnly implementation
|
||||
// that this test suite exercised instead. That class has been removed.
|
||||
var method = typeof(KisApiClient).GetMethod("AssertReadOnly", BindingFlags.Static | BindingFlags.NonPublic)
|
||||
?? throw new InvalidOperationException("AssertReadOnly method not found.");
|
||||
method.Invoke(client, new object[] { path, trId });
|
||||
}
|
||||
|
||||
private sealed class DummyHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK));
|
||||
}
|
||||
|
||||
private sealed class NoopConnectionFactory : IDbConnectionFactory
|
||||
{
|
||||
public System.Data.IDbConnection CreateConnection() => throw new NotSupportedException("Not needed for read-only guard tests.");
|
||||
method.Invoke(null, new object[] { path, trId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using QuantEngine.Core.Interfaces;
|
||||
using QuantEngine.Infrastructure.Data;
|
||||
|
||||
namespace QuantEngine.Infrastructure.External
|
||||
{
|
||||
public class KisCredentials
|
||||
{
|
||||
public string AppKey { get; }
|
||||
public string AppSecret { get; }
|
||||
public string Account { get; } // "real" | "mock"
|
||||
public string Domain { get; }
|
||||
|
||||
public KisCredentials(string appKey, string appSecret, string account)
|
||||
{
|
||||
AppKey = appKey;
|
||||
AppSecret = appSecret;
|
||||
Account = account;
|
||||
Domain = account == "real"
|
||||
? "https://openapi.koreainvestment.com:9443"
|
||||
: "https://openapivts.koreainvestment.com:29443";
|
||||
}
|
||||
|
||||
public static KisCredentials Load(string account = "mock")
|
||||
{
|
||||
string keyVar = account == "real" ? "KIS_APP_Key" : "KIS_APP_Key_TEST";
|
||||
string secretVar = account == "real" ? "KIS_APP_Secret" : "KIS_APP_Secret_TEST";
|
||||
|
||||
string? appKey = Environment.GetEnvironmentVariable(keyVar);
|
||||
string? appSecret = Environment.GetEnvironmentVariable(secretVar);
|
||||
|
||||
if (string.IsNullOrEmpty(appKey) || string.IsNullOrEmpty(appSecret))
|
||||
{
|
||||
// Fallback registry checks are not cross-platform and environment variables should be defined.
|
||||
// In production/Linux it is env-only.
|
||||
throw new InvalidOperationException(
|
||||
$"KIS Credentials Environment Variables missing: {keyVar} or {secretVar}."
|
||||
);
|
||||
}
|
||||
|
||||
return new KisCredentials(appKey, appSecret, account);
|
||||
}
|
||||
}
|
||||
|
||||
public class KisApiClient : IKisApiClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IDbConnectionFactory _dbConnectionFactory;
|
||||
private readonly KisCredentials _creds;
|
||||
|
||||
private static readonly string[] ForbiddenPathSubstrings = { "/trading/" };
|
||||
private static readonly string[] ForbiddenTrIdPrefixes = { "TTTC08", "VTTC08", "TTTC01", "VTTC01", "TTTC8434R", "VTTC8434R" };
|
||||
|
||||
public KisApiClient(HttpClient httpClient, IDbConnectionFactory dbConnectionFactory, string account = "mock")
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_dbConnectionFactory = dbConnectionFactory;
|
||||
_creds = KisCredentials.Load(account);
|
||||
}
|
||||
|
||||
private void AssertReadOnly(string path, string trId)
|
||||
{
|
||||
foreach (var forbidden in ForbiddenPathSubstrings)
|
||||
{
|
||||
if (path.Contains(forbidden, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"BLOCKED: 주문 관련 경로 호출 시도 차단 — path={path}");
|
||||
}
|
||||
}
|
||||
foreach (var prefix in ForbiddenTrIdPrefixes)
|
||||
{
|
||||
if (trId.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"BLOCKED: 주문 관련 TR_ID 호출 시도 차단 — tr_id={trId}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> IssueOrReuseTokenAsync()
|
||||
{
|
||||
using var conn = _dbConnectionFactory.CreateConnection();
|
||||
conn.Open();
|
||||
|
||||
// 1. Try to load cached token
|
||||
var cached = await conn.QueryFirstOrDefaultAsync<(string access_token, string expires_at)>(
|
||||
"SELECT access_token, expires_at FROM quantengine.kis_tokens WHERE account = @Account",
|
||||
new { Account = _creds.Account }
|
||||
);
|
||||
|
||||
if (cached.access_token != null)
|
||||
{
|
||||
if (DateTime.TryParse(cached.expires_at, out var expiresAtUtc))
|
||||
{
|
||||
// Reuse token if it has more than 10 minutes left before expiration
|
||||
if (DateTime.UtcNow < expiresAtUtc.AddMinutes(-10))
|
||||
{
|
||||
return cached.access_token;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Request new token from KIS API
|
||||
var requestUrl = $"{_creds.Domain}/oauth2/tokenP";
|
||||
var requestBody = new
|
||||
{
|
||||
grant_type = "client_credentials",
|
||||
appkey = _creds.AppKey,
|
||||
appsecret = _creds.AppSecret
|
||||
};
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(requestUrl, requestBody);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var resData = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var accessToken = resData.GetProperty("access_token").GetString()
|
||||
?? throw new InvalidOperationException("Failed to parse access_token from response.");
|
||||
var expiresInSec = resData.GetProperty("expires_in").GetInt32();
|
||||
var expiresAt = DateTime.UtcNow.AddSeconds(expiresInSec);
|
||||
|
||||
// 3. Upsert token cache into PG database
|
||||
await conn.ExecuteAsync(@"
|
||||
INSERT INTO quantengine.kis_tokens (account, access_token, expires_at, updated_at)
|
||||
VALUES (@Account, @AccessToken, @ExpiresAt, @UpdatedAt)
|
||||
ON CONFLICT (account) DO UPDATE SET
|
||||
access_token = EXCLUDED.access_token,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
updated_at = EXCLUDED.updated_at",
|
||||
new
|
||||
{
|
||||
Account = _creds.Account,
|
||||
AccessToken = accessToken,
|
||||
ExpiresAt = expiresAt.ToString("o"),
|
||||
UpdatedAt = DateTime.UtcNow.ToString("o")
|
||||
}
|
||||
);
|
||||
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
private async Task<string> SendRequestAsync(string path, string trId, Dictionary<string, string> queryParams)
|
||||
{
|
||||
AssertReadOnly(path, trId);
|
||||
var token = await IssueOrReuseTokenAsync();
|
||||
|
||||
var queryBuilder = new List<string>();
|
||||
foreach (var kvp in queryParams)
|
||||
{
|
||||
queryBuilder.Add($"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value)}");
|
||||
}
|
||||
var fullUrl = $"{_creds.Domain}{path}?{string.Join("&", queryBuilder)}";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, fullUrl);
|
||||
request.Headers.Accept.Clear();
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
request.Headers.Add("authorization", $"Bearer {token}");
|
||||
request.Headers.Add("appkey", _creds.AppKey);
|
||||
request.Headers.Add("appsecret", _creds.AppSecret);
|
||||
request.Headers.Add("tr_id", trId);
|
||||
request.Headers.Add("custtype", "P");
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadAsStringAsync();
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, object>> GetCurrentPriceAsync(string code, string account = "mock")
|
||||
{
|
||||
var json = await SendRequestAsync(
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-price",
|
||||
"FHKST01010100",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "FID_COND_MRKT_DIV_CODE", "J" },
|
||||
{ "FID_INPUT_ISCD", code }
|
||||
}
|
||||
);
|
||||
return JsonSerializer.Deserialize<Dictionary<string, object>>(json) ?? new();
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, object>> GetAskingPrice10LevelAsync(string code, string account = "mock")
|
||||
{
|
||||
var json = await SendRequestAsync(
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn",
|
||||
"FHKST01010200",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "FID_COND_MRKT_DIV_CODE", "J" },
|
||||
{ "FID_INPUT_ISCD", code }
|
||||
}
|
||||
);
|
||||
return JsonSerializer.Deserialize<Dictionary<string, object>>(json) ?? new();
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, object>> GetDailyShortSaleAsync(string code, string startDate, string endDate, string account = "mock")
|
||||
{
|
||||
var json = await SendRequestAsync(
|
||||
"/uapi/domestic-stock/v1/quotations/daily-short-sale",
|
||||
"FHPST04830000",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "FID_COND_MRKT_DIV_CODE", "J" },
|
||||
{ "FID_INPUT_ISCD", code },
|
||||
{ "FID_INPUT_DATE_1", startDate },
|
||||
{ "FID_INPUT_DATE_2", endDate }
|
||||
}
|
||||
);
|
||||
return JsonSerializer.Deserialize<Dictionary<string, object>>(json) ?? new();
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, object>> GetDailyItemChartPriceAsync(string code, string startDate, string endDate, string period = "D", string account = "mock")
|
||||
{
|
||||
var json = await SendRequestAsync(
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice",
|
||||
"FHKST03010100",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "FID_COND_MRKT_DIV_CODE", "J" },
|
||||
{ "FID_INPUT_ISCD", code },
|
||||
{ "FID_INPUT_DATE_1", startDate },
|
||||
{ "FID_INPUT_DATE_2", endDate },
|
||||
{ "FID_PERIOD_DIV_CODE", period },
|
||||
{ "FID_ORG_ADJ_PRC", "0" }
|
||||
}
|
||||
);
|
||||
return JsonSerializer.Deserialize<Dictionary<string, object>>(json) ?? new();
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, object>> GetInvestorTrendAsync(string code, string account = "mock")
|
||||
{
|
||||
var json = await SendRequestAsync(
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-investor",
|
||||
"FHKST01010900",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "FID_COND_MRKT_DIV_CODE", "J" },
|
||||
{ "FID_INPUT_ISCD", code }
|
||||
}
|
||||
);
|
||||
return JsonSerializer.Deserialize<Dictionary<string, object>>(json) ?? new();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,12 +26,15 @@ CREATE TABLE IF NOT EXISTS quantengine.kis_collection_runs_audit (
|
||||
-- Foreign key constraint (optional - don't enforce if kis_collection_runs might be deleted)
|
||||
-- CONSTRAINT fk_kis_collection_runs_audit FOREIGN KEY (run_id)
|
||||
-- REFERENCES quantengine.kis_collection_runs(id) ON DELETE CASCADE
|
||||
|
||||
INDEX idx_kis_collection_runs_audit_run_id (run_id, changed_at DESC),
|
||||
INDEX idx_kis_collection_runs_audit_changed_by (changed_by, changed_at DESC),
|
||||
INDEX idx_kis_collection_runs_audit_timestamp (changed_at DESC)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kis_collection_runs_audit_run_id
|
||||
ON quantengine.kis_collection_runs_audit (run_id, changed_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_kis_collection_runs_audit_changed_by
|
||||
ON quantengine.kis_collection_runs_audit (changed_by, changed_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_kis_collection_runs_audit_timestamp
|
||||
ON quantengine.kis_collection_runs_audit (changed_at DESC);
|
||||
|
||||
-- ============================================================================
|
||||
-- kis_collection_snapshots_audit: Audit trail for snapshots
|
||||
-- ============================================================================
|
||||
@@ -55,12 +58,15 @@ CREATE TABLE IF NOT EXISTS quantengine.kis_collection_snapshots_audit (
|
||||
-- Foreign key constraint (optional)
|
||||
-- CONSTRAINT fk_kis_collection_snapshots_audit FOREIGN KEY (snapshot_id)
|
||||
-- REFERENCES quantengine.kis_collection_snapshots(id) ON DELETE CASCADE
|
||||
|
||||
INDEX idx_kis_collection_snapshots_audit_snapshot_id (snapshot_id, changed_at DESC),
|
||||
INDEX idx_kis_collection_snapshots_audit_changed_by (changed_by, changed_at DESC),
|
||||
INDEX idx_kis_collection_snapshots_audit_timestamp (changed_at DESC)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kis_collection_snapshots_audit_snapshot_id
|
||||
ON quantengine.kis_collection_snapshots_audit (snapshot_id, changed_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_kis_collection_snapshots_audit_changed_by
|
||||
ON quantengine.kis_collection_snapshots_audit (changed_by, changed_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_kis_collection_snapshots_audit_timestamp
|
||||
ON quantengine.kis_collection_snapshots_audit (changed_at DESC);
|
||||
|
||||
-- ============================================================================
|
||||
-- kis_collection_errors_audit: Audit trail for error records
|
||||
-- ============================================================================
|
||||
@@ -79,13 +85,16 @@ CREATE TABLE IF NOT EXISTS quantengine.kis_collection_errors_audit (
|
||||
new_values JSONB,
|
||||
|
||||
-- Audit trail indexing
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
INDEX idx_kis_collection_errors_audit_error_id (error_id, changed_at DESC),
|
||||
INDEX idx_kis_collection_errors_audit_changed_by (changed_by, changed_at DESC),
|
||||
INDEX idx_kis_collection_errors_audit_timestamp (changed_at DESC)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kis_collection_errors_audit_error_id
|
||||
ON quantengine.kis_collection_errors_audit (error_id, changed_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_kis_collection_errors_audit_changed_by
|
||||
ON quantengine.kis_collection_errors_audit (changed_by, changed_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_kis_collection_errors_audit_timestamp
|
||||
ON quantengine.kis_collection_errors_audit (changed_at DESC);
|
||||
|
||||
-- ============================================================================
|
||||
-- Trigger Functions: Auto-log changes to kis_collection_runs
|
||||
-- ============================================================================
|
||||
|
||||
@@ -22,11 +22,11 @@ public class KisApiClient : IKisApiClient
|
||||
private const int TokenRefreshSkewMinutes = 10;
|
||||
|
||||
private static readonly string[] ForbiddenPathSubstrings = { "/trading/" };
|
||||
private static readonly string[] ForbiddenTrIdPrefixes =
|
||||
{
|
||||
"TTTC08", "VTTC08", "TTTC01", "VTTC01",
|
||||
"TTTC8434R", "VTTC8434R"
|
||||
};
|
||||
|
||||
// 실제 매수/매도 주문 TR_ID는 전부 TTTC/VTTC로 시작한다 (governance/rules/06_no_direct_api_trading.yaml).
|
||||
// 개별 주문 코드를 나열하면 목록에 없는 신규 주문 TR_ID가 새어나갈 수 있으므로 접두사 전체를 차단한다.
|
||||
// 이 클라이언트가 실제로 호출하는 조회용 TR_ID는 전부 FH로 시작해 이 규칙과 절대 겹치지 않는다.
|
||||
private static readonly string[] ForbiddenTrIdPrefixes = { "TTTC", "VTTC" };
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ITokenCache _tokenCache;
|
||||
|
||||
@@ -75,18 +75,32 @@ namespace QuantEngine.Web.Pages.Admin.Database
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
if (conn.State != ConnectionState.Open) conn.Open();
|
||||
|
||||
// Column/PK names come from the request, so they must be checked against the
|
||||
// table's real columns before going anywhere near a SQL string — otherwise an
|
||||
// attacker-controlled form field name reaches the query unescaped.
|
||||
var columnWhitelist = await LoadColumnWhitelistAsync(conn, tableName);
|
||||
|
||||
if (string.IsNullOrEmpty(pkColumn) || !columnWhitelist.Contains(pkColumn))
|
||||
{
|
||||
ErrorMessage = "허용되지 않은 기본 키 컬럼입니다.";
|
||||
return Page();
|
||||
}
|
||||
|
||||
// Load target columns to update
|
||||
var columns = new List<string>();
|
||||
var parameters = new List<NpgsqlParameter>();
|
||||
|
||||
|
||||
foreach (var key in Request.Form.Keys)
|
||||
{
|
||||
if (key == "tableName" || key == "pkColumn" || key == "pkValue" || key == "__RequestVerificationToken")
|
||||
continue;
|
||||
|
||||
if (!columnWhitelist.Contains(key))
|
||||
continue; // 테이블에 실재하지 않는 컬럼명은 무시 (SQL 인젝션 방지)
|
||||
|
||||
var val = Request.Form[key].ToString();
|
||||
columns.Add($"\"{key}\" = @{key}");
|
||||
|
||||
|
||||
var param = new NpgsqlParameter($"@{key}", NpgsqlTypes.NpgsqlDbType.Text);
|
||||
param.Value = (object?)val ?? DBNull.Value;
|
||||
parameters.Add(param);
|
||||
@@ -133,6 +147,8 @@ namespace QuantEngine.Web.Pages.Admin.Database
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
if (conn.State != ConnectionState.Open) conn.Open();
|
||||
|
||||
var columnWhitelist = await LoadColumnWhitelistAsync(conn, tableName);
|
||||
|
||||
var colNames = new List<string>();
|
||||
var paramNames = new List<string>();
|
||||
var parameters = new List<NpgsqlParameter>();
|
||||
@@ -142,6 +158,9 @@ namespace QuantEngine.Web.Pages.Admin.Database
|
||||
if (key == "tableName" || key == "__RequestVerificationToken")
|
||||
continue;
|
||||
|
||||
if (!columnWhitelist.Contains(key))
|
||||
continue; // 테이블에 실재하지 않는 컬럼명은 무시 (SQL 인젝션 방지)
|
||||
|
||||
var val = Request.Form[key].ToString();
|
||||
colNames.Add($"\"{key}\"");
|
||||
paramNames.Add($"@{key}");
|
||||
@@ -171,6 +190,35 @@ namespace QuantEngine.Web.Pages.Admin.Database
|
||||
return RedirectToPage(new { tableName });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DB-verified column names for a table, used to validate any identifier (PK column,
|
||||
/// form field names) before it is interpolated into a SQL string. Table names arriving
|
||||
/// here must already be whitelist-checked against TableList by the caller.
|
||||
/// </summary>
|
||||
private async Task<HashSet<string>> LoadColumnWhitelistAsync(IDbConnection conn, string tableName)
|
||||
{
|
||||
var parts = tableName.Split('.');
|
||||
var schema = parts[0];
|
||||
var tableOnly = parts[1];
|
||||
|
||||
var sql = @"
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = @schema AND table_name = @table_only;";
|
||||
|
||||
using var cmd = new NpgsqlCommand(sql, (NpgsqlConnection)conn);
|
||||
cmd.Parameters.AddWithValue("@schema", schema);
|
||||
cmd.Parameters.AddWithValue("@table_only", tableOnly);
|
||||
|
||||
var columns = new HashSet<string>(StringComparer.Ordinal);
|
||||
using var reader = await cmd.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
columns.Add(reader.GetString(0));
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
private async Task LoadTableListAsync()
|
||||
{
|
||||
TableList.Clear();
|
||||
|
||||
Reference in New Issue
Block a user