diff --git a/certified-connectors/Snowflake v2/.gitignore b/certified-connectors/Snowflake v2/.gitignore index 809bbd611e..1560de3c22 100644 --- a/certified-connectors/Snowflake v2/.gitignore +++ b/certified-connectors/Snowflake v2/.gitignore @@ -6,6 +6,7 @@ !/SnowflakeV2CoreLogic/ !/SnowflakeTestApp/ !/SnowflakeTestApp.Tests/ +!/SnowflakeV2CoreLogicTests/ !/ConnectorArtifacts/ # .config folder is not ignored @@ -413,4 +414,4 @@ __pycache__/ tmp/ # Ignore launch settings for Visual Studio projects -launchSettings.json \ No newline at end of file +launchSettings.json diff --git a/certified-connectors/Snowflake v2/SnowflakeTestApp.Tests/Data/TableDataInjectionEndpointIntegrationTest.cs b/certified-connectors/Snowflake v2/SnowflakeTestApp.Tests/Data/TableDataInjectionEndpointIntegrationTest.cs new file mode 100644 index 0000000000..259dc25ff0 --- /dev/null +++ b/certified-connectors/Snowflake v2/SnowflakeTestApp.Tests/Data/TableDataInjectionEndpointIntegrationTest.cs @@ -0,0 +1,215 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json.Linq; + +namespace SnowflakeTestApp.Tests.Data +{ + /// + /// End-to-end SQL-injection tests for the table data (items) endpoint. These fire real HTTP + /// requests at a running SnowflakeTestApp (which forwards to a live Snowflake instance) and + /// assert that identifier/clause injection attempts are rejected before any SQL is executed. + /// + /// The primary case reproduces a vulnerability confirmed against the live environment: passing + /// (SELECT CURRENT_USER()) t as the "table name" to + /// GET /datasets('default')/tables('{table}')/items caused the unpatched backend to build + /// SELECT * FROM (SELECT CURRENT_USER()) t ... and return the Snowflake session user. + /// A patched backend must reject the request (via EnsureValidQualifiedSnowflakeIdentifier) + /// and must never echo the exfiltrated value. + /// + /// All payloads are read-only and non-destructive. + /// + [TestClass] + public class TableDataInjectionEndpointIntegrationTest : BaseIntegrationTest + { + private const string TestDataset = "default"; + private const string ControlTable = "CUSTOMERS"; + + [TestInitialize] + public override void TestInitialize() + { + base.TestInitialize(); + EnsureApplicationIsRunning(); + } + + /// + /// Control: a legitimate table must still return 200. This proves the endpoint/route works, + /// so the rejections asserted by the injection tests below are specific to the payloads and + /// not an unrelated failure. + /// + [TestMethod] + public async Task GetItemsEndpoint_WithValidTable_ReturnsOk_Control() + { + AddAuthHeaders(); + + var response = await HttpClient.GetAsync( + $"{BaseUrl}/datasets('{TestDataset}')/tables('{ControlTable}')/items"); + var content = await response.Content.ReadAsStringAsync(); + LogResponse("Control (valid table)", response, content); + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, + "Control request with a valid table name should succeed."); + } + + /// + /// Exact reproduction of the live-confirmed exploit. Asserts the request is rejected AND the + /// response body does not contain the Snowflake session user (i.e. the subquery never ran). + /// The current user is resolved up front via the /sql endpoint so we know the precise value a + /// successful injection would leak. + /// + [TestMethod] + public async Task GetItemsEndpoint_WithCurrentUserSubqueryTableName_IsRejectedAndDoesNotLeak() + { + var currentUser = await GetCurrentSnowflakeUserAsync(); + Assert.IsFalse(string.IsNullOrWhiteSpace(currentUser), + "Precondition: should be able to resolve CURRENT_USER() via the /sql endpoint."); + + AddAuthHeaders(); + + const string injectionTable = "(SELECT CURRENT_USER()) t"; + var url = $"{BaseUrl}/datasets('{TestDataset}')/tables('{Uri.EscapeDataString(injectionTable)}')/items"; + + var response = await HttpClient.GetAsync(url); + var content = await response.Content.ReadAsStringAsync(); + LogResponse($"Table-name injection '{injectionTable}'", response, content); + + Assert.AreNotEqual(HttpStatusCode.OK, response.StatusCode, + $"Table-name injection must be rejected before any SQL runs. Status: {response.StatusCode}, Body: {content}"); + + Assert.IsFalse( + content.IndexOf(currentUser, StringComparison.OrdinalIgnoreCase) >= 0, + "Response must not contain CURRENT_USER(); its presence means the injected subquery executed."); + } + + /// + /// Same exploit as above but with the payload double URL-encoded, mirroring the exact + /// wire format used against the live environment. The controller decodes the table twice + /// (OData model binding, then HttpUtility.UrlDecode), so a double-encoded value collapses + /// back to (SELECT CURRENT_USER()) t before validation. This is defense-in-depth against + /// decode surprises: it must still be rejected and must not leak the session user. + /// + [TestMethod] + public async Task GetItemsEndpoint_WithDoubleEncodedCurrentUserSubquery_IsRejectedAndDoesNotLeak() + { + var currentUser = await GetCurrentSnowflakeUserAsync(); + Assert.IsFalse(string.IsNullOrWhiteSpace(currentUser), + "Precondition: should be able to resolve CURRENT_USER() via the /sql endpoint."); + + AddAuthHeaders(); + + const string injectionTable = "(SELECT CURRENT_USER()) t"; + var doubleEncoded = Uri.EscapeDataString(Uri.EscapeDataString(injectionTable)); + var url = $"{BaseUrl}/datasets('{TestDataset}')/tables('{doubleEncoded}')/items"; + + var response = await HttpClient.GetAsync(url); + var content = await response.Content.ReadAsStringAsync(); + LogResponse($"Double-encoded table-name injection '{doubleEncoded}'", response, content); + + Assert.AreNotEqual(HttpStatusCode.OK, response.StatusCode, + $"Double-encoded table-name injection must be rejected. Status: {response.StatusCode}, Body: {content}"); + + Assert.IsFalse( + content.IndexOf(currentUser, StringComparison.OrdinalIgnoreCase) >= 0, + "Response must not contain CURRENT_USER(); its presence means the injected subquery executed."); + } + + /// + /// Additional read-only, non-destructive injection payloads supplied as the table name. Each + /// must be rejected (identifier validation fails before the statement is built/executed). + /// + [DataTestMethod] + [DataRow("(SELECT CURRENT_USER()) t")] + [DataRow("(SELECT 1) x")] + [DataRow("CUSTOMERS UNION SELECT CURRENT_USER()")] + [DataRow("CUSTOMERS WHERE 1=1")] + [DataRow("CUSTOMERS--")] + public async Task GetItemsEndpoint_WithMaliciousTableName_IsRejected(string injectionTable) + { + AddAuthHeaders(); + + var url = $"{BaseUrl}/datasets('{TestDataset}')/tables('{Uri.EscapeDataString(injectionTable)}')/items"; + + var response = await HttpClient.GetAsync(url); + var content = await response.Content.ReadAsStringAsync(); + LogResponse($"Malicious table name '{injectionTable}'", response, content); + + Assert.AreNotEqual(HttpStatusCode.OK, response.StatusCode, + $"Malicious table name '{injectionTable}' must be rejected. Status: {response.StatusCode}, Body: {content}"); + } + + /// + /// Injection via the OData $orderby clause against a valid table must be rejected + /// (via EnsureValidOrderByClause). + /// + [TestMethod] + public async Task GetItemsEndpoint_WithMaliciousOrderBy_IsRejected() + { + AddAuthHeaders(); + + const string maliciousOrderBy = "NAME; SELECT CURRENT_USER()"; + var url = $"{BaseUrl}/datasets('{TestDataset}')/tables('{ControlTable}')/items?$orderby={Uri.EscapeDataString(maliciousOrderBy)}"; + + var response = await HttpClient.GetAsync(url); + var content = await response.Content.ReadAsStringAsync(); + LogResponse("Malicious $orderby", response, content); + + Assert.AreNotEqual(HttpStatusCode.OK, response.StatusCode, + $"Malicious $orderby must be rejected. Status: {response.StatusCode}, Body: {content}"); + } + + /// + /// Injection via the OData $select clause against a valid table must be rejected + /// (via EnsureValidSelectClause) and must not leak the session user. + /// + [TestMethod] + public async Task GetItemsEndpoint_WithMaliciousSelect_IsRejectedAndDoesNotLeak() + { + var currentUser = await GetCurrentSnowflakeUserAsync(); + Assert.IsFalse(string.IsNullOrWhiteSpace(currentUser), + "Precondition: should be able to resolve CURRENT_USER() via the /sql endpoint."); + + AddAuthHeaders(); + + const string maliciousSelect = "NAME, (SELECT CURRENT_USER())"; + var url = $"{BaseUrl}/datasets('{TestDataset}')/tables('{ControlTable}')/items?$select={Uri.EscapeDataString(maliciousSelect)}"; + + var response = await HttpClient.GetAsync(url); + var content = await response.Content.ReadAsStringAsync(); + LogResponse("Malicious $select", response, content); + + Assert.AreNotEqual(HttpStatusCode.OK, response.StatusCode, + $"Malicious $select must be rejected. Status: {response.StatusCode}, Body: {content}"); + + Assert.IsFalse( + content.IndexOf(currentUser, StringComparison.OrdinalIgnoreCase) >= 0, + "Response must not contain CURRENT_USER(); its presence means the injected projection executed."); + } + + private void LogResponse(string label, HttpResponseMessage response, string content) + { + TestContext.WriteLine($"[{label}] HTTP {(int)response.StatusCode} {response.StatusCode}"); + TestContext.WriteLine($"[{label}] Response body: {content}"); + } + + private void AddAuthHeaders() + { + var testToken = GetTestToken(); + HttpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {testToken}"); + HttpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + } + + /// + /// Resolves the current Snowflake session user via the /sql endpoint. This is the exact value + /// a successful CURRENT_USER() injection would exfiltrate, used to assert non-leakage. + /// + private static async Task GetCurrentSnowflakeUserAsync() + { + var raw = await DataSeeder.ExecuteSqlStatement("SELECT CURRENT_USER() AS USR"); + var json = JObject.Parse(raw); + return (string)json["Data"]?[0]?["USR"]; + } + } +} diff --git a/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/Providers/SnowflakeDBOperations.cs b/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/Providers/SnowflakeDBOperations.cs index de7e370dfb..ccf2531876 100644 --- a/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/Providers/SnowflakeDBOperations.cs +++ b/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/Providers/SnowflakeDBOperations.cs @@ -135,6 +135,8 @@ public SnowflakeDBOperations( { SnowflakeTableData? snowflakeTableData = null; + table.EnsureValidQualifiedSnowflakeIdentifier("Table Name"); + var fieldsToSelect = "*"; string? orderBy = null; var top = "51"; @@ -148,6 +150,11 @@ public SnowflakeDBOperations( try { queryOptions = QueryOptions.Parse(options); + fieldsToSelect = queryOptions.Select != null ? queryOptions.Select.EnsureValidSelectClause("Select") : "*"; + orderBy = options.OrderBy != null ? options.OrderBy.RawValue.EnsureValidOrderByClause("Order By") : null; + top = queryOptions.IsTopSet ? queryOptions.Top.ToString() : Constants.DefaultNumberOfRowsToReturn.ToString(); + skip = queryOptions.Skip.ToString(); + filter = ConvertODataFilterToSql(options, connectionParameters?.UseCaseInsensitiveFilters ?? false); } catch (ArgumentException ex) { @@ -157,12 +164,6 @@ public SnowflakeDBOperations( HttpStatusCode.BadRequest, ex.Message)); } - - fieldsToSelect = queryOptions.Select != null ? queryOptions.Select : "*"; - orderBy = options.OrderBy != null ? options.OrderBy.RawValue : null; - top = queryOptions.IsTopSet ? queryOptions.Top.ToString() : Constants.DefaultNumberOfRowsToReturn.ToString(); - skip = queryOptions.Skip.ToString(); - filter = ConvertODataFilterToSql(options, connectionParameters?.UseCaseInsensitiveFilters ?? false); } using (var latencyLogger = new LatencyLogger(Constants.ListAllItemsAsync, logger)) @@ -221,12 +222,8 @@ public SnowflakeDBOperations( } } - // Add request bindings - SnowflakeRequestBindings stmtBindings = new SnowflakeRequestBindings(); - stmtBindings.AddTextBinding(1, table); - // Fetch the metadata - snowflakeTableData = await snowflakeClient.CallAPIAsync(httpClient, stmt, $"{endpoint} - ListAllItems", stmtBindings, connectionParameters, null, false).ConfigureAwait(true); + snowflakeTableData = await snowflakeClient.CallAPIAsync(httpClient, stmt, $"{endpoint} - ListAllItems", null, connectionParameters, null, false).ConfigureAwait(true); } return snowflakeTableData; @@ -255,7 +252,7 @@ public string ConvertODataFilterToSql(ODataQueryOptions options, bool useCaseIns SnowflakeTableData? data = null; // Check the table name and field name adhere to the Snowflake schema - tableName.EnsureValidSnowflakeIdentifier("Table Name"); + tableName.EnsureValidQualifiedSnowflakeIdentifier("Table Name"); fieldToQuery.EnsureValidSnowflakeIdentifier("Field Name"); using (var latencyLogger = new LatencyLogger(Constants.GetObjectAsync, logger)) @@ -287,6 +284,12 @@ public string ConvertODataFilterToSql(ODataQueryOptions options, bool useCaseIns var columns = dataToInsert.DynamicProperties.Keys; var values = dataToInsert.DynamicProperties.Values; + table.EnsureValidQualifiedSnowflakeIdentifier("Table Name"); + foreach (var column in columns) + { + column.EnsureValidSnowflakeIdentifier("Column Name"); + } + var valuesPlaceholders = new StringBuilder(); for (int i = 0; i < values.Count; i++) @@ -341,6 +344,13 @@ internal async Task UpdateItemAsync( throw new InvalidOperationException("The number of columns and values do not match"); } + table.EnsureValidQualifiedSnowflakeIdentifier("Table Name"); + primaryKeyColumn.EnsureValidSnowflakeIdentifier("Primary Key Column"); + foreach (var column in columns) + { + column.EnsureValidSnowflakeIdentifier("Column Name"); + } + // Loop through all the columns to create the update string var bindingCounter = 1; for (int i = 0; i < columns.Length; i++) @@ -380,6 +390,9 @@ internal async Task DeleteItemAsync( using (var latencyLogger = new LatencyLogger(Constants.DeleteItemAsync, logger)) { + table.EnsureValidQualifiedSnowflakeIdentifier("Table Name"); + primaryKeyColumn.EnsureValidSnowflakeIdentifier("Primary Key Column"); + var query = $"DELETE FROM {table} where {primaryKeyColumn}=?"; // Add request bindings @@ -399,6 +412,8 @@ internal async Task GetNumberOfRecordsAvailableInTableAsync( SnowflakeConnectionParameters connectionParameters, string endpoint) { + table.EnsureValidQualifiedSnowflakeIdentifier("Table Name"); + var query = "SELECT COUNT(*) FROM " + table; if (options != null) @@ -446,12 +461,16 @@ internal async Task GetInformationSchemaAsync(SnowflakeConne return dataWithoutValidation; } - var queryWithSnowflakeConfigValidation = $"USE ROLE \"{role}\";USE WAREHOUSE \"{warehouse}\";USE DATABASE \"{database}\";USE SCHEMA \"{schema}\";SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = '{schema}';"; + var queryWithSnowflakeConfigValidation = $"USE ROLE \"{role.EscapeSnowflakeQuotedIdentifier()}\";USE WAREHOUSE \"{warehouse.EscapeSnowflakeQuotedIdentifier()}\";USE DATABASE \"{database.EscapeSnowflakeQuotedIdentifier()}\";USE SCHEMA \"{schema.EscapeSnowflakeQuotedIdentifier()}\";SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ?;"; + + SnowflakeRequestBindings schemaBindings = new SnowflakeRequestBindings(); + schemaBindings.AddTextBinding(1, schema); + RequestParameters requestParameters = new RequestParameters { MULTI_STATEMENT_COUNT = 5, }; - var dataWithValidation = await snowflakeClient.CallAPIAsync(httpClient, queryWithSnowflakeConfigValidation, $"{endpoint} - GetInformationSchemaValidation", null, null, requestParameters, true).ConfigureAwait(true); + var dataWithValidation = await snowflakeClient.CallAPIAsync(httpClient, queryWithSnowflakeConfigValidation, $"{endpoint} - GetInformationSchemaValidation", schemaBindings, null, requestParameters, true).ConfigureAwait(true); return dataWithValidation; } } diff --git a/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/Utilities/EnsureExtensions.cs b/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/Utilities/EnsureExtensions.cs index 8dca047e9d..cb18ed636e 100644 --- a/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/Utilities/EnsureExtensions.cs +++ b/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/Utilities/EnsureExtensions.cs @@ -4,6 +4,8 @@ namespace SnowflakeV2CoreLogic.Utilities { using System; + using System.Collections.Generic; + using System.Text; using System.Text.RegularExpressions; /// @@ -12,16 +14,26 @@ namespace SnowflakeV2CoreLogic.Utilities internal static class EnsureExtensions { // Setup the regex for the snowflake identifier - private static readonly string UnquotedIdentifierRegexRules = @"^[a-zA-Z_][a-zA-Z0-9_$]{0,255}"; + private static readonly string UnquotedIdentifierRegexRules = @"[a-zA-Z_][a-zA-Z0-9_$]{0,255}"; - // Quoted identifiers can be between 0 - 253 (not including quotes), and can contain ascii or extended ascii values: ^"[\x00-\x7F\x80-\xFF\u0100-\uFFFF]{0,253}"$ - private static readonly string QuotedIdentifierRegexRules = @"^""[\x00-\x7F\x80-\xFF\u0100-\uFFFF]{0,253}""$"; + // Quoted identifiers are delimited by double quotes and may contain any characters. An + // embedded double quote must be escaped by doubling it ("") per Snowflake's rules, so a lone + // (unescaped) double quote ends the identifier. Combined with the start/end anchoring below, + // this accepts every valid quoted identifier while preventing a value from breaking out of + // the quoting (any trailing SQL after an unescaped quote fails the \z anchor). + private static readonly string QuotedIdentifierRegexRules = @"""(?:[^""]|""""){0,253}"""; - private static readonly string IdentifierFullRegex = $"{UnquotedIdentifierRegexRules}|{QuotedIdentifierRegexRules}"; + // The whole value must be a single identifier (anchored start-to-end with \A and \z) so that + // a valid prefix followed by arbitrary SQL cannot pass validation. + private static readonly string IdentifierFullRegex = $@"\A(?:{UnquotedIdentifierRegexRules}|{QuotedIdentifierRegexRules})\z"; private static readonly Regex IdentifierRegexPattern = new Regex(IdentifierFullRegex, RegexOptions.Compiled); - // Setup the regex for the snowflake URL - private static readonly string UrlFullRegex = @"^([a-zA-Z0-9-_.]+\.snowflakecomputing\.com|[a-zA-Z0-9-_.]+\.privatelink\.snowflakecomputing\.com)$"; + // Matches a value that is a valid *unquoted* Snowflake identifier (anchored start-to-end). + private static readonly Regex UnquotedIdentifierRegexPattern = new Regex($@"\A{UnquotedIdentifierRegexRules}\z", RegexOptions.Compiled); + + // Setup the regex for the snowflake URL. The top-level domain is ".com" for global + // deployments and ".cn" for the China deployment (e.g. account.region.snowflakecomputing.cn). + private static readonly string UrlFullRegex = @"^([a-zA-Z0-9-_.]+\.snowflakecomputing\.(?:com|cn)|[a-zA-Z0-9-_.]+\.privatelink\.snowflakecomputing\.(?:com|cn))$"; private static readonly Regex UrlRegexPattern = new Regex(UrlFullRegex, RegexOptions.Compiled); /// @@ -94,17 +106,272 @@ public static string EnsureValidSnowflakeIdentifier( { if (string.IsNullOrWhiteSpace(identifier)) { - throw new ArgumentNullException(identifier); + throw new ArgumentNullException(nameOfIdentifier); } if (!IdentifierRegexPattern.IsMatch(identifier)) { - throw new ArgumentException($"Invalid snowflake identifier: {nameOfIdentifier}. Must adhere to the following regex: ${IdentifierFullRegex}"); + throw new ArgumentException($"Invalid snowflake identifier: {nameOfIdentifier}."); + } + + return identifier; + } + + /// + /// Validates a possibly-qualified Snowflake object identifier (for example TABLE, + /// SCHEMA.TABLE or DATABASE.SCHEMA.TABLE) that is interpolated directly into + /// the SQL statement. Each dot-separated part must be a valid (quoted or unquoted) Snowflake + /// identifier. The value is split in a quote-aware manner, so a quoted part may itself + /// contain a dot (for example DB."my.schema".TABLE). + /// + /// The (optionally qualified) identifier. + /// Identifier for this value (used in error messages). + /// The original value if every part passes validation. + public static string EnsureValidQualifiedSnowflakeIdentifier( + this string identifier, + string nameOfIdentifier) + { + if (string.IsNullOrWhiteSpace(identifier)) + { + throw new ArgumentNullException(nameOfIdentifier); + } + + foreach (var part in SplitTopLevel(identifier, '.')) + { + if (string.IsNullOrWhiteSpace(part)) + { + throw new ArgumentException($"Invalid snowflake identifier: {nameOfIdentifier}. Qualified name parts must not be empty."); + } + + part.EnsureValidSnowflakeIdentifier(nameOfIdentifier); } return identifier; } + /// + /// Returns a safe SQL representation of a caller-supplied identifier (for example a filter + /// property name) for direct interpolation into a statement. A value that is already a valid + /// unquoted Snowflake identifier is returned unchanged, preserving Snowflake's + /// case-insensitive resolution for the common case. Any other value is wrapped in double + /// quotes with embedded quotes escaped (" -> ""), producing a safe, + /// case-sensitive quoted identifier. Because every embedded quote is doubled, the value can + /// never break out of the quoting, so this is injection-safe for arbitrary input. + /// + /// The identifier to render. + /// Identifier for this value (used in error messages). + /// The identifier as-is when unquoted-valid; otherwise a quoted, escaped identifier. + public static string ToSafeSnowflakeIdentifier( + this string identifier, + string nameOfIdentifier) + { + if (string.IsNullOrWhiteSpace(identifier)) + { + throw new ArgumentNullException(nameOfIdentifier); + } + + if (UnquotedIdentifierRegexPattern.IsMatch(identifier)) + { + return identifier; + } + + return $"\"{identifier.EscapeSnowflakeQuotedIdentifier()}\""; + } + + /// + /// Validates an OData $select projection that is interpolated directly into the SQL + /// statement. The projection must be either * or a comma-separated list of valid + /// Snowflake identifiers; anything else (sub-queries, expressions, stacked statements, ...) + /// is rejected. Quoted identifiers may contain commas (the field list is split in a + /// quote-aware manner). + /// + /// The select projection. + /// Identifier for this value (used in error messages). + /// The original value if every field passes validation. + public static string EnsureValidSelectClause( + this string select, + string nameOfSelect) + { + if (string.IsNullOrWhiteSpace(select)) + { + throw new ArgumentNullException(nameOfSelect); + } + + if (string.Equals(select.Trim(), "*", StringComparison.Ordinal)) + { + return select; + } + + foreach (var field in SplitTopLevel(select, ',')) + { + var trimmedField = field.Trim(); + + if (string.Equals(trimmedField, "*", StringComparison.Ordinal)) + { + continue; + } + + if (trimmedField.Length == 0) + { + throw new ArgumentException($"Invalid select clause: {nameOfSelect}. Fields must not be empty."); + } + + trimmedField.EnsureValidSnowflakeIdentifier(nameOfSelect); + } + + return select; + } + + /// + /// Validates an OData $orderby clause that is interpolated directly into the SQL + /// statement. Each comma-separated entry must be a valid Snowflake identifier optionally + /// followed by an asc/desc direction; anything else is rejected. Quoted + /// identifiers may contain whitespace and commas (the clause is parsed in a quote-aware + /// manner). + /// + /// The order-by clause. + /// Identifier for this value (used in error messages). + /// The original value if every entry passes validation. + public static string EnsureValidOrderByClause( + this string orderBy, + string nameOfOrderBy) + { + if (string.IsNullOrWhiteSpace(orderBy)) + { + throw new ArgumentNullException(nameOfOrderBy); + } + + foreach (var entry in SplitTopLevel(orderBy, ',')) + { + SplitOrderByEntry(entry, out var identifier, out var direction); + + if (identifier.Length == 0) + { + throw new ArgumentException($"Invalid order by clause: {nameOfOrderBy}. Each entry must be ' [asc|desc]'."); + } + + identifier.EnsureValidSnowflakeIdentifier(nameOfOrderBy); + + if (direction.Length > 0 + && !string.Equals(direction, "asc", StringComparison.OrdinalIgnoreCase) + && !string.Equals(direction, "desc", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException($"Invalid order by direction: {nameOfOrderBy}. Only 'asc' and 'desc' are allowed."); + } + } + + return orderBy; + } + + /// + /// Splits a clause on the given delimiter, ignoring delimiters that appear inside a double + /// quoted identifier (where an embedded double quote is escaped by doubling it). + /// + private static IEnumerable SplitTopLevel(string input, char delimiter) + { + var segments = new List(); + var current = new StringBuilder(); + var inQuotes = false; + + for (int i = 0; i < input.Length; i++) + { + var c = input[i]; + + if (c == '"') + { + current.Append(c); + + if (inQuotes && i + 1 < input.Length && input[i + 1] == '"') + { + current.Append(input[++i]); + } + else + { + inQuotes = !inQuotes; + } + } + else if (c == delimiter && !inQuotes) + { + segments.Add(current.ToString()); + current.Clear(); + } + else + { + current.Append(c); + } + } + + segments.Add(current.ToString()); + return segments; + } + + /// + /// Splits a single order-by entry into its identifier and optional direction. The identifier + /// may be a double quoted identifier containing whitespace; the direction (if present) is the + /// remaining text after the identifier. + /// + private static void SplitOrderByEntry(string entry, out string identifier, out string direction) + { + var trimmed = entry.Trim(); + + if (trimmed.Length > 0 && trimmed[0] == '"') + { + var i = 1; + while (i < trimmed.Length) + { + if (trimmed[i] == '"') + { + if (i + 1 < trimmed.Length && trimmed[i + 1] == '"') + { + i += 2; + continue; + } + + break; + } + + i++; + } + + if (i >= trimmed.Length) + { + identifier = trimmed; + direction = string.Empty; + return; + } + + identifier = trimmed.Substring(0, i + 1); + direction = trimmed.Substring(i + 1).Trim(); + } + else + { + var whitespaceIndex = trimmed.IndexOfAny(new[] { ' ', '\t', '\r', '\n' }); + + if (whitespaceIndex < 0) + { + identifier = trimmed; + direction = string.Empty; + } + else + { + identifier = trimmed.Substring(0, whitespaceIndex); + direction = trimmed.Substring(whitespaceIndex).Trim(); + } + } + } + + /// + /// Escapes the contents of a Snowflake quoted identifier by doubling any embedded double + /// quotes, so a value wrapped in double quotes by the caller cannot terminate the quoting. + /// + /// The identifier body to escape. + /// The escaped identifier body. + public static string EscapeSnowflakeQuotedIdentifier( + this string identifier) + { + return identifier == null ? string.Empty : identifier.Replace("\"", "\"\""); + } + /// /// Validates if a Snowflake URL is valid /// @@ -117,15 +384,15 @@ public static string EnsureValidSnowflakeUrl( { if (string.IsNullOrWhiteSpace(url)) { - throw new ArgumentNullException(url); + throw new ArgumentNullException(nameOfUrl); } if (!UrlRegexPattern.IsMatch(url)) { - throw new ArgumentException($"Invalid snowflake URL: {nameOfUrl}. Must adhere to the following regex: ${UrlFullRegex}"); + throw new ArgumentException($"Invalid snowflake URL: {nameOfUrl}."); } return url; } } -} \ No newline at end of file +} diff --git a/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/Utilities/ODataToSqlParser.cs b/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/Utilities/ODataToSqlParser.cs index 3ff19b3e70..dd5d0dfeea 100644 --- a/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/Utilities/ODataToSqlParser.cs +++ b/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/Utilities/ODataToSqlParser.cs @@ -11,6 +11,9 @@ namespace SnowflakeV2CoreLogic.Utilities /// /// OData to SQL Parser /// + /// Caller-supplied string values are escaped before being embedded as SQL string literals, and + /// identifiers (column/property names) are validated as Snowflake identifiers. + /// /// Supported Operations: /// /// Comparison Operators: @@ -75,20 +78,19 @@ private string ParseExpression(SingleValueNode expression) } else if (expression is SingleValuePropertyAccessNode propertyAccessNode) { - return propertyAccessNode.Property.Name; + return propertyAccessNode.Property.Name.ToSafeSnowflakeIdentifier("Filter property"); } // Handle open property access (dynamic properties) else if (expression is SingleValueOpenPropertyAccessNode openPropertyNode) { - // For open properties, we use the property name directly - return openPropertyNode.Name; + return openPropertyNode.Name.ToSafeSnowflakeIdentifier("Filter property"); } else if (expression is ConstantNode constantNode) { if (constantNode.Value == null) return "NULL"; - else if (constantNode.Value is string) - return $"'{constantNode.Value}'"; + else if (constantNode.Value is string stringValue) + return $"'{EscapeStringLiteral(stringValue)}'"; else return constantNode.Value.ToString(); } @@ -96,6 +98,15 @@ private string ParseExpression(SingleValueNode expression) throw new NotSupportedException($"Unsupported expression type: {expression.GetType().Name}"); } + /// + /// Escapes a string so it can be safely embedded inside a single-quoted Snowflake string literal. + /// Backslashes are escaped first (Snowflake honours backslash escape sequences) and then single quotes. + /// + private static string EscapeStringLiteral(string value) + { + return value.Replace("\\", "\\\\").Replace("'", "''"); + } + private string ParseBinaryOperator(BinaryOperatorNode binaryOperatorNode) { var left = ParseExpression(binaryOperatorNode.Left); @@ -138,7 +149,7 @@ private string ParseFunctionCall(SingleValueFunctionCallNode functionCallNode) var property = ParseExpression(arguments[0] as SingleValueNode); var value = ParseExpression(arguments[1] as SingleValueNode); - // Remove quotes from value if present + // Remove the surrounding quotes (the escaped content is preserved). if (value.StartsWith("'") && value.EndsWith("'")) { value = value.Substring(1, value.Length - 2); diff --git a/certified-connectors/Snowflake v2/SnowflakeV2CoreLogicTests/Utilities/EnsureExtensionsInjectionTest.cs b/certified-connectors/Snowflake v2/SnowflakeV2CoreLogicTests/Utilities/EnsureExtensionsInjectionTest.cs new file mode 100644 index 0000000000..c777c332a0 --- /dev/null +++ b/certified-connectors/Snowflake v2/SnowflakeV2CoreLogicTests/Utilities/EnsureExtensionsInjectionTest.cs @@ -0,0 +1,208 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +namespace SnowflakeV2CoreLogic.Tests.Utilities +{ + using System; + using SnowflakeV2CoreLogic.Utilities; + + /// + /// Unit tests covering the input-validation/escaping used to prevent SQL injection through + /// identifiers, the OData $select/$orderby clauses, and quoted identifiers. + /// These tests run in-process and do not require the application or a live Snowflake instance. + /// + [TestClass] + public sealed class EnsureExtensionsInjectionTest + { + // ---- EnsureValidSnowflakeIdentifier ---- + + [DataTestMethod] + [DataRow("CUSTOMERS")] + [DataRow("_underscore")] + [DataRow("My_Table$1")] + [DataRow("a")] + [DataRow("\"Quoted Identifier\"")] + [DataRow("\"with a , comma\"")] + [DataRow("\"with \"\"escaped\"\" quotes\"")] + [DataRow("\"Multiple Spaces\"")] + public void EnsureValidSnowflakeIdentifier_AcceptsValidIdentifiers(string identifier) + { + Assert.AreEqual(identifier, identifier.EnsureValidSnowflakeIdentifier("identifier")); + } + + [DataTestMethod] + [DataRow("CUSTOMERS; DROP TABLE SECRETS")] + [DataRow("CUSTOMERS UNION SELECT * FROM SECRETS")] + [DataRow("CUSTOMERS--comment")] + [DataRow("CUSTOMERS WHERE 1=1")] + [DataRow("1nvalidStart")] + [DataRow("has space")] + [DataRow("\"breakout\"; DROP TABLE X--\"")] + [DataRow("\"unterminated")] + [DataRow("'single'")] + public void EnsureValidSnowflakeIdentifier_RejectsInjectionPayloads(string identifier) + { + Assert.ThrowsException(() => identifier.EnsureValidSnowflakeIdentifier("identifier")); + } + + // ---- EnsureValidQualifiedSnowflakeIdentifier ---- + + [DataTestMethod] + [DataRow("CUSTOMERS")] + [DataRow("SCHEMA.TABLE")] + [DataRow("DB.SCHEMA.TABLE")] + [DataRow("\"My DB\".\"My Schema\".\"My Table\"")] + [DataRow("DB.\"my.schema\".TABLE")] + public void EnsureValidQualifiedSnowflakeIdentifier_AcceptsQualifiedNames(string identifier) + { + Assert.AreEqual(identifier, identifier.EnsureValidQualifiedSnowflakeIdentifier("Table Name")); + } + + [DataTestMethod] + [DataRow("DB..TABLE")] + [DataRow(".TABLE")] + [DataRow("TABLE.")] + [DataRow("DB.SCHEMA.TABLE; DROP TABLE SECRETS")] + [DataRow("DB.SCHEMA.TABLE UNION SELECT * FROM SECRETS")] + [DataRow("DB.1nvalid")] + [DataRow("DB.\"unterminated")] + [DataRow("(SELECT CURRENT_USER()) t")] + public void EnsureValidQualifiedSnowflakeIdentifier_RejectsInjectionPayloads(string identifier) + { + Assert.ThrowsException(() => identifier.EnsureValidQualifiedSnowflakeIdentifier("Table Name")); + } + + /// + /// Regression test for the live-reproduced SQL injection: passing + /// (SELECT CURRENT_USER()) t as the "table name" to the .../tables/{table}/items + /// operation caused the unpatched backend to inline it as + /// SELECT * FROM (SELECT CURRENT_USER()) t ... and leak the Snowflake session user. + /// This is the exact guard ListAllItemsAsync applies to the table name, so it must reject the payload. + /// + [TestMethod] + public void EnsureValidQualifiedSnowflakeIdentifier_RejectsCurrentUserSubqueryInjection() + { + const string payload = "(SELECT CURRENT_USER()) t"; + + Assert.ThrowsException( + () => payload.EnsureValidQualifiedSnowflakeIdentifier("Table Name")); + } + + // ---- EnsureValidSelectClause ---- + + [DataTestMethod] + [DataRow("*")] + [DataRow(" * ")] + [DataRow("NAME")] + [DataRow("NAME,EMAIL")] + [DataRow("NAME, EMAIL , PHONE")] + [DataRow("NAME , EMAIL")] + [DataRow(" NAME , EMAIL ")] + [DataRow("\"First Name\",\"Last Name\"")] + [DataRow("\"Last, First\",EMAIL")] + public void EnsureValidSelectClause_AcceptsValidProjections(string select) + { + Assert.AreEqual(select, select.EnsureValidSelectClause("Select")); + } + + [DataTestMethod] + [DataRow("NAME, (SELECT secret FROM admin)")] + [DataRow("NAME; DROP TABLE SECRETS")] + [DataRow("(CASE WHEN 1=1 THEN NAME ELSE EMAIL END)")] + [DataRow("NAME UNION SELECT password FROM users")] + [DataRow("*, (SELECT 1)")] + [DataRow("NAME,")] + [DataRow("NAME,,EMAIL")] + [DataRow(",NAME")] + public void EnsureValidSelectClause_RejectsInjectionPayloads(string select) + { + Assert.ThrowsException(() => select.EnsureValidSelectClause("Select")); + } + + // ---- EnsureValidOrderByClause ---- + + [DataTestMethod] + [DataRow("NAME")] + [DataRow("NAME asc")] + [DataRow("NAME DESC")] + [DataRow("NAME asc, EMAIL desc")] + [DataRow("NAME, EMAIL")] + [DataRow("NAME, EMAIL desc")] + [DataRow("NAME asc")] + [DataRow("NAME asc, EMAIL desc")] + [DataRow(" NAME asc ")] + [DataRow("\"Quoted\" desc")] + [DataRow("\"First Name\"")] + [DataRow("\"First Name\" asc")] + [DataRow("\"First Name\" desc")] + [DataRow("\"First Name\" desc, \"Last Name\"")] + [DataRow("\"Last, First\" asc")] + public void EnsureValidOrderByClause_AcceptsValidClauses(string orderBy) + { + Assert.AreEqual(orderBy, orderBy.EnsureValidOrderByClause("Order By")); + } + + [DataTestMethod] + [DataRow("NAME; DROP TABLE SECRETS")] + [DataRow("NAME asc desc")] + [DataRow("(SELECT 1)")] + [DataRow("NAME UNION SELECT 1")] + [DataRow("CASE WHEN (SELECT COUNT(*) FROM secrets) > 0 THEN 1 ELSE 2 END")] + [DataRow("1; DELETE FROM customers")] + [DataRow("NAME asc,")] + [DataRow("NAME,,EMAIL")] + [DataRow(",NAME desc")] + public void EnsureValidOrderByClause_RejectsInjectionPayloads(string orderBy) + { + Assert.ThrowsException(() => orderBy.EnsureValidOrderByClause("Order By")); + } + + // ---- ToSafeSnowflakeIdentifier ---- + + [DataTestMethod] + [DataRow("IS_ACTIVE", "IS_ACTIVE")] + [DataRow("FIRST_NAME", "FIRST_NAME")] + [DataRow("_x$1", "_x$1")] + public void ToSafeSnowflakeIdentifier_LeavesValidUnquotedIdentifiersUnchanged(string input, string expected) + { + Assert.AreEqual(expected, input.ToSafeSnowflakeIdentifier("Filter property")); + } + + [DataTestMethod] + [DataRow("first-name", "\"first-name\"")] + [DataRow("my.col", "\"my.col\"")] + [DataRow("1starts_with_digit", "\"1starts_with_digit\"")] + [DataRow("first name", "\"first name\"")] // space + [DataRow("o'brien", "\"o'brien\"")] // single quote (not doubled - it is harmless inside a double-quoted id) + [DataRow("a\"b", "\"a\"\"b\"")] // embedded double quote -> doubled + [DataRow("x;y--", "\"x;y--\"")] // semicolon + comment + [DataRow("DROP TABLE x", "\"DROP TABLE x\"")] // spaces + keywords + [DataRow("col\"; DROP TABLE y--", "\"col\"\"; DROP TABLE y--\"")] // breakout attempt + [DataRow("x\" OR \"1\"=\"1", "\"x\"\" OR \"\"1\"\"=\"\"1\"")] + public void ToSafeSnowflakeIdentifier_QuotesAndEscapesEverythingElse(string input, string expected) + { + Assert.AreEqual(expected, input.ToSafeSnowflakeIdentifier("Filter property")); + } + + [DataTestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + public void ToSafeSnowflakeIdentifier_RejectsNullOrWhitespace(string input) + { + Assert.ThrowsException(() => input.ToSafeSnowflakeIdentifier("Filter property")); + } + + // ---- EscapeSnowflakeQuotedIdentifier ---- + + [DataTestMethod] + [DataRow("plain", "plain")] + [DataRow("a\"b", "a\"\"b")] + [DataRow("end\"", "end\"\"")] + [DataRow("x\";DROP TABLE y--", "x\"\";DROP TABLE y--")] + public void EscapeSnowflakeQuotedIdentifier_DoublesEmbeddedQuotes(string input, string expected) + { + Assert.AreEqual(expected, input.EscapeSnowflakeQuotedIdentifier()); + } + } +} diff --git a/certified-connectors/Snowflake v2/SnowflakeV2CoreLogicTests/Utilities/EnsureExtensionsTest.cs b/certified-connectors/Snowflake v2/SnowflakeV2CoreLogicTests/Utilities/EnsureExtensionsTest.cs new file mode 100644 index 0000000000..e5eb73c8da --- /dev/null +++ b/certified-connectors/Snowflake v2/SnowflakeV2CoreLogicTests/Utilities/EnsureExtensionsTest.cs @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +namespace SnowflakeV2CoreLogic.Tests.Utilities +{ + using System; + using SnowflakeV2CoreLogic.Utilities; + + [TestClass] + public sealed class EnsureExtensionsTest + { + #region EnsureValidSnowflakeUrl — valid hostnames + + [TestMethod] + public void EnsureValidSnowflakeUrl_ValidStandardHostname_Returns() + { + var result = "myaccount.snowflakecomputing.com".EnsureValidSnowflakeUrl("Instance"); + Assert.AreEqual("myaccount.snowflakecomputing.com", result); + } + + [TestMethod] + public void EnsureValidSnowflakeUrl_ValidPrivateLinkHostname_Returns() + { + var result = "myaccount.privatelink.snowflakecomputing.com".EnsureValidSnowflakeUrl("Instance"); + Assert.AreEqual("myaccount.privatelink.snowflakecomputing.com", result); + } + + [TestMethod] + public void EnsureValidSnowflakeUrl_ValidHostnameWithDashes_Returns() + { + var result = "my-org-account.snowflakecomputing.com".EnsureValidSnowflakeUrl("Instance"); + Assert.AreEqual("my-org-account.snowflakecomputing.com", result); + } + + [TestMethod] + public void EnsureValidSnowflakeUrl_ValidHostnameWithDots_Returns() + { + var result = "org.account.snowflakecomputing.com".EnsureValidSnowflakeUrl("Instance"); + Assert.AreEqual("org.account.snowflakecomputing.com", result); + } + + [TestMethod] + public void EnsureValidSnowflakeUrl_ValidChinaHostname_Returns() + { + var result = "myaccount.cn-north-1.snowflakecomputing.cn".EnsureValidSnowflakeUrl("Instance"); + Assert.AreEqual("myaccount.cn-north-1.snowflakecomputing.cn", result); + } + + [TestMethod] + public void EnsureValidSnowflakeUrl_ValidChinaPrivateLinkHostname_Returns() + { + var result = "myaccount.privatelink.snowflakecomputing.cn".EnsureValidSnowflakeUrl("Instance"); + Assert.AreEqual("myaccount.privatelink.snowflakecomputing.cn", result); + } + + #endregion + + #region EnsureValidSnowflakeUrl — invalid hostnames + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void EnsureValidSnowflakeUrl_ArbitraryHostname_Throws() + { + "evil-server.com".EnsureValidSnowflakeUrl("Instance"); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void EnsureValidSnowflakeUrl_SubstringBypassWithSuffix_Throws() + { + "foo.snowflakecomputing.com.evil.com".EnsureValidSnowflakeUrl("Instance"); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void EnsureValidSnowflakeUrl_ChinaSubstringBypassWithSuffix_Throws() + { + "foo.snowflakecomputing.cn.evil.com".EnsureValidSnowflakeUrl("Instance"); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void EnsureValidSnowflakeUrl_WrongTld_Throws() + { + "myaccount.snowflakecomputing.cm".EnsureValidSnowflakeUrl("Instance"); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void EnsureValidSnowflakeUrl_SubstringBypassWithPrefix_Throws() + { + "evil.com.snowflakecomputing.com.attacker.com".EnsureValidSnowflakeUrl("Instance"); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void EnsureValidSnowflakeUrl_SnowflakeDomainAsSubdomain_Throws() + { + "attacker.com/foo.snowflakecomputing.com".EnsureValidSnowflakeUrl("Instance"); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public void EnsureValidSnowflakeUrl_NullValue_Throws() + { + ((string)null!).EnsureValidSnowflakeUrl("Instance"); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public void EnsureValidSnowflakeUrl_EmptyString_Throws() + { + "".EnsureValidSnowflakeUrl("Instance"); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public void EnsureValidSnowflakeUrl_WhitespaceOnly_Throws() + { + " ".EnsureValidSnowflakeUrl("Instance"); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void EnsureValidSnowflakeUrl_IpAddress_Throws() + { + "192.168.1.1".EnsureValidSnowflakeUrl("Instance"); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void EnsureValidSnowflakeUrl_Localhost_Throws() + { + "localhost".EnsureValidSnowflakeUrl("Instance"); + } + + #endregion + } +} diff --git a/certified-connectors/Snowflake v2/SnowflakeV2CoreLogicTests/Utilities/ODataToSqlParserTest.cs b/certified-connectors/Snowflake v2/SnowflakeV2CoreLogicTests/Utilities/ODataToSqlParserTest.cs index d54c722acd..5c1f6650ee 100644 --- a/certified-connectors/Snowflake v2/SnowflakeV2CoreLogicTests/Utilities/ODataToSqlParserTest.cs +++ b/certified-connectors/Snowflake v2/SnowflakeV2CoreLogicTests/Utilities/ODataToSqlParserTest.cs @@ -397,5 +397,60 @@ public void ParseFilterToSql_CaseInsensitive_EqUnaffected() } #endregion + + #region String literal escaping ($filter injection defense) + + [TestMethod] + public void ParseFilterToSql_EscapesSingleQuotesInStringLiteral() + { + var filter = ParseFilter("Name eq 'O''Brien'"); + var result = parser.ParseFilterToSql(filter); + Assert.AreEqual("Name = 'O''Brien'", result); + } + + [TestMethod] + public void ParseFilterToSql_NeutralizesInjectionInStringValue() + { + // The doubled quotes keep the payload inside the literal; it cannot terminate the string. + var filter = ParseFilter("Name eq 'x'';DROP TABLE SECRETS--'"); + var result = parser.ParseFilterToSql(filter); + Assert.AreEqual("Name = 'x'';DROP TABLE SECRETS--'", result); + } + + [TestMethod] + public void ParseFilterToSql_EscapesBackslashInStringLiteral() + { + var filter = ParseFilter(@"Name eq 'a\b'"); + var result = parser.ParseFilterToSql(filter); + Assert.AreEqual(@"Name = 'a\\b'", result); + } + + [TestMethod] + public void ParseFilterToSql_EscapesTrailingBackslash() + { + // A trailing backslash must be doubled so it cannot escape the closing quote in Snowflake. + var filter = ParseFilter(@"Name eq 'a\'"); + var result = parser.ParseFilterToSql(filter); + Assert.AreEqual(@"Name = 'a\\'", result); + } + + [TestMethod] + public void ParseFilterToSql_NeutralizesBackslashQuoteBreakout() + { + // Backslash is doubled first, then the quote, so neither can break out of the literal. + var filter = ParseFilter(@"Name eq '\''; DROP'"); + var result = parser.ParseFilterToSql(filter); + Assert.AreEqual(@"Name = '\\''; DROP'", result); + } + + [TestMethod] + public void ParseFilterToSql_ContainsEscapesQuotesInValue() + { + var filter = ParseFilter("contains(Name, 'a''b')"); + var result = parser.ParseFilterToSql(filter); + Assert.AreEqual("Name LIKE '%a''b%'", result); + } + + #endregion } }