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
}
}