From 2e6a0c35c08171c1752e024385ea9b877d8c784d Mon Sep 17 00:00:00 2001
From: Piotr Zalas <127133187+sfc-gh-pzalas@users.noreply.github.com>
Date: Wed, 25 Mar 2026 13:26:40 +0100
Subject: [PATCH 1/5] NO-FLOW Fix build warnings (#19)
---
...icrosoft.Azure.Connectors.SnowflakeV2Contracts.csproj | 1 +
.../SnowflakeTestApp.Tests/SnowflakeTestApp.Tests.csproj | 4 ++--
.../SnowflakeTestApp/SnowflakeTestApp.csproj | 3 ++-
.../Providers/SnowflakeTableDataProvider.cs | 9 +++++++--
.../SnowflakeV2CoreLogic/SnowflakeV2CoreLogic.csproj | 1 +
.../Utilities/SnowflakeToODataHelper.cs | 2 +-
.../SnowflakeV2CoreLogic.Tests.csproj | 1 +
7 files changed, 15 insertions(+), 6 deletions(-)
diff --git a/certified-connectors/Snowflake v2/Contracts/Microsoft.Azure.Connectors.SnowflakeV2Contracts.csproj b/certified-connectors/Snowflake v2/Contracts/Microsoft.Azure.Connectors.SnowflakeV2Contracts.csproj
index 3e8b3f7ba9..d51cc3088d 100644
--- a/certified-connectors/Snowflake v2/Contracts/Microsoft.Azure.Connectors.SnowflakeV2Contracts.csproj
+++ b/certified-connectors/Snowflake v2/Contracts/Microsoft.Azure.Connectors.SnowflakeV2Contracts.csproj
@@ -4,6 +4,7 @@
Microsoft.Azure.Connectors.SnowflakeV2Contracts
net48
8.0
+ true
true
true
0.0.1
diff --git a/certified-connectors/Snowflake v2/SnowflakeTestApp.Tests/SnowflakeTestApp.Tests.csproj b/certified-connectors/Snowflake v2/SnowflakeTestApp.Tests/SnowflakeTestApp.Tests.csproj
index 728089f02f..554fcb5f39 100644
--- a/certified-connectors/Snowflake v2/SnowflakeTestApp.Tests/SnowflakeTestApp.Tests.csproj
+++ b/certified-connectors/Snowflake v2/SnowflakeTestApp.Tests/SnowflakeTestApp.Tests.csproj
@@ -1,4 +1,4 @@
-
+
net48
@@ -8,7 +8,7 @@
true
true
..\SnowflakeTestApp\SnowflakeV2RuleSet.ruleset
- false
+ true
4
AnyCPU
diff --git a/certified-connectors/Snowflake v2/SnowflakeTestApp/SnowflakeTestApp.csproj b/certified-connectors/Snowflake v2/SnowflakeTestApp/SnowflakeTestApp.csproj
index 5998d75f6c..7aabbbc72c 100644
--- a/certified-connectors/Snowflake v2/SnowflakeTestApp/SnowflakeTestApp.csproj
+++ b/certified-connectors/Snowflake v2/SnowflakeTestApp/SnowflakeTestApp.csproj
@@ -1,4 +1,4 @@
-
+
Debug
@@ -21,6 +21,7 @@
+ true
SnowflakeV2RuleSet.ruleset
diff --git a/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/Providers/SnowflakeTableDataProvider.cs b/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/Providers/SnowflakeTableDataProvider.cs
index bce2265604..dc65192943 100644
--- a/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/Providers/SnowflakeTableDataProvider.cs
+++ b/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/Providers/SnowflakeTableDataProvider.cs
@@ -65,6 +65,11 @@ public async Task> ListItemsAsync(
throw new ArgumentNullException(nameof(table));
}
+ if (options == null)
+ {
+ throw new ArgumentNullException(nameof(options));
+ }
+
SnowflakeConnectionParameters connectionParameters = snowflakeConnectionParametersProvider.GetConnectionParameters();
connectionParameters = SnowflakeConnectionParametersProvider.UpdateConnParametersToUseDataset(request, dataSet, connectionParameters);
@@ -100,7 +105,7 @@ public async Task> ListItemsAsync(
return partitionResponse.ToListOfItems();
}
- bool countRequested = options?.Count?.RawValue?.Equals("true", StringComparison.InvariantCultureIgnoreCase) == true;
+ bool countRequested = options.Count?.RawValue?.Equals("true", StringComparison.InvariantCultureIgnoreCase) == true;
var dataTask = snowflakeDBOperations.ListAllItemsAsync(table, "GET datasets/{dataset}/tables/{table}/items", options, connectionParameters);
Task? countTask = countRequested
@@ -119,7 +124,7 @@ public async Task> ListItemsAsync(
int partitionCount = queryResponse.ResultSetMetaData?.PartitionInfo?.Count ?? 1;
string? statementHandle = queryResponse.StatementHandle;
- if (partitionCount > 1 && !string.IsNullOrEmpty(statementHandle))
+ if (partitionCount > 1 && statementHandle != null && statementHandle.Length > 0)
{
int rowsReturnedInPartition = queryResponse.Data?.Count ?? 0;
diff --git a/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/SnowflakeV2CoreLogic.csproj b/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/SnowflakeV2CoreLogic.csproj
index b143f25772..3a6571eda7 100644
--- a/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/SnowflakeV2CoreLogic.csproj
+++ b/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/SnowflakeV2CoreLogic.csproj
@@ -4,6 +4,7 @@
Microsoft.Azure.Connectors.SnowflakeV2CoreLogic
net48
8.0
+ true
true
SnowflakeV2RuleSet.ruleset
diff --git a/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/Utilities/SnowflakeToODataHelper.cs b/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/Utilities/SnowflakeToODataHelper.cs
index 89c6e2b0f6..9b126e2734 100644
--- a/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/Utilities/SnowflakeToODataHelper.cs
+++ b/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/Utilities/SnowflakeToODataHelper.cs
@@ -294,7 +294,7 @@ public static bool TryParsePartitionSkipToken(string? skipToken, out string? sta
partitionIndex = 0;
totalPartitions = 0;
- if (string.IsNullOrEmpty(skipToken))
+ if (skipToken == null || skipToken.Length == 0)
{
return false;
}
diff --git a/certified-connectors/Snowflake v2/SnowflakeV2CoreLogicTests/SnowflakeV2CoreLogic.Tests.csproj b/certified-connectors/Snowflake v2/SnowflakeV2CoreLogicTests/SnowflakeV2CoreLogic.Tests.csproj
index 5af859e4c9..428cb7ee2d 100644
--- a/certified-connectors/Snowflake v2/SnowflakeV2CoreLogicTests/SnowflakeV2CoreLogic.Tests.csproj
+++ b/certified-connectors/Snowflake v2/SnowflakeV2CoreLogicTests/SnowflakeV2CoreLogic.Tests.csproj
@@ -5,6 +5,7 @@
latest
enable
enable
+ true
false
true
From 2155c90ccd2528f9267d0b3bc750f5a825787c9f Mon Sep 17 00:00:00 2001
From: Kuba Bogusz
Date: Mon, 1 Jun 2026 12:08:57 +0200
Subject: [PATCH 2/5] FLOW-11254 Validate configure URL is a Snowflake domain
---
.../Sql/SqlEndpointIntegrationTest.cs | 8 ++++----
.../Mocks/ConnectionParametersProviderMock.cs | 2 +-
.../Controllers/SnowflakeSQLController.cs | 3 +++
.../SnowflakeV2CoreLogic/SnowflakeV2CoreLogic.csproj | 3 +++
.../SnowflakeV2CoreLogic/Utilities/EnsureExtensions.cs | 2 +-
5 files changed, 12 insertions(+), 6 deletions(-)
diff --git a/certified-connectors/Snowflake v2/SnowflakeTestApp.Tests/Sql/SqlEndpointIntegrationTest.cs b/certified-connectors/Snowflake v2/SnowflakeTestApp.Tests/Sql/SqlEndpointIntegrationTest.cs
index 3ed1879bfd..eec7b8a0d3 100644
--- a/certified-connectors/Snowflake v2/SnowflakeTestApp.Tests/Sql/SqlEndpointIntegrationTest.cs
+++ b/certified-connectors/Snowflake v2/SnowflakeTestApp.Tests/Sql/SqlEndpointIntegrationTest.cs
@@ -246,10 +246,10 @@ public async Task CancelStatementEndpoint_WithAuth_ReturnsBadRequest()
}
///
- /// Test cancellation of already completed SQL statement - should return 422
+ /// Test cancellation of already completed SQL statement - Snowflake returns 200
///
[TestMethod]
- public async Task SqlExecutionFlow_CancelCompletedStatement_Returns422()
+ public async Task SqlExecutionFlow_CancelCompletedStatement_Returns200()
{
var testToken = GetTestToken();
HttpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {testToken}");
@@ -281,11 +281,11 @@ public async Task SqlExecutionFlow_CancelCompletedStatement_Returns422()
string statementHandle = executeResult["Metadata"]["StatementHandle"].ToString();
Assert.IsFalse(string.IsNullOrEmpty(statementHandle), "Statement handle should be present in response");
- // Try to cancel already completed statement - should return 422
+ // Try to cancel already completed statement - Snowflake returns 200
var cancelContent = new StringContent("{}", Encoding.UTF8, "application/json");
var cancelResponse = await HttpClient.PostAsync($"{BaseUrl}/sql/{statementHandle}/cancel", cancelContent);
- Assert.AreEqual(422, (int)cancelResponse.StatusCode, "Cancel should return 422 for already completed statement");
+ Assert.AreEqual(HttpStatusCode.OK, cancelResponse.StatusCode, "Cancel of completed statement should return 200");
}
}
}
diff --git a/certified-connectors/Snowflake v2/SnowflakeTestApp/Mocks/ConnectionParametersProviderMock.cs b/certified-connectors/Snowflake v2/SnowflakeTestApp/Mocks/ConnectionParametersProviderMock.cs
index ebcccd797b..b59a97da5e 100644
--- a/certified-connectors/Snowflake v2/SnowflakeTestApp/Mocks/ConnectionParametersProviderMock.cs
+++ b/certified-connectors/Snowflake v2/SnowflakeTestApp/Mocks/ConnectionParametersProviderMock.cs
@@ -160,4 +160,4 @@ public TokenMock(string token)
public DateTime TokenAcquireTime => DateTime.UtcNow.AddMinutes(-1);
}
-}
\ No newline at end of file
+}
diff --git a/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/Controllers/SnowflakeSQLController.cs b/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/Controllers/SnowflakeSQLController.cs
index f211c87047..af109a16ec 100644
--- a/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/Controllers/SnowflakeSQLController.cs
+++ b/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/Controllers/SnowflakeSQLController.cs
@@ -182,6 +182,9 @@ private static void ValidateInstanceUrl(
{
throw new ArgumentException($"The Instance parameter cannot contain a trailing slash. Please remove it and try again.");
}
+
+ // Validate that the instance belongs to a valid Snowflake domain
+ instanceUrl.EnsureValidSnowflakeUrl("Instance");
}
}
}
\ No newline at end of file
diff --git a/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/SnowflakeV2CoreLogic.csproj b/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/SnowflakeV2CoreLogic.csproj
index 3a6571eda7..5c4541a0f1 100644
--- a/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/SnowflakeV2CoreLogic.csproj
+++ b/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/SnowflakeV2CoreLogic.csproj
@@ -11,6 +11,9 @@
+
+
+
diff --git a/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/Utilities/EnsureExtensions.cs b/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/Utilities/EnsureExtensions.cs
index 18ce05cafb..8dca047e9d 100644
--- a/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/Utilities/EnsureExtensions.cs
+++ b/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/Utilities/EnsureExtensions.cs
@@ -21,7 +21,7 @@ internal static class EnsureExtensions
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";
+ private static readonly string UrlFullRegex = @"^([a-zA-Z0-9-_.]+\.snowflakecomputing\.com|[a-zA-Z0-9-_.]+\.privatelink\.snowflakecomputing\.com)$";
private static readonly Regex UrlRegexPattern = new Regex(UrlFullRegex, RegexOptions.Compiled);
///
From 9c53039835281b7271f7d5e50a97b22271361369 Mon Sep 17 00:00:00 2001
From: Kuba Bogusz
Date: Wed, 17 Jun 2026 17:51:45 +0200
Subject: [PATCH 3/5] SNOW-3663308 Snowflake Connector: handle different
timestamp precisions (#22)
* SNOW-3663308 Snowflake Connector: handle different timestamp precisions
* FLOW-11254 Cleanup testing code
---
.../Utilities/SnowflakeToODataHelper.cs | 45 ++++++++++----
.../Utilities/SnowflakeToODataHelperTest.cs | 62 +++++++++++++++++++
2 files changed, 96 insertions(+), 11 deletions(-)
create mode 100644 certified-connectors/Snowflake v2/SnowflakeV2CoreLogicTests/Utilities/SnowflakeToODataHelperTest.cs
diff --git a/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/Utilities/SnowflakeToODataHelper.cs b/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/Utilities/SnowflakeToODataHelper.cs
index 9b126e2734..e58cb30de3 100644
--- a/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/Utilities/SnowflakeToODataHelper.cs
+++ b/certified-connectors/Snowflake v2/SnowflakeV2CoreLogic/Utilities/SnowflakeToODataHelper.cs
@@ -390,16 +390,22 @@ private static string ConvertSnowflakeDate(
private static string ConvertSnowflakeDateTimeNtzToString(
string snowflakeDateTime)
{
- // Snowflake returns data as "unixTime.F9" (meaning utc.9 fractional seconds) which doesn't parse natively with .NET
- // We need to split the two parts up and parse handle them separately before recombining them
+ // Snowflake returns data as "unixTime.F" (seconds since epoch, optionally followed by fractional
+ // seconds) which doesn't parse natively with .NET. The number of fractional digits depends on the
+ // column's scale (0-9): scale 0 columns are returned with no fractional part at all (no '.'), while
+ // higher scales include that many fractional digits. We split the parts and handle them separately.
string[] dateTimeParts = snowflakeDateTime.Split('.');
// Convert the time since epoch to a datetimeoffset
var parsedDateTime = DateTimeOffset.FromUnixTimeSeconds(long.Parse(dateTimeParts[0]));
- DateTimeOffset parsedDateTimeWithFullPrecision = ParseAndAddFractionalSecondsToDateTime(dateTimeParts[1], parsedDateTime);
+ // A fractional component is only present when the column scale is greater than 0.
+ if (dateTimeParts.Length > 1)
+ {
+ parsedDateTime = ParseAndAddFractionalSecondsToDateTime(dateTimeParts[1], parsedDateTime);
+ }
- return parsedDateTimeWithFullPrecision.ToString(DateTimeOutputFormat);
+ return parsedDateTime.ToString(DateTimeOutputFormat);
}
///
@@ -410,16 +416,21 @@ private static string ConvertSnowflakeDateTimeNtzToString(
private static string ConvertSnowflakeDateTimeWithTzToString(
string snowflakeDateTime)
{
- // Snowflake returns data as "unixTime.F9 +0000" which doesn't parse natively with .NET
- // We need to split the components two parts up and parse them separately before recombining them
+ // Snowflake returns data as "unixTime.F +0000" which doesn't parse natively with .NET.
+ // As with TIMESTAMP_NTZ, the fractional part is only present when the column scale is greater than 0.
+ // We first strip the trailing time zone offset, then split the seconds and fractional parts.
// Note: the offset is not used because snowflake stores the timestamp in UTC which is what we want to return
- string[] dateTimeParts = snowflakeDateTime.Split('.', ' ');
+ string timePart = snowflakeDateTime.Split(' ')[0];
+ string[] dateTimeParts = timePart.Split('.');
// Convert the time since epoch to a datetimeoffset
var parsedDateTime = DateTimeOffset.FromUnixTimeSeconds(long.Parse(dateTimeParts[0]));
- // Include the fractional seconds
- parsedDateTime = ParseAndAddFractionalSecondsToDateTime(dateTimeParts[1], parsedDateTime);
+ // Include the fractional seconds when present (scale > 0)
+ if (dateTimeParts.Length > 1)
+ {
+ parsedDateTime = ParseAndAddFractionalSecondsToDateTime(dateTimeParts[1], parsedDateTime);
+ }
return parsedDateTime.ToString(DateTimeOutputFormat);
}
@@ -434,8 +445,20 @@ private static DateTimeOffset ParseAndAddFractionalSecondsToDateTime(
string fractionalSeconds,
DateTimeOffset dateTime)
{
- // Add the nanoseconds to the parsed datetime
- var nanoseconds = long.Parse(fractionalSeconds);
+ if (string.IsNullOrEmpty(fractionalSeconds))
+ {
+ return dateTime;
+ }
+
+ // The fractional component is a decimal fraction of a second whose length matches the column's
+ // scale (1-9 digits). To interpret it correctly regardless of scale we normalize it to nanosecond
+ // precision (9 digits) by right-padding with zeros, e.g. scale 6 ".123456" -> "123456000" ns.
+ // Anything beyond 9 digits is truncated since nanoseconds are the finest unit Snowflake supports.
+ string nanosecondsString = fractionalSeconds.Length >= 9
+ ? fractionalSeconds.Substring(0, 9)
+ : fractionalSeconds.PadRight(9, '0');
+
+ var nanoseconds = long.Parse(nanosecondsString);
// Convert nanoseconds to Ticks (1 Tick == 100 nanoseconds)
var ticks = nanoseconds / 100;
diff --git a/certified-connectors/Snowflake v2/SnowflakeV2CoreLogicTests/Utilities/SnowflakeToODataHelperTest.cs b/certified-connectors/Snowflake v2/SnowflakeV2CoreLogicTests/Utilities/SnowflakeToODataHelperTest.cs
new file mode 100644
index 0000000000..543b1742df
--- /dev/null
+++ b/certified-connectors/Snowflake v2/SnowflakeV2CoreLogicTests/Utilities/SnowflakeToODataHelperTest.cs
@@ -0,0 +1,62 @@
+namespace SnowflakeV2CoreLogic.Tests.Utilities
+{
+ using SnowflakeV2CoreLogic;
+ using SnowflakeV2CoreLogic.Utilities;
+
+ [TestClass]
+ public sealed class SnowflakeToODataHelperTest
+ {
+
+ [DataRow("1700000000", "2023-11-14 22:13:20.0000000")]
+ [DataRow("1700000000.5", "2023-11-14 22:13:20.5000000")]
+ [DataRow("1700000000.05", "2023-11-14 22:13:20.0500000")]
+ [DataRow("1700000000.123", "2023-11-14 22:13:20.1230000")]
+ [DataRow("1700000000.123456", "2023-11-14 22:13:20.1234560")]
+ [DataRow("1700000000.123456789", "2023-11-14 22:13:20.1234567")]
+ [DataTestMethod]
+ public void CastSnowflakeDataToCorrectType_TimestampNtz_SupportsAllScales(string snowflakeValue, string expected)
+ {
+ var result = SnowflakeToODataHelper.CastSnowflakeDataToCorrectType(
+ Constants.SFDataTypeTimestampNoTimeZone,
+ precision: null,
+ data: snowflakeValue);
+
+ Assert.AreEqual(expected, result);
+ }
+
+ [DataRow("1700000000", "2023-11-14 22:13:20.0000000")]
+ [DataRow("1700000000.1", "2023-11-14 22:13:20.1000000")]
+ [DataRow("1700000000.12", "2023-11-14 22:13:20.1200000")]
+ [DataRow("1700000000.123", "2023-11-14 22:13:20.1230000")]
+ [DataRow("1700000000.123456", "2023-11-14 22:13:20.1234560")]
+ [DataRow("1700000000.123456789", "2023-11-14 22:13:20.1234567")]
+ [DataTestMethod]
+ public void CastSnowflakeDataToCorrectType_TimestampLtz_SupportsAllScales(string snowflakeValue, string expected)
+ {
+ var result = SnowflakeToODataHelper.CastSnowflakeDataToCorrectType(
+ Constants.SFDataTypeTimestampLocalTimeZone,
+ precision: null,
+ data: snowflakeValue);
+
+ Assert.AreEqual(expected, result);
+ }
+
+ [DataRow("1700000000 +0000", "2023-11-14 22:13:20.0000000")]
+ [DataRow("1700000000.5 -0800", "2023-11-14 22:13:20.5000000")]
+ [DataRow("1700000000.05 +0530", "2023-11-14 22:13:20.0500000")]
+ [DataRow("1700000000.123 +0000", "2023-11-14 22:13:20.1230000")]
+ [DataRow("1700000000.123456 +0000", "2023-11-14 22:13:20.1234560")]
+ [DataRow("1700000000.123456789 +0000", "2023-11-14 22:13:20.1234567")]
+ [DataTestMethod]
+ public void CastSnowflakeDataToCorrectType_TimestampTz_SupportsAllScales(string snowflakeValue, string expected)
+ {
+ var result = SnowflakeToODataHelper.CastSnowflakeDataToCorrectType(
+ Constants.SFDataTypeTimestampWithTimezone,
+ precision: null,
+ data: snowflakeValue);
+
+ Assert.AreEqual(expected, result);
+ }
+
+ }
+}
From 7804b7d2ed1123f746c5a19cf56693cff0d51952 Mon Sep 17 00:00:00 2001
From: Kuba Bogusz
Date: Mon, 13 Jul 2026 13:13:22 +0200
Subject: [PATCH 4/5] FLOW-11881 Input validation hardening (#24)
---
certified-connectors/Snowflake v2/.gitignore | 7 +
...bleDataInjectionEndpointIntegrationTest.cs | 215 +++++++++++++
.../Providers/SnowflakeDBOperations.cs | 47 ++-
.../Utilities/EnsureExtensions.cs | 289 +++++++++++++++++-
.../Utilities/ODataToSqlParser.cs | 23 +-
.../EnsureExtensionsInjectionTest.cs | 208 +++++++++++++
.../Utilities/EnsureExtensionsTest.cs | 139 +++++++++
.../Utilities/ODataToSqlParserTest.cs | 55 ++++
8 files changed, 952 insertions(+), 31 deletions(-)
create mode 100644 certified-connectors/Snowflake v2/SnowflakeTestApp.Tests/Data/TableDataInjectionEndpointIntegrationTest.cs
create mode 100644 certified-connectors/Snowflake v2/SnowflakeV2CoreLogicTests/Utilities/EnsureExtensionsInjectionTest.cs
create mode 100644 certified-connectors/Snowflake v2/SnowflakeV2CoreLogicTests/Utilities/EnsureExtensionsTest.cs
diff --git a/certified-connectors/Snowflake v2/.gitignore b/certified-connectors/Snowflake v2/.gitignore
index 809bbd611e..7bd452139c 100644
--- a/certified-connectors/Snowflake v2/.gitignore
+++ b/certified-connectors/Snowflake v2/.gitignore
@@ -8,6 +8,13 @@
!/SnowflakeTestApp.Tests/
!/ConnectorArtifacts/
+# SnowflakeV2CoreLogicTests: ignore the project by default and only track sources we own
+!/SnowflakeV2CoreLogicTests/
+/SnowflakeV2CoreLogicTests/*
+!/SnowflakeV2CoreLogicTests/Utilities/
+!/SnowflakeV2CoreLogicTests/MSTestSettings.cs
+!/SnowflakeV2CoreLogicTests/*.csproj
+
# .config folder is not ignored
# but ignore all subfolders
# except .extensions and .toolsettings
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
}
}
From f5bdf3babae8253cb061ffb15ba72e9b61a1cb35 Mon Sep 17 00:00:00 2001
From: Kuba Bogusz
Date: Mon, 13 Jul 2026 13:33:58 +0200
Subject: [PATCH 5/5] Fix .gitignore rule
---
certified-connectors/Snowflake v2/.gitignore | 10 ++--------
1 file changed, 2 insertions(+), 8 deletions(-)
diff --git a/certified-connectors/Snowflake v2/.gitignore b/certified-connectors/Snowflake v2/.gitignore
index 7bd452139c..1560de3c22 100644
--- a/certified-connectors/Snowflake v2/.gitignore
+++ b/certified-connectors/Snowflake v2/.gitignore
@@ -6,14 +6,8 @@
!/SnowflakeV2CoreLogic/
!/SnowflakeTestApp/
!/SnowflakeTestApp.Tests/
-!/ConnectorArtifacts/
-
-# SnowflakeV2CoreLogicTests: ignore the project by default and only track sources we own
!/SnowflakeV2CoreLogicTests/
-/SnowflakeV2CoreLogicTests/*
-!/SnowflakeV2CoreLogicTests/Utilities/
-!/SnowflakeV2CoreLogicTests/MSTestSettings.cs
-!/SnowflakeV2CoreLogicTests/*.csproj
+!/ConnectorArtifacts/
# .config folder is not ignored
# but ignore all subfolders
@@ -420,4 +414,4 @@ __pycache__/
tmp/
# Ignore launch settings for Visual Studio projects
-launchSettings.json
\ No newline at end of file
+launchSettings.json