Skip to content

Commit 0744bfe

Browse files
authored
Added query parameterization (#2355)
* Added query parameterization * updates * updates * Fix the case SELECT c["it's a property"] FROM c * address feedback * add more kusto tests * update * dotnet format * add more tests
1 parent 9cb8272 commit 0744bfe

19 files changed

Lines changed: 1735 additions & 12 deletions

File tree

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System.Text;
5+
6+
namespace Microsoft.Mcp.Core.Helpers;
7+
8+
/// <summary>
9+
/// Provides escaping and sanitization utilities for Kusto Query Language (KQL)
10+
/// strings and identifiers, preventing injection attacks when constructing
11+
/// KQL queries with user-supplied values.
12+
/// </summary>
13+
public static class KqlSanitizer
14+
{
15+
/// <summary>
16+
/// Escapes a single-quoted KQL string value by doubling any embedded single quotes.
17+
/// Use this when interpolating a value into a KQL <c>where</c> clause, e.g.:
18+
/// <c>$"MyTable | where Name == '{KqlSanitizer.EscapeStringValue(name)}'"</c>
19+
/// </summary>
20+
public static string EscapeStringValue(string value)
21+
{
22+
ArgumentNullException.ThrowIfNull(value);
23+
return value.Replace("'", "''", StringComparison.Ordinal);
24+
}
25+
26+
/// <summary>
27+
/// Wraps a KQL identifier (table name, column name, etc.) in bracket notation
28+
/// with proper escaping: <c>['identifier']</c>. This prevents injection when
29+
/// an identifier is supplied by user input.
30+
/// </summary>
31+
public static string EscapeIdentifier(string identifier)
32+
{
33+
ArgumentNullException.ThrowIfNull(identifier);
34+
return $"['{identifier.Replace("'", "''", StringComparison.Ordinal)}']";
35+
}
36+
37+
/// <summary>
38+
/// Sanitizes KQL string literals by parsing each single-quoted literal,
39+
/// normalizing doubled-quote escapes, and re-encoding with proper escaping.
40+
/// This prevents injection through string literal breakout where a crafted
41+
/// literal value could escape the quote context and inject KQL operators.
42+
/// Correctly skips double-quoted strings, verbatim strings (@'...', @"..."),
43+
/// obfuscated strings (h'...', h@'...'), and line comments (//).
44+
/// </summary>
45+
public static string SanitizeStringLiterals(string query)
46+
{
47+
ArgumentNullException.ThrowIfNull(query);
48+
var result = new StringBuilder(query.Length);
49+
var i = 0;
50+
51+
while (i < query.Length)
52+
{
53+
// Handle h prefix (obfuscated strings): h'...', h"...", h@'...', h@"..."
54+
if ((query[i] == 'h' || query[i] == 'H') && i + 1 < query.Length &&
55+
(query[i + 1] == '\'' || query[i + 1] == '"' || query[i + 1] == '@'))
56+
{
57+
if (query[i + 1] == '@' && i + 2 < query.Length && (query[i + 2] == '\'' || query[i + 2] == '"'))
58+
{
59+
// h@'...' or h@"..." — obfuscated verbatim string
60+
var quoteChar = query[i + 2];
61+
result.Append(query[i]); // h
62+
result.Append(query[i + 1]); // @
63+
result.Append(query[i + 2]); // opening quote
64+
i += 3;
65+
SkipQuotedContent(query, result, ref i, quoteChar);
66+
}
67+
else if (query[i + 1] == '\'' || query[i + 1] == '"')
68+
{
69+
// h'...' or h"..." — obfuscated string
70+
var quoteChar = query[i + 1];
71+
result.Append(query[i]); // h
72+
result.Append(query[i + 1]); // opening quote
73+
i += 2;
74+
SkipQuotedContent(query, result, ref i, quoteChar);
75+
}
76+
else
77+
{
78+
result.Append(query[i]);
79+
i++;
80+
}
81+
}
82+
// Handle @ prefix (verbatim strings): @'...', @"..."
83+
else if (query[i] == '@' && i + 1 < query.Length && (query[i + 1] == '\'' || query[i + 1] == '"'))
84+
{
85+
var quoteChar = query[i + 1];
86+
result.Append(query[i]); // @
87+
result.Append(query[i + 1]); // opening quote
88+
i += 2;
89+
SkipQuotedContent(query, result, ref i, quoteChar);
90+
}
91+
// Handle double-quoted strings: "..."
92+
else if (query[i] == '"')
93+
{
94+
result.Append(query[i]);
95+
i++;
96+
SkipQuotedContent(query, result, ref i, '"');
97+
}
98+
// Handle line comments: // to end of line
99+
else if (query[i] == '/' && i + 1 < query.Length && query[i + 1] == '/')
100+
{
101+
while (i < query.Length && query[i] != '\n')
102+
{
103+
result.Append(query[i]);
104+
i++;
105+
}
106+
}
107+
// Handle single-quoted string literals — sanitize these
108+
else if (query[i] == '\'')
109+
{
110+
var value = new StringBuilder();
111+
i++; // skip opening quote
112+
113+
while (i < query.Length)
114+
{
115+
if (query[i] == '\'' && i + 1 < query.Length && query[i + 1] == '\'')
116+
{
117+
// KQL doubled-quote escape
118+
value.Append('\'');
119+
i += 2;
120+
}
121+
else if (query[i] == '\'')
122+
{
123+
// End of string literal
124+
i++;
125+
break;
126+
}
127+
else
128+
{
129+
value.Append(query[i]);
130+
i++;
131+
}
132+
}
133+
134+
// Re-encode with proper escaping
135+
result.Append('\'');
136+
result.Append(value.ToString().Replace("'", "''", StringComparison.Ordinal));
137+
result.Append('\'');
138+
}
139+
else
140+
{
141+
result.Append(query[i]);
142+
i++;
143+
}
144+
}
145+
146+
return result.ToString();
147+
}
148+
149+
/// <summary>
150+
/// Copies characters from <paramref name="query"/> into <paramref name="result"/>
151+
/// until the matching closing <paramref name="quoteChar"/> is found.
152+
/// Doubled quotes are treated as escape sequences and preserved as-is.
153+
/// </summary>
154+
private static void SkipQuotedContent(string query, StringBuilder result, ref int i, char quoteChar)
155+
{
156+
while (i < query.Length)
157+
{
158+
result.Append(query[i]);
159+
if (query[i] == quoteChar)
160+
{
161+
i++;
162+
if (i < query.Length && query[i] == quoteChar)
163+
{
164+
// Doubled quote escape — continue
165+
result.Append(query[i]);
166+
i++;
167+
}
168+
else
169+
{
170+
break;
171+
}
172+
}
173+
else
174+
{
175+
i++;
176+
}
177+
}
178+
}
179+
}

0 commit comments

Comments
 (0)