diff --git a/src/DurableTask.AzureStorage/Tracking/AzureTableQueryFilter.cs b/src/DurableTask.AzureStorage/Tracking/AzureTableQueryFilter.cs
new file mode 100644
index 000000000..ac0790124
--- /dev/null
+++ b/src/DurableTask.AzureStorage/Tracking/AzureTableQueryFilter.cs
@@ -0,0 +1,76 @@
+// ----------------------------------------------------------------------------------
+// Copyright Microsoft Corporation
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+// ----------------------------------------------------------------------------------
+
+namespace DurableTask.AzureStorage.Tracking
+{
+ using System.Runtime.CompilerServices;
+ using Azure.Data.Tables;
+
+ ///
+ /// Builds individual OData filter conditions for Azure Table Storage queries with proper escaping
+ /// to prevent OData injection via user-influenced values (e.g. instance IDs, execution IDs, task hub
+ /// names). Each helper returns a single condition string; callers compose them with " and " / " or ".
+ ///
+ static class AzureTableQueryFilter
+ {
+ ///
+ /// Builds a PartitionKey eq '...' condition for an orchestration instance ID. The instance ID
+ /// is unsanitized: this method applies itself and
+ /// then OData quote-escaping. Do not pre-sanitize the value, because
+ /// is not idempotent. Contrast with
+ /// / , which take an
+ /// already-sanitized partition key.
+ ///
+ public static string PartitionKeyEquals(string unsanitizedInstanceId)
+ {
+ string sanitizedInstanceId = KeySanitation.EscapePartitionKey(unsanitizedInstanceId);
+ return TableClient.CreateQueryFilter($"PartitionKey eq {sanitizedInstanceId}");
+ }
+
+ ///
+ /// Builds a {columnName} eq '...' condition with OData quote-escaped.
+ /// This does not apply : it is for
+ /// non-partition-key columns (e.g. ExecutionId, EventType, RowKey), so is
+ /// used as-is aside from OData escaping. The must be a trusted constant
+ /// (never user input), since it is emitted as literal filter text rather than an escaped value.
+ ///
+ public static string ColumnEquals(string columnName, string rawValue)
+ {
+ // CreateQueryFilter takes a FormattableString and escapes/quotes each interpolation hole as a
+ // value, while leaving the literal text of the format untouched. The column name must remain
+ // literal (an interpolated {columnName} would be emitted as a quoted value, e.g. 'ExecutionId'
+ // eq 'x', which is invalid), but it is a runtime parameter rather than a compile-time literal,
+ // so a normal interpolated string ($"...") can't express that. FormattableStringFactory lets us
+ // bake the column name into the format text while keeping the value as the only escaped argument.
+ return TableClient.CreateQueryFilter(FormattableStringFactory.Create(columnName + " eq {0}", rawValue));
+ }
+
+ ///
+ /// Builds a PartitionKey ge '...' condition. The value must already be sanitized via
+ /// by the caller.
+ ///
+ public static string PartitionKeyGreaterOrEqual(string sanitizedPartitionKey)
+ {
+ return TableClient.CreateQueryFilter($"PartitionKey ge {sanitizedPartitionKey}");
+ }
+
+ ///
+ /// Builds a PartitionKey lt '...' condition. The value must already be sanitized via
+ /// by the caller.
+ ///
+ public static string PartitionKeyLessThan(string sanitizedPartitionKey)
+ {
+ return TableClient.CreateQueryFilter($"PartitionKey lt {sanitizedPartitionKey}");
+ }
+ }
+}
diff --git a/src/DurableTask.AzureStorage/Tracking/AzureTableTrackingStore.cs b/src/DurableTask.AzureStorage/Tracking/AzureTableTrackingStore.cs
index ca98f4dd3..586207d02 100644
--- a/src/DurableTask.AzureStorage/Tracking/AzureTableTrackingStore.cs
+++ b/src/DurableTask.AzureStorage/Tracking/AzureTableTrackingStore.cs
@@ -233,10 +233,13 @@ public override async Task GetHistoryEventsAsync(string in
TableQueryResponse GetHistoryEntitiesResponseInfoAsync(string instanceId, string expectedExecutionId, IList projectionColumns, CancellationToken cancellationToken)
{
- string filter = $"{nameof(ITableEntity.PartitionKey)} eq '{KeySanitation.EscapePartitionKey(instanceId)}'";
+ string filter = AzureTableQueryFilter.PartitionKeyEquals(instanceId);
if (!string.IsNullOrEmpty(expectedExecutionId))
{
- filter += $" and ({nameof(ITableEntity.RowKey)} eq '{SentinelRowKey}' or {nameof(OrchestrationInstance.ExecutionId)} eq '{expectedExecutionId}')";
+ // Use parameterized filters to prevent OData injection via crafted execution IDs
+ string sentinelCondition = AzureTableQueryFilter.ColumnEquals(nameof(ITableEntity.RowKey), SentinelRowKey);
+ string executionIdCondition = AzureTableQueryFilter.ColumnEquals(nameof(OrchestrationInstance.ExecutionId), expectedExecutionId);
+ filter += $" and ({sentinelCondition} or {executionIdCondition})";
}
return this.HistoryTable.ExecuteQueryAsync(filter, select: projectionColumns, cancellationToken: cancellationToken);
@@ -278,7 +281,7 @@ public override async IAsyncEnumerable RewindHistoryAsync(string instanc
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
bool hasFailedSubOrchestrations = false;
- string partitionFilter = $"{nameof(ITableEntity.PartitionKey)} eq '{KeySanitation.EscapePartitionKey(instanceId)}'";
+ string partitionFilter = AzureTableQueryFilter.PartitionKeyEquals(instanceId);
string orchestratorStartedFilter = $"{partitionFilter} and {nameof(HistoryEvent.EventType)} eq '{nameof(EventType.OrchestratorStarted)}'";
IReadOnlyList orchestratorStartedEntities = await this.QueryHistoryAsync(orchestratorStartedFilter, instanceId, cancellationToken);
@@ -289,7 +292,8 @@ public override async IAsyncEnumerable RewindHistoryAsync(string instanc
string executionId = recentStartRow[0].GetString(nameof(OrchestrationInstance.ExecutionId));
DateTime instanceTimestamp = recentStartRow[0].Timestamp.GetValueOrDefault().DateTime;
- string executionIdFilter = $"{nameof(OrchestrationInstance.ExecutionId)} eq '{executionId}'";
+ // Use parameterized filter to prevent OData injection via crafted execution IDs
+ string executionIdFilter = AzureTableQueryFilter.ColumnEquals(nameof(OrchestrationInstance.ExecutionId), executionId);
var updateFilterBuilder = new StringBuilder();
updateFilterBuilder.Append($"{partitionFilter}");
@@ -711,9 +715,8 @@ public override Task PurgeHistoryAsync(DateTime thresholdDateTimeUtc, Orchestrat
///
public override async Task PurgeInstanceHistoryAsync(string instanceId, CancellationToken cancellationToken = default)
{
- string sanitizedInstanceId = KeySanitation.EscapePartitionKey(instanceId);
-
- string filter = $"{PartitionKeyProperty} eq '{sanitizedInstanceId}' and {RowKeyProperty} eq ''";
+ // Use parameterized filters to prevent OData injection via crafted instance IDs
+ string filter = $"{AzureTableQueryFilter.PartitionKeyEquals(instanceId)} and {AzureTableQueryFilter.ColumnEquals(RowKeyProperty, string.Empty)}";
var results = await this.InstancesTable
.ExecuteQueryAsync(filter, cancellationToken: cancellationToken)
.GetResultsAsync(cancellationToken: cancellationToken);
@@ -1173,9 +1176,10 @@ static TableEntity GetSingleEntityFromHistoryTableResults(IReadOnlyList $"TaskHubName eq '{x}'"))}");
+ // Use parameterized filter to prevent OData injection via crafted task hub names
+ conditions.Add(string.Join(" or ", this.TaskHubNames.Select(x => AzureTableQueryFilter.ColumnEquals("TaskHubName", x))));
}
if (!string.IsNullOrEmpty(this.InstanceIdPrefix))
@@ -140,8 +141,9 @@ internal ODataCondition ToOData()
string greaterThanPrefix = sanitizedPrefix.Substring(0, length) + incrementedLastChar;
- conditions.Add($"{nameof(OrchestrationInstanceStatus.PartitionKey)} ge '{sanitizedPrefix}'");
- conditions.Add($"{nameof(OrchestrationInstanceStatus.PartitionKey)} lt '{greaterThanPrefix}'");
+ // Use parameterized filters to prevent OData injection via crafted instance ID prefixes.
+ conditions.Add(AzureTableQueryFilter.PartitionKeyGreaterOrEqual(sanitizedPrefix));
+ conditions.Add(AzureTableQueryFilter.PartitionKeyLessThan(greaterThanPrefix));
}
else if (this.ExcludeEntities)
{
@@ -150,8 +152,8 @@ internal ODataCondition ToOData()
if (this.InstanceId != null)
{
- string sanitizedInstanceId = KeySanitation.EscapePartitionKey(this.InstanceId);
- conditions.Add($"{nameof(OrchestrationInstanceStatus.PartitionKey)} eq '{sanitizedInstanceId}'");
+ // Use parameterized filter to prevent OData injection via crafted instance IDs
+ conditions.Add(AzureTableQueryFilter.PartitionKeyEquals(this.InstanceId));
}
return conditions.Count switch
diff --git a/src/DurableTask.ServiceBus/Tracking/AzureTableClient.cs b/src/DurableTask.ServiceBus/Tracking/AzureTableClient.cs
index ac0b40292..c0e28a8df 100644
--- a/src/DurableTask.ServiceBus/Tracking/AzureTableClient.cs
+++ b/src/DurableTask.ServiceBus/Tracking/AzureTableClient.cs
@@ -42,6 +42,12 @@ static readonly IDictionary ComparisonOperatorMap
{{ FilterComparisonType.Equals, AzureTableConstants.EqualityOperator},
{ FilterComparisonType.NotEquals, AzureTableConstants.InEqualityOperator}};
+ ///
+ /// Escapes a string value for safe use in an OData filter expression
+ /// by doubling single quotes (' → '').
+ ///
+ static string EscapeODataValue(string value) => value?.Replace("'", "''");
+
volatile TableClient historyTableClient;
volatile TableClient jumpStartTableClient;
@@ -234,7 +240,7 @@ string GetPrimaryFilterExpression(OrchestrationStateQueryFilter filter, bool isJ
{
filterExpression = string.Format(CultureInfo.InvariantCulture,
AzureTableConstants.PrimaryInstanceQueryRangeTemplate,
- typedFilter.InstanceId, ComputeNextKeyInRange(typedFilter.InstanceId));
+ EscapeODataValue(typedFilter.InstanceId), EscapeODataValue(ComputeNextKeyInRange(typedFilter.InstanceId)));
}
else
{
@@ -242,14 +248,14 @@ string GetPrimaryFilterExpression(OrchestrationStateQueryFilter filter, bool isJ
{
filterExpression = string.Format(CultureInfo.InvariantCulture,
AzureTableConstants.PrimaryInstanceQueryRangeTemplate,
- typedFilter.InstanceId, ComputeNextKeyInRange(typedFilter.InstanceId));
+ EscapeODataValue(typedFilter.InstanceId), EscapeODataValue(ComputeNextKeyInRange(typedFilter.InstanceId)));
}
else
{
filterExpression = string.Format(CultureInfo.InvariantCulture,
AzureTableConstants.PrimaryInstanceQueryExactTemplate,
- typedFilter.InstanceId,
- typedFilter.ExecutionId);
+ EscapeODataValue(typedFilter.InstanceId),
+ EscapeODataValue(typedFilter.ExecutionId));
}
}
}
@@ -275,20 +281,20 @@ string GetSecondaryFilterExpression(OrchestrationStateQueryFilter filter)
{
query = string.Format(CultureInfo.InvariantCulture,
AzureTableConstants.InstanceQuerySecondaryFilterRangeTemplate,
- orchestrationStateInstanceFilter.InstanceId, ComputeNextKeyInRange(orchestrationStateInstanceFilter.InstanceId));
+ EscapeODataValue(orchestrationStateInstanceFilter.InstanceId), EscapeODataValue(ComputeNextKeyInRange(orchestrationStateInstanceFilter.InstanceId)));
}
else
{
if (string.IsNullOrWhiteSpace(orchestrationStateInstanceFilter.ExecutionId))
{
query = string.Format(CultureInfo.InvariantCulture,
- AzureTableConstants.InstanceQuerySecondaryFilterTemplate, orchestrationStateInstanceFilter.InstanceId);
+ AzureTableConstants.InstanceQuerySecondaryFilterTemplate, EscapeODataValue(orchestrationStateInstanceFilter.InstanceId));
}
else
{
query = string.Format(CultureInfo.InvariantCulture,
- AzureTableConstants.InstanceQuerySecondaryFilterExactTemplate, orchestrationStateInstanceFilter.InstanceId,
- orchestrationStateInstanceFilter.ExecutionId);
+ AzureTableConstants.InstanceQuerySecondaryFilterExactTemplate, EscapeODataValue(orchestrationStateInstanceFilter.InstanceId),
+ EscapeODataValue(orchestrationStateInstanceFilter.ExecutionId));
}
}
}
@@ -297,20 +303,20 @@ string GetSecondaryFilterExpression(OrchestrationStateQueryFilter filter)
if (orchestrationStateNameVersionFilter.Version == null)
{
query = string.Format(CultureInfo.InvariantCulture,
- AzureTableConstants.NameVersionQuerySecondaryFilterTemplate, orchestrationStateNameVersionFilter.Name);
+ AzureTableConstants.NameVersionQuerySecondaryFilterTemplate, EscapeODataValue(orchestrationStateNameVersionFilter.Name));
}
else
{
query = string.Format(CultureInfo.InvariantCulture,
- AzureTableConstants.NameVersionQuerySecondaryFilterExactTemplate, orchestrationStateNameVersionFilter.Name,
- orchestrationStateNameVersionFilter.Version);
+ AzureTableConstants.NameVersionQuerySecondaryFilterExactTemplate, EscapeODataValue(orchestrationStateNameVersionFilter.Name),
+ EscapeODataValue(orchestrationStateNameVersionFilter.Version));
}
}
else if (filter is OrchestrationStateStatusFilter orchestrationStateStatusFilter)
{
string template = AzureTableConstants.StatusQuerySecondaryFilterTemplate;
query = string.Format(CultureInfo.InvariantCulture,
- template, ComparisonOperatorMap[orchestrationStateStatusFilter.ComparisonType], orchestrationStateStatusFilter.Status);
+ template, ComparisonOperatorMap[orchestrationStateStatusFilter.ComparisonType], EscapeODataValue(orchestrationStateStatusFilter.Status.ToString()));
}
else if (filter is OrchestrationStateTimeRangeFilter orchestrationStateTimeRangeFilter)
{
@@ -383,7 +389,7 @@ public async Task> ReadOr
AzureTableConstants.JoinDelimiterPlusOne;
string filter = string.Format(CultureInfo.InvariantCulture, AzureTableConstants.TableRangeQueryFormat,
- partitionKey, rowKeyLower, rowKeyUpper);
+ EscapeODataValue(partitionKey), EscapeODataValue(rowKeyLower), EscapeODataValue(rowKeyUpper));
var pageableResults = historyTableClient.QueryAsync(filter);
diff --git a/test/DurableTask.AzureStorage.Tests/AzureStorageScenarioTests.cs b/test/DurableTask.AzureStorage.Tests/AzureStorageScenarioTests.cs
index 809381207..07b064aca 100644
--- a/test/DurableTask.AzureStorage.Tests/AzureStorageScenarioTests.cs
+++ b/test/DurableTask.AzureStorage.Tests/AzureStorageScenarioTests.cs
@@ -357,6 +357,31 @@ public async Task PurgeInstanceHistoryForSingleInstanceWithoutLargeMessageBlobs(
}
}
+ [TestMethod]
+ public async Task PurgeInstanceHistory_InstanceIdWithSingleQuote_Succeeds()
+ {
+ // Regression test for OData injection: an instance ID containing a single quote
+ // must be escaped when building the purge filter. Without escaping, the resulting
+ // OData filter is malformed and the purge query fails.
+ using (TestOrchestrationHost host = TestHelpers.GetTestOrchestrationHost(enableExtendedSessions: false))
+ {
+ string instanceId = "purge'inject-" + Guid.NewGuid();
+ await host.StartAsync();
+ TestOrchestrationClient client = await host.StartOrchestrationAsync(typeof(Orchestrations.Factorial), 110, instanceId);
+ await client.WaitForCompletionAsync(TimeSpan.FromSeconds(60));
+
+ List historyEvents = await client.GetOrchestrationHistoryAsync(instanceId);
+ Assert.IsTrue(historyEvents.Count > 0);
+
+ await client.PurgeInstanceHistory();
+
+ List historyEventsAfterPurging = await client.GetOrchestrationHistoryAsync(instanceId);
+ Assert.AreEqual(0, historyEventsAfterPurging.Count);
+
+ await host.StopAsync();
+ }
+ }
+
[TestMethod]
public async Task ValidateCustomStatusPersists()
{
diff --git a/test/DurableTask.AzureStorage.Tests/AzureTableQueryFilterTests.cs b/test/DurableTask.AzureStorage.Tests/AzureTableQueryFilterTests.cs
new file mode 100644
index 000000000..b9a41b5c5
--- /dev/null
+++ b/test/DurableTask.AzureStorage.Tests/AzureTableQueryFilterTests.cs
@@ -0,0 +1,59 @@
+// ----------------------------------------------------------------------------------
+// Copyright Microsoft Corporation
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+// ----------------------------------------------------------------------------------
+
+namespace DurableTask.AzureStorage.Tests
+{
+ using DurableTask.AzureStorage.Tracking;
+ using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+ [TestClass]
+ public class AzureTableQueryFilterTests
+ {
+ // PartitionKeyEquals applies KeySanitation.EscapePartitionKey (storage-key characters) and then
+ // OData quote-escaping (single quotes doubled).
+ [DataTestMethod]
+ [DataRow("instance1", "PartitionKey eq 'instance1'")]
+ [DataRow("inst'ance", "PartitionKey eq 'inst''ance'")]
+ [DataRow("in#st'ance", "PartitionKey eq 'in^2st''ance'")]
+ public void PartitionKeyEquals(string instanceId, string expectedFilter)
+ {
+ Assert.AreEqual(expectedFilter, AzureTableQueryFilter.PartitionKeyEquals(instanceId));
+ }
+
+ // ColumnEquals OData-escapes the value (single quotes doubled); the column name is literal text.
+ [DataTestMethod]
+ [DataRow("ExecutionId", "abc", "ExecutionId eq 'abc'")]
+ [DataRow("ExecutionId", "a'b", "ExecutionId eq 'a''b'")]
+ [DataRow("RowKey", "", "RowKey eq ''")]
+ public void ColumnEquals(string columnName, string value, string expectedFilter)
+ {
+ Assert.AreEqual(expectedFilter, AzureTableQueryFilter.ColumnEquals(columnName, value));
+ }
+
+ [DataTestMethod]
+ [DataRow("prefix", "PartitionKey ge 'prefix'")]
+ [DataRow("pre'fix", "PartitionKey ge 'pre''fix'")]
+ public void PartitionKeyGreaterOrEqual(string sanitizedPartitionKey, string expectedFilter)
+ {
+ Assert.AreEqual(expectedFilter, AzureTableQueryFilter.PartitionKeyGreaterOrEqual(sanitizedPartitionKey));
+ }
+
+ [DataTestMethod]
+ [DataRow("prefix", "PartitionKey lt 'prefix'")]
+ [DataRow("pre'fix", "PartitionKey lt 'pre''fix'")]
+ public void PartitionKeyLessThan(string sanitizedPartitionKey, string expectedFilter)
+ {
+ Assert.AreEqual(expectedFilter, AzureTableQueryFilter.PartitionKeyLessThan(sanitizedPartitionKey));
+ }
+ }
+}
diff --git a/test/DurableTask.AzureStorage.Tests/OrchestrationInstanceStatusQueryConditionTest.cs b/test/DurableTask.AzureStorage.Tests/OrchestrationInstanceStatusQueryConditionTest.cs
index 995a5bc57..9c6b20d43 100644
--- a/test/DurableTask.AzureStorage.Tests/OrchestrationInstanceStatusQueryConditionTest.cs
+++ b/test/DurableTask.AzureStorage.Tests/OrchestrationInstanceStatusQueryConditionTest.cs
@@ -196,5 +196,51 @@ public void OrchestrationInstanceQuery_EmptyInstanceId()
string result = condition.ToOData().Filter;
Assert.AreEqual("PartitionKey eq ''", result);
}
+
+ [TestMethod]
+ public void OrchestrationInstanceQuery_TaskHubName_SingleQuoteEscaped()
+ {
+ // Verifies that a single quote in TaskHubName is properly escaped,
+ // preventing OData filter injection.
+ var condition = new OrchestrationInstanceStatusQueryCondition
+ {
+ TaskHubNames = new string[] { "Hub'Injected" }
+ };
+
+ string filter = condition.ToOData().Filter;
+ Assert.AreEqual("TaskHubName eq 'Hub''Injected'", filter);
+ }
+
+ [TestMethod]
+ public void OrchestrationInstanceQuery_InstanceIdPrefix_SingleQuoteEscaped()
+ {
+ // Verifies that a single quote in InstanceIdPrefix is properly escaped,
+ // preventing OData filter injection.
+ var condition = new OrchestrationInstanceStatusQueryCondition
+ {
+ InstanceIdPrefix = "prefix'inject",
+ };
+
+ string filter = condition.ToOData().Filter;
+ // The prefix goes through EscapePartitionKey (which doesn't touch quotes)
+ // then through CreateQueryFilter (which escapes the quote for OData).
+ Assert.IsTrue(filter.Contains("prefix''inject"), $"Expected escaped quote in filter: {filter}");
+ Assert.IsTrue(filter.Contains("PartitionKey ge"), $"Expected ge condition in filter: {filter}");
+ Assert.IsTrue(filter.Contains("PartitionKey lt"), $"Expected lt condition in filter: {filter}");
+ }
+
+ [TestMethod]
+ public void OrchestrationInstanceQuery_InstanceId_SingleQuoteEscaped()
+ {
+ // Verifies that a single quote in InstanceId is properly escaped,
+ // preventing OData filter injection.
+ var condition = new OrchestrationInstanceStatusQueryCondition
+ {
+ InstanceId = "instance'id",
+ };
+
+ string filter = condition.ToOData().Filter;
+ Assert.AreEqual("PartitionKey eq 'instance''id'", filter);
+ }
}
}
diff --git a/test/DurableTask.ServiceBus.Tests/AzureTableClientTest.cs b/test/DurableTask.ServiceBus.Tests/AzureTableClientTest.cs
index 9a8778609..157d5d93e 100644
--- a/test/DurableTask.ServiceBus.Tests/AzureTableClientTest.cs
+++ b/test/DurableTask.ServiceBus.Tests/AzureTableClientTest.cs
@@ -58,5 +58,50 @@ public void CreateQueryWithPrimaryAndSecondaryFilter()
Assert.AreEqual("(PartitionKey eq 'IS') and (RowKey ge 'ID_EID_myInstance') and (RowKey lt 'ID_EID_myInstancf') and (InstanceId eq 'myInstance') and (Name eq 'myName')", query);
}
+
+ [TestMethod]
+ public void CreateQueryWithInstanceId_SingleQuoteEscaped()
+ {
+ // Verifies that a single quote in InstanceId is properly escaped,
+ // preventing OData filter injection.
+ var tableClient = new AzureTableClient("myHub", ConnectionString);
+ var stateQuery = new OrchestrationStateQuery();
+ stateQuery.AddInstanceFilter("instance'inject");
+
+ var query = tableClient.CreateQueryInternal(stateQuery, false);
+
+ // The single quote must be doubled in the OData filter
+ Assert.IsTrue(query.Contains("instance''inject"), $"Expected escaped quote in query: {query}");
+ }
+
+ [TestMethod]
+ public void CreateQueryWithInstanceIdAndExecutionId_SingleQuoteEscaped()
+ {
+ // Verifies that single quotes in InstanceId and ExecutionId are properly escaped,
+ // preventing OData filter injection.
+ var tableClient = new AzureTableClient("myHub", ConnectionString);
+ var stateQuery = new OrchestrationStateQuery();
+ stateQuery.AddInstanceFilter("inst'ance", "exec'ution");
+
+ var query = tableClient.CreateQueryInternal(stateQuery, false);
+
+ // Both values must have their single quotes escaped
+ Assert.IsTrue(query.Contains("inst''ance"), $"Expected escaped instance quote in query: {query}");
+ Assert.IsTrue(query.Contains("exec''ution"), $"Expected escaped execution quote in query: {query}");
+ }
+
+ [TestMethod]
+ public void CreateQueryWithNameFilter_SingleQuoteEscaped()
+ {
+ // Verifies that a single quote in orchestration Name is properly escaped.
+ var tableClient = new AzureTableClient("myHub", ConnectionString);
+ var stateQuery = new OrchestrationStateQuery();
+ stateQuery.AddInstanceFilter("myInstance");
+ stateQuery.AddNameVersionFilter("my'Name");
+
+ var query = tableClient.CreateQueryInternal(stateQuery, false);
+
+ Assert.IsTrue(query.Contains("my''Name"), $"Expected escaped name quote in query: {query}");
+ }
}
}