Skip to content

Commit a723561

Browse files
committed
fix: validate and pin bulk selection time ranges
1 parent b5593a5 commit a723561

7 files changed

Lines changed: 304 additions & 28 deletions

File tree

‎src/Exceptionless.Core/Services/BulkOperations/EventBulkSelectionResolver.cs‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,14 @@ public async Task<BulkSelectionPreparation> PrepareAsync(BackgroundTask task, Ca
6767
if (appFilter is null)
6868
return new BulkSelectionPreparation(BulkOperationCheckResult.Ok);
6969

70+
// Counting and paging must use the same relative-date anchor, even when preparation runs after the selected day.
71+
string? frozenFilter = await RelativeDateFreezer.FreezeAsync(task.Bulk!.Selection.Filter,
72+
task.Bulk.Selection.UtcCreatedBefore ?? task.CreatedUtc, IsDateFieldAsync);
73+
7074
// The events list resolves stack level terms (for example status:open) and removes deleted stacks by looking up
7175
// matching stack ids. Do that once, here, and keep the result so every page uses the same stacks.
7276
var query = ApplyScope(new RepositoryQuery<PersistentEvent>(), appFilter, task.Bulk!.Selection)
73-
.FilterExpression(task.Bulk!.Selection.Filter)
77+
.FilterExpression(frozenFilter)
7478
.EnforceEventStackFilter();
7579

7680
CountResult count;
@@ -85,8 +89,7 @@ public async Task<BulkSelectionPreparation> PrepareAsync(BackgroundTask task, Ca
8589

8690
var pinned = new PinnedEventSelection
8791
{
88-
// Relative dates in the filter are pinned to the creation time, like the outer time window.
89-
EventFilter = await RelativeDateFreezer.FreezeAsync(query.GetFilterExpression(), task.Bulk!.Selection.UtcCreatedBefore ?? task.CreatedUtc, IsDateFieldAsync),
92+
EventFilter = query.GetFilterExpression(),
9093
ExcludedStackIds = query.GetExcludedStacks().ToArray(),
9194
StackIds = query.GetStacks().ToArray()
9295
};

‎src/Exceptionless.Core/Services/BulkOperations/RelativeDateFreezer.cs‎

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -50,46 +50,65 @@ public override async Task VisitAsync(TermRangeNode node, IQueryVisitorContext c
5050
if (!IsRelative(node.Min) && !IsRelative(node.Max))
5151
return;
5252

53-
// A range inside a group such as date:(>now-2h AND <=now) takes its field from the group.
53+
if (!await EnsureDateFieldAsync(node, [node.Min, node.Max]))
54+
return;
55+
56+
if (IsRelative(node.Min))
57+
{
58+
node.Min = _anchor + node.Min![3..];
59+
node.IsMinQuotedTerm = true;
60+
}
61+
62+
if (IsRelative(node.Max))
63+
{
64+
node.Max = _anchor + node.Max![3..];
65+
node.IsMaxQuotedTerm = true;
66+
}
67+
}
68+
69+
public override async Task VisitAsync(TermNode node, IQueryVisitorContext context)
70+
{
71+
if (node.IsRegexTerm || node.Proximity is not null || !IsRelative(node.Term) || node.Term!.IndexOfAny(['*', '?']) >= 0
72+
|| !await EnsureDateFieldAsync(node, [node.Term]))
73+
return;
74+
75+
// Elasticsearch treats a date term such as now/d as an inclusive date range, so it needs the same anchor.
76+
node.Term = _anchor + node.Term![3..];
77+
node.IsQuotedTerm = true;
78+
}
79+
80+
private async ValueTask<bool> EnsureDateFieldAsync(IFieldQueryNode node, string?[] terms)
81+
{
82+
// An unfielded term or range inside a group takes its field from the group.
5483
string? field = node.Field ?? GetGroupField(node);
5584
if (String.IsNullOrEmpty(field))
56-
return;
85+
return false;
5786

5887
if (node.Field is null)
5988
{
6089
// The group already decided the stored field: a custom field left unchanged there is not a date.
6190
if (!IsStoredDateField(field) && (IsCustomField(field) || !await isDateFieldAsync(field)))
62-
return;
91+
return false;
6392
}
6493
else
6594
{
66-
// Custom data fields are stored by type, and the events index picks a date field only when the bounds look like
67-
// dates. Freezing the bounds would hide that, so name the stored date field directly.
68-
string? storedField = EventFieldsQueryVisitor.GetCustomFieldName(field, [node.Min, node.Max]);
95+
// Custom data fields are stored by type. Freezing their original terms would hide the date type,
96+
// so name the stored date field directly.
97+
string? storedField = EventFieldsQueryVisitor.GetCustomFieldName(field, terms);
6998
if (IsCustomField(field))
7099
{
71100
if (!IsStoredDateField(storedField))
72-
return;
101+
return false;
73102

74103
node.Field = storedField;
75104
}
76105
else if (!IsStoredDateField(field) && !await isDateFieldAsync(field))
77106
{
78-
return;
107+
return false;
79108
}
80109
}
81110

82-
if (IsRelative(node.Min))
83-
{
84-
node.Min = _anchor + node.Min![3..];
85-
node.IsMinQuotedTerm = true;
86-
}
87-
88-
if (IsRelative(node.Max))
89-
{
90-
node.Max = _anchor + node.Max![3..];
91-
node.IsMaxQuotedTerm = true;
92-
}
111+
return true;
93112
}
94113

95114
private static bool IsCustomField(string field)
@@ -115,7 +134,7 @@ private static bool IsStoredDateField(string? field)
115134

116135
private static bool IsRelative(string? value)
117136
{
118-
return value is not null && value.StartsWith("now", StringComparison.OrdinalIgnoreCase) && (value.Length == 3 || value[3] is '+' or '-' or '/');
137+
return value is not null && value.StartsWith("now", StringComparison.Ordinal) && (value.Length == 3 || value[3] is '+' or '-' or '/');
119138
}
120139
}
121140
}

‎src/Exceptionless.Web/Api/Handlers/BackgroundTaskHandler.cs‎

Lines changed: 113 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Text.RegularExpressions;
12
using Exceptionless.Core;
23
using Exceptionless.Core.Extensions;
34
using Exceptionless.Core.Models;
@@ -7,6 +8,9 @@
78
using Exceptionless.Core.Repositories.Configuration;
89
using Exceptionless.Core.Services.BackgroundTasks;
910
using Exceptionless.Core.Services.BulkOperations;
11+
using Exceptionless.DateTimeExtensions;
12+
using Exceptionless.DateTimeExtensions.FormatParsers;
13+
using Exceptionless.DateTimeExtensions.FormatParsers.PartParsers;
1014
using Exceptionless.Web.Api.Infrastructure;
1115
using Exceptionless.Web.Api.Messages;
1216
using Exceptionless.Web.Api.Results;
@@ -30,7 +34,6 @@ public class BackgroundTaskHandler(
3034
ILogger<BackgroundTaskHandler> logger)
3135
{
3236
private const int MaxIds = 1000;
33-
private static readonly ICollection<string> _allowedDateFields = new List<string> { EventIndex.Alias.Date };
3437

3538
private HttpContext HttpContext => httpContextAccessor.HttpContext ?? throw new InvalidOperationException("HttpContext is unavailable.");
3639

@@ -192,10 +195,17 @@ public async Task<Result<AcceptedResponse<ViewBackgroundTask>>> Handle(CancelBac
192195
if (model.Ids is { Count: > 0 })
193196
return (null, Result.Invalid(ValidationError.Create("selection", "A filter selection cannot include ids.")));
194197

195-
// Resolve the relative time range once, now. The end can never be later than this moment, so events that
196-
// arrive after the operation is created are not part of it.
197-
var timeInfo = TimeRangeParser.GetTimeInfo(model.Time, model.Offset, timeProvider, _allowedDateFields, EventIndex.Alias.Date,
198-
organization.GetRetentionUtcCutoff(appOptions.MaximumRetentionDays, timeProvider));
198+
if (!TryParseSelectionOffset(model.Offset, out var utcOffset))
199+
return (null, Result.Invalid(ValidationError.Create("selection.offset", "The UTC offset must be a valid whole-minute offset between -14h and 14h.")));
200+
201+
// Validate the requested range before retention and open-end defaults can turn a parse failure into all history.
202+
// Reuse the parsed range so validation and execution resolve relative dates at exactly the same instant.
203+
if (!TryParseSelectionTime(model.Time, new DateTimeOffset(utcNow).ToOffset(utcOffset), out var range))
204+
return (null, Result.Invalid(ValidationError.Create("selection.time", "The time range is not valid.")));
205+
206+
var timeInfo = new TimeInfo { Field = EventIndex.Alias.Date, Offset = utcOffset, Range = range! };
207+
timeInfo.ApplyMinimumUtcStartDate(organization.GetRetentionUtcCutoff(appOptions.MaximumRetentionDays, timeProvider));
208+
timeInfo.AdjustEndTimeIfMaxValue(timeProvider);
199209
var utcStart = timeInfo.Range.UtcStart;
200210
var utcEnd = timeInfo.Range.UtcEnd > utcNow ? utcNow : timeInfo.Range.UtcEnd;
201211
if (utcStart > utcEnd)
@@ -214,6 +224,104 @@ public async Task<Result<AcceptedResponse<ViewBackgroundTask>>> Handle(CancelBac
214224
return (selection, null);
215225
}
216226

227+
internal static bool TryParseSelectionTime(string? time, DateTimeOffset relativeBaseTime, out DateTimeRange? range)
228+
{
229+
if (String.IsNullOrWhiteSpace(time))
230+
{
231+
range = DateTimeRange.Empty;
232+
return true;
233+
}
234+
235+
string expression = time.Trim();
236+
if (expression.StartsWith($"{EventIndex.Alias.Date}|", StringComparison.Ordinal))
237+
expression = expression[(EventIndex.Alias.Date.Length + 1)..];
238+
239+
// DateTimeRange.Parse uses the same unbounded sentinel for invalid input and a valid [* TO *] range.
240+
// Its two-part parser also supplies missing bounds, so require both to have been parsed explicitly.
241+
range = null;
242+
try
243+
{
244+
foreach (var parser in DateTimeRange.FormatParsers)
245+
{
246+
if (parser is TwoPartFormatParser twoPartParser)
247+
{
248+
var bounds = new List<DateTimeOffset>(2);
249+
var partParsers = twoPartParser.Parsers.Select<IPartParser, IPartParser>(partParser => partParser is WildcardPartParser
250+
? new SelectionTimeWildcardPartParser(bounds)
251+
: new SelectionTimePartParser(partParser, bounds));
252+
range = new TwoPartFormatParser(partParsers).Parse(expression, relativeBaseTime);
253+
if (range is not null && (bounds.Count != 2 || bounds[0] > bounds[1]))
254+
{
255+
range = null;
256+
return false;
257+
}
258+
}
259+
else
260+
{
261+
range = parser.Parse(expression, relativeBaseTime);
262+
}
263+
264+
if (range is not null)
265+
return true;
266+
}
267+
}
268+
catch (Exception ex) when (ex is ArgumentException or FormatException or OverflowException)
269+
{
270+
range = null;
271+
}
272+
273+
return false;
274+
}
275+
276+
internal static bool TryParseSelectionOffset(string? offset, out TimeSpan utcOffset)
277+
{
278+
utcOffset = TimeSpan.Zero;
279+
if (String.IsNullOrWhiteSpace(offset))
280+
return true;
281+
282+
try
283+
{
284+
if (!TimeUnit.TryParse(offset, out var parsedOffset) || !parsedOffset.HasValue
285+
|| parsedOffset.Value < TimeSpan.FromHours(-14) || parsedOffset.Value > TimeSpan.FromHours(14)
286+
|| parsedOffset.Value.Ticks % TimeSpan.TicksPerMinute != 0)
287+
return false;
288+
289+
utcOffset = parsedOffset.Value;
290+
return true;
291+
}
292+
catch (Exception ex) when (ex is ArgumentException or FormatException or OverflowException)
293+
{
294+
return false;
295+
}
296+
}
297+
298+
private sealed class SelectionTimePartParser(IPartParser inner, List<DateTimeOffset> bounds) : IPartParser
299+
{
300+
public Regex Regex => inner.Regex;
301+
302+
public DateTimeOffset? Parse(Match match, DateTimeOffset relativeBaseTime, bool isUpperLimit)
303+
{
304+
var value = inner.Parse(match, relativeBaseTime, isUpperLimit);
305+
if (value.HasValue)
306+
bounds.Add(value.Value);
307+
308+
return value;
309+
}
310+
}
311+
312+
// The two-part parser checks this type to keep wildcard bounds open regardless of bracket inclusivity.
313+
private sealed class SelectionTimeWildcardPartParser(List<DateTimeOffset> bounds) : WildcardPartParser, IPartParser
314+
{
315+
DateTimeOffset? IPartParser.Parse(Match match, DateTimeOffset relativeBaseTime, bool isUpperLimit)
316+
{
317+
var value = base.Parse(match, relativeBaseTime, isUpperLimit);
318+
if (value.HasValue)
319+
bounds.Add(value.Value);
320+
321+
return value;
322+
}
323+
}
324+
217325
private static Result<T> ToResult<T>(BulkOperationCheckResult check)
218326
{
219327
return check.Status switch

‎tests/Exceptionless.Tests/Api/Endpoints/BulkOperationEndpointTests.cs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,14 @@ public async Task Create_InvalidRequests_AreRejectedWithoutStartingAnything()
430430
FilterRequest(SampleDataService.TEST_ORG_ID, null, "") with { Parameters = new Dictionary<string, string> { ["x"] = "y" } },
431431
FilterRequest(SampleDataService.TEST_ORG_ID, null, "type:(log"),
432432
FilterRequest(SampleDataService.TEST_ORG_ID, null, "", "[now+1d TO now+2d]"),
433+
FilterRequest(SampleDataService.TEST_ORG_ID, null, "", "not a date"),
434+
FilterRequest(SampleDataService.TEST_ORG_ID, null, "", "date|not a date"),
435+
FilterRequest(SampleDataService.TEST_ORG_ID, null, "", "date|"),
436+
FilterRequest(SampleDataService.TEST_ORG_ID, null, "", "-"),
437+
FilterRequest(SampleDataService.TEST_ORG_ID, null, "", "[ - ]"),
438+
FilterRequest(SampleDataService.TEST_ORG_ID, null, "", "[now/d TO now-1d/d]"),
439+
FilterRequest(SampleDataService.TEST_ORG_ID, null, "") with { Selection = new NewBulkOperationSelection { Kind = BulkOperationSelectionKind.Filter, Time = "[now/d TO now/d]", Offset = "not an offset" } },
440+
FilterRequest(SampleDataService.TEST_ORG_ID, null, "") with { Selection = new NewBulkOperationSelection { Kind = BulkOperationSelectionKind.Filter, Time = "[now/d TO now/d]", Offset = "2147483647d" } },
433441
FilterRequest(SampleDataService.TEST_ORG_ID, null, "") with { Selection = new NewBulkOperationSelection { Kind = BulkOperationSelectionKind.Filter, Ids = ["537650f3b77efe23a47914f3"] } },
434442
FilterRequest(SampleDataService.TEST_ORG_ID, null, "") with { Selection = new NewBulkOperationSelection { Kind = BulkOperationSelectionKind.Ids, Ids = [] } },
435443
FilterRequest(SampleDataService.TEST_ORG_ID, null, "") with { Selection = new NewBulkOperationSelection { Kind = BulkOperationSelectionKind.Ids, Ids = ["not-an-id"] } },
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
using System.Globalization;
2+
using Exceptionless.Web.Api.Handlers;
3+
using Foundatio.Xunit;
4+
using Xunit;
5+
6+
namespace Exceptionless.Tests.Api.Handlers;
7+
8+
public class BackgroundTaskHandlerTests : TestWithLoggingBase
9+
{
10+
private static readonly DateTimeOffset UtcNow = new(2026, 11, 3, 12, 0, 0, TimeSpan.Zero);
11+
12+
public BackgroundTaskHandlerTests(ITestOutputHelper output) : base(output) { }
13+
14+
[Theory]
15+
[InlineData("not a date")]
16+
[InlineData("24h")]
17+
[InlineData("date|not a date")]
18+
[InlineData("date|")]
19+
[InlineData("created_utc|[now-1d TO now]")]
20+
[InlineData("-")]
21+
[InlineData("[ - ]")]
22+
[InlineData("- now")]
23+
[InlineData("now -")]
24+
[InlineData("[now-1d TO now")]
25+
[InlineData("[now/d TO now-1d/d]")]
26+
[InlineData("last 0 days")]
27+
[InlineData("last 999999999999999999 days")]
28+
public void TryParseSelectionTime_InvalidRange_IsRejectedBeforeDefaultsCanBroadenIt(string time)
29+
{
30+
// Arrange
31+
var relativeBaseTime = UtcNow;
32+
33+
// Act
34+
bool parsed = BackgroundTaskHandler.TryParseSelectionTime(time, relativeBaseTime, out var range);
35+
36+
// Assert
37+
Assert.False(parsed);
38+
Assert.Null(range);
39+
}
40+
41+
[Theory]
42+
[InlineData(null, 0, "0001-01-01T00:00:00Z", "9999-12-31T23:59:59.9999999Z")]
43+
[InlineData("", 0, "0001-01-01T00:00:00Z", "9999-12-31T23:59:59.9999999Z")]
44+
[InlineData("[now-1d TO now]", 0, "2026-11-02T12:00:00Z", "2026-11-03T12:00:00Z")]
45+
[InlineData("date|[now/d TO now/d]", -5, "2026-11-03T05:00:00Z", "2026-11-04T04:59:59.999Z")]
46+
[InlineData("[now-1d TO *]", 0, "2026-11-02T12:00:00Z", "9999-12-31T23:59:59.9999999Z")]
47+
[InlineData("[* TO now]", 0, "0001-01-01T00:00:00Z", "2026-11-03T12:00:00Z")]
48+
[InlineData("[* TO *]", 0, "0001-01-01T00:00:00Z", "9999-12-31T23:59:59.9999999Z")]
49+
[InlineData("{* TO *}", 0, "0001-01-01T00:00:00Z", "9999-12-31T23:59:59.9999999Z")]
50+
[InlineData("<now/d", 0, "0001-01-01T00:00:00Z", "2026-11-03T00:00:00Z")]
51+
[InlineData("last 7 days", 0, "2026-10-27T00:00:00Z", "2026-11-03T12:00:00Z")]
52+
[InlineData("date|[2026-11-01T00:00:00Z||+1d/d TO 2026-11-01T00:00:00Z||+2d/d]", 0, "2026-11-02T00:00:00Z", "2026-11-03T23:59:59.999Z")]
53+
[InlineData("[2026-11-01T00:00:00.123Z TO 2026-11-01T00:00:00.123Z]", 0, "2026-11-01T00:00:00.123Z", "2026-11-01T00:00:00.123Z")]
54+
public void TryParseSelectionTime_ValidRange_PreservesTheRequestedBounds(string? time, int offsetHours, string expectedStart, string expectedEnd)
55+
{
56+
// Arrange
57+
var relativeBaseTime = UtcNow.ToOffset(TimeSpan.FromHours(offsetHours));
58+
59+
// Act
60+
bool parsed = BackgroundTaskHandler.TryParseSelectionTime(time, relativeBaseTime, out var range);
61+
62+
// Assert
63+
Assert.True(parsed);
64+
Assert.NotNull(range);
65+
Assert.Equal(DateTimeOffset.Parse(expectedStart, CultureInfo.InvariantCulture).UtcDateTime, range.UtcStart);
66+
Assert.Equal(DateTimeOffset.Parse(expectedEnd, CultureInfo.InvariantCulture).UtcDateTime, range.UtcEnd);
67+
}
68+
69+
[Theory]
70+
[InlineData(null, true, 0)]
71+
[InlineData("", true, 0)]
72+
[InlineData("-5h", true, -300)]
73+
[InlineData("330m", true, 330)]
74+
[InlineData("14h", true, 840)]
75+
[InlineData("-14h", true, -840)]
76+
[InlineData("not an offset", false, 0)]
77+
[InlineData("15h", false, 0)]
78+
[InlineData("1s", false, 0)]
79+
[InlineData("2147483647d", false, 0)]
80+
public void TryParseSelectionOffset_ValidatesOffsetsWithoutThrowingOrFallingBackToUtc(string? offset, bool expectedSuccess, int expectedMinutes)
81+
{
82+
// Arrange
83+
var expectedOffset = TimeSpan.FromMinutes(expectedMinutes);
84+
85+
// Act
86+
bool parsed = BackgroundTaskHandler.TryParseSelectionOffset(offset, out var utcOffset);
87+
88+
// Assert
89+
Assert.Equal(expectedSuccess, parsed);
90+
Assert.Equal(expectedOffset, utcOffset);
91+
}
92+
}

0 commit comments

Comments
 (0)
⌂ 换网址