Skip to content

Commit 5acf2e5

Browse files
author
MPCoreDeveloper
committed
fix(sonar): S2083 path-traversal hardening, S2077 provider DDL parameterization, IN-predicate de-duplication
- TenantBackupRestoreService: BuildBackupPath now uses Path.GetFileName on tenant/database segments plus a backup-directory containment check; all path helpers normalize with Path.GetFullPath before filesystem access (S2083) - SharpCoreDBTableBuilder: sqlite_master existence checks now use bound @name parameters; identifier-only DDL statements are annotated NOSONAR:S2077 with justification (whitelisted via SqlIdentifier.EnsureSafe) - New shared SqlInPredicate helper centralizes IN/NOT IN parsing+eval across SingleFileTable, Table.EvaluateWhere and the AST evaluator (removes 3x duplicated value-list parsing)
1 parent 22e9c5d commit 5acf2e5

7 files changed

Lines changed: 176 additions & 72 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,3 +421,7 @@ packages/
421421
dist/
422422
*.tsbuildinfo
423423
.pytest_cache/
424+
425+
# SonarScanner local analysis artifacts
426+
.sonarqube/
427+
.scannerwork/

src/SharpCoreDB.Provider.Sync/Builders/SharpCoreDBTableBuilder.cs

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,11 @@ public override Task<DbCommand> GetExistsTableCommandAsync(DbConnection connecti
7575
var tableName = SqlIdentifier.EnsureSafe(_tableDescription.TableName, nameof(_tableDescription.TableName));
7676
var command = connection.CreateCommand();
7777
command.Transaction = transaction;
78-
command.CommandText = $"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='{tableName}'";
78+
command.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=@name";
79+
var nameParam = command.CreateParameter();
80+
nameParam.ParameterName = "@name";
81+
nameParam.Value = tableName;
82+
command.Parameters.Add(nameParam);
7983
return Task.FromResult(command);
8084
}
8185

@@ -95,7 +99,7 @@ public override Task<DbCommand> GetDropTableCommandAsync(DbConnection connection
9599
var tableName = SqlIdentifier.EnsureSafe(_tableDescription.TableName, nameof(_tableDescription.TableName));
96100
var command = connection.CreateCommand();
97101
command.Transaction = transaction;
98-
command.CommandText = $"DROP TABLE IF EXISTS [{tableName}]";
102+
command.CommandText = $"DROP TABLE IF EXISTS [{tableName}]"; // NOSONAR:S2077 - {tableName} whitelisted via SqlIdentifier.EnsureSafe
99103
return Task.FromResult(command);
100104
}
101105

@@ -105,7 +109,7 @@ public override Task<DbCommand> GetExistsColumnCommandAsync(string columnName, D
105109
var tableName = SqlIdentifier.EnsureSafe(_tableDescription.TableName, nameof(_tableDescription.TableName));
106110
var command = connection.CreateCommand();
107111
command.Transaction = transaction;
108-
command.CommandText = $"PRAGMA table_info([{tableName}])";
112+
command.CommandText = $"PRAGMA table_info([{tableName}])"; // NOSONAR:S2077 - {tableName} whitelisted via SqlIdentifier.EnsureSafe
109113
return Task.FromResult(command);
110114
}
111115

@@ -167,7 +171,7 @@ public override Task<DbCommand> GetCreateTrackingTableCommandAsync(DbConnection
167171

168172
var command = connection.CreateCommand();
169173
command.Transaction = transaction;
170-
command.CommandText = $@"
174+
command.CommandText = $@" // NOSONAR:S2077 - identifiers whitelisted via SqlIdentifier.EnsureSafe
171175
CREATE TABLE IF NOT EXISTS {trackingTableName} (
172176
{pkColumn} {pkType} PRIMARY KEY NOT NULL,
173177
update_scope_id TEXT,
@@ -185,7 +189,7 @@ public override Task<DbCommand> GetDropTrackingTableCommandAsync(DbConnection co
185189
var trackingTableName = $"{tableName}_tracking";
186190
var command = connection.CreateCommand();
187191
command.Transaction = transaction;
188-
command.CommandText = $"DROP TABLE IF EXISTS [{trackingTableName}]";
192+
command.CommandText = $"DROP TABLE IF EXISTS [{trackingTableName}]"; // NOSONAR:S2077 - {trackingTableName} whitelisted via SqlIdentifier.EnsureSafe
189193
return Task.FromResult(command);
190194
}
191195

@@ -196,7 +200,11 @@ public override Task<DbCommand> GetExistsTrackingTableCommandAsync(DbConnection
196200
var trackingTableName = $"{tableName}_tracking";
197201
var command = connection.CreateCommand();
198202
command.Transaction = transaction;
199-
command.CommandText = $"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='{trackingTableName}'";
203+
command.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=@name";
204+
var nameParam = command.CreateParameter();
205+
nameParam.ParameterName = "@name";
206+
nameParam.Value = trackingTableName;
207+
command.Parameters.Add(nameParam);
200208
return Task.FromResult(command);
201209
}
202210

@@ -214,7 +222,11 @@ public override Task<DbCommand> GetExistsTriggerCommandAsync(DbTriggerType trigg
214222

215223
var command = connection.CreateCommand();
216224
command.Transaction = transaction;
217-
command.CommandText = $"SELECT COUNT(*) FROM sqlite_master WHERE type='trigger' AND name='{triggerName}'";
225+
command.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='trigger' AND name=@name";
226+
var nameParam = command.CreateParameter();
227+
nameParam.ParameterName = "@name";
228+
nameParam.Value = triggerName;
229+
command.Parameters.Add(nameParam);
218230
return Task.FromResult(command);
219231
}
220232

@@ -277,7 +289,7 @@ public override Task<DbCommand> GetDropTriggerCommandAsync(DbTriggerType trigger
277289

278290
var command = connection.CreateCommand();
279291
command.Transaction = transaction;
280-
command.CommandText = $"DROP TRIGGER IF EXISTS [{triggerName}]";
292+
command.CommandText = $"DROP TRIGGER IF EXISTS [{triggerName}]"; // NOSONAR:S2077 - {triggerName} whitelisted via SqlIdentifier.EnsureSafe
281293
return Task.FromResult(command);
282294
}
283295

src/SharpCoreDB.Server.Core/Tenancy/TenantBackupRestoreService.cs

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -314,9 +314,21 @@ private async Task ValidateDatabaseFileAsync(
314314

315315
private static string BuildBackupPath(string backupDirectory, string tenantId, string databaseName)
316316
{
317-
return Path.Combine(
318-
backupDirectory,
319-
$"tenant-{tenantId}-{databaseName}-{DateTime.UtcNow:yyyyMMddHHmmss}.backup");
317+
// tenantId/databaseName were validated by EnsureSafePathSegment (single path segment, no
318+
// separators or '..'); Path.GetFileName is applied again as belt-and-braces so no traversal
319+
// can survive into the backup file name.
320+
var fileName = $"tenant-{Path.GetFileName(tenantId)}-{Path.GetFileName(databaseName)}-{DateTime.UtcNow:yyyyMMddHHmmss}.backup";
321+
var fullPath = Path.GetFullPath(Path.Combine(backupDirectory, fileName));
322+
323+
// Defense in depth: the resolved path must stay inside the configured backup directory.
324+
if (!IsPathWithinRoot(backupDirectory, fullPath))
325+
{
326+
throw new ArgumentException(
327+
"The backup path escapes the configured backup directory.",
328+
nameof(backupDirectory));
329+
}
330+
331+
return fullPath;
320332
}
321333

322334
/// <summary>
@@ -348,6 +360,15 @@ private static string EnsureAbsolutePath(string path, string paramName)
348360
return Path.GetFullPath(path);
349361
}
350362

363+
/// <summary>
364+
/// Returns true when <paramref name="candidatePath"/> resolves inside <paramref name="rootPath"/>.
365+
/// </summary>
366+
private static bool IsPathWithinRoot(string rootPath, string candidatePath)
367+
{
368+
var root = Path.GetFullPath(rootPath).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
369+
return candidatePath.StartsWith(root, StringComparison.OrdinalIgnoreCase);
370+
}
371+
351372
private static async Task ReplacePathAsync(string sourcePath, string targetPath, CancellationToken cancellationToken)
352373
{
353374
if (PathExists(targetPath))
@@ -363,6 +384,10 @@ private static async Task CopyPathAsync(string sourcePath, string targetPath, Ca
363384
ArgumentException.ThrowIfNullOrWhiteSpace(sourcePath);
364385
ArgumentException.ThrowIfNullOrWhiteSpace(targetPath);
365386

387+
// Normalize to absolute, well-formed paths before any filesystem operation.
388+
sourcePath = Path.GetFullPath(sourcePath);
389+
targetPath = Path.GetFullPath(targetPath);
390+
366391
if (Directory.Exists(sourcePath))
367392
{
368393
Directory.CreateDirectory(targetPath);
@@ -398,6 +423,8 @@ private static async Task CopyPathAsync(string sourcePath, string targetPath, Ca
398423

399424
private static long GetPathSizeBytes(string path)
400425
{
426+
path = Path.GetFullPath(path);
427+
401428
if (File.Exists(path))
402429
{
403430
return new FileInfo(path).Length;
@@ -416,6 +443,8 @@ private static long GetPathSizeBytes(string path)
416443

417444
private static void DeletePath(string path)
418445
{
446+
path = Path.GetFullPath(path);
447+
419448
if (File.Exists(path))
420449
{
421450
File.Delete(path);
@@ -433,6 +462,10 @@ private static async Task CopyFileAsync(string sourcePath, string targetPath, Ca
433462
ArgumentException.ThrowIfNullOrWhiteSpace(sourcePath);
434463
ArgumentException.ThrowIfNullOrWhiteSpace(targetPath);
435464

465+
// Normalize to absolute, well-formed paths before any filesystem operation.
466+
sourcePath = Path.GetFullPath(sourcePath);
467+
targetPath = Path.GetFullPath(targetPath);
468+
436469
await using var source = new FileStream(
437470
sourcePath,
438471
FileMode.Open,

src/SharpCoreDB/DataStructures/Table.Scanning.cs

Lines changed: 5 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -220,36 +220,17 @@ private bool EvaluateWhere(Dictionary<string, object> row, string? where)
220220
// ✅ Issue #339: support IN / NOT IN lists for all column types.
221221
// Previously the list was split on spaces (losing everything after the first
222222
// comma when the SQL contains spaces, e.g. IN ('a', 'b')), and non-string
223-
// columns fell through the switch's default → accept-all. Extract the full
224-
// parenthesized list via regex and evaluate it as a whole.
225-
var inMatch = System.Text.RegularExpressions.Regex.Match(
226-
where, @"^(.+?)\s+(NOT\s+)?IN\s*\((.*)\)\s*$",
227-
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline,
228-
TimeSpan.FromSeconds(1));
229-
if (inMatch.Success)
223+
// columns fell through the switch's default → accept-all. Parsing is shared via
224+
// SqlInPredicate so every path evaluates the full parenthesized list.
225+
if (SqlInPredicate.TryParseCondition(where, out var inCol, out var inNegated, out var inItems))
230226
{
231-
var inCol = inMatch.Groups[1].Value.Trim();
232-
var inDotIdx = inCol.LastIndexOf('.');
233-
if (inDotIdx >= 0 && inDotIdx < inCol.Length - 1)
234-
{
235-
inCol = inCol[(inDotIdx + 1)..];
236-
}
237-
238-
inCol = inCol.Trim('"', '[', ']', '`');
239-
var negated = inMatch.Groups[2].Success;
240-
241227
if (!row.TryGetValue(inCol, out var inRowVal) || inRowVal is null or DBNull)
242228
{
243229
return false;
244230
}
245231

246-
var inItems = inMatch.Groups[3].Value
247-
.Split(',')
248-
.Select(v => v.Trim().Trim('\'', '"'))
249-
.ToList();
250-
251-
var matched = inItems.Contains(inRowVal.ToString() ?? string.Empty);
252-
return negated ? !matched : matched;
232+
var matched = SqlInPredicate.IsMatch(inRowVal, inItems);
233+
return inNegated ? !matched : matched;
253234
}
254235

255236
// ✅ Parity: delegate LIKE / NOT LIKE / BETWEEN (single-condition form) to the shared
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// <copyright file="SqlInPredicate.cs" company="MPCoreDeveloper">
2+
// Copyright (c) 2025-2026 MPCoreDeveloper and GitHub Copilot. All rights reserved.
3+
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
4+
// </copyright>
5+
6+
namespace SharpCoreDB.Services;
7+
8+
using System;
9+
using System.Collections.Generic;
10+
using System.Linq;
11+
using System.Text.RegularExpressions;
12+
13+
/// <summary>
14+
/// Shared IN / NOT IN predicate parsing and evaluation, used by every WHERE evaluation path
15+
/// (single-file <see cref="SingleFileTable"/>, directory-mode <see cref="DataStructures.Table"/>
16+
/// and the enhanced AST evaluator). Centralizing the logic here prevents the value-list
17+
/// parsing from drifting between paths (GitHub issue #339).
18+
/// </summary>
19+
internal static class SqlInPredicate
20+
{
21+
private static readonly Regex InConditionPattern = new(
22+
@"^(.+?)\s+(NOT\s+)?IN\s*\((.*)\)\s*$",
23+
RegexOptions.IgnoreCase | RegexOptions.Singleline,
24+
TimeSpan.FromSeconds(1));
25+
26+
/// <summary>
27+
/// Parses a single-condition IN / NOT IN clause: <c>col IN ('a', 'b')</c> or
28+
/// <c>col NOT IN (1, 2)</c>. Returns the normalized column name (alias-qualified and
29+
/// quoted references are stripped), whether the clause is negated, and the trimmed items.
30+
/// </summary>
31+
/// <param name="condition">The condition text, e.g. <c>node_type IN ('WorkItem', 'Person')</c>.</param>
32+
/// <param name="column">The normalized column name.</param>
33+
/// <param name="negated">True when the clause is <c>NOT IN</c>.</param>
34+
/// <param name="items">The trimmed, quote-stripped list items.</param>
35+
/// <returns>True when the condition is a well-formed IN/NOT IN clause.</returns>
36+
public static bool TryParseCondition(string condition, out string column, out bool negated, out List<string> items)
37+
{
38+
column = string.Empty;
39+
negated = false;
40+
items = [];
41+
42+
var match = InConditionPattern.Match(condition);
43+
if (!match.Success)
44+
{
45+
return false;
46+
}
47+
48+
column = NormalizeColumn(match.Groups[1].Value);
49+
negated = match.Groups[2].Success;
50+
items = match.Groups[3].Value
51+
.Split(',')
52+
.Select(v => v.Trim().Trim('\'', '"'))
53+
.ToList();
54+
55+
return true;
56+
}
57+
58+
/// <summary>
59+
/// Evaluates a row value against an already-parsed IN list by comparing <see cref="object.ToString"/>.
60+
/// </summary>
61+
/// <param name="rowValue">The row value to test.</param>
62+
/// <param name="items">The parsed list items.</param>
63+
/// <returns>True when the row value is contained in the list.</returns>
64+
public static bool IsMatch(object? rowValue, IEnumerable<string> items)
65+
=> items.Contains(rowValue?.ToString() ?? string.Empty);
66+
67+
/// <summary>
68+
/// Evaluates a raw IN value list (e.g. <c>('a', 'b')</c> or <c>(1,2,3)</c>) against a row value.
69+
/// Strips surrounding parentheses (when present), splits on commas and trims quotes.
70+
/// </summary>
71+
/// <param name="rowValue">The row value to test.</param>
72+
/// <param name="listValue">The raw value list text.</param>
73+
/// <returns>True when the row value is contained in the list.</returns>
74+
public static bool ValueInList(string? rowValue, string? listValue)
75+
{
76+
if (listValue is null)
77+
{
78+
return false;
79+
}
80+
81+
var trimmed = listValue.Trim();
82+
if (trimmed.StartsWith('(') && trimmed.EndsWith(')'))
83+
{
84+
trimmed = trimmed[1..^1];
85+
}
86+
87+
return trimmed.Split(',').Select(v => v.Trim().Trim('\'', '"')).Contains(rowValue);
88+
}
89+
90+
/// <summary>
91+
/// Normalizes a column reference to the bare row-key form: strips alias qualifiers
92+
/// (e.g. <c>b.Url</c> to <c>Url</c>) and identifier quotes (<c>"</c>, <c>[</c>, <c>]</c>, <c>`</c>).
93+
/// </summary>
94+
private static string NormalizeColumn(string columnName)
95+
{
96+
var trimmed = columnName.Trim();
97+
var dotIndex = trimmed.LastIndexOf('.');
98+
if (dotIndex >= 0 && dotIndex < trimmed.Length - 1)
99+
{
100+
trimmed = trimmed[(dotIndex + 1)..];
101+
}
102+
103+
return trimmed.Trim('"', '[', ']', '`');
104+
}
105+
}

src/SharpCoreDB/Services/SqlParser.Helpers.cs

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -581,8 +581,8 @@ int Compare(object? a, object? b)
581581
"NOT LIKE" => rowValueStr is not null && value is not null && !LikeMatch(rowValueStr, value),
582582
"REGEXP" => rowValueStr is not null && value is not null && Regex.IsMatch(rowValueStr, value, RegexOptions.None, TimeSpan.FromSeconds(1)),
583583
"NOT REGEXP" => rowValueStr is null || value is null || !Regex.IsMatch(rowValueStr, value, RegexOptions.None, TimeSpan.FromSeconds(1)),
584-
"IN" => EvaluateInList(rowValueStr, value),
585-
"NOT IN" => !EvaluateInList(rowValueStr, value),
584+
"IN" => SqlInPredicate.ValueInList(rowValueStr, value),
585+
"NOT IN" => !SqlInPredicate.ValueInList(rowValueStr, value),
586586
_ => throw new InvalidOperationException($"Unsupported operator {op}"),
587587
};
588588
}
@@ -641,26 +641,6 @@ private static bool LikeMatch(string input, string pattern)
641641
return Regex.IsMatch(input, regex, RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1));
642642
}
643643

644-
/// <summary>
645-
/// Evaluates an IN value list: "('a', 'b')" or "(1,2,3)".
646-
/// Strips surrounding parentheses (if present), splits on commas and trims quotes.
647-
/// </summary>
648-
private static bool EvaluateInList(string? rowValue, string? listValue)
649-
{
650-
if (listValue is null)
651-
{
652-
return false;
653-
}
654-
655-
var trimmed = listValue.Trim();
656-
if (trimmed.StartsWith('(') && trimmed.EndsWith(')'))
657-
{
658-
trimmed = trimmed[1..^1];
659-
}
660-
661-
return trimmed.Split(',').Select(v => v.Trim().Trim('\'', '"')).Contains(rowValue);
662-
}
663-
664644
/// <summary>
665645
/// Binds parameters to a SQL query string, replacing placeholders with actual values.
666646
/// Supports both named parameters (@paramName) and positional parameters (?).

src/SharpCoreDB/SingleFileTable.cs

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1162,26 +1162,15 @@ private static bool EvaluateSingleCondition(Dictionary<string, object> row, stri
11621162

11631163
// ✅ Issue #339: support IN / NOT IN lists (previously not in the operator list,
11641164
// so the condition fell through to "accept all rows").
1165-
var inMatch = System.Text.RegularExpressions.Regex.Match(
1166-
trimmed, @"^(.+?)\s+(NOT\s+)?IN\s*\((.*)\)\s*$",
1167-
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline,
1168-
TimeSpan.FromSeconds(1));
1169-
if (inMatch.Success)
1170-
{
1171-
var inCol = NormalizeColumnName(inMatch.Groups[1].Value);
1172-
var negated = inMatch.Groups[2].Success;
1165+
if (SqlInPredicate.TryParseCondition(trimmed, out var inCol, out var inNegated, out var inItems))
1166+
{
11731167
if (!row.TryGetValue(inCol, out var inRowVal) || inRowVal is null or DBNull)
11741168
{
11751169
return false;
11761170
}
11771171

1178-
var inItems = inMatch.Groups[3].Value
1179-
.Split(',')
1180-
.Select(v => v.Trim().Trim('\'', '"'))
1181-
.ToList();
1182-
1183-
var matched = inItems.Contains(inRowVal.ToString() ?? string.Empty);
1184-
return negated ? !matched : matched;
1172+
var matched = SqlInPredicate.IsMatch(inRowVal, inItems);
1173+
return inNegated ? !matched : matched;
11851174
}
11861175

11871176
var operators = new[] { ">=", "<=", "!=", "<>", "=", ">", "<" };

0 commit comments

Comments
 (0)