Skip to content

Commit 89f6215

Browse files
SQL query built from user-controlled sources
1 parent 139e2ad commit 89f6215

2 files changed

Lines changed: 67 additions & 7 deletions

File tree

App/kernel-memory/extensions/Postgres/Postgres/Internals/PostgresDbClient.cs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -561,7 +561,7 @@ public async IAsyncEnumerable<PostgresMemoryRecord> GetListAsync(
561561
NpgsqlCommand cmd = connection.CreateCommand();
562562
await using (cmd.ConfigureAwait(false))
563563
{
564-
#pragma warning disable CA2100 // SQL reviewed
564+
#pragma warning disable CA2100 // SQL reviewed - tableName secured via PostgresSchema.ValidateTableName + quote escaping, columns/filters from internal methods
565565
cmd.CommandText = @$"
566566
SELECT {columns} FROM {tableName}
567567
WHERE {filterSql}
@@ -572,6 +572,7 @@ OFFSET @offset
572572

573573
cmd.Parameters.AddWithValue("@limit", limit);
574574
cmd.Parameters.AddWithValue("@offset", offset);
575+
#pragma warning restore CA2100
575576

576577
if (sqlUserValues != null)
577578
{
@@ -636,7 +637,7 @@ public async Task DeleteAsync(
636637
NpgsqlCommand cmd = connection.CreateCommand();
637638
await using (cmd.ConfigureAwait(false))
638639
{
639-
#pragma warning disable CA2100 // SQL reviewed
640+
#pragma warning disable CA2100 // SQL reviewed - tableName secured via PostgresSchema.ValidateTableName + quote escaping
640641
cmd.CommandText = $"DELETE FROM {tableName} WHERE {this._colId}=@id";
641642
cmd.Parameters.AddWithValue("@id", id);
642643
#pragma warning restore CA2100
@@ -732,6 +733,12 @@ private PostgresMemoryRecord ReadEntry(NpgsqlDataReader dataReader, bool withEmb
732733

733734
/// <summary>
734735
/// Get full table name with schema from table name
736+
/// <summary>
737+
/// Returns the table name including schema and prefix, with proper PostgreSQL identifier escaping.
738+
/// Security: Provides multiple layers of protection against SQL injection:
739+
/// 1. PostgresSchema.ValidateTableName() ensures only safe characters (alphanumeric + hyphens)
740+
/// 2. Double quotes provide PostgreSQL identifier escaping
741+
/// 3. Additional quote escaping for defense-in-depth
735742
/// </summary>
736743
/// <param name="tableName"></param>
737744
/// <returns>Valid table name including schema</returns>
@@ -740,7 +747,10 @@ private string WithSchemaAndTableNamePrefix(string tableName)
740747
tableName = this.WithTableNamePrefix(tableName);
741748
PostgresSchema.ValidateTableName(tableName);
742749

743-
return $"{this._schema}.\"{tableName}\"";
750+
// Additional security: escape any double quotes in table name to prevent SQL injection
751+
// Even though we validate input, this provides defense in depth
752+
var escapedTableName = tableName.Replace("\"", "\"\"");
753+
return $"{this._schema}.\"{escapedTableName}\"";
744754
}
745755

746756
private string WithTableNamePrefix(string tableName)

App/kernel-memory/extensions/SQLServer/SQLServer/SqlServerMemory.cs

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,14 @@ namespace Microsoft.KernelMemory.MemoryDb.SQLServer;
1515

1616
/// <summary>
1717
/// Represents a memory store implementation that uses a SQL Server database as its backing store.
18+
///
19+
/// Security Notes:
20+
/// - All user-controlled input (index names) is normalized and validated using NormalizeIndexName()
21+
/// - Table names are properly escaped using GetFullTableName() with SQL bracket escaping
22+
/// - Index names are properly escaped using GetSafeIndexName() with SQL bracket escaping
23+
/// - Multiple layers of defense against SQL injection are implemented
1824
/// </summary>
19-
#pragma warning disable CA2100 // SQL reviewed for user input validation
25+
#pragma warning disable CA2100 // SQL reviewed for user input validation - comprehensive security measures implemented
2026
public sealed class SqlServerMemory : IMemoryDb, IMemoryDbUpsertBatch, IDisposable
2127
{
2228
/// <summary>
@@ -93,8 +99,8 @@ [vector_value] [float] NOT NULL
9399
FOREIGN KEY ([memory_id]) REFERENCES {this.GetFullTableName(this._config.MemoryTableName)}([id])
94100
);
95101
96-
IF OBJECT_ID(N'[{this._config.Schema}.IXC_{$"{this._config.EmbeddingsTableName}_{index}"}]', N'U') IS NULL
97-
CREATE CLUSTERED COLUMNSTORE INDEX [IXC_{$"{this._config.EmbeddingsTableName}_{index}"}]
102+
IF OBJECT_ID(N'{GetSafeIndexName($"{this._config.Schema}.IXC_{this._config.EmbeddingsTableName}_{index}")}', N'U') IS NULL
103+
CREATE CLUSTERED COLUMNSTORE INDEX {GetSafeIndexName($"IXC_{this._config.EmbeddingsTableName}_{index}")}
98104
ON {this.GetFullTableName($"{this._config.EmbeddingsTableName}_{index}")}
99105
{(this._cachedServerVersion >= 16 ? "ORDER ([memory_id])" : "")};
100106
@@ -285,6 +291,10 @@ public async IAsyncEnumerable<MemoryRecord> GetListAsync(
285291
{
286292
var tagFilters = new TagCollection();
287293

294+
// Security Note: The 'index' parameter used in this SQL construction is secured through:
295+
// 1. NormalizeIndexName() validation (only alphanumeric + hyphens allowed)
296+
// 2. GetFullTableName() SQL bracket escaping (] -> ]])
297+
// 3. GenerateFilters() uses proper parameterization
288298
command.CommandText = $@"
289299
WITH [filters] AS
290300
(
@@ -359,6 +369,10 @@ SELECT TOP (@limit)
359369
try
360370
{
361371
var generatedFilters = this.GenerateFilters(index, command.Parameters, filters);
372+
// Security Note: The 'index' parameter used in this SQL construction is secured through:
373+
// 1. NormalizeIndexName() validation (only alphanumeric + hyphens allowed)
374+
// 2. GetFullTableName() SQL bracket escaping (] -> ]])
375+
// 3. Multiple layers of defense against SQL injection
362376
command.CommandText = $@"
363377
WITH
364378
[embedding] as
@@ -553,6 +567,7 @@ public void Dispose()
553567

554568
// Note: "_" is allowed in SQL Server, but we normalize it to "-" for consistency with other DBs
555569
private static readonly Regex s_replaceIndexNameCharsRegex = new(@"[\s|\\|/|.|_|:]");
570+
private static readonly Regex s_validIdentifierRegex = new(@"^[a-zA-Z0-9\-]+$");
556571
private const string ValidSeparator = "-";
557572

558573
/// <summary>
@@ -662,10 +677,30 @@ private async Task<bool> DoesIndexExistsAsync(string indexName,
662677
/// Gets the full table name with schema.
663678
/// </summary>
664679
/// <param name="tableName">The table name.</param>
680+
/// <summary>
681+
/// Gets the full table name with schema and proper SQL escaping.
682+
/// Provides defense-in-depth by escaping potential injection characters.
683+
/// </summary>
684+
/// <param name="tableName">The table name.</param>
665685
/// <returns></returns>
666686
private string GetFullTableName(string tableName)
667687
{
668-
return $"[{this._config.Schema}].[{tableName}]";
688+
// Additional security: escape any square brackets in table name to prevent SQL injection
689+
// Even though we validate input, this provides defense in depth
690+
var escapedTableName = tableName.Replace("]", "]]");
691+
return $"[{this._config.Schema}].[{escapedTableName}]";
692+
}
693+
694+
/// <summary>
695+
/// Gets a safely escaped index name for use in SQL DDL statements.
696+
/// </summary>
697+
/// <param name="indexName">The index name to escape</param>
698+
/// <returns>A safely escaped index name</returns>
699+
private static string GetSafeIndexName(string indexName)
700+
{
701+
// Escape square brackets to prevent SQL injection in index names
702+
var escapedIndexName = indexName.Replace("]", "]]");
703+
return $"[{escapedIndexName}]";
669704
}
670705

671706
/// <summary>
@@ -757,12 +792,27 @@ private async Task<MemoryRecord> ReadEntryAsync(SqlDataReader dataReader, bool w
757792
return entry;
758793
}
759794

795+
/// <summary>
796+
/// Normalizes and validates an index name to ensure it's safe for use in SQL queries.
797+
/// This method provides multiple layers of security against SQL injection:
798+
/// 1. Normalizes characters to safe alternatives
799+
/// 2. Validates that the result contains only safe characters (alphanumeric and hyphens)
800+
/// </summary>
801+
/// <param name="index">The index name to normalize</param>
802+
/// <returns>A normalized and validated index name safe for SQL usage</returns>
803+
/// <exception cref="ArgumentException">Thrown if the normalized index contains unsafe characters</exception>
760804
private static string NormalizeIndexName(string index)
761805
{
762806
ArgumentNullExceptionEx.ThrowIfNullOrWhiteSpace(index, nameof(index), "The index name is empty");
763807

764808
index = s_replaceIndexNameCharsRegex.Replace(index.Trim().ToLowerInvariant(), ValidSeparator);
765809

810+
// Additional security validation: ensure the normalized index contains only safe characters
811+
if (!s_validIdentifierRegex.IsMatch(index))
812+
{
813+
throw new ArgumentException($"Invalid index name after normalization: {index}. Only alphanumeric characters and hyphens are allowed.", nameof(index));
814+
}
815+
766816
return index;
767817
}
768818

0 commit comments

Comments
 (0)