From 58e5e44d34d82be97dd071a325740752529f68c0 Mon Sep 17 00:00:00 2001 From: Anatoli Beliaev Date: Wed, 17 Jun 2026 15:16:49 -0700 Subject: [PATCH 1/5] Prevent OData injection by using parameterized filters and escaping quote characters --- .../Tracking/AzureTableTrackingStore.cs | 14 ++++-- ...chestrationInstanceStatusQueryCondition.cs | 13 ++++-- .../Tracking/AzureTableClient.cs | 32 +++++++------ ...trationInstanceStatusQueryConditionTest.cs | 46 +++++++++++++++++++ .../AzureTableClientTest.cs | 45 ++++++++++++++++++ 5 files changed, 129 insertions(+), 21 deletions(-) diff --git a/src/DurableTask.AzureStorage/Tracking/AzureTableTrackingStore.cs b/src/DurableTask.AzureStorage/Tracking/AzureTableTrackingStore.cs index ca98f4dd3..af5981143 100644 --- a/src/DurableTask.AzureStorage/Tracking/AzureTableTrackingStore.cs +++ b/src/DurableTask.AzureStorage/Tracking/AzureTableTrackingStore.cs @@ -233,10 +233,14 @@ 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 escapedInstanceId = KeySanitation.EscapePartitionKey(instanceId); + string filter = TableClient.CreateQueryFilter($"PartitionKey eq {escapedInstanceId}"); if (!string.IsNullOrEmpty(expectedExecutionId)) { - filter += $" and ({nameof(ITableEntity.RowKey)} eq '{SentinelRowKey}' or {nameof(OrchestrationInstance.ExecutionId)} eq '{expectedExecutionId}')"; + // Use parameterized filter to prevent OData injection via crafted execution IDs + string sentinelCondition = TableClient.CreateQueryFilter($"RowKey eq {SentinelRowKey}"); + string executionIdCondition = TableClient.CreateQueryFilter($"ExecutionId eq {expectedExecutionId}"); + filter += $" and ({sentinelCondition} or {executionIdCondition})"; } return this.HistoryTable.ExecuteQueryAsync(filter, select: projectionColumns, cancellationToken: cancellationToken); @@ -278,7 +282,8 @@ public override async IAsyncEnumerable RewindHistoryAsync(string instanc //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// bool hasFailedSubOrchestrations = false; - string partitionFilter = $"{nameof(ITableEntity.PartitionKey)} eq '{KeySanitation.EscapePartitionKey(instanceId)}'"; + string escapedInstanceId = KeySanitation.EscapePartitionKey(instanceId); + string partitionFilter = TableClient.CreateQueryFilter($"PartitionKey eq {escapedInstanceId}"); string orchestratorStartedFilter = $"{partitionFilter} and {nameof(HistoryEvent.EventType)} eq '{nameof(EventType.OrchestratorStarted)}'"; IReadOnlyList orchestratorStartedEntities = await this.QueryHistoryAsync(orchestratorStartedFilter, instanceId, cancellationToken); @@ -289,7 +294,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 = TableClient.CreateQueryFilter($"ExecutionId eq {executionId}"); var updateFilterBuilder = new StringBuilder(); updateFilterBuilder.Append($"{partitionFilter}"); diff --git a/src/DurableTask.AzureStorage/Tracking/OrchestrationInstanceStatusQueryCondition.cs b/src/DurableTask.AzureStorage/Tracking/OrchestrationInstanceStatusQueryCondition.cs index 0f47a3979..b7fa9a5e6 100644 --- a/src/DurableTask.AzureStorage/Tracking/OrchestrationInstanceStatusQueryCondition.cs +++ b/src/DurableTask.AzureStorage/Tracking/OrchestrationInstanceStatusQueryCondition.cs @@ -16,6 +16,7 @@ namespace DurableTask.AzureStorage.Tracking using System; using System.Collections.Generic; using System.Linq; + using Azure.Data.Tables; using DurableTask.Core; /// @@ -129,7 +130,8 @@ internal ODataCondition ToOData() if (this.TaskHubNames != null && this.TaskHubNames.Any()) { - conditions.Add($"{string.Join(" or ", this.TaskHubNames.Select(x => $"TaskHubName eq '{x}'"))}"); + // Use parameterized filter to prevent OData injection via crafted task hub names + conditions.Add($"{string.Join(" or ", this.TaskHubNames.Select(x => TableClient.CreateQueryFilter($"TaskHubName eq {x}")))}"); } if (!string.IsNullOrEmpty(this.InstanceIdPrefix)) @@ -140,8 +142,10 @@ 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 filter to prevent OData injection via crafted instance ID prefixes. + // Property names must be literal text (not interpolated) for CreateQueryFilter. + conditions.Add(TableClient.CreateQueryFilter($"PartitionKey ge {sanitizedPrefix}")); + conditions.Add(TableClient.CreateQueryFilter($"PartitionKey lt {greaterThanPrefix}")); } else if (this.ExcludeEntities) { @@ -151,7 +155,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(TableClient.CreateQueryFilter($"PartitionKey eq {sanitizedInstanceId}")); } 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/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}"); + } } } From e58a8407025c3532e88cceb9683341310d32b282 Mon Sep 17 00:00:00 2001 From: Anatoli Beliaev Date: Thu, 18 Jun 2026 14:55:37 -0700 Subject: [PATCH 2/5] Handle more OData filter injection cases --- .../AzureTableQueryFilterTests.cs | 59 +++++++++++++++++ .../Tracking/AzureTableQueryFilter.cs | 66 +++++++++++++++++++ .../Tracking/AzureTableTrackingStore.cs | 33 +++++----- ...chestrationInstanceStatusQueryCondition.cs | 13 ++-- .../AzureStorageScenarioTests.cs | 25 +++++++ 5 files changed, 171 insertions(+), 25 deletions(-) create mode 100644 Test/DurableTask.AzureStorage.Tests/AzureTableQueryFilterTests.cs create mode 100644 src/DurableTask.AzureStorage/Tracking/AzureTableQueryFilter.cs 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/src/DurableTask.AzureStorage/Tracking/AzureTableQueryFilter.cs b/src/DurableTask.AzureStorage/Tracking/AzureTableQueryFilter.cs new file mode 100644 index 000000000..ea09b63ee --- /dev/null +++ b/src/DurableTask.AzureStorage/Tracking/AzureTableQueryFilter.cs @@ -0,0 +1,66 @@ +// ---------------------------------------------------------------------------------- +// 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 the given orchestration instance ID. + /// Applies for storage-key sanitization and then + /// OData quote-escaping. Pass the raw instance ID; do not pre-sanitize it, because + /// is not idempotent. + /// + public static string PartitionKeyEquals(string instanceId) + { + string sanitizedInstanceId = KeySanitation.EscapePartitionKey(instanceId); + return TableClient.CreateQueryFilter($"PartitionKey eq {sanitizedInstanceId}"); + } + + /// + /// Builds a {columnName} eq '...' condition with OData-escaped. + /// 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 value) + { + return TableClient.CreateQueryFilter(FormattableStringFactory.Create(columnName + " eq {0}", value)); + } + + /// + /// 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 af5981143..84aaf6072 100644 --- a/src/DurableTask.AzureStorage/Tracking/AzureTableTrackingStore.cs +++ b/src/DurableTask.AzureStorage/Tracking/AzureTableTrackingStore.cs @@ -233,13 +233,12 @@ public override async Task GetHistoryEventsAsync(string in TableQueryResponse GetHistoryEntitiesResponseInfoAsync(string instanceId, string expectedExecutionId, IList projectionColumns, CancellationToken cancellationToken) { - string escapedInstanceId = KeySanitation.EscapePartitionKey(instanceId); - string filter = TableClient.CreateQueryFilter($"PartitionKey eq {escapedInstanceId}"); + string filter = AzureTableQueryFilter.PartitionKeyEquals(instanceId); if (!string.IsNullOrEmpty(expectedExecutionId)) { - // Use parameterized filter to prevent OData injection via crafted execution IDs - string sentinelCondition = TableClient.CreateQueryFilter($"RowKey eq {SentinelRowKey}"); - string executionIdCondition = TableClient.CreateQueryFilter($"ExecutionId eq {expectedExecutionId}"); + // Use parameterized filters to prevent OData injection via crafted execution IDs + string sentinelCondition = AzureTableQueryFilter.ColumnEquals("RowKey", SentinelRowKey); + string executionIdCondition = AzureTableQueryFilter.ColumnEquals("ExecutionId", expectedExecutionId); filter += $" and ({sentinelCondition} or {executionIdCondition})"; } @@ -282,8 +281,7 @@ public override async IAsyncEnumerable RewindHistoryAsync(string instanc //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// bool hasFailedSubOrchestrations = false; - string escapedInstanceId = KeySanitation.EscapePartitionKey(instanceId); - string partitionFilter = TableClient.CreateQueryFilter($"PartitionKey eq {escapedInstanceId}"); + string partitionFilter = AzureTableQueryFilter.PartitionKeyEquals(instanceId); string orchestratorStartedFilter = $"{partitionFilter} and {nameof(HistoryEvent.EventType)} eq '{nameof(EventType.OrchestratorStarted)}'"; IReadOnlyList orchestratorStartedEntities = await this.QueryHistoryAsync(orchestratorStartedFilter, instanceId, cancellationToken); @@ -295,7 +293,7 @@ public override async IAsyncEnumerable RewindHistoryAsync(string instanc DateTime instanceTimestamp = recentStartRow[0].Timestamp.GetValueOrDefault().DateTime; // Use parameterized filter to prevent OData injection via crafted execution IDs - string executionIdFilter = TableClient.CreateQueryFilter($"ExecutionId eq {executionId}"); + string executionIdFilter = AzureTableQueryFilter.ColumnEquals("ExecutionId", executionId); var updateFilterBuilder = new StringBuilder(); updateFilterBuilder.Append($"{partitionFilter}"); @@ -717,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("RowKey", string.Empty)}"; var results = await this.InstancesTable .ExecuteQueryAsync(filter, cancellationToken: cancellationToken) .GetResultsAsync(cancellationToken: cancellationToken); @@ -1179,9 +1176,10 @@ static TableEntity GetSingleEntityFromHistoryTableResults(IReadOnlyList @@ -131,7 +130,7 @@ internal ODataCondition ToOData() if (this.TaskHubNames != null && this.TaskHubNames.Any()) { // Use parameterized filter to prevent OData injection via crafted task hub names - conditions.Add($"{string.Join(" or ", this.TaskHubNames.Select(x => TableClient.CreateQueryFilter($"TaskHubName eq {x}")))}"); + conditions.Add(string.Join(" or ", this.TaskHubNames.Select(x => AzureTableQueryFilter.ColumnEquals("TaskHubName", x)))); } if (!string.IsNullOrEmpty(this.InstanceIdPrefix)) @@ -142,10 +141,9 @@ internal ODataCondition ToOData() string greaterThanPrefix = sanitizedPrefix.Substring(0, length) + incrementedLastChar; - // Use parameterized filter to prevent OData injection via crafted instance ID prefixes. - // Property names must be literal text (not interpolated) for CreateQueryFilter. - conditions.Add(TableClient.CreateQueryFilter($"PartitionKey ge {sanitizedPrefix}")); - conditions.Add(TableClient.CreateQueryFilter($"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) { @@ -154,9 +152,8 @@ internal ODataCondition ToOData() if (this.InstanceId != null) { - string sanitizedInstanceId = KeySanitation.EscapePartitionKey(this.InstanceId); // Use parameterized filter to prevent OData injection via crafted instance IDs - conditions.Add(TableClient.CreateQueryFilter($"PartitionKey eq {sanitizedInstanceId}")); + conditions.Add(AzureTableQueryFilter.PartitionKeyEquals(this.InstanceId)); } return conditions.Count switch diff --git a/test/DurableTask.AzureStorage.Tests/AzureStorageScenarioTests.cs b/test/DurableTask.AzureStorage.Tests/AzureStorageScenarioTests.cs index 809381207..4f706a406 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().ToString(); + 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() { From e4437671ae02b6cb22979df875428d24946e47b3 Mon Sep 17 00:00:00 2001 From: Anatoli Beliaev Date: Thu, 18 Jun 2026 19:24:37 -0700 Subject: [PATCH 3/5] Remove redundant ToString() call' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- .../DurableTask.AzureStorage.Tests/AzureStorageScenarioTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/DurableTask.AzureStorage.Tests/AzureStorageScenarioTests.cs b/test/DurableTask.AzureStorage.Tests/AzureStorageScenarioTests.cs index 4f706a406..07b064aca 100644 --- a/test/DurableTask.AzureStorage.Tests/AzureStorageScenarioTests.cs +++ b/test/DurableTask.AzureStorage.Tests/AzureStorageScenarioTests.cs @@ -365,7 +365,7 @@ public async Task PurgeInstanceHistory_InstanceIdWithSingleQuote_Succeeds() // OData filter is malformed and the purge query fails. using (TestOrchestrationHost host = TestHelpers.GetTestOrchestrationHost(enableExtendedSessions: false)) { - string instanceId = "purge'inject-" + Guid.NewGuid().ToString(); + 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)); From 1c9b4be711cafa3bb8079cb89d25c0c2e29d9951 Mon Sep 17 00:00:00 2001 From: Anatoli Beliaev Date: Thu, 18 Jun 2026 20:03:51 -0700 Subject: [PATCH 4/5] Move AzureTableQueryFilterTests.cs to lowercase test/ directory The file was tracked under Test/ (uppercase) while the test project and solution live under test/ (lowercase). On case-sensitive filesystems (e.g. CI) the file would not be part of the project, so the new tests would not be compiled or run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DurableTask.AzureStorage.Tests/AzureTableQueryFilterTests.cs | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {Test => test}/DurableTask.AzureStorage.Tests/AzureTableQueryFilterTests.cs (100%) diff --git a/Test/DurableTask.AzureStorage.Tests/AzureTableQueryFilterTests.cs b/test/DurableTask.AzureStorage.Tests/AzureTableQueryFilterTests.cs similarity index 100% rename from Test/DurableTask.AzureStorage.Tests/AzureTableQueryFilterTests.cs rename to test/DurableTask.AzureStorage.Tests/AzureTableQueryFilterTests.cs From d0ad619f98c71909fb87896554a52d6a8e89a767 Mon Sep 17 00:00:00 2001 From: Anatoli Beliaev Date: Fri, 19 Jun 2026 13:56:13 -0700 Subject: [PATCH 5/5] Address code review feedback --- .../Tracking/AzureTableQueryFilter.cs | 32 ++++++++++++------- .../Tracking/AzureTableTrackingStore.cs | 16 +++++----- 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/src/DurableTask.AzureStorage/Tracking/AzureTableQueryFilter.cs b/src/DurableTask.AzureStorage/Tracking/AzureTableQueryFilter.cs index ea09b63ee..ac0790124 100644 --- a/src/DurableTask.AzureStorage/Tracking/AzureTableQueryFilter.cs +++ b/src/DurableTask.AzureStorage/Tracking/AzureTableQueryFilter.cs @@ -24,25 +24,35 @@ namespace DurableTask.AzureStorage.Tracking static class AzureTableQueryFilter { /// - /// Builds a PartitionKey eq '...' condition for the given orchestration instance ID. - /// Applies for storage-key sanitization and then - /// OData quote-escaping. Pass the raw instance ID; do not pre-sanitize it, because - /// is not idempotent. + /// 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 instanceId) + public static string PartitionKeyEquals(string unsanitizedInstanceId) { - string sanitizedInstanceId = KeySanitation.EscapePartitionKey(instanceId); + string sanitizedInstanceId = KeySanitation.EscapePartitionKey(unsanitizedInstanceId); return TableClient.CreateQueryFilter($"PartitionKey eq {sanitizedInstanceId}"); } /// - /// Builds a {columnName} eq '...' condition with OData-escaped. - /// The must be a trusted constant (never user input), since it is - /// emitted as literal filter text rather than an escaped value. + /// 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 value) + public static string ColumnEquals(string columnName, string rawValue) { - return TableClient.CreateQueryFilter(FormattableStringFactory.Create(columnName + " eq {0}", value)); + // 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)); } /// diff --git a/src/DurableTask.AzureStorage/Tracking/AzureTableTrackingStore.cs b/src/DurableTask.AzureStorage/Tracking/AzureTableTrackingStore.cs index 84aaf6072..586207d02 100644 --- a/src/DurableTask.AzureStorage/Tracking/AzureTableTrackingStore.cs +++ b/src/DurableTask.AzureStorage/Tracking/AzureTableTrackingStore.cs @@ -237,8 +237,8 @@ TableQueryResponse GetHistoryEntitiesResponseInfoAsync(string insta if (!string.IsNullOrEmpty(expectedExecutionId)) { // Use parameterized filters to prevent OData injection via crafted execution IDs - string sentinelCondition = AzureTableQueryFilter.ColumnEquals("RowKey", SentinelRowKey); - string executionIdCondition = AzureTableQueryFilter.ColumnEquals("ExecutionId", expectedExecutionId); + string sentinelCondition = AzureTableQueryFilter.ColumnEquals(nameof(ITableEntity.RowKey), SentinelRowKey); + string executionIdCondition = AzureTableQueryFilter.ColumnEquals(nameof(OrchestrationInstance.ExecutionId), expectedExecutionId); filter += $" and ({sentinelCondition} or {executionIdCondition})"; } @@ -293,7 +293,7 @@ public override async IAsyncEnumerable RewindHistoryAsync(string instanc DateTime instanceTimestamp = recentStartRow[0].Timestamp.GetValueOrDefault().DateTime; // Use parameterized filter to prevent OData injection via crafted execution IDs - string executionIdFilter = AzureTableQueryFilter.ColumnEquals("ExecutionId", executionId); + string executionIdFilter = AzureTableQueryFilter.ColumnEquals(nameof(OrchestrationInstance.ExecutionId), executionId); var updateFilterBuilder = new StringBuilder(); updateFilterBuilder.Append($"{partitionFilter}"); @@ -716,7 +716,7 @@ public override Task PurgeHistoryAsync(DateTime thresholdDateTimeUtc, Orchestrat public override async Task PurgeInstanceHistoryAsync(string instanceId, CancellationToken cancellationToken = default) { // Use parameterized filters to prevent OData injection via crafted instance IDs - string filter = $"{AzureTableQueryFilter.PartitionKeyEquals(instanceId)} and {AzureTableQueryFilter.ColumnEquals("RowKey", string.Empty)}"; + string filter = $"{AzureTableQueryFilter.PartitionKeyEquals(instanceId)} and {AzureTableQueryFilter.ColumnEquals(RowKeyProperty, string.Empty)}"; var results = await this.InstancesTable .ExecuteQueryAsync(filter, cancellationToken: cancellationToken) .GetResultsAsync(cancellationToken: cancellationToken); @@ -1178,8 +1178,8 @@ static TableEntity GetSingleEntityFromHistoryTableResults(IReadOnlyList