From ccf82aae4b4547ecc97bc4842b79b7f51caf795b Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:54:11 -0400 Subject: [PATCH] Harden DuckDB queries: parameterize values, escape paths, fix IsArchiving race Addresses security findings from #840: - #846: Escape single quotes in file paths interpolated into read_parquet() and COPY TO - #847: Use DuckDB $1 parameters for DateTime values instead of string interpolation - #849: Make IsArchiving volatile-backed to prevent stale reads across threads Co-Authored-By: Claude Opus 4.6 (1M context) --- Lite/Database/DuckDbInitializer.cs | 8 ++++++-- Lite/Services/ArchiveService.cs | 33 ++++++++++++++++++++---------- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/Lite/Database/DuckDbInitializer.cs b/Lite/Database/DuckDbInitializer.cs index 93b93053..c7295783 100644 --- a/Lite/Database/DuckDbInitializer.cs +++ b/Lite/Database/DuckDbInitializer.cs @@ -724,7 +724,7 @@ public async Task CreateArchiveViewsAsync() string viewSql; if (hasParquetFiles) { - var globPath = parquetGlob.Replace("\\", "/"); + var globPath = EscapeSqlPath(parquetGlob.Replace("\\", "/")); if (table == "config_alert_log") { viewSql = $@"CREATE OR REPLACE VIEW v_{table} AS @@ -935,5 +935,9 @@ public async Task ResetDatabaseAsync() await InitializeAsync(); } - + /// + /// Escapes single quotes in a file path for safe interpolation into DuckDB SQL. + /// DuckDB does not support parameterized paths in read_parquet() or COPY TO. + /// + internal static string EscapeSqlPath(string path) => path.Replace("'", "''"); } diff --git a/Lite/Services/ArchiveService.cs b/Lite/Services/ArchiveService.cs index d286589b..35d9c7e1 100644 --- a/Lite/Services/ArchiveService.cs +++ b/Lite/Services/ArchiveService.cs @@ -33,8 +33,14 @@ public class ArchiveService /// /// Indicates whether an archival operation is currently in progress. /// UI code can check this to warn users before dismiss or show a status indicator. + /// Volatile-backed to ensure cross-thread visibility without locking. /// - public static bool IsArchiving { get; private set; } + private static volatile bool s_isArchiving; + public static bool IsArchiving + { + get => s_isArchiving; + private set => s_isArchiving = value; + } /* Tables eligible for archival with their time column. IMPORTANT: Every table with time-series data must be listed here, @@ -133,7 +139,8 @@ Archive views use glob (*_table.parquet) to pick up all files. */ /* Delete archived rows from hot table */ using var deleteCmd = connection.CreateCommand(); - deleteCmd.CommandText = $"DELETE FROM {table} WHERE {timeColumn} < '{cutoffDate:yyyy-MM-dd HH:mm:ss}'"; + deleteCmd.CommandText = $"DELETE FROM {table} WHERE {timeColumn} < $1"; + deleteCmd.Parameters.Add(new DuckDBParameter { Value = cutoffDate }); await deleteCmd.ExecuteNonQueryAsync(); _logger?.LogInformation("Archived {Count} rows from {Table} to {Path}", rowCount, table, parquetPath); @@ -161,7 +168,8 @@ Archive views use glob (*_table.parquet) to pick up all files. */ private static async Task GetRowCountBeforeCutoff(DuckDBConnection connection, string table, string timeColumn, DateTime cutoff) { using var cmd = connection.CreateCommand(); - cmd.CommandText = $"SELECT COUNT(*) FROM {table} WHERE {timeColumn} < '{cutoff:yyyy-MM-dd HH:mm:ss}'"; + cmd.CommandText = $"SELECT COUNT(*) FROM {table} WHERE {timeColumn} < $1"; + cmd.Parameters.Add(new DuckDBParameter { Value = cutoff }); var result = await cmd.ExecuteScalarAsync(); return Convert.ToInt64(result); } @@ -171,11 +179,14 @@ private static async Task ExportToParquet(DuckDBConnection connection, string ta using var cmd = connection.CreateCommand(); cmd.CommandText = $@" COPY ( - SELECT * FROM {table} WHERE {timeColumn} < '{cutoff:yyyy-MM-dd HH:mm:ss}' -) TO '{filePath}' (FORMAT PARQUET, COMPRESSION ZSTD)"; + SELECT * FROM {table} WHERE {timeColumn} < $1 +) TO '{EscapeSqlPath(filePath)}' (FORMAT PARQUET, COMPRESSION ZSTD)"; + cmd.Parameters.Add(new DuckDBParameter { Value = cutoff }); await cmd.ExecuteNonQueryAsync(); } + private static string EscapeSqlPath(string path) => DuckDbInitializer.EscapeSqlPath(path); + /* Columns to exclude during compaction — dead weight from legacy archives */ private static readonly Dictionary CompactionExcludeColumns = new() { @@ -343,7 +354,7 @@ Each group gets its own DuckDB connection so memory is fully released between gr { using var schemaCon = new DuckDBConnection("DataSource=:memory:"); schemaCon.Open(); - var allPathList = string.Join(", ", sourcePaths.Select(p => $"'{p}'")); + var allPathList = string.Join(", ", sourcePaths.Select(p => $"'{EscapeSqlPath(p)}'")); using var schemaCmd = schemaCon.CreateCommand(); schemaCmd.CommandText = $"SELECT column_name FROM (DESCRIBE SELECT * FROM read_parquet([{allPathList}], union_by_name=true))"; using var reader = schemaCmd.ExecuteReader(); @@ -368,10 +379,10 @@ Each group gets its own DuckDB connection so memory is fully released between gr pragma.ExecuteNonQuery(); } - var pathList = string.Join(", ", sourcePaths.Select(p => $"'{p}'")); + var pathList = string.Join(", ", sourcePaths.Select(p => $"'{EscapeSqlPath(p)}'")); using var cmd = con.CreateCommand(); cmd.CommandText = $"COPY (SELECT {selectClause} FROM read_parquet([{pathList}], union_by_name=true)) " + - $"TO '{tempPath}' (FORMAT PARQUET, COMPRESSION ZSTD, ROW_GROUP_SIZE 122880)"; + $"TO '{EscapeSqlPath(tempPath)}' (FORMAT PARQUET, COMPRESSION ZSTD, ROW_GROUP_SIZE 122880)"; cmd.ExecuteNonQuery(); } else @@ -399,10 +410,10 @@ Sort smallest-first so early merges are cheap. */ pragma.ExecuteNonQuery(); } - var pairList = $"'{currentPath}', '{sorted[i]}'"; + var pairList = $"'{EscapeSqlPath(currentPath)}', '{EscapeSqlPath(sorted[i])}'"; using var cmd = con.CreateCommand(); cmd.CommandText = $"COPY (SELECT {selectClause} FROM read_parquet([{pairList}], union_by_name=true)) " + - $"TO '{stepOutput}' (FORMAT PARQUET, COMPRESSION ZSTD, ROW_GROUP_SIZE 122880)"; + $"TO '{EscapeSqlPath(stepOutput)}' (FORMAT PARQUET, COMPRESSION ZSTD, ROW_GROUP_SIZE 122880)"; cmd.ExecuteNonQuery(); /* Clean up previous intermediate file */ @@ -511,7 +522,7 @@ Archive views use glob (*_table.parquet) to pick up all files. */ .Replace("\\", "/"); using var exportCmd = connection.CreateCommand(); - exportCmd.CommandText = $"COPY (SELECT * FROM {table}) TO '{parquetPath}' (FORMAT PARQUET, COMPRESSION ZSTD)"; + exportCmd.CommandText = $"COPY (SELECT * FROM {table}) TO '{EscapeSqlPath(parquetPath)}' (FORMAT PARQUET, COMPRESSION ZSTD)"; await exportCmd.ExecuteNonQueryAsync(); _logger?.LogInformation("Archived {Count} rows from {Table}", rowCount, table);